Testing and Linting
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 receivedTwo 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 suiteWhat 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 foractiveis more reliable than a timer.post_shutdown_testwithassertExitCodesis 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_IDto something unusual andROS_AUTOMATIC_DISCOVERY_RANGEtoLOCALHOSTin 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 packagesWhat to get right regardless of the runner:
rosdep install --from-paths src --ignore-src -r -yin CI, always. It is what catches a dependency that is installed on your machine and missing frompackage.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 buildof 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 -sheadless, 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 --verboseas 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.