writing-tests
Kiro Crew repo only: how to write a backend pytest test with NO side effects that does not flake. Use when adding, editing, reviewing or debugging a test there: which conftest applies, what leaks (tem
SKILL.mdsrc/kiro_crew/builtin_skills/kirocrew-dev/writing-tests/SKILL.md
GitHubWriting a Kiro Crew test that does not leak and does not flake
Scope guard: this skill applies ONLY to the Kiro Crew source repository (or a worktree of it). Its rules are conventions of this repo's suite. In any other project, ignore it.
The canonical, longer reference is
docs/system-specs/common/testing-conventions.md. If this skill and that document disagree, the document wins. What this skill adds is the decision order — what to check first, and what the failure looks like when you get it wrong.
Where this skill stops, and which sibling takes over
This skill owns authoring a test: isolation, determinism, cross-platform behaviour, diagnosing a residue failure, and suite speed. Three siblings own the neighbouring steps, and none of them restates what is here:
| You are doing | Skill |
|---|---|
| Writing, fixing, or speeding up a test | this skill |
| Running the build gate in a worktree, and deciding whether a red is yours | kirocrew-worktree-dev |
| Driving a branch to a review-ready PR and through CI | kirocrew-prepare-pr |
| Polling that PR until it is green | babysit |
The line that matters most in practice: kirocrew-worktree-dev tells you whether a
failure is yours (re-run it on origin/main, mine CI for what is genuinely flaky);
once it is yours, the fix is here. Do not fix a flake from a summary of the
determinism classes — pick the class from the symptom, in Rule 2.
The two properties, and why they are one problem
A test must be hermetic (no effect that outlives it, anywhere but its own tmp dir)
and deterministic (same verdict every run, on every platform, in any order). They
are the same problem because the suite runs -n auto --dist loadgroup: a side effect
is not just untidy, it is the input to another test on the same worker, and it
surfaces as a flake in a file you never touched.
Rule 0 — Know which conftest is under your file BEFORE you isolate anything
setup.cfg declares two testpaths and they do not get the same fixtures. The
table is in testing-conventions.md
§ Which conftest you are standing on — read it rather than a second copy here, which
would drift. The short version: only test/ gets test/conftest.py;
src/kiro_crew/apps/builtins/**/tests/ (and the one **/*_tests/ suite) gets the
rootdir conftest.py plus that suite's own conftest.py where one exists.
The rootdir conftest.py is the host floor — the guards that protect the
developer's machine, so they hold everywhere. That covers damage (temp dirs, the data
home, services) and resource exhaustion: the xdist worker budget is registered there,
from the repo-root xdist_budget.py, because a run that spawns one worker per core on a
small laptop takes the machine down and does not care which testpath asked. Everything
else is in test/conftest.py.
Getting this wrong is the most expensive mistake in this suite, because it fails
silently and asymmetrically: an in-package test that assumes test/conftest.py's
fixtures passes on CI (where the operator's home is empty) and damages a real install
locally. When you add isolation, ask: could a test in ANY testpath damage the host —
or consume enough memory, cores or disk to take it down — without this? If yes it
belongs at the rootdir; if no, put it in test/conftest.py, where the in-package suites
pay nothing for it.
Rule 1 — The side-effect floor: what actually leaks
Work down this list. Each item is a real leak that has happened here.
1a. Temp directories — register the destruction in the SAME scope
Prefer tmp_path. If you need mkdtemp(), register cleanup with addCleanup on the
next line — never an rmtree in tearDown, because unittest skips tearDown
entirely when setUp raises, so that shape leaks on exactly the failing runs nobody
watches. The before/after example is in testing-conventions.md § Rules.
ignore_errors=True also hides a cleanup that could not finish, so it is not proof
of anything. The floor helps, but it is not your discipline: the rootdir conftest redirects
tempfile's base per run, removes it, and reports residue. CI's Backend Tests* jobs
set KIROCREW_TMP_RESIDUE_STRICT=1, so a leak of yours turns that shard red; a local run
only warns unless you set it. Need a data home? Use the conftest's per-test KIROCREW_HOME
pin, not a private temp home: a background worker can re-create a home you removed.
(Details: testing-conventions.md § Rules.)
Why it earns a guard: /tmp is often a tmpfs with a fixed inode budget
(1,048,576 on the hosts this was measured on) and it returns ENOSPC to every other
process on the machine while 90% of the bytes are free. It is not a tidiness issue,
it is a "your shell stops working" issue.
1b. The operator's data home, and ~/.kiro, which is a DIFFERENT axis
KIROCREW_HOME is pinned per test at the rootdir, which is what makes config_dir()
safe — and it needs to be, because resolving it is not a read: it creates the home
and its marker, and can run the ~/.kirocrew → ~/.kiro/crew migration.
Six shapes escape the env var:
monkeypatch.undo()in the body of a test. It reverts EVERY record on the shared instance — your own patches, every fixture that set something through it (storespinsKIROCREW_HOMEthat way), and until the floor got its own instance, the floor itself. A restore that ranempty_trash()afterundo()emptied the operator's real trash. A patch you need to drop before the test ends goes inwith pytest.MonkeyPatch.context() as patched:;undo()is not a scoped tool.A detached task.
asyncio.create_task(...)at boot with no one awaiting it keeps running after the test that started it has returned; when its work resolves a path (peek_next→spool_path()→data_home(), on ato_threadworker) it resolves the OPERATOR's home. Fixed where the task is scheduled:_start_channel_transportsresolvesspool_path()on the loop and hands it to the task, so the worker reads a location fixed at boot. Resolve every environment-derived input of a detached task on the calling side before detaching, or give the test a handle it can await.What a closed loop leaves behind. pytest-asyncio 0.21 ends the loop with a bare
loop.close(). A pending task's coroutine then getsGeneratorExitat garbage collection, so itsfinallyblocks run then (_run_chat's queue cycle reaches a syncKiroCrewConfig.load()); a default-executor job (to_thread) is abandoned mid-flight. Both resolvedconfig_dir()after the unpin and grew a fresh fakeHOME's~/.kiro/crew. The floor'stryfirstteardown hook now cancels pending tasks, runs them to completion and joins the executor while the pins hold, the wayasyncio.runshuts down. Acoroutine ... was never awaitedwarning pointing at that hook means your test left an unstarted turn behind: await it, or keep the patch that was meant to catch it in force until it has run.A default that bypasses the pin on purpose.
PodConfig.load()derives the pod plane from_default_home()/Path.home()so a pod cannot redirect the host's registry; the floor pinsKIROCREW_POD_ROOT/KIROCREW_POD_ENV_DIRfor that reason. A new resolver with its own default gets a floor pin AND a ratchet intest_host_isolation_floor.pyin the same change.Import-time from
config_dir()(subagent_persistence._SUBAGENTS_DIR): the var is read after the module captured the path. Each has its own autouse pin. A collection-time probe is the same shape one step earlier: a module-level_can_spawn()used by askipifruns before any pin and read the REALconfig.json; run such probes under an emptyKIROCREW_HOME.Import-time from
Path.home():~/.kirois kiro-cli's home, machine-wide and shared with the real installed agent —~/.kiro/settings/mcp.jsonis the live agent's MCP server list._isolate_shared_kiro_pathsredirects these from a table, andtest/test_host_isolation_floor.pyfails whensrc/grows a new one. That ratchet covers IMPORT-TIME bindings only. The lazy resolverconfig.paths.kiro_home()— and sokiro_agents_dir()/kiro_sessions_dir()— is yours to isolate, withKIRO_HOMEor by patchingPath.home(); they are not interchangeable, because the env var outranks the other. A test that skipped this projected the developer's installed agent specs and failed with anAcpRuntimeErrornaming a prompt file in an unrelated worktree.Some entries are excluded, and for two opposite reasons — already redirected elsewhere (the macOS launchd set, moved as one group by
_isolate_launchd_paths), or a security anchor that must never move: the file browser's allow-list rootfile_explorer/server._HOME. Stub the reader, never move the anchor. Redirecting a matcher so a test can pass makes it assert against a pattern that no longer matches the thing it protects. An anchor that stops being an import-timePath.home()binding leaves this tripwire's reach entirely —kiro_usage_api's kiro-cli sqlite tuples now resolve the home insideidentity_stores.sqlite_dbs(), so their anchor rule is pinned bytest_identity_stores.py::TestUsageTuplesAnchorTheRealHomeinstead. Read_EXCLUDEDfor the live list rather than a copy here.
1c. A child process inherits pytest's CWD, which is the repo root
This is the leak a reviewer cannot see: no line says open(..., "w"), the write
happens in a grandchild, and the test can assert against tmp_path and pass while the
artifact sits in the checkout. An empty file produced this way has been committed and
shipped from this repo.
- Pass
cwd=<a directory under tmp_path>to any child that MAY create a file, and to every helper that spawns one. - Scope the assertion to where that child's CWD actually is. An assertion over
tmp_pathproves nothing about a child that ran somewhere else — and a security test whose payload escapes its own assertion is worse than no test, because it reports a guarantee it never checked. - A read-only query is exempt, and sometimes must be:
git check-ignoreagainst the checkout is asking a question about the checkout.
The rootdir conftest fails the run on new non-ignored entries at the repository root, which is how this announces itself.
Two more things a spawning test owes, both learned from processes that outlived the run by six days, spinning at 1464% CPU between them:
HOMEandPATHare a PAIR. If you hand a child a fabricated environment, substituting one while inheriting the other is the defect. An inheritedPATHon a developer host routinely leads with a version-manager shim directory (mise, asdf, pyenv, volta, nodenv), and a shim resolves its tool set fromHOME— so with a substitutedHOMEthe bare namepython3ornodereaches the MANAGER, which finds no tool state and never execs anything. It spins, forever. Pin the real interpreter's own directory first onPATH, or resolve the tool to its real executable before building the env. Do not drop theHOMEsubstitution instead: it is usually a blast-radius bound somebody chose on purpose.- Reap the process GROUP, on every exit path. A child is routinely a wrapper that
forks, so
Popen.kill()reaps the wrapper and leaves the real work running — and a boundedwait()ending in a barepassthen reports success. Usestart_new_session=True+os.killpgon POSIX andCREATE_NEW_PROCESS_GROUP+taskkill /T /Fon Windows;test/installer_test_helpers.run_boundedis the reference. Atry/finallyaround the whole post-spawn body, not just the timeout branch, is what makes "every exit path" true.
1d. Background lifecycle — the one that beats every filesystem cleanup
A singleton with a daemon thread cannot be cleaned up by tidying files. The worked
example is sel.py: SecurityEventLog is a process singleton, its writer is a daemon
thread, and _init_locked binds the directory once from whatever _default_dir()
resolved then. So the first test to call sel() fixes it for the whole worker, the
thread keeps writing after that test ends, and _flush_batch opens with
mkdir(parents=True, exist_ok=True) — re-creating the directory after tearDown
deleted it. MEASURED: exactly one stray directory per run of the ops-mission-control
suite. Full telling, with the stack it came from:
testing-conventions.md
§ Rules.
The fix was not better cleanup. It was giving the singleton a session-scoped directory belonging to no individual test. When you touch a subsystem with a background worker, ask: which directory did its thread capture, and does anything delete that directory underneath it?
The same shape, one level up: a stub that replaces a shutdown/close/stop is
not a stop. Three tests needed to observe that the metrics provider's shutdown
was called, so they replaced it with a recorder — and the real shutdown is what stops
OpenTelemetry's exporter thread. That thread then survived for the life of the worker,
and because the OTel SDK reinstalls it in every fork child via os.register_at_fork,
the sandbox's userns probe forked a MULTITHREADED child. unshare(CLONE_NEWUSER)
implies CLONE_THREAD, which the kernel refuses with EINVAL unless the caller is
single-threaded, and EINVAL is indistinguishable from "no CONFIG_USER_NS" — so the
worker cached "this host has no sandbox backend" and every later sandboxed spawn failed
closed. 19 red tests in two app suites, each passing alone, none of them a metrics test.
Spy and delegate; never replace a lifecycle method. The rootdir conftest now fails
the test that leaves an exporter thread running.
Two general lessons worth carrying out of that one: a thread whose target is a bound
method keeps its own object alive, so dropping references never collects it; and
anything registered with os.register_at_fork runs inside os.fork(), before it
returns, so a fork child is not reliably single-threaded.
Related traps in the same family:
- A
MagicMockconfig reads TRUTHY. PatchingKiroCrewConfig.loadwith a bareMagicMockmakescfg.telemetry.enabledtruthy, which starts a real recorder and a reader thread, and resolvesPath(cfg.local_dir)to a relative path that writes into the repo. That is whyKIROCREW_TELEMETRY=0is forced for the whole suite. Any subsystem gated on a truthy attribute read off a config object has this shape. - A sleeper child that outlives the test. The suite spawns
python -c "import time; time.sleep(30)"to simulate a hung process. Put the kill/wait in afinallyor anaddCleanup, never only on the happy path. - A fixed port. Bind port
0. A fixed number collides across xdist workers and with the operator's running gateway.
1e. Never leave the process working directory somewhere else
The CWD is per-PROCESS, so under xdist one test's os.chdir becomes every later test's
starting directory on that worker. Use monkeypatch.chdir, which reverts itself.
This is the leak with the widest blast radius measured here. Because a passing test's
tmp_path is removed at its own teardown, a test that chdirs into tmp_path and does
not come back leaves the worker in a deleted directory, and Path.cwd() then raises
FileNotFoundError for every later test that reaches it — including from inside
production code. The measured instance is in
testing-conventions.md
§ Rules; the shape to recognise is that it reads as "the suite is flaky" — many files,
each passing in isolation. The rootdir conftest restores the CWD before any fixture
teardown, but write the test so it would not need to.
1f. Never register a real cron job, and never touch a real service
The rootdir conftest traps the stdlib spawn funnels and refuses a
systemctl/launchctl invocation carrying a mutating verb. Read-only queries
(show, cat, is-active) are allowed and need no stub. A test reaching the make-live
cutover path must stub both _run_cmd and _dropin_path.
Rule 2 — Determinism: seven classes, one correct fix each
Never "fix" a flake with a rerun, a longer sleep, a weakened assertion, or a skip.
Full detail and examples: testing-conventions § Determinism. Its short form, twelve
MUST rules, is the
Determinism contract
at the top of that document, and the checklist below opens with the same twelve.
The seven classes, the tell that identifies each, and the ONE correct fix for each are in testing-conventions.md § Determinism. Read the section matching your symptom before changing anything: most of them have a fix that looks like the obvious one and is not.
What this skill adds is when to go looking — a test that passes alone and fails in the suite, or
one that splits by Python version rather than by machine load, is one of those seven and not a
mystery. Do not reach for a rerun, a longer sleep, a weakened assertion, or a skip.
The seventh class is the one a sleep hides best: two stamps written back to back can be
EQUAL. On Windows through Python 3.12 time.time() steps ~15.6 ms, like
time.monotonic(), and some filesystems store mtimes in whole seconds, so a sleep
between two writes makes a tie less likely, never impossible. Set the stamps (os.utime(ns=...), a stepped
clock, an explicit created_at), assert strictly, and pin a time-sorted output's
tie-break with an equal-stamp pair.
Prove a test rather than trusting a green run: 20 repeats at -n0, 10 at -n 4 beside
its neighbours, and a shuffled order. A FIX to a flaky test also shows the forced
condition (a coarse clock, a late executor, a delay at the named seam) red on the parent
commit and green on the fix (testing-conventions § Proving a determinism fix).
The sixth class has a tell of its own: the run ends early, not red. On Windows a test that
blocks past --timeout is not failed, its xdist worker is killed, and with
--max-worker-restart=0 the run aborts with every uncollected result missing. If a full run
reports a few thousand tests instead of 100k+, look for worker ... crashed while running in the
log: the named test is one that can wait forever. Wait on the observable state, not a guessed
sleep, and bound the await whose refusal is under test with asyncio.wait_for so a missed
refusal fails by name at that line.
An adjacent trap: a patch target that misses. Patch the namespace whose
globals the code under test actually reads. It fails in both directions — patching a
package re-export when the caller reads its own defining module, or patching
pkg.mod.fn when the caller did from pkg.mod import fn and holds its own binding.
Either way the real function runs, the assertion passes for the wrong reason, and the
test pays real time. Treat an unexpectedly slow "mocked" test as evidence the mock
missed. The opposite trap is a patch that is too WIDE: a side effect on a module global
also fires for every background worker that calls it (the eventlog-io legacy fold calls
members.read_dm_binding). Patch the function awaited in the window you mean, and pin
the order of the calls the test relies on.
Another: the host is an input, and a "surely-unused" number is not a constant.
999999 reads as an impossible PID and is not — pid_max is 4194304, so on a
long-running host it names a live process. That broke two tests in opposite ways: one
stopped pruning an entry whose owner "must be dead", and one accused a planted ps shim
of executing when it had not. If the code probes the PID, pin the probe; if the number
must never appear in real output, pick one no OS can allocate (99999999999).
The host's free MEMORY is an input too, and the most-used probe of it is
SubagentManager.spawn, which queues — registering nothing in _tasks — on a
pressured machine. A queued spawn is still a SubagentInfo, so the test dies a line
later on a bare KeyError, not on the assert that would have named the cause. Any
file driving spawn takes pytestmark = pytest.mark.usefixtures("healthy_host_memory"),
and test_subagent_spawn_host_pin.py fails when a new one does not. The guard it
pins, and why it stays transparent for the parser's own tests, are in
testing-conventions § Determinism 1. Both the fixture and its ratchet are
test/-only — healthy_host_memory lives in test/conftest_opt_in.py, bound by
test/conftest.py, and test_subagent_spawn_host_pin.py scans test/*.py
non-recursively. An in-package app suite cannot request the fixture and is not
swept, so a test there that drives SubagentManager.spawn must pin the floor reading itself.
One more, for tests of a single-flight or coalescing path ("N concurrent readers
share ONE scan"): the property only holds for readers that arrive WHILE the shared
operation is in flight, so the test has to keep it in flight until they have. An
instant stub does not: asyncio.gather starts the leader first, its executor job
finishes on the pool thread before the loop reaches the await, and on Python 3.13
the wrapped future is already done — awaiting a done future does not yield. The
leader then completes with a waiter count of one and the next reader assembles again,
once in five loaded runs. Gate the stub on a threading.Event, poll until every
reader is registered, then release it. Whatever the count of assemblies asserts, the
test must first establish the concurrency it is asserting about.
Rule 3 — Cross-platform: macOS, Linux (x86_64 + arm64), Windows
- Route POSIX calls through
platform_compat. See docs/system-specs/common/platform-compat.md. Most important:os.kill(pid, 0)TERMINATES the target on Windows — it is not a liveness probe. Useplatform_compat.pid_exists. - Path length is a real constraint. Windows caps a path at 260 characters unless
long paths are enabled, and a macOS
AF_UNIXsun_pathis capped at ~104 bytes — which a macOSbasetempalone already exceeds. That is whytest/tmpdir_helpers.short_tmp_base()exists, and why anything that prefixes every temp path in the suite has to be measured, not assumed. - Case-insensitive filesystems. macOS and Windows are case-insensitive by default, so a test asserting that two paths differing only in case are distinct is broken there.
- Windows clock granularity. Through Python 3.12
time.time()andtime.monotonic()step ~15.6 ms, and so doEvent.wait, lock and queue timeouts; onlytime.sleephas been high-resolution since 3.11 — so a short sleep can put two writes on one stamp, and "two writes have different timestamps" is a flake there (class 7). - Probe, do not guess the platform.
test/conftest.py::_can_create_symlinkis the model: creating a symlink needsSeCreateSymbolicLinkPrivilege, which CI runners hold and an ordinary shell does not, so a blanketskipif(IS_WINDOWS)would drop the assertion exactly where it needs to run. Use themake_escaping_link/make_dir_linkhelpers, which fall back to a junction. - arm64 vs x86_64 rarely matters for test logic, but check it when you touch memory
or page arithmetic (
SC_PAGE_SIZEis 16K on some arm64 configurations) or a dependency with per-architecture wheels. - Windows gaps are tracked as burn-down lists, not scattered skips:
test/windows-collect-ignore.txtandtest/windows-expected-failures.txt. Anything NOT listed still fails the job. Delete a line when you fix its test. - Never
--deselecta whole file for a missing host capability. Guard the test that needs it —skipif(not userns_available())for the OS sandbox — so the report names the capability. A deselect is invisible in the output, takes the file's other tests with it, and never goes red when its reason expires: eleven such files kept 608 tests off every PR, of which 523 would have run on all three platforms, long after the cost that justified the exclusion had been fixed.
Rule 4 — Diagnosing a residue failure
The residue report runs in a session-fixture teardown, so it is attributed to the last test the worker ran, which is almost never the culprit. The guard carries its own bisector — use it instead of guessing:
KIROCREW_TMP_PER_TEST=1 pytest src/kiro_crew/apps/builtins/<app>/tests -n0 -q
# AssertionError: 1 temporary entry outlived this run under /tmp/kc-pytest-you-951504:
# test_provider_listing_never_contains_a_token/tmpw2kvty2z
Each residue name becomes <test id>/<leaked name>. If the leak survives a tearDown
that visibly removes it, suspect Rule 1d: something re-created the path after
cleanup. Confirm by wrapping os.mkdir in a throwaway -p plugin and printing a stack
for paths under the run's temp root — that is how the sel-writer thread was found.
For a repository-root residue failure, the cause is almost always Rule 1c: a subprocess
spawned without cwd=.
Rule 5 — Keep the parallel suite fast
At 100k+ tests, per-test setup cost dominates any single slow test — an autouse
fixture is paid once per test, over 100,000 times. Profile, never guess; compare candidates back to back on
the same host (run the change, then the base in git worktree add --detach <dir> <base>;
never git stash: every worktree shares one stash list), because a loaded host makes an
absolute number meaningless.
The recurring wins, in order of leverage:
- An autouse fixture that costs more than it protects — one requesting a
tmp_pathit never uses; a repeatedtmp_path_factory.mktemp(it scans the whole basetemp to pick its suffix, so it slows as siblings accumulate); an unconditionalmkdirwhere the consumer only needs a path. Measure the whole chain against a file of trivialassert Truetests. - A production timeout or poll the test never asserts on —
monkeypatchthe interval to0; the branch still executes, only the waiting goes. - An expensive immutable thing built per test — a real
gitrepo costs ~1–1.6s in subprocesses. Build it oncescope="session"andcopytreeit per test; copy from the template rather than yielding it, so nothing one test does can reach another's. - A module-cached tree scan paid once per WORKER — an
lru_cachedrglob+ast.parseofsrc/(~30 s) is computed again on every xdist worker the module's tests land on; five full runs measured eighteen ratchet modules each re-scanning on 3–5 workers, ~22 CPU-minutes per run. Addpytestmark = pytest.mark.xdist_group(name="tree_scan_<module>")— one group per file, never one shared group — and the scan runs once per run.
After any speedup, mutate the production code the test covers and confirm the test
still FAILS. A test made faster by checking less is a regression. Restore from a copy
of the file you mutated, not from git — git checkout -- discards unrelated uncommitted
work — and sequence with ;, not &&, or the restore only runs when the mutation
did not work.
Rule 6 — MEMORY is the other budget, and collection is most of it
A worker costs ~2.0 GiB (measured median at -n 12; worst observed 2.8 GiB), and
~1,499 MiB of that is paid before your test runs: every xdist worker independently
collects every item in both testpaths (106,491), and 99% of that footprint is private,
so more workers never amortize it. This is why -n auto is bounded by available memory
— on an 8–16 GiB laptop the full suite otherwise swaps the machine.
Both halves of that model doubled between audits (from ~57k items / ~750 MiB), so
re-measure rather than trusting the numbers above once the suite grows by half
again: --collect-only -n0 reproduces a worker's collection peak. The reservation in
xdist_budget.py is sized on them, and an under-sized reservation is how a laptop
starts swapping.
The consequence for how you write a test:
- An allocation at module scope is multiplied by every worker, and outlives the
test. A big literal inside
@pytest.mark.parametrizeis the worst shape: it is built while the module is IMPORTED and the mark keeps it alive on the function object for the whole session, so one test's payload is charged to all of them. Two such literals — a 64 MiB frame and a 40 MB string — cost 102 MiB per worker until they were moved into the test bodies behind a sentinel. - A transient peak counts too, because a worker's high-water mark is what the
budget must reserve. Building an image as a list of per-pixel tuples cost ~390 MiB
for a 17 MB PNG; one
frombytesover a bytes buffer was 10x smaller and byte-equivalent. - Derive a size from the production constant rather than restating it. A literal one past a production size ceiling, restated beside that constant, hides both the coupling and the cost, and goes stale silently.
- Suspect a module-scope literal whenever a file's collection RSS is large; measure
it with
pytest <file> --collect-onlyand/proc/self/status'sVmHWM.
Checklist before you push a test
Determinism top 12
One box per rule of the Determinism contract,
naming the shape; the contract line holds the detail and its numbers. The helpers are in
kiro_crew.testing (clock, wait, ids). Every other item below is a specialised trap.
- D1: wait on the asserted state with a raising, bounded poll (
wait_until,async_wait_until,until_parked), never a sleep (class 2) - D2: one clock, installed on the module-under-test's own binding
(
ManualClock.install, themanual_clockfixture) (class 2) - D3: set the timestamps (
ManualClock(tick=...),os.utime(ns=...),seq_ids), assert order strictly, pin the tie-break (class 7) - D4: no asserted order the code does not define (class 7)
- D5: the product's zone and a frozen instant, never the host's (
local_tz) (class 1) - D6: seeded RNGs and unallocatable fake PIDs (
seeded_rng,unallocatable_pids) (class 1) - D7: no upper bound on a measured duration beyond what the contract allows (class 5)
- D8: every self-unblocked await, join or communicate bounded, failing by name (class 6)
- D9: listeners bind port 0 and report the port (Rules)
- D10: loopback only, the network stubbed at the product's seam (side effects)
- D11: globals through
monkeypatch, no in-process reload, evictions restored (class 4) - D12: proven by repeats, a shuffled order and, for a fix, the forced condition (proving a fix)
Specialised traps (search when your test touches X)
- Nothing outlives the run: no temp residue, no write to
~/.kiroor the real data home, no cron job, no service change, no file in the checkout - Every
mkdtemphasaddCleanupon the next line (or usestmp_path) - Every child that may create a file gets
cwd=undertmp_path, and every assertion is scoped to where that child actually ran - Anything whose default directory is
Path.cwd()(TaskRunner(work_dir=...)) is constructed with an explicittmp_path; a gitignored name at the repo root is the one leak the residue guard cannot see - A background worker gets every environment-derived input (paths AND config) from the dispatching thread, and registers itself so the rootdir conftest teardown can join it
- Every thread, task, child process, socket and connection it starts is stopped in a
finallyor anaddCleanup - Globals mutated through
monkeypatch, never raw assignment — includingos.environkeys a REAL production startup path is known to write (PLAYWRIGHT_MCP_OUTPUT_DIR,KIROCREW_TELEMETRY,PATH), restored in the shared helper that drives it - A variable the code under test WRITES and that is absent before the test goes
through
forget_env_at_teardown(monkeypatch, name)—delenv(raising=False)on an absent key records no undo, and adelenvAFTER the write puts the written value back - No
monkeypatch.undo()in a test body: it reverts every fixture's records too. A patch you need to drop early lives inwith pytest.MonkeyPatch.context() as patched: - No module-level
skipifprobe that readsKiroCrewConfig/config_dir()/Path.home()— it runs before any pin and observes the operator's real config - A collection-time probe that can construct a process singleton (
sel(), a config loader) RETIRES it before its temp home is removed — the session floor only resets singletons at the first test's setup, and a live one re-creates the deleted path - Every
MagicMockattribute the code under test converts or compares (int(),len(),bool(),<) is set explicitly in the mock helper —int(MagicMock())is 1, and a drain loop reads that as "one still pending" until its deadline. A test whose duration equals a production timeout has hit this - A complexity guard (ReDoS, "stays linear") is sized so the regression it exists to
catch FAILS it inside
--timeoutrather than hangs the worker: RAMP the pump one unit at a time on thread CPU (assert_rejected_without_backtracking), never a single huge input on a wall clock — no fixed "small" size is safe against every growth rate - A test of a refused location names one the guard refuses on THIS host:
/usrisC:\usron Windows — accepted by the resolver, and then created on the system drive - Fixture paths are absolute on EVERY host:
host_abs("usr", "bin"), never a/usr/binliteral (ntpath.isabsrejects a driveless path from Python 3.13); a path that belongs to a simulated platform is judged with that platform's module (posixpath) - Nothing assumes the ancestry of
tmp_pathis bare (no.venv, no project marker above it), that127.0.0.1:1refuses connections, thatpython3is on PATH (spawnsys.executable), or thatgit/ghsit in a trusted system directory - A fabricated child environment does not substitute
HOMEwhile inheritingPATH(or vice versa): with a shim-ledPATHthe barepython3/nodeis a version manager that finds no tool state under the newHOMEand spins forever - Every child that could outlive the test is reaped by process GROUP
(
start_new_session=True+os.killpg;taskkill /T /Fon Windows) from atry/finallyaround the whole post-spawn body, not only the timeout branch - A
skipifon an external tool gates on the VERSION floor the code needs, not justshutil.which(tool) is not None - A gate that walks the REPO ROOT prunes
.worktrees/(another branch's checkout), or asksgit ls-filesinstead of walking - A fixture that stubs away the only code path releasing a permit, lock or in-flight claim gives it back itself — restored to what the test INHERITED, not to a pristine value
- A source ratchet strips docstrings with
ast, not by subtracting__doc__frominspect.getsource(3.13 dedents docstrings) - A test whose contract IS a real symlink is listed in
test/requires-real-symlinks.txt; one that needs a directory that resolves elsewhere usesmake_dir_link - No
AsyncMockstanding in for a synchronous method; everycancel()awaited. A bareAsyncMock()provider makes EVERY accessor an awaitable, and the sync ones (context_window_tokens(),mcp_session_report()) becomenever awaitedwarnings attributed to a LATER test — build the double from a factory that pins them asMagicMock, and a stand-in for a spawner orwait_formustclose()the coroutine it swallows - No module-level asyncio primitive (
Lock/Event/Future/in-flight dict) reachable from the code under test without a per-test reset - Nothing can block forever: every await the test itself must unblock is wrapped in a
bounded
wait_for; nosleep(0.05)standing in for "let the other task register" - A test of a coalescing / single-flight path holds the shared operation open (a gated stub) until every reader has registered, then releases it — an instant stub lets the leader finish before the others arrive, and a done future never yields
- A module that
rglob+ast.parsessrc/once per module also carriespytestmark = pytest.mark.xdist_group(name="tree_scan_<module>"), or every xdist worker it touches re-runs the scan - A scan of the WHOLE repo goes through
source_corpus.repo_files()/repo_files_named(...), neverrglob/os.walkfrom the root — a walk descends gitignored trees and any nested worktree, so the gate reports that copy as the offender, or (with anany(...)assertion) keeps passing on it; the gate keeps its own scope filter, because_vendoris tracked - A fixture stamped from a module-level
NOWis only compared by production code whose clock is pinned to that sameNOWon the module-under-test's owntimebinding (D2) -- never two clocks - After
await handler(...), an assertion on something a worker thread emits viacall_soon_threadsafewaits on that signal, not on the handler returning - No upper bound on a rate, a sample count, or a measured duration beyond what D7 allows
- Source files read via
_REPO_ROOT = Path(__file__).resolve().parents[N], never a relativePath("src/...")— xdist workers may change CWD - Passes at
-n0and under-n auto, and passes when run alone - No large allocation at module scope — especially not inside
parametrize, where every worker pays it at collection and holds it for the session - Cross-platform:
platform_compatfor process/signal/lock calls, no assumption about path separators, case sensitivity,/tmp, or timer granularity -
@pytest.mark.asyncioonly onasync deftests — never a module-levelpytestmarkover a file that also holds sync tests - Every aiohttp
app[...]write happens before theTestClient/TestServerstarts; an override of something a productionon_startuphook creates is itself a lateron_startuphook; an already-set-up runner's app is never wrapped in a secondTestServer(serverunner.serverthrough aServerRunner) - A fixture venv is built with
symlinks=not IS_WINDOWS, and every probe of the RUNNING interpreter's packaging (find_spec("pip")) is pinned — a uv venv has nopip, and a copied python-build-standalone binary does not start - A fake
subprocess.runroutes on whole argv tokens, never on a substring of the joined argv — aTMPDIRpath element can contain any word, including the test's id - A directory chmodded to a non-writable mode under
tmp_pathgets owner rwx back in a finalizer; a nested pytest the test spawns gets--basetempunder the outertmp_path(the default is the SHARED per-user tree every other run prunes) - A module- or session-scoped fixture that patches env does it through
pytest.MonkeyPatch.context()scoped to what actually needs it, never a rawos.environ[...]write and never aMonkeyPatchheld for the whole module when the value it builds is already materialised; an env value each test's subprocess must see (a git identity) is set per test through the function-scopedmonkeypatch, since a session-lifetime patch still reaches every later suite on the worker - A default that is not HOME-derived (
workspace_root()→/Volumes/workplaceon macOS) is relocated by patching the resolver, and the result is asserted to be undertmp_path - A resolver that finds a per-user tool (
gh,mise,say) is pinned at the seam production reads, never left to find the host's binary; an address probe thatconnects a datagram socket off-loopback is stubbed with an inert socket subclass - A stub for a runner that owns deferred cleanup (
cleanup_paths) reaps them itself; a file with a.locksidecar lives undertmp_path; a spawned real binary getsTMPDIR/TMP/TEMPinside the test's temp tree in itsenv - A thread count that rises is only a leak if the new threads are not a named bounded
pool (
mc-*,sel-writer) — print thread names in the probe first; aThread(target=lambda: ...)that is expected to raise captures the exception and asserts on it instead of leaving aPytestUnhandledThreadExceptionWarning - A tree ratchet streams parsed trees and caches RESULTS, never a list of ASTs shared
between scanners; a growth/linearity ratchet measures the algorithm's own work
(bytes produced, items visited), not wall-clock and not interpreter call counts
(
str.joinis one C call at any length) - Nothing assumes
tmp_pathis OUTSIDE a git repository: a fixture whose verdict comes from an upward walk (nearest.git,install.sh+setup.cfg, project markers from the cwd, git discovery) plants the boundary it reads — a developer'sTMPDIRmay sit inside the checkout, and in a linked worktree the.gitsuch a walk finds is a FILE - A nested
python -m pyteston files undertmp_pathpasses its own-c <ini>,--rootdirand--color=no: otherwise it adopts the repository's ini, and the repoaddoptsreshape the very output the outer test greps - A stub for an
os.*function reached through a module alias (monkeypatch.setattr("<mod>.os.unlink", ...)) forwards every non-owned call to the saved real function with*args, **kwargsINTACT — droppingdir_fdre-aims pytest's ownrmtreeat the cwd — and asserts a bare relative name never reaches the stub - A test that compiles or imports a throwaway source under
tmp_pathkeeps its bytecode there (sys.pycache_prefixundertmp_path, or the suite'sno_bytecode()helper), because the suite-wide mirror is keyed on the absolute source path and a per-run path never gets read again - A fixture needing a SHORT path (
AF_UNIXsun_path, a redaction-sensitive path) callstmpdir_helpers.short_tmp_base()— never a literal/tmp, and nevertempfile.gettempdir(), which is too long under a pinnedTMPDIR - No raw
os.kill(pid, 0)in test code — it TERMINATES the target on Windows; route liveness throughplatform_compat.pid_exists/pid_liveness, and any teardown signal to a pid published by a child captures the target's identity at spawn and revalidates before signalling - Every fetch seam a code path can take is routed, not just the one the happy path uses (a JSON + text stub still lets a BLOB fetch reach the network)
- A skip condition is decidable the same way on every run of one host: no ctime tick, no "did a real tool answer in time", no "did an earlier test in this worker arm a hook" — build the condition, resolve it from disk, or measure in a fresh interpreter
- A handle the object under test opens for the process lifetime (a store's SQLite connection + writer thread, a lazily-opened index) is closed by the fixture that built it; when production has no close path, that gap is the finding
- No exception INSTANCE in a
parametrizelist (pytest.param(OSError(...))): the instance lives for the module,raise errhangs a__traceback__on it, and the frames on that traceback keep every local alive — a handle, a lease, a socket — for the rest of the worker. Parametrize the errno and build the exception inside the test - A test that asserts process-global "nothing retained" state (
lease._held, a registry, a pool) is only as good as the files that share the worker: the file that exercises the failure path pins the same table empty in its own teardown, so the retention is reported where it was created rather than forty files later - "Let it finish" is never a fixed
sleep: wait on the state you are about to assert (poll it off-loop under a bounded deadline, or await its event) — two 200 ms sleeps that were enough at-n0readstartingfor every row on a loaded Windows worker - An unclosed
sqlite3.Connectionis a self-cycle on 3.11+ and refcounting never frees itsdb/-wal/-shm. Close every inlineVectorMemoryStore/KnowledgeStore/SkillsLoader/SubagentManageras the SQLite teardown item below says, e.g. throughtest/conftest.py'sopenedfixture. AKnowledgeStore'sclose()is fine on the owning thread; use_close_all_for_tests()when another thread held a connection - An exact bound on monotonic float arithmetic (
(now + cap) - now <= cap) can miss by one ulp on some platforms; compare with a named slack (_FLOAT_SLACK = 1e-6, as intest/test_knowledge.py) orpytest.approx, never a bare<= - A fixture that plants a path under
tmp_pathand asserts a production "not under$HOME" refusal does NOT fire pinsPath.home(the seam the product reads) to a sibling that is not an ancestor of the fixture — a developer'sTMPDIRmay sit inside home — and a mirror test plants INSIDE the patched home to prove the refusal still fires - A helper that asserts a WARNING count filters
caplog.recordsby the logger the test enabled (rec.name == <logger>), never the unfiltered root capture: an executor thread another test'sSessionManagerarmed logs on its own logger mid-test; and a test that builds a realSessionManagerrelies on conftest's_no_boot_sandbox_sweeppin rather than patching the sweep itself unless the sweep is its subject - A teardown that signals a pid the body has already proven dead (a reaped root, a
grandchild init collected) signals only while
process_start_timestill matches the identity recorded at spawn, and refuses an unpinned pid; a "nonexistent pid" probe uses a number above/proc/sys/kernel/pid_max, not a convention - A resolver the product caches for the process (
functools.lru_cachedssh -V,code_fingerprint()) is pinned at MODULE scope with an explicit opt-out fixture — pinning only the flagged tests moves the real spawn to the next reader; a class-local copy of a conftest fixture re-implements ALL of its pins or requests the original instead - A production
git -C <scratch>spawn the test cannot passcwd=to runs under a fixturemonkeypatch.chdir(tmp_path), so nothing inherits the checkout as process cwd; the product keepscwd=Nonethere on purpose (a missing clone must stay git's rc=128, not aFileNotFoundErrorbefore git runs) - A backend a daemon reaps on disconnect goes through the pool's TRACKED reap
(
pool.spawn_shutdown), never an inlineawait shutdown()in a handler'sfinally: teardown cancels the handler after the backend has left the map, and the child then belongs to nobody - A child whose environment or argv the test must READ BACK through the kernel
(
KERN_PROCARGS2,/proc/<pid>/environ) is a non-platform binary -- the test's ownsys.executable-- neversleeporenv: macOS 26 answers an argv-only record for Apple platform binaries even to a same-uid reader, so the oracle readsNoneand a "refused" assertion passes for the wrong reason - The bounds on nested
python -m pytestruns fit INSIDE the per-test--timeout: N sequential children each capped at 60 s inside a 120 s test can only fail the test ahead of pytest-timeout, never protect it; independent children run concurrently (wall = the slowest) and each gets the outer budget less a margin - A handle the product REWRITES asynchronously (a claim's
.taskthat the run swaps for its inner task) is never read off shared state after an awaited response -- take it from the seam that hands it over (attach_run_task), or the assertion names whichever task won the race - A fixture never answers a production path with a FIXED absolute host location to keep
a golden host-free: the product WRITES into the window it is handed (
record_ownerunlinks.owner, the gate probeshutil.rmtrees it), and a path that "never exists" is onemkdiron some host away from a real deletion -- answer a real directory undertmp_pathand stub the golden's env contribution instead - A test whose red survives neither running the file ALONE nor running it WITHOUT
-p probe_pluginis the probe's finding, not the suite's: a per-test observer that reachesos.paththrough the module a spied test patches records itself as the code under test - A child that polices its OWN peak memory reads
/proc/self/statusVmHWMon Linux, nevergetrusage(RUSAGE_SELF).ru_maxrss:execveseedsru_maxrsswith the parent's high-water mark, so a child of a 2 GiB xdist worker (or gateway) reads over its ceiling before it has parsed a byte -- green at-n0, red under the suite; the test that pins it plants a BLOATED PARENT and asserts the child still finishes - A test that plants a fake executable under
tmp_pathand expects a guard LATER than the working-tree fence to fire pins the fence root (_WORKTREE_ROOT) to atmp_pathsibling that is not the fake's ancestor -- under a repo-internalTMPDIRthe fence wins first, and a patchedwhich()that answers the fake forgittoo can hide it on CI - A module that boots the REAL
start_dashboardpinshooks_integration._lifecycle_dispatcher/_route_registrytoNoneBEFORE the boot (somonkeypatchrestores them); a module whose assertions assume "never booted" says so with an autouse pin. Attribute a flaky 409 by its BODY (hooks disable failed: ... MagicMock ... await), not by the endpoint that answered - A PTY test that asserts a child receives a signal gates the send on
os.tcgetpgrpon the PTY's own descriptor leaving the shell's group -- the line-discipline ECHO of the command is not proof the shell has read it (VINTRflushes unread input and the test passes with no child ever born) -- and reaps the session group in afinally; a spawn that hands a shell a terminal resets inheritedSIG_IGNtoSIG_DFLfirst, because a backgrounded launcher's ignored SIGINT survivesexecinto every descendant - A module-scoped autouse fixture never wraps the module in
mock.patch.dict(os.environ, {...}): the keys are live between tests for every module the worker runs meanwhile, while the function-scoped conftest floor already overrides the home during each body -- set the feature flag per test withmonkeypatch.setenv - A memoised per-file read on a tree scan (
lru_cacheon_read_text, atuple(...)corpus helper) keeps every file resident for the process; the seam streams, and the test consumes the iterator (count, path set,zip(strict=True)) instead of materialising it - A test's own
kiro-cli --versionis never the HOST's: every agent-spec write reachesinstalled_kiro_cli_version(), cached per process, so the module pins it toSPEC_PERMISSIONS_MIN_VERSION(house fixture) or the host's install decides the contract under test - A coreutil a real-bash harness must neutralise (
sleepin a retry backoff) is a shell FUNCTION defined at the top of the driver script, never an executable planted earlier onPATH: Git for Windows'bin\bash.exelauncher prepends/usr/binto whateverPATHit is handed, so the shim never wins there and every failing case sleeps the whole budget; record each call and assert the SCHEDULE, not the clock - A Windows Job
ActiveProcessLimitsized as "this child and nothing else" is1 + platform_compat.python_launcher_hops(): a venv'sScripts\python.exeis a redirector that spawns the real interpreter as its child, so a ceiling of exactly one refuses the spawn it was meant to bound (exit 101,Unable to create process using) - A test that asserts a key PASSES THROUGH a scrub (
HOME,PATH) plants that key in the parent first — CI runners exportHOMEon Windows, a server session does not, and the assertion otherwise measures the host - A refusal raised by an object that holds a process-global resource (a crew-log handle
and its write lease) is asserted through a CLASS-based context manager whose
__exit__copies the exception's fields into a plain record and returns -- neverpytest.raises(...) as exc(theExceptionInfoand the test frame form a cycle that keepsappend'sselfalive until the cyclic collector runs) and never a@contextlib.contextmanager(throwing into a generator adds the same cycle through the generator's frame); the file's autouse teardown pins the table (lease._held) empty withoutgc.collect() - A stand-in handed to a table that judges liveness by probe (
RUNTIME_TENANCY._alivereadsis_alive/is_process_alive) either answers the probe or its test releases the claim itself -- a fake with neither is alive for the rest of the worker -- and a stubbed_dispose_*still owes every release the real one performs - A test that hands the reaper a reset that never returns (
_hanging_reset) pins_RESET_TIMEOUTthe way its sibling classes do; a passing test whose wall time lands on a product constant (30.0 s, 60.0 s) with the CPU idle is spending that constant, not measuring it --classify.py's TIMEOUT-SHAPED rows name them -
monkeypatch.delenv(key, raising=False)on a key that is ABSENT records no undo: when the product thenos.environ.setdefaults it,setenvthe key first so the fixture's undo removes whatever the product leaves - A harness that runs a real shell script SUPPLIES every tool the script requires that
the host may lack (
jqbeside theghandcurlit already stubs) as a shell function in itsBASH_ENVfile, implementing exactly the invocations the script makes and failing loudly on any other -- never apytest.skipon the missing tool, which is a loosened ratchet (a-ratchet-may-only-tighten); probe the host through the bash the script will run under, notshutil.which, and prefer the real binary where that bash has one - A leaked child whose spawning thread is a NAMED pool reaper (
mc-*-reaper) is the sharedexecutorspool warming, not the test's child: the fix is at the pool (ashutdownthat joins its reaper BEFORE the kill loop, so a tick past its stop check cannot refill the slot it just emptied) and at session end (shutdown_maintenance_executor()in atest/conftest.pyfixture), never a reap in the first test that happened to trigger it - Two writers creating one sidecar on a fresh directory open it
O_CREAT | O_EXCLfirst and reopen withoutO_CREATonFileExistsError: a nonexclusiveO_CREATcan lose the create race on Darwin with a bareENOENTfor the leaf, and apruneor flush that swallows it silently skips its work (_open_lock_sidecar) - A test double's pid (
4242) is a shared name across hundreds of files, so any process-wide table keyed by pid (runtime_ownership's leases and tenancy) is reset on BOTH sides of every test by atest/conftest.pyautouse fixture; a kill-gate assertion that readsrefusedwhere it expects the failed-kill wording is a neighbour's lease, not the gate - A race detector never parks on a
threading.Barrier(..., timeout=)a correct interleaving cannot reach: the correct code then pays the whole timeout every run and pass is told from fail by elapsed time. Park on anEvent, signal the arrival you are waiting for through the seam under test, and assert the event count while parked -
monkeypatch.chdirdoes not put acwdon the spawn: the descriptor still sayscwd=None, indistinguishable to a per-spawn audit from a spawn in the checkout. Pin the helper's seam instead --runner=partial(subprocess.run, cwd=...), or the script module'ssubprocessbinding replaced with a namespace whoseruncarriescwd - An object whose constructor opens SQLite (
SubagentManager->tasks.db,KnowledgeStoreper thread,SkillsLoader-> the skill index) is closed through its production close path at teardown --test/conftest.py'sclose_subagent_managers/close_skills_loadersopt-in fixtures,opened(...), or_close_all_for_tests()when ANOTHER thread held a connection (close()is per-thread) -- never left to the cyclic collector, whose timing is what makes the descriptor count flap between runs