$npx -y skills add arpitg1304/robotics-agent-skills --skill robotics-testingTesting strategies, patterns, and tools for robotics software. Use this skill when writing unit tests, integration tests, simulation tests, or hardware-in-the-loop tests for robot systems. Trigger whenever the user mentions testing ROS nodes, pytest with ROS, launch_testing, simu
| 1 | # Robotics Testing Skill |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | - Writing unit tests for ROS1/ROS2 nodes |
| 5 | - Setting up integration tests with launch_testing |
| 6 | - Mocking hardware (sensors, actuators) for CI/CD |
| 7 | - Building simulation-based test suites |
| 8 | - Testing perception pipelines with ground truth |
| 9 | - Validating trajectory planners and controllers |
| 10 | - Setting up CI/CD pipelines for robotics packages |
| 11 | - Debugging flaky tests in robotics systems |
| 12 | |
| 13 | ## The Robotics Testing Pyramid |
| 14 | |
| 15 | ``` |
| 16 | ╱╲ |
| 17 | ╱ ╲ Field Tests |
| 18 | ╱ ╲ (Real robot, real environment) |
| 19 | ╱──────╲ |
| 20 | ╱ ╲ Hardware-in-the-Loop (HIL) |
| 21 | ╱ ╲ (Real hardware, controlled environment) |
| 22 | ╱────────────╲ |
| 23 | ╱ ╲ Simulation Tests |
| 24 | ╱ ╲ (Full sim, realistic physics) |
| 25 | ╱──────────────────╲ |
| 26 | ╱ ╲ Integration Tests |
| 27 | ╱ ╲ (Multi-node, message passing) |
| 28 | ╱────────────────────────╲ |
| 29 | ╱ ╲ Unit Tests |
| 30 | ╱____________________________╲ (Single function/class, fast, deterministic) |
| 31 | |
| 32 | MORE tests at the bottom, FEWER at the top. |
| 33 | Bottom = fast, cheap, deterministic. Top = slow, expensive, realistic. |
| 34 | ``` |
| 35 | |
| 36 | ## Unit Testing Patterns |
| 37 | |
| 38 | ### Testing ROS2 Nodes with pytest |
| 39 | |
| 40 | ```python |
| 41 | # test_perception_node.py |
| 42 | import pytest |
| 43 | import rclpy |
| 44 | from rclpy.node import Node |
| 45 | from sensor_msgs.msg import Image |
| 46 | from my_pkg.perception_node import PerceptionNode |
| 47 | import numpy as np |
| 48 | |
| 49 | @pytest.fixture(scope='module') |
| 50 | def ros_context(): |
| 51 | """Initialize ROS2 context once per test module""" |
| 52 | rclpy.init() |
| 53 | yield |
| 54 | rclpy.shutdown() |
| 55 | |
| 56 | @pytest.fixture |
| 57 | def perception_node(ros_context): |
| 58 | """Create a fresh perception node for each test""" |
| 59 | node = PerceptionNode() |
| 60 | yield node |
| 61 | node.destroy_node() |
| 62 | |
| 63 | @pytest.fixture |
| 64 | def test_image(): |
| 65 | """Generate a synthetic test image""" |
| 66 | msg = Image() |
| 67 | msg.height = 256 |
| 68 | msg.width = 256 |
| 69 | msg.encoding = 'rgb8' |
| 70 | msg.step = 256 * 3 |
| 71 | msg.data = np.random.randint(0, 255, (256, 256, 3), |
| 72 | dtype=np.uint8).tobytes() |
| 73 | return msg |
| 74 | |
| 75 | class TestPerceptionNode: |
| 76 | |
| 77 | def test_initialization(self, perception_node): |
| 78 | """Node should initialize with correct default parameters""" |
| 79 | assert perception_node.get_parameter('confidence_threshold').value == 0.7 |
| 80 | assert perception_node.get_parameter('rate_hz').value == 30.0 |
| 81 | |
| 82 | def test_parameter_validation(self, perception_node): |
| 83 | """Node should reject invalid parameter values""" |
| 84 | from rcl_interfaces.msg import SetParametersResult |
| 85 | result = perception_node.set_parameters([ |
| 86 | rclpy.parameter.Parameter('confidence_threshold', |
| 87 | value=-0.5) # Invalid! |
| 88 | ]) |
| 89 | assert not result[0].successful |
| 90 | |
| 91 | def test_image_callback_publishes_detections(self, perception_node, test_image): |
| 92 | """Processing an image should produce detection output""" |
| 93 | received = [] |
| 94 | |
| 95 | # Create a test subscriber |
| 96 | sub_node = Node('test_subscriber') |
| 97 | sub_node.create_subscription( |
| 98 | DetectionArray, '/perception/detections', |
| 99 | lambda msg: received.append(msg), 10) |
| 100 | |
| 101 | # Simulate image callback |
| 102 | perception_node.image_callback(test_image) |
| 103 | |
| 104 | # Spin briefly to allow message propagation |
| 105 | rclpy.spin_once(sub_node, timeout_sec=1.0) |
| 106 | rclpy.spin_once(perception_node, timeout_sec=1.0) |
| 107 | |
| 108 | # Verify |
| 109 | assert len(received) > 0 |
| 110 | sub_node.destroy_node() |
| 111 | |
| 112 | def test_empty_image_handling(self, perception_node): |
| 113 | """Node should handle empty/corrupted images gracefully""" |
| 114 | empty_msg = Image() # No data |
| 115 | # Should not crash |
| 116 | perception_node.image_callback(empty_msg) |
| 117 | ``` |
| 118 | |
| 119 | ### Testing Pure Functions (No ROS Dependency) |
| 120 | |
| 121 | ```python |
| 122 | # test_kinematics.py |
| 123 | import pytest |
| 124 | import numpy as np |
| 125 | from my_pkg.kinematics import ( |
| 126 | forward_kinematics, inverse_kinematics, |
| 127 | quaternion_multiply, transform_point |
| 128 | ) |
| 129 | |
| 130 | class TestForwardKinematics: |
| 131 | |
| 132 | @pytest.mark.parametrize("joint_angles,expected_pos", [ |
| 133 | # Home position |
| 134 | (np.zeros(7), np.array([0.088, 0.0, 1.033])), |
| 135 | # Known calibrated pose |
| 136 | (np |