Skip to main content

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) >= 3

launch_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 --verbose

Common 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