Testing strategy¶
The unit suite is 2781 tests. Run in one process that is about nine minutes — long enough that the gate gets skipped, and a gate that gets skipped is not a gate. Run across this machine's cores it is 66 seconds, so the whole suite is affordable on every change.
That single fact shapes everything below: ExaMLOps does not use test-impact selection. Picking
"just the affected tests" would save perhaps another minute and would silently miss the large
family of guard tests in this repo that read files rather than importing them — a change to
Makefile, .gitlab-ci.yml or an ADR has no import edge for a coverage-based selector to follow.
Running everything, quickly, is both simpler and safer.
The tiers¶
| Tier | When | Command | Time | What it covers |
|---|---|---|---|---|
| 0 | on every file edit | (automatic) | <1s | ruff check --fix + ruff format on the edited file |
| 0.5 | while working an area | make watch W=tests/unit/test_x.py |
seconds/save | the scoped tests, re-run failed-first on every save |
| 1 | inner loop | make test-fast |
~70s | whole unit suite, parallel |
| 2 | before pushing | make gate |
~2min | lint · format · typecheck · unit · docs |
| 3 | before a release | make preflight |
~10min | full CI mirror: integration, dashboard, Postgres, compose, Helm |
| 4 | CI | (automatic) | — | every tier-3 job, plus live-service jobs |
Tier 0 is a Claude Code PostToolUse hook in .claude/settings.json; it runs ruff on any .py
file written inside the repo. Formatting breaks are the single most common cause of a red
pipeline here, and this closes the usual route to one.
It does not catch everything. The hook fires on the editor's write, so a file created another
way — a shell heredoc, sed -i, a generator — is not formatted by it. That is why tier 2 still
runs ruff format --check rather than trusting tier 0: the fast hook is a convenience, and the
gate is the guarantee.
Tier 2 is also installed as a git pre-push gate — see Installing the hooks below.
Running tests¶
make test-fast # the one to use — whole unit suite, parallel, quiet
make test-fast JOBS=4 # cap the workers on a loaded laptop
make watch W=tests/unit/test_x.py # tier 0.5 — keep it running; every save re-runs the scope
make test-failed # re-run last run's failures first, then the rest
make test-slowest # the 30 slowest tests
make test-serial # single-process; use to confirm a parallel-only failure
pytest tests/unit/test_x.py # one file — no `-n`, so there is no worker start-up cost
make watch is deliberately serial and scoped: the whole tree on every save is what
test-fast is for, and xdist worker start-up would cost more than a scoped serial run.
make typecheck-fast is the same idea for types — a dmypy daemon over the same four
roots as make typecheck, so after a slow first run every re-check is seconds. Both are
conveniences; make gate before pushing is still the guarantee.
-n auto is deliberately not in pyproject.toml's addopts. Putting it there would make a
single-file run pay worker start-up for nothing; it belongs on the targets that run the whole
suite.
Why parallel is safe here¶
It was not, until the isolation leak was closed. About forty test modules set
os.environ["PLATFORM_DB"] = str(tmp_path / …) directly instead of through monkeypatch, so
the value outlived the test that set it. Serially this is invisible — the directory still exists
and the stale database is simply unused. Under -n auto it produced four failures that every one
of those tests passes on its own: a --json command whose output would not parse, because
warning: platform datastore unavailable had been printed ahead of the JSON.
The warning itself was correct and went to stderr; CliRunner merges the streams, which is why it
reached the parsed output. An autouse fixture in tests/conftest.py now gives every test a private
PLATFORM_DB before its body runs. Because it uses monkeypatch.setenv, it also undoes any direct
os.environ write made during the test — the leak is closed for the sloppy modules without editing
forty files.
The rule this establishes: a test must not depend on state left by another test. If a test only passes serially, that is a bug in the test, not a reason to stop running in parallel.
What is deliberately not parallel¶
test:postgres in CI runs single-process on purpose. Its workers would share one schema that the
isolation fixture truncates between tests, so parallelising it would be genuinely, badly flaky.
EXAMLOPS_POSTGRES_SCHEMA isolates one instance, not one worker.
Flaky tests¶
A flaky test is worse than a missing one: it teaches everyone to re-run the suite until it is green, which is the same as having no suite. Fix them; do not add automatic reruns.
The one flake found when parallelism was introduced is a useful pattern. coord_rate_allow is a
fixed-window limiter comparing window_start <= CURRENT_TIMESTAMP - <window> seconds, and SQLite's
CURRENT_TIMESTAMP has whole-second resolution. With a 1-second window, two calls milliseconds
apart that straddle a second tick are both read as starting a new window — so the test failed about
one run in five, in isolation. The fix asserts the denial over a 60-second window, where a
one-second tick cannot reach the boundary, and keeps the 1-second window only for the reset
assertion, which is the safe direction (a coarse clock can make a window look more elapsed, never
less).
Markers¶
There is exactly one, and it is applied:
live tests also skip themselves when their opt-in variable (EXAMLOPS_POSTGRES_TEST_DSN,
EXAMLOPS_REDIS_TEST_URL) is unset, so the suite stays runnable on a laptop with nothing running.
The marker is what makes them selectable; the env check is what makes them safe.
Two rules keep the marker list honest, because a marker is a claim about the suite:
--strict-markersis inaddopts. An unregistered marker is an error, not a silent no-op — without it,@pytest.mark.slwodoes nothing and the test everyone believes is tagged is not.- A guard (
tests/unit/test_every_test_can_fail.py) fails if a marker is registered inpyproject.tomlbut applied to no test. Aslowmarker was briefly registered here, described as being excluded frommake test-fast, applied to nothing, and excluded by nothing. It was removed rather than retro-fitted: at 66s for the whole suite there is no reason to skip anything from the inner loop, andmake test-slowestmeasures real durations, which cannot rot the way a hand-applied label does. An empty category is worse than no category —-m slowwould have selected nothing and reported success.
Installing the hooks¶
This installs the tier-2 gate and never overwrites an existing pre-push. On a machine where
that filename already belongs to another guard — the AI-attribution guard is one — the CI gate is
installed beside it as pre-push-ci and the command prints the one line that chains them:
Both git directories are handled (.git and .git-private). To push without the test half when
you already know the suite is green:
The gate never silently passes: if .venv/bin/pytest is missing it says the tests did not run
rather than printing nothing, because absence of output must not read as success.
The other suites¶
The root unit suite is the big one, but it is not the whole estate. All four Python suites now run parallel, each verified green before the flag was applied:
| Suite | Target | Serial | Parallel |
|---|---|---|---|
| Root unit (2781) | make test-fast |
520s | 66s |
| Dashboard backend (486) | make dashboard-check-backend |
35s | 12s |
| Skipper agent (337) | make skipper-test |
36s | 17s |
| Control plane (130) | make ci-control-plane |
21s | 13s |
Roughly 10 minutes of testing becomes under 2. The dashboard, agent and control-plane suites are I/O-bound rather than CPU-bound, so they gain 2–3× where the root suite gains 7.9× — worth having, and none of them needed an isolation fix to get there.
make ci-examlops runs the root suite parallel too. That is not a performance choice: the target's
whole job is to mirror the GitHub examlops job, and a mirror that runs the suite differently
from CI is the failure it exists to prevent.
CI¶
The GitHub examlops job and the GitLab test:examlops job both run the unit suite with
-n auto. The remaining jobs (agent, control-plane, dashboard, Helm, docs, Postgres) are unchanged.
See docs/reference/cli-generated.md for the command surface and .gitlab-ci.yml for the gate
wiring — the needs: list on deploy:lxp is the gate, so a new blocking job must be added
there too.