Most agent-patch review stops at the diff of src/ and tests/. That is one layer too high. The patch that makes a red suite green often edits the setup layer — conftest.py, factories, seed data, golden files — and those edits are invisible to any check that only counts collected test IDs or compares test bodies.
This post adds the missing layer: a fingerprint over fixture files, a recorded map of which test uses which fixture, property assertions on the fixtures themselves, and a flake freeze ledger that expires. Everything below is a small script you can run in CI, not a framework.
The failure mode: green suite, different world
Three shapes recur in agent patches. A factory starts returning objects with the same email, so a uniqueness check becomes unreachable. A conftest.py fixture stops rolling back the DB session, so test N+1 sees rows from test N. A golden JSON file is regenerated to match new output, so the assertion now compares the patch to itself.
Each of those passes the suite honestly. None of them is a code change you would have approved if you had seen it. So make the setup layer an explicit, reviewable input.
Step 1 — Fingerprint the setup layer
Start with a content digest over every file that can change test state rather than test assertions.
# audit/setup_fingerprint.py import hashlib, json, pathlib, sys ROOT = pathlib.Path(__file__).resolve().parent.parent GLOBS = ( "conftest.py", "**/conftest.py", "tests/fixtures/**/*.py", "tests/factories/**/*.py", "tests/data/**/*", ) def digest() -> dict[str, str]: out = {} for pattern in GLOBS: for path in ROOT.glob(pattern): if not path.is_file() or "__pycache__" in path.parts: continue rel = path.relative_to(ROOT).as_posix() out[rel] = hashlib.sha256(path.read_bytes()).hexdigest() return dict(sorted(out.items())) if __name__ == "__main__": current = digest() if len(sys.argv) > 1 and sys.argv[1] == "--write": (ROOT / "audit/setup_baseline.json").write_text( json.dumps(current, indent=2, sort_keys=True) + "\n" ) print(f"wrote {len(current)} setup files") else: baseline = json.loads((ROOT / "audit/setup_baseline.json").read_text()) changed = sorted( p for p in set(baseline) | set(current) if baseline.get(p) != current.get(p) ) print("\n".join(changed) or "setup layer unchanged") sys.exit(1 if changed else 0) Run --write on the parent tree once, commit the baseline, and let the patch run the compare form. A non-zero exit means the review now has to look at setup files it would otherwise skim.
Step 2 — Record which test needs which fixture
A changed factory matters only if some test consumed it. Collect the usage map during a normal run.
# audit/fixture_usage_plugin.py import json, os, pathlib _usage: dict[str, list[str]] = {} def pytest_collection_modifyitems(session, config, items): for item in items: names = getattr(item, "fixturenames", None) if names is None: # older/newer internals shift; degrade instead of crash names = [] _usage[item.nodeid] = sorted(set(names)) def pytest_sessionfinish(session, exitstatus): out = pathlib.Path(os.environ.get("FIXTURE_USAGE_OUT", "audit/fixture_usage.json")) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(_usage, indent=2, sort_keys=True) + "\n") Enable it from the root conftest.py so CI and local runs agree:
pytest_plugins = ["audit.fixture_usage_plugin"] python -m pytest -q -p no:cacheprovider python -c "import json;u=json.load(open('audit/fixture_usage.json'));print(len(u),'tests mapped')" The map is a heuristic: it records fixture names, not definitions. Combine it with a grep for def <name>( under the setup globs to attribute a fixture to a file. Good enough to answer "does anyone still use this?" — and honest about not being a call graph.
Step 3 — Assert properties of the fixtures, not just outputs
Fixture bugs are invariants bugs. Write a handful of properties that fail loudly when the setup layer drifts. Adapt fixture names to your project; these are runnable as written against a conventional factory + session fixture.
# tests/test_setup_properties.py from tests.factories import make_user def test_factory_ids_are_unique(): ids = {make_user().id for _ in range(500)} assert len(ids) == 500, f"factory collided: {500 - len(ids)} duplicates in 500 draws" def test_factory_does_not_leak_defaults(): a, b = make_user(), make_user() assert a.email != b.email, "email default is a constant" assert a.created_at <= b.created_at, "created_at is not monotonic" def test_db_fixture_isolates_writes(db_session): db_session.add(make_user(email="isolation@example.com")) db_session.flush() assert db_session.query(type(make_user())).count() == 1 Three properties, one minute of runtime, and they catch the exact class of patch that turns an assertion into a tautology. Keep them in a file the patch author is unlikely to touch, and require review when it changes.
Step 4 — Freeze flakes with an expiry date
Quarantining a flaky test is legitimate. Quarantining it forever, or quarantining a test the patch itself introduced, is not. Make the freeze data, not a comment.
[ { "test": "tests/test_billing.py::test_retry_window", "frozen_at": "2026-10-10", "expires": "2026-11-09", "reason": "upstream clock skew in CI; see #4120", "owner": "payments" } ] Three rules make the ledger useful:
-
expiresis mandatory and at most 30 days out; an expired entry fails CI. - A frozen test may not be edited by the same patch that relies on the freeze — thaw first, then change.
- A newly added test may never appear in the ledger. You cannot freeze evidence you just created.
Step 5 — One gate that enforces all four
# audit/gate.py import datetime, json, pathlib, subprocess, sys ROOT = pathlib.Path(__file__).resolve().parent.parent SETUP_MARKERS = ("conftest.py", "tests/fixtures/", "tests/factories/", "tests/data/") def git(*args): return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True).stdout.split() def main() -> int: changed = git("diff", "--name-only", "origin/main") added = git("diff", "--name-only", "--diff-filter=A", "origin/main") failures, today = [], datetime.date.today() touched_setup = [p for p in changed if any(m in p for m in SETUP_MARKERS)] if touched_setup and not (ROOT / "audit/fixture_changes.md").exists(): failures.append( f"setup layer changed {touched_setup} without audit/fixture_changes.md" ) ledger = json.loads((ROOT / "audit/flake_ledger.json").read_text()) for entry in ledger: path = entry["test"].split("::")[0] if datetime.date.fromisoformat(entry["expires"]) < today: failures.append(f"freeze expired: {entry['test']} ({entry['expires']})") if path in changed: failures.append(f"patch edits a frozen test: {path} — thaw it first") if path in added: failures.append(f"new test submitted already frozen: {path}") for f in failures: print(f"FAIL {f}") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main()) Wire it as the first CI step, before tests. It costs under a second and it fails on the two things review reliably misses: silent setup edits and self-granted quarantine.
Where MonkeyCode fits, and where it does not
Property candidates are the tedious part: reading a factory and inventing invariants. I used MonkeyCode's free model access for that proposal loop — dump a fixture file, ask for five candidate properties, keep the ones that are falsifiable, throw the rest away. The gate above stays deterministic; no model runs at merge time. Its free server option is the other half: sampling a suspected flake over a week of repeated runs needs a machine that is not your laptop and not a metered CI runner.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Decision table for a red run on an agent patch
| Observation | Most likely cause | Action |
|---|---|---|
| Setup fingerprint changed, no fixture changelog | Patch rewrote fixtures or seed data | Require audit/fixture_changes.md; review the fixture diff first |
| Fingerprint clean, one test flips between runs | Genuine flake, or order dependency | Sample 20 runs; if it flips, freeze with expiry — never inline |
| Fingerprint clean, test now passes for the wrong reason | Assertion weakened or property removed | Diff tests/test_setup_properties.py; restore it |
| Property test fails after a factory edit | Invariant broken by setup change | Fix the factory; do not relax the property |
| Frozen test edited in the same patch | Quarantine used as a shortcut | Reject; thaw, fix, re-freeze if still flaky |
Limitations and who should skip this
This gate is about state, not correctness. It will not tell you whether the patch solves the bug — only whether the test world stayed comparable. The fixture usage map is name-based and will over-attribute fixtures defined in shared base classes. The property file needs a human to keep it meaningful; three noisy properties are worse than none.
Skip it if your suite is small enough that a reviewer reads every fixture diff, if you have no CI gate to hang the script on, or if your flakiness comes from a third-party network call you cannot replay — there, the honest fix is a recorded transport, not a longer freeze. And if your team treats audit/flake_ledger.json as a place to park problems indefinitely, delete the ledger: you have built a monument, not a gate.
Start with Step 1 today. One baseline file, one second of CI, and the next agent patch that quietly rewrites conftest.py shows up as a red line instead of a green suite.
