Running the test suite in parallel
The Spec Kitty test suite runs safely in parallel locally and in CI, typically at least 2× faster on a machine with four or more cores. This page explains the one correct local command, why it is shaped the way it is, and how to reproduce the coverage-neutrality gates CI uses.
For what to do when a test goes red on CI unrelated to your diff — budget gates vs. correctness flakes vs. environmental flakes, and why we never retry-to-green — see the test-flakiness handling policy.
The local command
PWHEADLESS=1 pytest tests/ -n auto --dist loadfile -p no:cacheprovider
# daemon/real-port tests run serially:
PWHEADLESS=1 pytest tests/sync/test_orphan_sweep.py -n0 -q
The first command runs the bulk of the suite across worker processes. The second command runs the daemon/real-port tests serially. Run both; the parallel command deliberately leaves the serial-only tests for the second pass.
Why --dist loadfile (never bare --dist load)
pytest-xdist supports several distribution modes. We always use loadfile:
loadfilekeeps every test that lives in the same file on a single worker. File-scoped fixtures (scope="module", file-level collection ordering, shared module state) keep working exactly as they do serially.load(the bare default) scatters a single file's tests across multiple workers. That breaks file-scoped fixtures and any test that relies on collection order within a file.
For that reason: always pass --dist loadfile; never use bare --dist load. CI uses loadfile for the same reason.
-p no:cacheprovider disables pytest's cache plugin so a parallel run never
races on the shared .pytest_cache directory.
Per-worker HOME isolation (the master enabler)
A parallel run never touches the real ~/.spec-kitty. Each pytest-xdist
worker — and the serial "master" run when you omit -n auto — gets its own
isolated home directory. The isolation is set up in tests/conftest.py:
pytest_configurepointsHOME/USERPROFILEand the XDG dirs (XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME) at a per-worker base before collection, so modules that bind a home-derived path at import time (for examplespecify_cli.sync.daemon.SPEC_KITTY_DIR) resolve into the isolated home.- An autouse, function-scoped fixture re-asserts the
HOME/USERPROFILE/ XDG env vars for every test, keyed by worker id, so call-timePath.home()reads are isolated too. It does not monkeypatchPath.home(that approach was the cycle-1 regression that broke ~16tests/synccases — the fixture relies onPath.home()natively resolvingHOMEviaexpanduser), so a test that sets up its own tmp home viasetenv('HOME', ...)cleanly overrides the per-worker baseline.
The per-worker base is keyed by the xdist test-run UID and the worker id, so two
workers in the same run get distinct homes (no collision) and successive runs do
not reuse stale state. The regression guard
tests/architectural/test_real_home_isolation_guard.py (SC-006) and
tests/test_worker_home_isolation.py prove this invariant.
Because the real ~/.spec-kitty is never bound, you do not need to back it up or
worry about a parallel run truncating your real queue.db.
The serial daemon pass
Per-worker HOME isolation protects per-user state, but it does not protect
OS-global resources such as real TCP ports or singleton daemons. Tests that bind
the reserved daemon port range (9400–9449) — tests/sync/test_orphan_sweep.py —
must run in their own serial pass:
PWHEADLESS=1 pytest tests/sync/test_orphan_sweep.py -n0 -q
-n0 forces serial execution even when xdist is installed. These tests are
excluded from the parallel pool so two workers never contend for the same port.
Volume env gates (SPEC_KITTY_ULID_VOLUME_FULL)
Some tests exercise large-volume ULID generation. By default they run at a reduced scale so the local default stays fast; the full scale is reachable via an env gate (and is exercised on the nightly/full path). The assertion logic is identical across scales — only the volume changes.
pytest <ulid_test> -q # reduced (fast, default)
SPEC_KITTY_ULID_VOLUME_FULL=1 pytest <ulid_test> -q # full (nightly parity)
Running the stability ratchet locally
Before any shard is flipped to parallel, it must pass the stability ratchet (C-RATCHET): N consecutive green parallel runs with no new flakes. The same entrypoint CI uses is available locally (the WP02 coverage-safety harness):
python -m tests._support.coverage_safety.ratchet -n 3 -- tests/agent -m "not slow"
Exit code 0 means all N runs were green and the flip is accepted; 1 means it
was rejected and the summary names any new or flaky failures. The Python API is
run_ratchet(...) from tests._support.coverage_safety. See
tests/_support/coverage_safety/README.md for the full harness (collection
equivalence and anti-vacuity mutation checks).
Validate the acceleration (copy-pasteable)
These are the mission's reproducible validation steps. Run them from the repo
root to confirm the parallel run is coverage-neutral and at least 2× faster than
serial. (.venv/bin/pytest is the synced project interpreter; substitute
pytest if you run it directly.)
# 1. Serial baseline (whole-suite wall clock) and a per-shard nodeid reference.
time .venv/bin/pytest tests/ -q -p no:cacheprovider # serial baseline
.venv/bin/pytest tests/charter --collect-only -q | sort > /tmp/charter-serial.nodeids
# 2. Collection equivalence: serial vs parallel must collect identical nodeids.
.venv/bin/pytest tests/charter -n auto --dist loadfile --collect-only -q \
| sort > /tmp/charter-par.nodeids
diff /tmp/charter-serial.nodeids /tmp/charter-par.nodeids # must be empty
# 3. Stability ratchet: 3 consecutive green parallel runs (the same gate CI uses).
python -m tests._support.coverage_safety.ratchet -n 3 -- \
tests/charter -m "fast and not windows_ci"
# 4. Parallel-vs-serial timing: target ≥2× faster on a ≥4-core machine.
time PWHEADLESS=1 .venv/bin/pytest tests/ -n auto --dist loadfile -p no:cacheprovider \
--deselect tests/sync/test_orphan_sweep.py
time PWHEADLESS=1 .venv/bin/pytest tests/sync/test_orphan_sweep.py -n0 -q # serial pass
# 5. Real home untouched: mtime/inode unchanged (or path still absent) after the run.
ls -la ~/.spec-kitty 2>/dev/null
Status: local default vs CI shard flips
- Local parallel command — validated and safe now. Per-worker HOME isolation
(WP04) is in place, so the local command above runs without touching your real
~/.spec-kitty. Use it today. - This mission's CI shard flips (
ci-test-topology-performance-01KXBJRT, §"CI shard topology" below) — shipped, no real CI run yet. Collection equivalence forintegration-tests-next,slow-tests, and the rebalancedfast-tests-core-miscwas verified locally before landing, but (unlike the older flips below) this branch has not yet run in real CI at all — seedocs/plans/testing/ci-job-timings.mdfor the honest PENDING wall-clock record, filled in once a real run exists. - Older shard flips (a separate, earlier mission:
test-suite-acceleration,docs/plans/testing/test-suite-acceleration-plan.md) — landed, pending 3×-green confirmation.fast-tests-doctrine,fast-tests-cli,fast-tests-charter,fast-tests-agent, andfast-tests-status's re-route each carry aPENDING-CIcomment in.github/workflows/ci-quality.ymlnoting collection-equivalence was verified locally but three consecutive green CI runs (the stability ratchet, C-RATCHET below) have not yet confirmed the flip under real runner contention. These are a distinct, still-open item from the current mission and are not resolved by it. The full "≥2× faster" claim for both sets of flips is host-dependent; locally you should still see a clear speedup on a multi-core machine regardless of either flip's CI-confirmation status.
CI shard topology (ci-test-topology-performance-01KXBJRT)
The project's PR-gating CI (ci-quality.yml) re-topologized its slowest jobs
in mission ci-test-topology-performance-01KXBJRT, generalizing the
arch-adversarial shard mechanism (tests/_arch_shard_map.py +
tests/conftest.py's collection hook + a parametrized completeness guard)
into a shared N-group registry so arch and next are two rows of one
mechanism, not two cloned ones (FR-002/C-003).
integration-tests-next — the headline next_shard_N matrix
Previously a single, fully-serial job (69.2 min, 441 tests, no
-n auto/--durations). It is now a 3-leg matrix
(next_shard_1/next_shard_2/next_shard_3, strategy.fail-fast: false)
driven by tests/_next_shard_map.py's registration into the shared
SHARD_GROUPS registry, covering the same 3 roots
(tests/next, tests/specify_cli/next, tests/runtime) across all three
legs — the next_shard_N pytest marker (applied at collection time by the
shared hook), not paths, partitions the work. Each leg runs
-n auto --dist loadfile -p no:cacheprovider --durations=25. Known gap:
the 3-way file assignment is still WP01's placeholder greedy bin-pack (by a
def test_ count proxy) — it has not yet been rebalanced from a real
--durations=25 run the way fast-tests-core-misc below was; see
ci-job-timings.md §4 for the
follow-up once real per-leg minutes exist.
slow-tests — narrowed paths + parallel
Path-narrowed to the directories that actually hold @slow-marked tests
(derived via git grep -rl "pytest.mark.slow" tests/), with the pre-existing
--ignore=tests/e2e --ignore=tests/cross_cutting pair kept verbatim, plus
--ignore= entries for all 4 FIXED_RANGE_SUITES real-port members
(structural exclusion, not marker-only — see below) and -n auto --dist loadfile added. Collection-equivalence verified locally against the
pre-narrowing selection (identical 45-test set at time of writing).
Orphan-sweep hoisted to its own concurrent job
test_orphan_sweep.py (the fixed-range daemon/real-port suite) used to be a
serial -n0 step inside fast-tests-sync, sitting on that job's critical
path even though it never touched the parallel worker pool. It is now its own
job, fast-tests-sync-orphan-sweep, triggered by the same sync path filter
so it runs concurrently with fast-tests-sync instead of after it — still
serial (-n0, real ports are not protected by per-worker HOME isolation),
just no longer blocking a sibling job's completion. The equivalent serial
split exists on the integration side too:
integration-tests-sync-real-port carries the 3 other
FIXED_RANGE_SUITES members out of integration-tests-sync, which is
otherwise parallelized.
fast-tests-core-misc — rebalanced 3-shard matrix
Already a shard matrix, but imbalanced (specify-cli-rest 12.2 min vs
core-misc 4.7 min). Rebalanced by splitting the heavy specify-cli-rest
leg's real subdirectories/files (measured via a real
-m "fast and not windows_ci" collect-only pass: 3241 of 6320 combined
tests) into a sibling specify-cli-rest-2 shard — a plain path/--ignore=
bin-split (like the job's own existing core-misc residual pattern), not a
new SHARD_GROUPS registry row. specify-cli-rest keeps its
WP02-_REQUIRED_CORE_MISC_SHARDS-required name. Union verified unchanged
(3241 + 3079 == the pre-split 6320-test selection).
fast-tests-charter / fast-tests-agent — split into concurrent jobs
fast-tests-charter (12.0 min) ran two sequential -n auto steps (charter,
then agent) that summed on one job's wall-clock despite each already being
parallel. fast-tests-agent's historical needs: fast-tests-charter edge
was an ordering artifact, not a true cross-job data dependency (the two run
on separate runners with separate checkouts); it now mirrors
fast-tests-charter's own upstream needs so both run concurrently.
The serial integration-tests-* sweep
14 single-directory integration-tests-* jobs (agent, charter, cli,
dashboard, doctrine, lanes, merge, missions, next,
post-merge/post_merge, release, review, status, sync, upgrade)
now run -n auto --dist loadfile -p no:cacheprovider. integration-tests-sync
was split real-port-first the same way fast-tests-sync was: a parallel
residual plus a dedicated serial integration-tests-sync-real-port job.
Real-port serial isolation is structural, not marker-only (closes #2590)
The fixed-range daemon family (tests/_real_port_suites.FIXED_RANGE_SUITES,
4 files under tests/sync/ that bind find_free_port_in_range) is excluded
from every parallel pool with an explicit --ignore= entry, not just relying
on a module-level marker mismatching the job's -m filter — a marker-only
guarantee is not structural, and #2590 tracked exactly that gap. This is
enforced by tests/architectural/test_serial_port_preservation.py (GC-3,
generalized to consume the whole registry) and
tests/architectural/test_workflow_dist_lint.py (GC-4, C-002/C-007).
Coverage-preservation guard (GC-2b)
Every re-topologized job's selection is checked against a committed,
frozen pre-change baseline of real pytest --collect-only node-ids
(tests/architectural/baselines/<job>-nodeids.txt,
tests/architectural/test_gate_coverage.py's
test_gc2b_current_selection_matches_baseline) — 0 dropped, 0 double-run,
not merely "the same number of tests." See
ci-coverage-union-audit.md
for the mission's own cross-cutting run of this guard, including one found
(non-coverage) gap.
Sonar coverage-denominator exclusions
sonar-project.properties' sonar.coverage.exclusions now removes
one-shot migration/backfill glue (src/specify_cli/migration/**, the
singular sibling the plural-only **/migrations/** sonar.exclusions glob
misses), non-Python dashboard assets (**/static/**), and thin CLI
entrypoints (**/__main__.py) from the coverage percentage denominator
(files stay in issue/duplication/hotspot analysis — only the coverage% is
affected). Known drift from the original spec: spec.md's FR-012 also
named src/specify_cli/next/** (the 3.3.0 deprecation shim) as a 4th
exclusion target; the shipped change only carries the first 3. Re-add
src/specify_cli/next/** in a follow-up if it is still an uncovered
deprecation shim at merge time — do not assume this doc drift is
intentional without checking.
UI-e2e coverage feed
ui-e2e.yml now runs its Playwright-driven tests/ui/ suite with
--cov=src/specify_cli/dashboard, producing coverage-ui-e2e.xml. Two
scope decisions are documented in that file rather than silently left
incomplete:
- Python coverage is produced but not yet Sonar-consumed —
ci-quality.yml'ssonarcloudjob only discovers artifacts from its own workflow run (download-artifact@v8, including itsprev_runfallback scoped toworkflow_id: 'ci-quality.yml'), which cannot see an artifact uploaded by the separateui-e2e.ymlworkflow run. Wiring aui-e2e.yml-scoped download step intoci-quality.yml'ssonarcloudjob is a tracked fast-follow, not yet done. - JS coverage (
dashboard.js, ~720 lines) is deferred — no Playwright-CDP or nyc/istanbul instrumentation was wired in this mission (judged out of budget for the job's 15-minute timeout); the interim mitigation is the**/static/**Sonar coverage exclusion above, which removesdashboard.jsfrom the denominator rather than crediting it with real coverage.