Unit Testing¶
AirStack unit tests are fast, hermetic, and purely Python — no Docker stack, no GPU, no running containers. They run locally in seconds via airstack test -m unit and automatically on every update to PRs targeting main or develop through unit-tests.yml.
Design principles¶
- Co-located with source. Test files live in
<package>/test/alongside the code they test. This is the standard ROS 2 / colcon convention and ensures tests are discovered by bothcolcon testandpytest. - Listed in one place.
tests/colcon_unit_test_packages.yamllists which packages have unit tests.tests/conftest.pyresolves each to itstest/dir and collects the non-lintertest_*.pyfiles under--import-mode=importlib. To add a package's unit tests, list it in that YAML. @pytest.mark.uniton every test, applied for you.conftest.pymarks items by file location, so test sources should not declare it themselves. Theunitmark keeps unit tests isolated from system tests that need Docker, GPUs, and sim licenses.
Repository layout¶
robot/ros_ws/src/
└── <layer>/<package>/
├── src/ # production source
└── test/
├── test_<name>.py # unit test source ← canonical location (collected directly)
├── test_<name>.cpp # C++ gtest source (optional)
└── fake_<name>.hpp # C++ test doubles (optional)
tests/
└── colcon_unit_test_packages.yaml # lists the packages whose test/ dirs are collected
Collected items point straight at the co-located source:
../robot/ros_ws/src/sensors/lidar_point_cloud_filter/test/test_validation_core.py::test_validate_filtered_ranges_ok PASSED
Running unit tests¶
# Locally — no container or Docker stack required
airstack test -m unit -v
# Or directly with pytest
export AIRSTACK_ROOT=$(pwd)
pip install -r tests/requirements.txt
pytest tests/ -m unit -v
The current suite completes in about 20 seconds on a developer workstation.
CI¶
The two languages take different runners because C++ needs a build and Python does
not. A gtest is a binary compiled against the package's headers and rclcpp, so it only
runs where the ROS toolchain is — colcon test inside the robot container. Python unit
tests stub ROS at the import boundary and touch no ROS runtime, so they need neither a
build nor a container, which keeps the whole suite in the fast feedback tier. Both are
gated in CI:
| Test | Runner | In CI via |
|---|---|---|
| C++ gtest | colcon test inside the robot container |
the build_packages mark (tests/system/test_build_packages.py::test_colcon_test_robot) |
Python, ament_python package |
root harness and colcon test |
unit-tests.yml and build_packages |
Python, ament_cmake package |
root harness only | unit-tests.yml |
colcon test picks up Python tests only when the package's build type makes it: an
ament_python package like lidar_point_cloud_filter exposes them through
setup.cfg (testpaths = test), while an ament_cmake package would need an
explicit ament_add_pytest_test — without it, its Python tests reach CI only
through the root harness.
Python unit tests are collected by unit-tests.yml's pytest tests/ -m unit
invocation on PR open, synchronize, and reopen. That job uses GitHub-hosted
ubuntu-latest; it does not queue for an OSMO GPU. The OSMO system-tests.yml
invocation uses the same safe tests/ collection boundary, but mark filtering may
deselect unit tests for targeted build/simulation runs. Run the same gate locally with:
airstack test -m unit -v
# or directly (requires tests/requirements.txt installed):
AIRSTACK_ROOT=$(pwd) pytest tests/ -m unit -v
Current test coverage¶
| Package | Test file | What is covered |
|---|---|---|
lidar_point_cloud_filter |
sensors/lidar_point_cloud_filter/test/test_validation_core.py |
Pure-numpy LiDAR range validation rules |
(Unit tests for module-owned packages — e.g. natnet_ros2 in the
asm_optitrack module — live in
the module repo and run in its CI.)
Adding a new unit test¶
Python¶
1. Write the test source in the package:
# robot/ros_ws/src/<layer>/<package>/test/test_my_module.py
import sys
from pathlib import Path
# Make the package importable without a colcon install
_src = Path(__file__).resolve().parent.parent / "src"
if str(_src) not in sys.path:
sys.path.insert(0, str(_src))
from my_module import my_function # noqa: E402
def test_basic():
assert my_function(1, 2) == 3
No @pytest.mark.unit — conftest.py applies it by file location. Import pytest
only if you need its API (approx, raises, parametrize, importorskip).
If the production code inherits from rclpy.node.Node, stub ROS at the import
boundary:
import sys
from unittest.mock import MagicMock
class _FakeNode:
def __init__(self, name): pass
def get_logger(self): return MagicMock()
def declare_parameter(self, *a, **kw): pass
def get_parameter(self, name):
m = MagicMock(); m.value = MagicMock(); return m
def create_subscription(self, *a, **kw): return MagicMock()
def create_publisher(self, *a, **kw): return MagicMock()
_rclpy_node_mod = MagicMock()
_rclpy_node_mod.Node = _FakeNode
sys.modules.setdefault("rclpy", MagicMock())
sys.modules["rclpy.node"] = _rclpy_node_mod
# ... then import your module
2. Register the package in tests/colcon_unit_test_packages.yaml:
robot:
packages:
- <your_package> # ← add here; conftest.py collects <pkg>/test/test_*.py
pytest_args: [] # forwarded to colcon via PYTEST_ADDOPTS; `-m` is ignored there
That's the whole registration. If the test imports package code, set up sys.path at the
top of the test file — see test_validation_core.py, which inserts its package root.
--import-mode=importlib (set in pytest.ini) means duplicate test_*.py basenames
across packages don't collide.
3. Verify:
C++ (gtest)¶
C++ tests live entirely in the package and run via colcon test.
CMakeLists.txt:
if(BUILD_TESTING)
find_package(ament_cmake_gtest REQUIRED)
ament_add_gtest(test_my_name test/test_my_name.cpp)
target_include_directories(test_my_name PRIVATE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/test>)
endif()
package.xml:
Run inside the robot container:
docker exec airstack-robot-desktop-1 bash -c \
"bws --cmake-args '-DBUILD_TESTING=ON' --packages-select <package>"
docker exec airstack-robot-desktop-1 bash -c \
"colcon test --packages-select <package> --event-handlers console_direct+"
The build_packages CI job (tests/system/test_build_packages.py) also runs
colcon test with BUILD_TESTING=ON so C++ gtests are gated in CI as well.
Extending to sim and GCS¶
The mechanism extends to other components via the same YAML. tests/harness/discovery.py
(_WORKSPACE_PKG_TEST_GLOBS) maps each workspace key to a source glob — robot →
robot/ros_ws/src/**/<pkg>/test, sim → simulation/**/<pkg>/test. Add a sim: (or a
new gcs:) workspace to the YAML, adding the glob for a new tree in tests/harness/discovery.py:
# tests/colcon_unit_test_packages.yaml
sim:
packages:
- <isaac_extension_name> # → simulation/**/<ext>/test collected directly
pytest tests/ -m unit discovers them automatically — no changes to pytest.ini
or CI needed.
See also¶
.agents/skills/add-unit-tests— step-by-step agent workflow- System tests — full Docker-stack integration tests
- CI/CD — pipeline overview and ephemeral runner architecture
- System test suite reference — marks, fixtures, CLI options