Testing and Linting

Unit tests in pytest and gtest, launch_testing for whole-graph tests, the ament linters and what they enforce, and CI that runs all of it.
Author

Benedict Thekkel

The Three Levels

Robot code has three testable layers, and conflating them is why test suites end up slow and flaky.

Level Tests Needs
unit pure functions, message conversion, maths nothing; no ROS graph
node one node’s callbacks and parameters an rclpy/rclcpp context, no other nodes
integration several nodes launched together launch_testing, a real graph

Most of a suite should be the first level, because it is the only one that runs in milliseconds. A transform calculation, a PointCloud2 decode, a costmap inflation or a state machine is a pure function of its inputs and should be tested as one, with no node at all. The demonstrations in ../03_Spatial_and_Temporal/00_tf2.ipynb and ../06_Navigation_and_Manipulation/02_Costmaps_Planners_and_Controllers.ipynb are exactly that shape: the logic extracted from the node and exercised directly.

Extracting logic out of callbacks is what makes a node testable. A callback that converts, computes and publishes in twenty lines can only be tested by standing up a graph; the same code split into a pure function plus a three-line callback can be tested in a millisecond.


Python: pytest

my_pkg/
  test/
    test_geometry.py          # unit
    test_node.py              # node level
    test_copyright.py         # linters, generated by ros2 pkg create
    test_flake8.py
    test_pep257.py
# test/test_geometry.py - no ROS at all
import numpy as np
from my_pkg.geometry import wrap_angle

def test_wrap_angle():
    assert np.isclose(wrap_angle(3 * np.pi), np.pi)
    assert np.isclose(wrap_angle(-3 * np.pi), -np.pi)

Node-level, where the context handling is the part that matters:

import pytest, rclpy
from my_pkg.drive import Drive

@pytest.fixture
def node():
    rclpy.init()
    n = Drive()
    yield n
    n.destroy_node()
    rclpy.shutdown()            # both halves, or the next test fails on an existing context

def test_clamps_speed(node):
    node.set_parameters([rclpy.parameter.Parameter("max_speed", value=0.2)])
    assert node.compute_command(requested=1.0) == pytest.approx(0.2)

def test_publishes_on_scan(node):
    received = []
    node.create_subscription(Twist, "cmd_vel", received.append, 10)
    node.on_scan(make_scan(ranges=[5.0] * 360))
    rclpy.spin_once(node, timeout_sec=0.1)      # callbacks do not run without a spin
    assert received

Two things bite here. rclpy.init() and shutdown() must be paired per test (a fixture, not module scope), or a failed test leaves a live context and every subsequent test errors on “context already initialized”. And nothing is delivered without a spin: a test that publishes and immediately asserts the subscriber saw it will fail, because no executor ran. spin_once with a timeout is the idiom.

setup.py needs tests_require and the test directory installed; ros2 pkg create does this.


C++: gtest

if(BUILD_TESTING)
  find_package(ament_cmake_gtest REQUIRED)
  ament_add_gtest(test_geometry test/test_geometry.cpp)
  target_link_libraries(test_geometry ${PROJECT_NAME})

  find_package(ament_lint_auto REQUIRED)
  ament_lint_auto_find_test_dependencies()
endif()
#include <gtest/gtest.h>
#include "my_pkg/geometry.hpp"

TEST(Geometry, WrapsAngle) {
  EXPECT_NEAR(wrap_angle(3 * M_PI), M_PI, 1e-9);
}

int main(int argc, char ** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

ament_add_gtest registers the test with colcon. ament_lint_auto_find_test_dependencies() pulls in every linter declared in package.xml, which is how the linter tests appear without being written.


launch_testing

For a test that needs several nodes running, launch_testing launches them, runs assertions against the live graph, then shuts everything down.

# test/test_integration.launch.py
import unittest, pytest, rclpy
import launch, launch_ros, launch_testing
from launch_testing.actions import ReadyToTest

@pytest.mark.launch_test
def generate_test_description():
    drive = launch_ros.actions.Node(package="my_pkg", executable="drive", name="drive")
    return launch.LaunchDescription([drive, ReadyToTest()]), {"drive": drive}

class TestDrive(unittest.TestCase):
    def test_publishes_cmd_vel(self, proc_output):
        rclpy.init()
        node = rclpy.create_node("tester")
        received = []
        node.create_subscription(Twist, "/cmd_vel", received.append, 10)
        end = time.time() + 5.0
        while time.time() < end and not received:
            rclpy.spin_once(node, timeout_sec=0.1)
        self.assertTrue(received, "no cmd_vel within 5 s")
        node.destroy_node(); rclpy.shutdown()

@launch_testing.post_shutdown_test()
class TestExitCodes(unittest.TestCase):
    def test_clean_exit(self, proc_info):
        launch_testing.asserts.assertExitCodes(proc_info)
launch_test test/test_integration.launch.py          # directly, with output
colcon test --packages-select my_pkg                 # as part of the suite

What to know before investing in these:

  • They are slow and the flakiest thing in a suite. Every one pays process startup plus DDS discovery, so a timeout that passes on a workstation fails on a loaded CI runner. Poll with a generous deadline rather than sleeping a fixed time.
  • ReadyToTest() marks when the tests may start, and putting it before a node that needs time to come up is a common source of intermittent failure. A lifecycle node plus waiting for active is more reliable than a timer.
  • post_shutdown_test with assertExitCodes is the cheapest real test available: it catches nodes that crash on shutdown, which is a large class of bug that no unit test sees.
  • Isolate the domain. Set ROS_DOMAIN_ID to something unusual and ROS_AUTOMATIC_DISCOVERY_RANGE to LOCALHOST in CI, or parallel jobs on one machine join each other’s graphs and tests interfere in ways that look random. See ../07_Middleware_DDS/00_Discovery_and_RMW.ipynb.

The ament Linters

ROS 2 ships linters as tests, so style is enforced by colcon test rather than by review.

Linter Checks
ament_copyright a licence header in every file
ament_flake8 Python style (PEP 8)
ament_pep257 Python docstrings
ament_mypy optional type checking
ament_uncrustify C++ formatting
ament_cpplint C++ style (Google-derived)
ament_cppcheck C++ static analysis
ament_lint_cmake CMakeLists style
ament_xmllint package.xml and launch XML
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>

ament_lint_common is the bundle, and ament_lint_auto_find_test_dependencies() turns each into a test.

colcon test --packages-select my_pkg
colcon test-result --verbose           # the only command that tells you what actually ran
ament_uncrustify --reformat src/        # fix rather than report
ament_flake8 my_pkg/

colcon test reports success when no tests were discovered, which is the single most misleading thing in the ROS 2 build system: a package with a broken BUILD_TESTING block passes. colcon test-result --verbose is what distinguishes “0 tests, 0 failures” from “42 tests, 0 failures”, and it belongs in CI as a separate step.

A note on the copyright linter: it fails on files without a recognised header, which on a fresh package means every file you just wrote. Either add headers or remove ament_copyright from package.xml deliberately, rather than leaving a permanently red test.


CI

Two approaches, and the choice is mostly about how much of the ROS ecosystem you need.

ros-tooling/action-ros-ci, the lighter option, good for pure ROS 2 packages:

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-24.04
    container: ros:jazzy-ros-base
    steps:
      - uses: actions/checkout@v4
      - uses: ros-tooling/setup-ros@v0.7
      - uses: ros-tooling/action-ros-ci@v0.3
        with:
          package-name: my_pkg
          target-ros2-distro: jazzy
          colcon-defaults: |
            { "test": { "event-handlers": ["console_direct+"] } }

industrial_ci, heavier and more thorough: it builds in a clean container, runs rosdep from scratch, and can test against several distributions in a matrix. It is the right choice when the package has system dependencies or must support more than one distribution.

      - uses: ros-industrial/industrial_ci@master
        env:
          ROS_DISTRO: jazzy
          ROS_REPO: main              # or 'testing' for pre-release packages

What to get right regardless of the runner:

  • rosdep install --from-paths src --ignore-src -r -y in CI, always. It is what catches a dependency that is installed on your machine and missing from package.xml, which is the most common reason a green local build fails for everyone else. See ../02_Build_and_Tooling/00_Workspaces_and_Packages.ipynb.
  • Cap parallelism. A hosted runner has two cores and limited RAM; an unrestricted colcon build of a large workspace gets OOM-killed, which presents as an unexplained compiler crash. MAKEFLAGS="-j2" colcon build --parallel-workers 2.
  • No GPU and no display. Anything needing RViz or a rendering Gazebo will not run; use gz sim -s headless, and keep GPU-dependent tests out of CI. See ../04_Simulation_and_Hardware/00_Gazebo_and_Bridges.ipynb.
  • Isolate DDS, as above, or concurrent jobs interfere.
  • Run colcon test-result --verbose as its own step so a suite that discovered nothing fails the build rather than passing it.
  • Cache the apt and colcon state, or every run reinstalls the same hundred packages.

Back to top