Skip to content

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 both colcon test and pytest.
  • Listed in one place. tests/colcon_unit_test_packages.yaml lists which packages have unit tests. tests/conftest.py resolves each to its test/ dir and collects the non-linter test_*.py files under --import-mode=importlib. To add a package's unit tests, list it in that YAML.
  • @pytest.mark.unit on every test, applied for you. conftest.py marks items by file location, so test sources should not declare it themselves. The unit mark 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.unitconftest.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:

airstack test -m unit -v

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:

<test_depend>ament_cmake_gtest</test_depend>

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 — robotrobot/ros_ws/src/**/<pkg>/test, simsimulation/**/<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