ROS2 Testing: How to Test Robot Software with pytest, Launch Tests, and Simulation
Robot software is some of the hardest software to test. It's concurrent, time-sensitive, hardware-dependent, and the behavior emerges from complex interactions between perception, planning, and control. Yet untested robot software fails in the real world — sometimes expensively.
ROS2 (Robot Operating System 2) provides a testing framework that's underused by most teams. This guide covers practical ROS2 testing: unit tests for nodes, integration tests with launch testing, and simulation-based testing with Gazebo.
ROS2 Testing Architecture
ROS2 provides three testing levels:
- Unit tests — test individual nodes in isolation, mock topics and services
- Launch tests — spin up real ROS2 nodes, test their interactions
- Simulation tests — run the full stack in Gazebo simulation
Setting Up Testing
# Install testing dependencies
sudo apt install ros-humble-launch-testing
pip install pytest-ros pytest-asyncioUnit Testing ROS2 Nodes
Unit test a node by spinning it in isolation with mocked I/O:
# tests/test_obstacle_detector.py
import pytest
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
from std_msgs.msg import Bool
from my_robot.obstacle_detector import ObstacleDetectorNode
import numpy as np
@pytest.fixture(scope="session")
def ros_context():
rclpy.init()
yield
rclpy.shutdown()
@pytest.fixture
def node(ros_context):
node = ObstacleDetectorNode()
yield node
node.destroy_node()
def make_laser_scan(ranges, angle_min=-1.57, angle_max=1.57):
"""Helper to create a LaserScan message."""
msg = LaserScan()
msg.header.frame_id = "laser"
msg.angle_min = angle_min
msg.angle_max = angle_max
msg.angle_increment = (angle_max - angle_min) / len(ranges)
msg.range_min = 0.1
msg.range_max = 10.0
msg.ranges = [float(r) for r in ranges]
return msg
def test_no_obstacle_when_clear(node):
"""Node should not detect obstacle when path is clear."""
scan = make_laser_scan([5.0] * 100) # All readings at 5m
result = node.process_scan(scan)
assert result.data == False, "Should not detect obstacle at 5m"
def test_obstacle_detected_close_range(node):
"""Node should detect obstacle within safety threshold."""
scan = make_laser_scan([0.3] * 100) # All readings at 0.3m
result = node.process_scan(scan)
assert result.data == True, "Should detect obstacle at 0.3m"
def test_single_close_reading_triggers_detection(node):
"""Even a single close reading in the frontal arc should trigger."""
ranges = [5.0] * 100
ranges[50] = 0.4 # One close reading in front
scan = make_laser_scan(ranges)
result = node.process_scan(scan)
assert result.data == True, "Single close frontal reading should trigger obstacle"
def test_close_reading_behind_robot_ignored(node):
"""Readings behind the robot should not trigger frontal obstacle detection."""
# Frontal arc: -0.5 to +0.5 rad
# Reading at -1.5 rad = behind/side
ranges = [5.0] * 100
ranges[0] = 0.3 # Close reading at -1.57 rad (far left/behind)
scan = make_laser_scan(ranges)
result = node.process_scan(scan)
assert result.data == False, "Rear reading should not trigger frontal obstacle"
def test_nan_ranges_handled_gracefully(node):
"""NaN ranges from sensor must not crash the node."""
ranges = [float('nan')] * 100
scan = make_laser_scan(ranges)
# Should not raise an exception
result = node.process_scan(scan)
assert result is not NoneIntegration Testing with Launch Testing
Launch tests spin up real ROS2 nodes and test their pub/sub interactions:
# tests/test_navigation_integration.launch.py
import unittest
import pytest
import rclpy
from launch import LaunchDescription
from launch_ros.actions import Node
from launch_testing.actions import ReadyToTest
import launch_testing
def generate_launch_description():
"""Launch the nodes under test."""
obstacle_detector = Node(
package="my_robot",
executable="obstacle_detector",
name="obstacle_detector",
parameters=[{"safety_distance": 0.5}],
)
nav_controller = Node(
package="my_robot",
executable="navigation_controller",
name="navigation_controller",
)
return LaunchDescription([
obstacle_detector,
nav_controller,
ReadyToTest(),
])
class TestNavigationIntegration(unittest.TestCase):
@classmethod
def setUpClass(cls):
rclpy.init()
cls.node = rclpy.create_node("test_node")
@classmethod
def tearDownClass(cls):
cls.node.destroy_node()
rclpy.shutdown()
def test_nodes_alive(self, proc_info):
"""Both nodes should be running."""
self.assertIsNotNone(
self.node.get_service_names_and_types(),
"ROS2 graph should be reachable"
)
def test_obstacle_stops_navigation(self, proc_info):
"""Publishing obstacle detection should stop the robot."""
import time
from std_msgs.msg import Bool
from geometry_msgs.msg import Twist
velocity_commands = []
# Subscribe to velocity commands
sub = self.node.create_subscription(
Twist, "/cmd_vel",
lambda msg: velocity_commands.append(msg),
10
)
# Publish obstacle detected
pub = self.node.create_publisher(Bool, "/obstacle_detected", 10)
time.sleep(0.5)
pub.publish(Bool(data=True))
time.sleep(1.0)
rclpy.spin_once(self.node, timeout_sec=0.1)
# Check that velocity commands went to zero
if velocity_commands:
last_cmd = velocity_commands[-1]
assert last_cmd.linear.x == 0.0, "Linear velocity should stop on obstacle"
assert last_cmd.angular.z == 0.0, "Angular velocity should stop on obstacle"
@pytest.mark.launch(generate_launch_description)
class TestNavigationIntegrationLaunch(TestNavigationIntegration):
passRun launch tests:
ros2 launch my_robot_tests test_navigation_integration.launch.py
# or via pytest
pytest tests/test_navigation_integration.launch.py -vTesting Services and Actions
# tests/test_robot_services.py
import pytest
import rclpy
from rclpy.action import ActionClient
from my_robot_interfaces.action import Navigate
from my_robot_interfaces.srv import GetPose
@pytest.fixture
def test_node():
rclpy.init()
node = rclpy.create_node("service_test_node")
yield node
node.destroy_node()
rclpy.shutdown()
def test_get_pose_service_available(test_node):
"""GetPose service must be available."""
client = test_node.create_client(GetPose, "/get_robot_pose")
available = client.wait_for_service(timeout_sec=5.0)
assert available, "GetPose service not available after 5 seconds"
def test_get_pose_returns_valid_response(test_node):
"""GetPose should return a valid pose."""
import asyncio
client = test_node.create_client(GetPose, "/get_robot_pose")
client.wait_for_service(timeout_sec=5.0)
request = GetPose.Request()
future = client.call_async(request)
rclpy.spin_until_future_complete(test_node, future, timeout_sec=5.0)
assert future.done(), "Service call did not complete"
response = future.result()
# Pose values should be finite numbers
assert -1e6 < response.pose.position.x < 1e6
assert -1e6 < response.pose.position.y < 1e6Simulation-Based Testing with Gazebo
For full integration tests without physical hardware:
# tests/test_gazebo_integration.py
import pytest
import subprocess
import time
import rclpy
from geometry_msgs.msg import PoseWithCovarianceStamped
@pytest.fixture(scope="session")
def gazebo_simulation():
"""Start Gazebo simulation and yield when ready."""
# Launch Gazebo with test world
proc = subprocess.Popen([
"ros2", "launch", "my_robot_sim",
"test_world.launch.py",
"gui:=false", # Headless for CI
])
# Wait for simulation to be ready
time.sleep(10) # Gazebo startup time
yield proc
proc.terminate()
proc.wait()
def test_robot_reaches_goal(gazebo_simulation):
"""Robot should navigate to a goal pose."""
rclpy.init()
nav_node = rclpy.create_node("navigation_test")
goal_reached = []
def pose_callback(msg):
x = msg.pose.pose.position.x
y = msg.pose.pose.position.y
# Check if within 0.3m of goal (3.0, 2.0)
if abs(x - 3.0) < 0.3 and abs(y - 2.0) < 0.3:
goal_reached.append(True)
nav_node.create_subscription(
PoseWithCovarianceStamped,
"/amcl_pose",
pose_callback,
10
)
# Send navigation goal
from my_robot_interfaces.action import Navigate
from rclpy.action import ActionClient
action_client = ActionClient(nav_node, Navigate, "/navigate_to_pose")
action_client.wait_for_server(timeout_sec=10.0)
goal = Navigate.Goal()
goal.target_pose.pose.position.x = 3.0
goal.target_pose.pose.position.y = 2.0
future = action_client.send_goal_async(goal)
# Wait for robot to reach goal (30 second timeout)
deadline = time.time() + 30
while not goal_reached and time.time() < deadline:
rclpy.spin_once(nav_node, timeout_sec=0.5)
nav_node.destroy_node()
rclpy.shutdown()
assert goal_reached, "Robot did not reach goal within 30 seconds"Testing Message Timing and Rates
For real-time robot systems, message rates matter:
# tests/test_message_timing.py
import time
import rclpy
from sensor_msgs.msg import Imu
def test_imu_publishes_at_correct_rate(ros_node):
"""IMU should publish at 100Hz (±10%)."""
timestamps = []
def callback(msg):
timestamps.append(time.time())
sub = ros_node.create_subscription(Imu, "/imu/data", callback, 100)
# Collect 2 seconds of data
start = time.time()
while time.time() - start < 2.0:
rclpy.spin_once(ros_node, timeout_sec=0.01)
assert len(timestamps) >= 10, f"Only received {len(timestamps)} IMU messages in 2s"
# Compute actual rate
duration = timestamps[-1] - timestamps[0]
actual_rate = (len(timestamps) - 1) / duration
expected_rate = 100.0
assert abs(actual_rate - expected_rate) / expected_rate <= 0.10, \
f"IMU rate {actual_rate:.1f} Hz, expected {expected_rate:.0f} ± 10%"
def test_camera_latency(ros_node):
"""Camera image should arrive within 100ms of capture."""
from sensor_msgs.msg import Image
latencies = []
def callback(msg):
now = ros_node.get_clock().now()
msg_time = rclpy.time.Time.from_msg(msg.header.stamp)
latency_ms = (now - msg_time).nanoseconds / 1e6
latencies.append(latency_ms)
sub = ros_node.create_subscription(Image, "/camera/image_raw", callback, 10)
start = time.time()
while time.time() - start < 3.0:
rclpy.spin_once(ros_node, timeout_sec=0.01)
if not latencies:
pytest.skip("No camera images received")
avg_latency = sum(latencies) / len(latencies)
assert avg_latency <= 100, \
f"Average camera latency {avg_latency:.1f}ms exceeds 100ms budget"CI/CD for ROS2
# .github/workflows/ros2-tests.yml
name: ROS2 Tests
on:
push:
paths: ['src/**', 'tests/**']
jobs:
unit-tests:
runs-on: ubuntu-22.04
container: ros:humble
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
source /opt/ros/humble/setup.bash
rosdep install --from-paths src --ignore-src -r -y
- name: Build
run: |
source /opt/ros/humble/setup.bash
colcon build --packages-select my_robot
- name: Run unit tests
run: |
source /opt/ros/humble/setup.bash
source install/setup.bash
colcon test --packages-select my_robot
colcon test-result --verbose
simulation-tests:
runs-on: ubuntu-22.04
container: ros:humble-ros-base # + Gazebo
steps:
- uses: actions/checkout@v4
- name: Run simulation tests (headless)
run: |
source /opt/ros/humble/setup.bash
export DISPLAY=:99
Xvfb :99 -screen 0 1024x768x24 &
pytest tests/test_gazebo_integration.py -v --timeout=120Summary
ROS2 provides a full testing framework — most teams just don't use it:
- Unit test nodes — mock topics and services, test processing logic in isolation
- Launch tests — spin up real nodes and test their pub/sub interactions
- Simulation tests — full-stack validation in Gazebo, no hardware required
- Test timing — message rates and latencies are functional requirements, verify them
- CI/CD — run tests on every commit with GitHub Actions + ROS Docker containers
Robot software that isn't tested is robot software that fails in the field. Use HelpMeTest to schedule simulation regression tests and alert your team when navigation or perception regressions appear.