Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 62 additions & 27 deletions .agents/skills/add-unit-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: add-unit-tests
description: Add Python or C++ unit tests to an AirStack ROS 2 package. Covers the co-location pattern (test source in package/test/), registering the package in colcon_unit_test_packages.yaml so pytest tests/ and airstack test -m unit collect it, and how to extend to sim and GCS modules.
description: Add Python or C++ unit tests to an AirStack ROS 2 package. Covers the co-location pattern (test source in package/test/), registering the package in colcon_unit_test_packages.yaml so pytest tests/ and airstack test -m unit collect it, and how to extend to sim components.
license: MIT
metadata:
author: AirLab CMU
Expand All @@ -15,17 +15,17 @@ Use this skill when:

- Adding Python unit tests for a ROS 2 package (perception, sensors, local, global, behavior, interface)
- Adding C++ unit tests (`gtest`) to a package already using `ament_cmake`
- Extending unit tests to sim-side Python (`tests/sim/`) or GCS modules (`tests/gcs/`)
- Verifying that `airstack test -m unit` and `pytest tests/` (CI) pick up your new tests
- Extending unit tests to sim-side Python (`simulation/**/<extension>/test/`)
- Verifying that `airstack test -m unit` picks up your new tests

For system tests (full Docker stack, sim, sensors, takeoff/hover/land) see the
`run-system-tests` skill instead.

## Architecture Overview

Unit test **source lives co-located with its package** (ROS 2 / colcon convention).
`tests/colcon_unit_test_packages.yaml` lists which packages have unit tests, and
`pytest tests/` collects them from there — you only edit files under the package itself.
`tests/colcon_unit_test_packages.yaml` lists which packages have unit tests, and the root
harness collects them from there — you only edit files under the package itself.

```
robot/ros_ws/src/<layer>/<package>/
Expand All @@ -47,10 +47,37 @@ auto-tagged `@pytest.mark.unit` by path, so `-m unit` selects it. ament lint fil

| Invocation | What runs |
|---|---|
| `pytest tests/ -m unit` | Package `test/test_*.py`, collected directly from source |
| `airstack test -m unit` | Same path |
| CI `system-tests.yml` (PR open / approved) | Same path via `pytest tests/` |
| `colcon test --packages-select <pkg>` | Real test in `package/test/` (incl. linters + C++) |
| `airstack test -m unit` | Package `test/test_*.py`, collected directly from source |
| `cd tests && pytest -m unit` | Same path — the containerless equivalent |
| `pytest tests/ -m unit` | Same path — what CI runs |
| `colcon test --packages-select <pkg>` | C++ gtests and linters; Python only for `ament_python` packages (see below) |

**Two runners, split by language — because C++ needs a build and Python does not.** A
gtest is a binary: it must be compiled against the package's headers and rclcpp, so it can
only run where the ROS toolchain is. That is `colcon test` inside the robot container,
which CI reaches via the **`build_packages`** mark
(`tests/system/test_build_packages.py::test_colcon_test_robot`, which builds with
`-DBUILD_TESTING=ON` first). Python unit tests are deliberately hermetic — they stub ROS
at the import boundary and touch no ROS runtime — so they need no build and no container,
which is what lets the root harness run all of them in about a second.

Preserve that property when adding tests: a Python test that needs a live ROS node belongs
in `tests/integration/` or `tests/system/`, not here.

Whether `colcon test` *also* picks up a package's Python tests depends on its build type:

| Package | Build type | Python tests under `colcon test` |
|---|---|---|
| `natnet_ros2` | `ament_cmake` | **No** — `CMakeLists.txt` registers `ament_add_gtest` but no `ament_add_pytest_test` |
| `lidar_point_cloud_filter` | `ament_python` | **Yes** — `setup.cfg` sets `testpaths = test`, so colcon's pytest runner finds them |

So a Python test in an `ament_cmake` package runs *only* via the root harness — which is
fine, since that is what CI invokes.

Naming a path *below* `tests/` narrows the run and skips the injection, so
`pytest tests/system/test_x.py` stays fast and does not drag in unit tests. The rule lives
in `harness.discovery.collection_is_broad` and is pinned by
`tests/meta/test_collection_contract.py`.

## Step-by-Step: Adding a Python Unit Test

Expand Down Expand Up @@ -87,13 +114,17 @@ if str(_src) not in sys.path:
from my_module import my_function # noqa: E402


@pytest.mark.unit
def test_my_function_basic():
assert my_function(1, 2) == 3
```

**Key points:**
- Always decorate with `@pytest.mark.unit` — this is the filter for fast runs.
- **Do not write `@pytest.mark.unit`.** `pytest_itemcollected` in `tests/conftest.py`
applies it by file location to everything under a registered package's `test/` dir.
Writing it by hand is redundant, and it warns (`PytestUnknownMarkWarning`) under any
invocation where `tests/pytest.ini` is not the configfile — e.g. `colcon test`.
- Import `pytest` only if you need its API (`approx`, `raises`, `parametrize`,
`importorskip`).
- Compute paths relative to `__file__` (`parent.parent / "src"`) — never hardcode
absolute paths.
- For packages with a Python module directory (`<pkg>/<pkg>/`), add the package
Expand Down Expand Up @@ -127,35 +158,39 @@ robot:
- natnet_ros2
- lidar_point_cloud_filter
- <your_package> # ← add here
pytest_args: "-m not linter"
pytest_args: []
```

Leave `pytest_args` empty. It is forwarded to `colcon test` via `PYTEST_ADDOPTS`, and
ament's pytest runner ignores `-m` there — a marker expression in this field silently
does nothing.

That's the whole registration. `conftest.py` globs
`robot/ros_ws/src/**/<your_package>/test`, collects its non-linter `test_*.py`, and marks
them `unit`. The test file must be self-contained: if it imports package code, set up
`sys.path` at the top of the test file (see `test_validation_core.py`, which inserts its
package root). Same YAML, different workspace key (`sim:`), for Isaac-extension unit tests.

### 5. Run locally to verify
### 4. Run locally to verify

```bash
# From repo root — no container needed
cd tests
pytest -m unit -v
# or
airstack test -m unit -v
# or, containerless:
AIRSTACK_ROOT=$(pwd) pytest tests/ -m unit -v
```

All 14+ existing tests plus your new ones should pass. Collected items point straight
All 155 existing tests plus your new ones should pass. Collected items point straight
at the co-located source:
```
../robot/ros_ws/src/<layer>/<package>/test/test_<name>.py::test_my_function_basic PASSED
```

### 6. CI picks it up automatically
### 5. Running in CI

Unit tests are discovered by `pytest tests/` and run as part of `system-tests.yml`
(triggered on PR open) — no changes to CI needed.
Unit tests ride along with every `system-tests.yml` run — it invokes `pytest tests/`,
which collects them. That workflow triggers on PR open, a `/pytest` comment, or
`workflow_dispatch` — deliberately not on every push, since the same run also drives the
GPU system tests. Run them locally in the meantime.

---

Expand Down Expand Up @@ -242,11 +277,11 @@ sim:
| Where does test source live? | `<component>/…/<package>/test/` (co-located with the package) |
| Where does pytest discover tests? | From the package `test/` dir listed in `colcon_unit_test_packages.yaml` |
| How are duplicate basenames handled? | `--import-mode=importlib` (set in `pytest.ini`) |
| What mark do all unit tests use? | `@pytest.mark.unit` (auto-applied by path in `conftest.py`) |
| What CI workflow runs them? | `system-tests.yml` — runs `pytest tests/` which includes unit tests |
| When does that workflow trigger? | PR opened, `/pytest` comment, `workflow_dispatch` |
| What mark do all unit tests use? | `@pytest.mark.unit` auto-applied by path in `conftest.py`; do not write it yourself |
| How do I run them? | `airstack test -m unit`, `cd tests && pytest -m unit`, or `pytest tests/ -m unit` |
| What CI workflow runs them? | `system-tests.yml`, via `pytest tests/` — see §5 |
| Do system tests (`liveliness`, etc.) run too? | No — `-m unit` filters to hermetic tests only |
| Does `colcon test` also run these? | Yes — Python tests in `package/test/` are discovered by colcon's pytest runner |
| Does `colcon test` also run these? | Only if the package registers them. `ament_add_gtest` covers C++; a Python test needs `ament_add_pytest_test`, which `natnet_ros2` does **not** have — its Python tests run only under the root harness |
| Can I add pure C++ gtests? | Yes — `ament_add_gtest` in CMakeLists.txt |

## Reference Implementations
Expand All @@ -261,8 +296,8 @@ Both are collected from their package `test/` dir.

## Files to Know

- `.github/workflows/system-tests.yml` — CI workflow (runs `pytest tests/` including unit tests)
- `tests/pytest.ini` — mark registration + `--import-mode=importlib`
- `.airstack/modules/dev.sh` — what `airstack test` runs (bare `pytest` with `working_dir` `tests/`)
- `tests/pytest.ini` — mark registration + `--import-mode=importlib` + `testpaths`
- `tests/colcon_unit_test_packages.yaml` — the package list driving unit-test collection
- `tests/conftest.py` — `unit_test_files()` / `pytest_configure` inject package tests; `pytest_itemcollected` auto-marks `unit`
- `tests/README.md` — full test harness reference
6 changes: 3 additions & 3 deletions .agents/skills/run-system-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ The suite lives at `tests/` (repo root) and is fully pytest-based. Configuration
|---|---|---|
| Hardware required | None — pure Python | Docker daemon, NVIDIA GPU, sim license |
| CI workflow | `system-tests.yml` (included in `pytest tests/`) | `system-tests.yml` (GPU OpenStack VM) |
| Trigger | Every push + PR (automatic) | PR opened, `/pytest` comment, `workflow_dispatch` |
| Trigger | PR opened, `/pytest` comment, `workflow_dispatch` | PR opened, `/pytest` comment, `workflow_dispatch` |
| Source location | `<pkg>/test/test_*.py` (collected directly, listed in `colcon_unit_test_packages.yaml`) | `tests/system/` |
| How to add | See `add-unit-tests` skill | See *Adding a New System Test* below |

Run unit tests without any Docker stack:

```bash
airstack test -m unit -v
# or
pytest tests/ -m unit -v # AIRSTACK_ROOT=$(pwd) for direct pytest
# or directly:
AIRSTACK_ROOT=$(pwd) pytest tests/ -m unit -v
```

For details on the co-located layout and adding new unit tests, see the
Expand Down
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PROJECT_NAME="airstack"
# If you've run ./airstack.sh setup, then this will auto-generate from the git commit hash every time a change is made
# to a Dockerfile or docker-compose.yaml file. Otherwise this can also be set explicitly to make a release version.
# auto-generated from git commit hash
VERSION="0.19.0-alpha.16"
VERSION="0.19.0-alpha.17"
# Choose "dev" or "prebuilt". "dev" is for mounted code that must be built live. "prebuilt" is for built ros_ws baked into the image
DOCKER_IMAGE_BUILD_MODE="dev"
# Where to push and pull images from. Can replace with your docker hub username if using docker hub.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Unit-test documentation now matches the co-located layout: the `add-unit-tests` and `run-system-tests` skills and the testing docs record which runner each language uses (C++ gtests via `colcon test` under the `build_packages` mark; Python via the root harness, plus `colcon test` for `ament_python` packages), and stop instructing authors to write `@pytest.mark.unit` by hand — `conftest.py` applies it by file location
- Ephemeral CI GPU runners spawn via NVIDIA OSMO (not OpenStack); `system-tests.yml` / `docker-build.yml` still use `airstack-ephemeral`
- Default system-test `--sim` is `isaacsim`; pass `--sim msairsim` to opt in to Microsoft AirSim
- `-m build_packages` CI runs pull `cache_*` images instead of baking sim images
Expand All @@ -33,8 +34,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `zed-l4t` image: ZED SDK 4.2 → 5.2 with the coupled ROS deps (`zed_msgs` 5.2.1, `point_cloud_transport(_plugins)` 4.x, add `backward_ros`)
- Unit tests are defined by `tests/colcon_unit_test_packages.yaml`: `conftest.py` collects each listed package's co-located `test/` dir under `--import-mode=importlib` and marks it `unit` (ament lint files are skipped and run under `colcon test`)

### Removed

- Pre-co-location unit-test scaffolding: the six per-layer stub READMEs under `tests/robot/` (which instructed authors to add tests in directories tests no longer live in) and `tests/sim/motive_emulator/README.md` (superseded by `simulation/isaac-sim/extensions/optitrack.natnet.emulator/` and `tests/integration/natnet/`)

### Fixed

- `pytest tests/` now collects the co-located unit tests, so they run with every `system-tests.yml` invocation. The guard in `tests/conftest.py` skipped injection whenever any path was on the command line, and `tests/` is a path — CI collected 97 of 252 items and the Python unit tests ran nowhere. Narrowing (`pytest tests/system/test_x.py`) still skips injection; the rule is `harness.discovery.collection_is_broad`, pinned by `tests/meta/test_collection_contract.py`
- Isaac Sim image: PX4 `ubuntu.sh` no longer fails dpkg configure on the NVIDIA base (`ca-certificates` / `software-properties-common`); use `--no-nuttx --no-sim-tools` like ms-airsim
- Robot image: pin `pytest<8.1` and disable `launch_testing` for colcon unit tests so ROS Jazzy's outdated pytest hook does not abort `colcon test`
- Robot name resolution now honors a pre-set `ROBOT_NAME` (e.g. injected via docker compose) instead of always overriding it from the container/hostname mapping (`robot/docker/.bashrc`)
Expand Down
2 changes: 1 addition & 1 deletion docs/development/intermediate/testing/ci_cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ flowchart LR

| Mark | Module | What it verifies | Bugs it is good at catching |
|---|---|---|---|
| `unit` | `tests/robot/`, `tests/sim/` proxies | Hermetic Python/numpy logic co-located with each ROS 2 package | Off-by-one and boundary errors in filters, converters, validators; regressions in pure algorithm code |
| `unit` | `<pkg>/test/` (co-located) | Hermetic Python/numpy logic co-located with each ROS 2 package | Off-by-one and boundary errors in filters, converters, validators; regressions in pure algorithm code |
| `build_docker` | `system/test_build_docker.py` | Every image builds; records image sizes | Broken Dockerfiles, deleted apt packages, upstream base-image drift, accidental image bloat |
| `build_packages` | `system/test_build_packages.py` | `colcon build` inside robot, GCS, and ms-airsim workspaces | Missing `package.xml` dependencies, uninstalled launch/config files, C++ breakage on a clean tree |
| `liveliness` | `system/test_liveliness.py` | Containers reach Running, `/clock` publishes, tmux panes alive, sentinel ROS 2 nodes present, compute snapshot, stability poll | Launch files that crash on start, nodes that die after 30 s, `ROBOT_NAME`/domain-ID misconfiguration, runaway CPU or memory |
Expand Down
10 changes: 5 additions & 5 deletions docs/development/intermediate/testing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ hardware requirement:

| Layer | Where | Mark / Tool | Hardware |
|---|---|---|---|
| **Unit tests** | `tests/robot/`, `tests/sim/` | `pytest -m unit` | None — pure Python |
| **Unit tests** | `<pkg>/test/` (co-located) | `airstack test -m unit` | None — pure Python |
| **Package tests** | `<pkg>/test/` | `colcon test` | Robot container |
| **System tests** | `tests/system/` | `pytest -m liveliness` etc. | Docker, GPU, sim license |

## Unit tests (`pytest -m unit`)
## Unit tests (`airstack test -m unit`)

Fast, hermetic Python tests that run in seconds with no Docker or GPU. Test source
lives **co-located with its ROS 2 package** (`<package>/test/`); the packages with unit
tests are listed in `tests/colcon_unit_test_packages.yaml`, and `pytest tests/` collects
tests are listed in `tests/colcon_unit_test_packages.yaml`, and the root harness collects
them from there.

```bash
Expand All @@ -22,8 +22,8 @@ airstack test -m unit -v
pytest tests/ -m unit -v
```

Unit tests run as part of `system-tests.yml` via `pytest tests/` and can also be
run locally with no Docker or GPU needed.
Unit tests run as part of `system-tests.yml` via `pytest tests/`, and can also be run
locally with no Docker or GPU needed.

→ **[Unit Testing Guide](unit_testing.md)** — patterns, CI workflow,
how to add tests for new packages (Python and C++ gtest).
Expand Down
Loading
Loading