Run LibreYOLO unit tests (the PR gate)
tests/unit/ is the merge-blocking suite: fast, CPU-only, no network, no real
weights. It runs on every push and PR to dev on Linux, macOS, and Windows
(.github/workflows/unit-tests.yml, Python 3.10). If this suite is green and
install smoke is green, the PR gate is green; e2e never runs on PRs.
The canonical commands
make test_pr_gate # exactly what CI runs
# Direct equivalent (what the cross-platform workflow executes):
LIBREYOLO_PR_GATE=1 uv run --no-sync pytest tests/unit -m "unit and not external_data and not network"
On this Windows box make and uv are not on the PowerShell PATH; use the
Bash tool with the repo venv:
# from the repo root (worktrees reuse the main checkout's .venv)
LIBREYOLO_PR_GATE=1 PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit \
-m "unit and not external_data and not network" -q
Scoped runs while iterating (drop LIBREYOLO_PR_GATE for speed if you are not
checking hermeticity):
PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit/cli -q # one area
PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit/test_tasks.py -q # one file
PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit -k "yolo9 and load" -q
Before pushing any change that touches libreyolo/, run at least the unit
files for the touched area, then the full gate if the change is shared code
(models/base, training/, validation/, cli/, data/).
How the markers work (the traps)
pyproject.toml sets addopts = "-m unit". Every pytest invocation
defaults to unit-marked tests only. This is why running an e2e file "does
nothing" without -m, and why a unit test missing its marker silently
never runs. The last -m on the command line wins.
- Every unit file declares
pytestmark = pytest.mark.unit at module top.
A new test file without it is invisible to CI. Check this first when a new
test "passes locally but CI never ran it".
external_data marks tests that need staged local weights or datasets
(e.g. the *_parity.py tests that compare against real upstream
checkpoints). They are excluded from the PR gate and run only where the
files are staged. Marking is per-test or per-module; the test should also
skip cleanly when its file is absent.
network marks tests that intentionally reach non-local hosts. Also
excluded from the gate.
The hermeticity contract (PR Gate v1.0, docs/testing.md)
With LIBREYOLO_PR_GATE=1, an autouse fixture in tests/conftest.py patches
HTTP entry points and fails any test that touches a non-localhost URL
(localhost stays allowed so DDP-on-localhost unit tests work). The contract:
- No downloads: no HF, no GitHub releases, no CDN, no datasets, no weights.
- No GPU, no CUDA, no vendor export runtimes required.
- Needs external bytes anyway? Use a local fixture or mock; if the real
artifact is essential, mark
external_data/network and accept it leaves
the gate, or move the coverage to e2e/nightly.
New unit tests are in the PR gate by default. That is the point: prefer
writing the test so it stays in.
Golden fixtures and parity tests
Two patterns to know before touching numeric code:
- Golden fixtures pin exact numeric behavior without external data:
tests/unit/fixtures/augment_golden/ (augmentation parity),
tests/unit/data/ocsort_parity_golden.json (tracker parity). If a
deliberate behavior change breaks a golden test, regenerate the fixture
with the generator script referenced in the test file header and say so in
the PR; never hand-edit golden values.
- Parity-vs-upstream tests (
test_*_parity.py) are external_data:
they load a real converted checkpoint and assert exact or near-exact output
agreement. They do not run in the gate; run them manually when touching a
ported family whose weights are staged under weights/.
Reading failures
N deselected, 0 selected on a file you expected to run: marker problem
(missing pytestmark or your -m excluded it), not a collection bug.
- A gate failure that mentions "External HTTP is blocked in the LibreYOLO PR
gate": the code under test tries to download; fix the test to use a
fixture, do not mark it
network just to make CI pass.
- Windows-only failures are real: CI runs Windows, so path handling
(
Path vs string, case, separators) and spawn multiprocessing must work.
- The suite must pass on Python 3.10 (CI's floor) even if the local venv is
newer; avoid 3.11+ syntax in tests and library code.
Writing a new unit test: checklist
pytestmark = pytest.mark.unit at module top.
- No network, no real weights: build tiny models from config, use
tmp_path, synthesize images with numpy.
- If it genuinely needs a staged artifact:
@pytest.mark.external_data
plus a clean skip when the artifact is missing.
- Fast: the whole gate is thousands of tests; keep each under ~1s.
- Run it under
LIBREYOLO_PR_GATE=1 once before pushing so the HTTP
blocker vets it the way CI will.
Related
docs/testing.md: the full test-tier contract (unit / smoke / e2e / QA).
skills/libreyolo-run-e2e-tests/: the GPU suite this skill is not.
skills/merge-to-dev/: run the touched-area unit tests before pushing.
1---2name: libreyolo-run-unit-tests3description: Run and write LibreYOLO's unit tests and the PR gate: the fast, hermetic, CPU-only suite under tests/unit/ that gates every push and PR to dev. Use whenever someone wants to run unit tests, "run the tests before pushing", check whether a change breaks the PR gate, run one test file or one area (CLI, a model family, augmentations), or add a new unit test that must stay inside the PR-gate contract. Covers the marker taxonomy (unit / external_data / network), the hermeticity rules and the HTTP blocker, the Windows no-make fallback, golden fixtures, and how to keep a new test PR-gate-safe. For the heavy GPU suite use libreyolo-run-e2e-tests instead.4---56# Run LibreYOLO unit tests (the PR gate)78`tests/unit/` is the merge-blocking suite: fast, CPU-only, no network, no real9weights. It runs on every push and PR to `dev` on Linux, macOS, and Windows10(`.github/workflows/unit-tests.yml`, Python 3.10). If this suite is green and11install smoke is green, the PR gate is green; e2e never runs on PRs.1213## The canonical commands1415```bash16make test_pr_gate # exactly what CI runs1718# Direct equivalent (what the cross-platform workflow executes):19LIBREYOLO_PR_GATE=1 uv run --no-sync pytest tests/unit -m "unit and not external_data and not network"20```2122On this Windows box `make` and `uv` are not on the PowerShell PATH; use the23Bash tool with the repo venv:2425```bash26# from the repo root (worktrees reuse the main checkout's .venv)27LIBREYOLO_PR_GATE=1 PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit \28 -m "unit and not external_data and not network" -q29```3031Scoped runs while iterating (drop `LIBREYOLO_PR_GATE` for speed if you are not32checking hermeticity):3334```bash35PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit/cli -q # one area36PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit/test_tasks.py -q # one file37PYTHONPATH=. .venv/Scripts/python.exe -m pytest tests/unit -k "yolo9 and load" -q38```3940Before pushing any change that touches `libreyolo/`, run at least the unit41files for the touched area, then the full gate if the change is shared code42(`models/base`, `training/`, `validation/`, `cli/`, `data/`).4344## How the markers work (the traps)4546- `pyproject.toml` sets `addopts = "-m unit"`. Every pytest invocation47 defaults to unit-marked tests only. This is why running an e2e file "does48 nothing" without `-m`, and why a unit test missing its marker silently49 never runs. The last `-m` on the command line wins.50- Every unit file declares `pytestmark = pytest.mark.unit` at module top.51 A new test file without it is invisible to CI. Check this first when a new52 test "passes locally but CI never ran it".53- `external_data` marks tests that need staged local weights or datasets54 (e.g. the `*_parity.py` tests that compare against real upstream55 checkpoints). They are excluded from the PR gate and run only where the56 files are staged. Marking is per-test or per-module; the test should also57 skip cleanly when its file is absent.58- `network` marks tests that intentionally reach non-local hosts. Also59 excluded from the gate.6061## The hermeticity contract (PR Gate v1.0, docs/testing.md)6263With `LIBREYOLO_PR_GATE=1`, an autouse fixture in `tests/conftest.py` patches64HTTP entry points and **fails** any test that touches a non-localhost URL65(localhost stays allowed so DDP-on-localhost unit tests work). The contract:6667- No downloads: no HF, no GitHub releases, no CDN, no datasets, no weights.68- No GPU, no CUDA, no vendor export runtimes required.69- Needs external bytes anyway? Use a local fixture or mock; if the real70 artifact is essential, mark `external_data`/`network` and accept it leaves71 the gate, or move the coverage to e2e/nightly.7273New unit tests are in the PR gate **by default**. That is the point: prefer74writing the test so it stays in.7576## Golden fixtures and parity tests7778Two patterns to know before touching numeric code:7980- **Golden fixtures** pin exact numeric behavior without external data:81 `tests/unit/fixtures/augment_golden/` (augmentation parity),82 `tests/unit/data/ocsort_parity_golden.json` (tracker parity). If a83 deliberate behavior change breaks a golden test, regenerate the fixture84 with the generator script referenced in the test file header and say so in85 the PR; never hand-edit golden values.86- **Parity-vs-upstream tests** (`test_*_parity.py`) are `external_data`:87 they load a real converted checkpoint and assert exact or near-exact output88 agreement. They do not run in the gate; run them manually when touching a89 ported family whose weights are staged under `weights/`.9091## Reading failures9293- `N deselected, 0 selected` on a file you expected to run: marker problem94 (missing `pytestmark` or your `-m` excluded it), not a collection bug.95- A gate failure that mentions "External HTTP is blocked in the LibreYOLO PR96 gate": the code under test tries to download; fix the test to use a97 fixture, do not mark it `network` just to make CI pass.98- Windows-only failures are real: CI runs Windows, so path handling99 (`Path` vs string, case, separators) and `spawn` multiprocessing must work.100- The suite must pass on Python 3.10 (CI's floor) even if the local venv is101 newer; avoid 3.11+ syntax in tests and library code.102103## Writing a new unit test: checklist1041051. `pytestmark = pytest.mark.unit` at module top.1062. No network, no real weights: build tiny models from config, use107 `tmp_path`, synthesize images with numpy.1083. If it genuinely needs a staged artifact: `@pytest.mark.external_data`109 plus a clean skip when the artifact is missing.1104. Fast: the whole gate is thousands of tests; keep each under ~1s.1115. Run it under `LIBREYOLO_PR_GATE=1` once before pushing so the HTTP112 blocker vets it the way CI will.113114## Related115116- `docs/testing.md`: the full test-tier contract (unit / smoke / e2e / QA).117- `skills/libreyolo-run-e2e-tests/`: the GPU suite this skill is not.118- `skills/merge-to-dev/`: run the touched-area unit tests before pushing.