ROS 2 Testing Guide 2026
ROS 2 has a rich testing ecosystem: Python unittest/pytest for unit tests, launch_testing for integration tests that bring up real nodes, GTest for C++ packages, and colcon test to run them all. This guide covers each layer from unit to integration.
ROS 2 Testing Pyramid
From fastest (bottom) to most realistic (top)
[Integration] launch_testing — real nodes, real DDS, real topics
[Node tests] rclpy + unittest / pytest — spin node in test process
[Unit tests] Pure Python/C++ unittest / GTest (no ROS runtime needed)
Python unittest with rclpy
# test/test_my_node.py
import unittest
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class TestMyNode(unittest.TestCase):
@classmethod
def setUpClass(cls):
rclpy.init()
@classmethod
def tearDownClass(cls):
rclpy.shutdown()
def setUp(self):
self.node = rclpy.create_node("test_node")
def tearDown(self):
self.node.destroy_node()
def test_publisher_creation(self):
pub = self.node.create_publisher(String, "/test_topic", 10)
self.assertIsNotNone(pub)
pub.destroy()
def test_pubsub_round_trip(self):
received = []
sub = self.node.create_subscription(
String, "/echo", lambda msg: received.append(msg.data), 10
)
pub = self.node.create_publisher(String, "/echo", 10)
msg = String()
msg.data = "hello"
pub.publish(msg)
rclpy.spin_once(self.node, timeout_sec=1.0)
self.assertEqual(received, ["hello"])
sub.destroy()
pub.destroy()
if __name__ == "__main__":
unittest.main()pytest Fixtures for ROS 2
# test/conftest.py
import pytest
import rclpy
@pytest.fixture(scope="session", autouse=True)
def ros_context():
rclpy.init()
yield
rclpy.shutdown()
@pytest.fixture
def node(ros_context):
n = rclpy.create_node("pytest_node")
yield n
n.destroy_node()
# test/test_with_pytest.py
from std_msgs.msg import Int32
def test_param_declared(node):
node.declare_parameter("speed", 1.0)
v = node.get_parameter("speed").value
assert v == pytest.approx(1.0)
def test_timer_fires(node):
count = []
node.create_timer(0.01, lambda: count.append(1))
import rclpy as _r
for _ in range(5):
_r.spin_once(node, timeout_sec=0.02)
assert len(count) >= 3launch_testing — Integration Tests
launch_testing starts a real launch description (with real nodes and DDS) and runs test cases against the live system.
# test/test_talker_listener.py
import unittest
import launch
import launch_ros
import launch_testing
import launch_testing.actions
import launch_testing.markers
import pytest
import rclpy
from std_msgs.msg import String
@pytest.mark.launch_test
@launch_testing.markers.keep_alive
def generate_test_description():
talker = launch_ros.actions.Node(
package="demo_nodes_py",
executable="talker",
name="talker",
)
listener = launch_ros.actions.Node(
package="demo_nodes_py",
executable="listener",
name="listener",
)
return launch.LaunchDescription([
talker,
listener,
launch_testing.actions.ReadyToTest(),
]), {"talker": talker, "listener": listener}
class TestTalkerListener(unittest.TestCase):
@classmethod
def setUpClass(cls):
rclpy.init()
cls.node = rclpy.create_node("test_talker_listener")
@classmethod
def tearDownClass(cls):
cls.node.destroy_node()
rclpy.shutdown()
def test_receives_message(self):
msgs = []
sub = self.node.create_subscription(
String, "/chatter", lambda m: msgs.append(m.data), 10
)
end_time = self.node.get_clock().now().nanoseconds + int(5e9)
while self.node.get_clock().now().nanoseconds < end_time:
rclpy.spin_once(self.node, timeout_sec=0.1)
if msgs:
break
self.assertTrue(len(msgs) > 0, "No messages received on /chatter")
sub.destroy()
# Post-shutdown checks (run after nodes have exited)
@launch_testing.post_shutdown_test()
class TestTalkerListenerShutdown(unittest.TestCase):
def test_exit_codes(self, proc_info):
launch_testing.asserts.assertExitCodes(proc_info)# Run with launch_pytest directly launch_pytest test/test_talker_listener.py # Or via colcon test colcon test --packages-select demo_nodes_py
Registering Tests in CMakeLists.txt
# CMakeLists.txt (ament_cmake package) find_package(ament_lint_auto REQUIRED) ament_lint_auto_find_test_dependencies() if(BUILD_TESTING) find_package(ament_cmake_pytest REQUIRED) find_package(launch_testing_ament_cmake REQUIRED) # Python unit tests ament_add_pytest_test(test_my_node test/test_my_node.py) # launch_testing integration test add_launch_test(test/test_talker_listener.py) endif()
setup.cfg (Python packages)
# setup.cfg [tool:pytest] junit_family = xunit2 [colcon.package_descriptor_file_extensions] ros_package_xml = package.xml
GTest for C++ Nodes
// test/test_my_cpp_node.cpp
#include <gtest/gtest.h>
#include <rclcpp/rclcpp.hpp>
#include "my_pkg/my_node.hpp"
class TestMyNode : public ::testing::Test {
protected:
void SetUp() override {
rclcpp::init(0, nullptr);
node_ = std::make_shared<MyNode>();
}
void TearDown() override {
node_.reset();
rclcpp::shutdown();
}
std::shared_ptr<MyNode> node_;
};
TEST_F(TestMyNode, InitializesCorrectly) {
ASSERT_NE(node_, nullptr);
EXPECT_EQ(node_->get_name(), std::string("my_node"));
}
int main(int argc, char ** argv) {
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}# CMakeLists.txt — add GTest if(BUILD_TESTING) find_package(ament_cmake_gtest REQUIRED) ament_add_gtest(test_my_cpp_node test/test_my_cpp_node.cpp) target_link_libraries(test_my_cpp_node my_pkg_lib rclcpp::rclcpp) endif()
colcon test Workflow
# Build with tests enabled (default) colcon build --cmake-args -DBUILD_TESTING=ON # Run all tests colcon test # Run tests for one package colcon test --packages-select my_pkg # Show test results (verbose) colcon test-result --all --verbose # Run only a specific test file colcon test --packages-select my_pkg \ --pytest-args test/test_my_node.py # Fail-fast on first failure colcon test --event-handlers console_cohesion+
GitHub Actions CI Example
# .github/workflows/ros2-test.yml
name: ROS 2 Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
container:
image: ros:jazzy-ros-base
steps:
- uses: actions/checkout@v4
with:
path: ros2_ws/src/my_pkg
- name: Install dependencies
run: |
apt-get update
rosdep update
rosdep install --from-paths ros2_ws/src --ignore-src -y
- name: Build
run: |
. /opt/ros/jazzy/setup.sh
cd ros2_ws
colcon build --cmake-args -DBUILD_TESTING=ON
- name: Test
run: |
. /opt/ros/jazzy/setup.sh
cd ros2_ws
colcon test --return-code-on-test-failure
colcon test-result --all --verboseCommon Issues
launch_test timeout / ReadyToTest never fires
Node failed to start. Check the process output in --show-all; usually a missing param or wrong package name.
pytest can't find rclpy
Not sourced: add source /opt/ros/jazzy/setup.bash to your shell or Dockerfile CMD before pytest.
colcon test shows 0 tests found
Tests not registered in CMakeLists.txt / setup.py. Add ament_add_pytest_test() or add_launch_test() in BUILD_TESTING block.
GTest fails with 'rclcpp not initialized'
Call rclcpp::init() in SetUp() and rclcpp::shutdown() in TearDown(). Don't init in main() for node tests.
launch_testing: 'module not found' for test file
launch_pytest requires the test file to define generate_test_description(). Missing that function causes import error.
Next Steps
- Run tests in Docker containers for fully reproducible CI environments.
- Add coverage reporting:
colcon test --pytest-args --cov=src --cov-report=xml - Use
ament_flake8andament_mypyas lint tests inament_lint_auto.