Skip to main content

Writing tests in pytest

Pytest is the canonical way to write Python tests for Roboticks. The roboticks package adds four decorators and a set of rclpy-aware assertion helpers on top of stock pytest. Nothing else changes — your fixtures, your conftest.py, your parametrize idioms keep working.

The four decorators

DecoratorPurposeExample
@confirms(*req_ids)Records which requirement IDs this test verifies@confirms("REQ-014", "REQ-022")
@tags(*tags)Free-form labels for filtering (e.g. smoke, nightly)@tags("nightly", "perception")
@deadline(milliseconds=int)Fails the test if it exceeds the wall-clock budget@deadline(milliseconds=100)
@requires_sim(engine, *, gpu)Routes the test to a sim-capable runner@requires_sim("gazebo", gpu=True)
All four stack and can decorate the same test in any order. See the SDK decorator reference for full semantics.
from roboticks import confirms, tags, deadline, requires_sim

@confirms("REQ-014", "REQ-022")
@tags("nightly", "navigation")
@requires_sim("gazebo", gpu=True)
@deadline(milliseconds=2000)
def test_robot_avoids_static_obstacle():
    ...
Decorators are inert without the platform. Locally they only mark functions. The pytest plugin reads them at collection time and writes them into JUnit XML on session-end. See Pytest plugin.

Rclpy assertion helpers

The SDK ships ROS2-aware assertions in roboticks.assertions. They spin a node briefly, wait for the predicate, and raise an informative AssertionError on timeout. The helpers are guarded: importing the module on a host without rclpy installed raises a clear RuntimeError instead of an opaque ImportError.
from roboticks.assertions import (
    assert_topic_published,
    assert_service_response,
    assert_action_result,
    assert_param_equals,
    assert_tf_transform,
)
Full signatures live in the SDK assertion reference.

Subscribe-and-assert on /cmd_vel

import pytest
from geometry_msgs.msg import Twist
from roboticks import confirms, deadline
from roboticks.assertions import assert_topic_published

@confirms("REQ-007")
@deadline(milliseconds=5000)
def test_teleop_publishes_cmd_vel(ros_context):
    # ros_context is your conftest fixture that has rclpy.init()'d
    msg = assert_topic_published(
        topic="/cmd_vel",
        msg_type=Twist,
        within=3.0,                       # seconds
        predicate=lambda m: m.linear.x > 0.1,
    )
    assert msg.linear.x > 0.1
    assert msg.angular.z == pytest.approx(0.0, abs=0.01)

Service call response

from example_interfaces.srv import AddTwoInts
from roboticks import confirms
from roboticks.assertions import assert_service_response

@confirms("REQ-019")
def test_add_two_ints_service(ros_context):
    request = AddTwoInts.Request(a=2, b=3)
    response = assert_service_response(
        service="/add_two_ints",
        srv_type=AddTwoInts,
        request=request,
        within=2.0,
    )
    assert response.sum == 5

Action result

from nav2_msgs.action import NavigateToPose
from roboticks import confirms, requires_sim
from roboticks.assertions import assert_action_result

@confirms("REQ-031")
@requires_sim("gazebo")
def test_nav_to_pose_reaches_goal(ros_context):
    goal = NavigateToPose.Goal()
    goal.pose.pose.position.x = 2.0
    result = assert_action_result(
        action="/navigate_to_pose",
        action_type=NavigateToPose,
        goal=goal,
        within=45.0,
    )
    assert result.result.error_code == 0

Conftest pattern

Spin up rclpy once per test session, and once per test give every test a clean executor. This pattern works for unit-scope ROS tests; for system tests use launch_testing instead.
# conftest.py
import pytest
import rclpy
from rclpy.executors import SingleThreadedExecutor

@pytest.fixture(scope="session", autouse=True)
def _ros():
    rclpy.init()
    yield
    rclpy.shutdown()

@pytest.fixture
def ros_context():
    executor = SingleThreadedExecutor()
    yield executor
    executor.shutdown()
For tests that need a node-under-test running, layer it on:
@pytest.fixture
def perception_node(ros_context):
    from my_pkg.perception_node import PerceptionNode
    node = PerceptionNode()
    ros_context.add_node(node)
    yield node
    node.destroy_node()

Parametrize and @confirms

@confirms is per-test-function, not per-test-id. Parametrized variants all confirm the same requirement set — which is usually what you want:
import pytest
from roboticks import confirms

@confirms("REQ-042")
@pytest.mark.parametrize("velocity", [0.1, 0.5, 1.0, 2.0])
def test_velocity_clamp(velocity):
    ...
If a parametrized branch should confirm a different requirement, split it into two test functions. Decorators are deliberately not parametrize-aware — that ambiguity is what got teams in trouble with older tools.

What the plugin emits

Per test, in the JUnit XML:
<testcase name="test_estop_halts_motion" classname="tests.test_estop" time="0.082">
  <properties>
    <property name="roboticks.confirms" value="REQ-001,REQ-014"/>
    <property name="roboticks.tags" value="safety,smoke"/>
    <property name="roboticks.deadline_ms" value="100"/>
  </properties>
</testcase>
See Wire contract for the full schema and version handshake.

Next

Fault injection

Drop topics, delay messages, kill nodes — under a context manager.

MCAP capture

Record bag files per test for failure forensics.

Launch testing

System tests that bring up multiple nodes.

Decorator reference

Full signatures and edge cases.