跳到正文
DEV Community· Finley Zhou·· 3 小時前AI 評分64

智能體補丁可能悄悄縮減測試套件:比較收集到的測試清單

Agent Patches Can Quietly Shrink Your Test Suite: Diff the Collected Inventory

AI 導讀

Finley Zhou 提出一套審查 AI 智能體補丁的測試策略,透過 pytest 快照測試清單並比較補丁前後差異,阻擋未經核准的測試刪除或靜音。策略以有期限的 allowlist 管理獲批例外,並建議用 property checks 測試新增功能。作者亦指出,清單差異無法識別通過但沒有有效斷言的測試,且前後收集時須使用相同參數。

正文

Agent Patches Can Quietly Shrink Your Test Suite: Diff the Collected Inventory

A patch that turns a red suite green by deleting, skipping, or narrowing tests is the cheapest cheat available to an automated contributor, and it is the one most review processes miss. Results-based review cannot see it: the run is green, the exit code is 0, and the CI log scrolls past the missing node. The rule I use is blunt — test results are only evidence if the inventory that produced them is unchanged or wider.

This is a testing strategy for agent-authored patches built on four pieces: a collected-inventory snapshot, a three-bucket diff, property checks for the new surface, and an expiring freeze on flakes. Everything below runs with pytest and the standard library.

The failure mode has more shapes than "deleted test"

Change in the patch How it hides
Test function removed Node disappears; total count drops by one in a 900-line log
@pytest.mark.skip added Node still runs in collection, reported as skipped
xfail added Reported as expected failure — looks like progress
conftest.py fixture stubbed to return a constant Node passes, assertion never sees real data
pytest.ini / pyproject.toml testpaths narrowed Whole directories silently outside the run
Parametrize list trimmed Fewer cases, same test name

Six different edits, one shared property: the set of tests that executed shrank while the summary line stayed reassuring.

Step 1 — Snapshot the inventory, not the results

pytest --collect-only -q gives you node IDs, but it drops markers and fixtures. A 20-line plugin gives you the full record as JSON.

# tools/inventory_plugin.py
import json

def pytest_addoption(parser):
    parser.addoption("--inventory-out", action="store", default=None)

def pytest_collection_modifyitems(config, items):
    out = config.getoption("--inventory-out")
    if not out:
        return
    rows = [
        {
            "node": item.nodeid,
            "marks": sorted(m.name for m in item.iter_markers()),
            "fixtures": sorted(getattr(item, "fixturenames", [])),
        }
        for item in items
    ]
    with open(out, "w", encoding="utf-8") as fh:
        json.dump(rows, fh, indent=1, sort_keys=True)

Capture before and after the patch is applied. tools/ needs an __init__.py, or set PYTHONPATH=. so -p tools.inventory_plugin resolves.

pytest --collect-only -q -p tools.inventory_plugin --inventory-out inventory/before.json
# apply the agent patch
pytest --collect-only -q -p tools.inventory_plugin --inventory-out inventory/after.json

Note that collection also picks up the CI config, so narrowing testpaths shows up as a mass "removed" event. That is the point: the snapshot measures what would run, not what happened to run.

Step 2 — Classify the diff into three buckets

Three buckets matter, plus one allowlist. Added nodes are free. Removed and newly-muted nodes are blockers unless a human signed them off.

#!/usr/bin/env python3
"""inventory_diff.py — exit 1 when a patch shrinks or mutes the test inventory."""
import json, sys
from pathlib import Path

MUTING = {"skip", "skipif", "xfail"}

def load(path):
    return {r["node"]: set(r["marks"]) for r in json.loads(Path(path).read_text())}

def classify(before_path, after_path):
    b, a = load(before_path), load(after_path)
    shared = b.keys() & a.keys()
    return {
        "added": sorted(a.keys() - b.keys()),
        "removed": sorted(b.keys() - a.keys()),
        "muted": sorted(n for n in shared if (a[n] - b[n]) & MUTING),
        "unmuted": sorted(n for n in shared if (b[n] - a[n]) & MUTING),
    }

def allowlisted(entries, node, today):
    return any(e["node"] == node and e["expires"] >= today for e in entries)

if __name__ == "__main__":
    report = classify(sys.argv[1], sys.argv[2])
    print(json.dumps(report, indent=1))
    allow = json.loads(Path("inventory/allow.json").read_text())
    today = __import__("datetime").date.today().isoformat()
    blockers = [n for n in report["removed"] + report["muted"]
                if not allowlisted(allow, n, today)]
    sys.exit(1 if blockers else 0)

The allowlist entry is deliberately annoying to write, because every entry is a claim that a test was wrong to exist:

[{ "node": "tests/test_legacy_io.py::test_pickle_roundtrip",
   "reason": "fixture removed with the legacy encoder in commit 4a91c2",
   "author": "@finley", "expires": "2026-11-30" }]

Step 3 — Add property checks for the surface the patch added

A patch that deletes no tests can still be untested. When an agent adds a new public function, the inventory diff shows added nodes — but not whether those nodes constrain anything. For pure or near-pure surface, start with properties rather than examples.

from hypothesis import given, strategies as st

@given(st.text())
def test_normalize_is_idempotent(s):
    once = normalize(s)
    assert normalize(once) == once

@given(st.text())
def test_normalize_is_empty_on_empty(n):
    assert normalize("") == ""

These two are templates, not a finished strategy: replace them with the invariants your patch actually threatens — ordering, idempotence, conservation of keys, monotonicity, round-trip with an existing inverse. The value here is that a property test fails on inputs the agent never imagined, which is precisely where example-based tests written by the same model tend to be blind.

Step 4 — Freeze flakes with an expiry, and keep them in the denominator

A flaky node is not evidence either way, so it must leave the numerator. It must not leave the denominator, or the suite quietly becomes the set of convenient tests.

[{ "node": "tests/test_worker.py::test_drain_order",
   "class": "ordering", "first_seen": "2026-10-09", "expires": "2026-10-23",
   "evidence": "runs 1188 vs 1193, same commit, different drain order",
   "owner": "@finley" }]

Three rules make the ledger useful instead of decorative. First, a frozen node still appears in after.json, so if it vanishes from collection the diff reports it as removed — freezing never licenses deletion. Second, the expiry is a date, not a status; the day it passes, the node is a normal failing test again and blocks the patch. Third, the freeze is committed in the same change that introduces it, so reviewers see the trade instead of a green check.

Step 5 — Adjudicate the diff with a free model, on a runner you do not babysit

Steps 1–4 produce an artifact nobody has time to read: a JSON list of removed and muted nodes with reasons attached. That triage is the only part that benefits from a model — for each node, decide whether the patch's stated scope justifies the change. I keep that job on MonkeyCode: the free model access covers the triage pass on every patch, and the free server option runs the collect-and-diff job off my laptop, which matters because the job's whole value is that it runs unconditionally. (These two availability claims are from the operator; check current terms before relying on them for anything load-bearing.)

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The triage prompt is short and constraining on purpose: here is the patch diff, here is the list of removed and muted nodes; for each, answer justified or collateral with the file path that supports the answer; do not propose new tests. The model's job is sorting, not authoring — an agent that can write tests can also write the skip marker that hides them.

Verdict table

Condition Verdict
removed or muted non-empty and not allowlisted REJECT — no human signature
Allowlisted, but the entry has expired REJECT — re-triage
Inventory unchanged or wider, frozen nodes within expiry, properties pass ACCEPT
Inventory wider, but every added node is skipped or xfailed REVIEW — added tests that assert nothing new

Limitations and who should not use this

It cannot detect a test that runs, passes, and asserts nothing — the inventory is a count of nodes, not of constraint, and tautological assertions need a separate check. It mis-reports on suites whose collection depends on environment variables or plugin options that differ between the before and after runs, so snapshot both sides with identical flags. A one-line allowlist entry is enough to wave through a deletion, which means the gate is only as good as the person who signs it.

Skip this approach if your suite is under a few dozen tests and one person reads every diff — the snapshot is overhead you will not repay. It is also a poor fit for repositories where collection is already non-deterministic, because the diff will be dominated by noise until that is fixed first.

Where to start

The smallest useful version is the plugin, the two --collect-only runs, and an exit code. That alone would have caught the patches I have seen pass review with a skipped integration test and no comment. The ledger and the properties come second; the triage pass on free model access is what makes them survivable on a busy week. Start by snapshotting your current inventory today and committing it — a baseline you never took is the one thing you cannot reconstruct later.

來源:DEV Community · dev.to