针对补丁注入 5 至 15 种合理故障,逐个独立运行测试,建立故障与测试的归因矩阵。矩阵区分未被捕获、仅被一个测试捕获和被多个测试捕获的故障,暴露测试缺口与脆弱断言。
测试套件全绿,只能证明补丁通过了测试。它不能证明:如果补丁有错,某个具体检查一定会失败。这是两种不同的判断,而 Agent 编写的补丁总是让人把它们混为一谈:新增了一个测试,测试套件变绿,然后就没人追问,究竟哪条断言真正起到了把关作用。
在信任这片绿色之前,我会做下面这项审计:注入一小组合理的故障,把每个故障分别交给每个测试单独运行,再记录究竟是哪个测试——如果有的话——捕获了它。最终产物是一张归因矩阵,而不是一个覆盖率数字。
大多数补丁合入检查只回答一个问题:代码出错时,测试套件会不会变红?这叫检测。归因则继续追问:是哪项检查让它变红的?
这个区别很重要,绝不是纸上谈兵。一个故障被十一个测试捕获,说明它得到了充分覆盖。一个故障恰好只被一个测试捕获,可能一次重构就会让它漏网。一个故障没有任何测试能捕获,说明你一直带着这个缺口交付代码。
因此,这份产物会为每个故障给出三种结果之一:漏检、仅捕获一次、被反复捕获。只有前两种需要采取行动。
不要编造离奇的 bug。写出五到十五个故障,都是审查这个补丁的人有理由担心的问题。把它们保存成 git 补丁,这样既能干净地重新应用,也能像代码一样接受审查。
faults/
001-range-off-by-one.patch
002-swap-arg-order.patch
003-drop-none-guard.patch
004-wrong-default-status.patch
005-cents-to-dollars-rounding.patch
故障补丁应该足够小,而且可以撤销:
--- a/src/pricing.py
+++ b/src/pricing.py
@@ -12,3 +12,3 @@ def bucket_total(rows, size):
- for i in range(0, len(rows), size):
+ for i in range(0, len(rows) - size, size):
第一步:阅读 Agent 的 diff,列出它声称会保持的不变量。第二步:为每个不变量编写一个会破坏它的故障。第三步:最多写十五个,就此打住。一份两分钟就能读完的目录,才有人持续维护;自动生成几千个变异的集合,没人会维护。
下面的运行脚本每次应用一个故障,先运行一次完整测试套件来测量检测能力,再单独运行收集到的每个测试来测量归因。还原操作通过 git 完成,因此即使运行崩溃,也不会把故障留在工作树中。
#!/usr/bin/env python3
"""attribution.py - which check catches which fault?"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent
FAULTS = sorted((ROOT / "faults").glob("*.patch"))
PYTEST = [sys.executable, "-m", "pytest", "-q", "-p", "no:randomly"]
def sh(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=900)
def git(*args: str) -> None:
subprocess.run(["git", *args], cwd=ROOT, check=True, capture_output=True, text=True)
def collect_tests() -> list[str]:
out = sh(PYTEST + ["--collect-only", "-q"]).stdout.splitlines()
return [line.strip() for line in out if "::" in line]
def green(test: str | None) -> bool:
return sh(PYTEST + ([test] if test else [])).returncode == 0
def main() -> int:
tests = collect_tests()
matrix: dict[str, dict[str, bool]] = {}
for fault in FAULTS:
git("apply", str(fault))
try:
row = {"__full_suite__": green(None)}
for t in tests:
row[t] = green(t)
matrix[fault.stem] = row
print(f"ran {fault.stem}", file=sys.stderr)
finally:
git("checkout", "--", ".")
git("clean", "-fd", "src", "tests")
pathlib.Path("attribution.json").write_text(json.dumps(matrix, indent=2))
return report(matrix)
def report(matrix: dict[str, dict[str, bool]]) -> int:
tests = [k for k in next(iter(matrix.values())) if k != "__full_suite__"]
escapes = 0
print(f"\n{'fault':34} {'suite':6} attribution")
for fault, row in matrix.items():
red = [t for t in tests if not row[t]]
if row["__full_suite__"]:
escapes += 1
label = "ESCAPED"
elif len(red) == 1:
label = f"single: {red[0]}"
else:
label = f"shared by {len(red)} tests"
print(f"{fault:34} {'red' if not row['__full_suite__'] else 'green':6} {label}")
return 1 if escapes else 0
if __name__ == "__main__":
raise SystemExit(main())
运行时,要给自己设定明确的预算。十二个故障对六十个测试,意味着要调用 pytest 732 次,因此先把测试收集范围限定在涉及补丁所改模块的文件中,再逐步扩大。
python -m pytest --collect-only -q | grep -E 'test_(pricing|orders)' > /tmp/scope.txt
python attribution.py 2>/tmp/attribution.log; echo "exit=$?"
这里的退出码是有意设计的:只要有故障漏检,任务就会失败。这样就可以把矩阵放进 CI,正常通过时不需要人工查看。
漏检率。 注入故障后,完整测试套件仍然全绿的故障占比。目标是零,否则就要针对每个故障明确写下接受理由。在处理完这一项之前,报告里的其他内容都不重要。
唯一检测者。 单独运行时,恰好只有一个测试变红的故障。这些就是你脆弱的合入依据。把它们记下来;这些测试不能随意跳过、重命名或冻结。
重复覆盖。 在多个测试中都会变红的故障。这通常无害,有时还很健康,但偶尔也说明:十二条断言都在测试同一件事,另外十一件事却无人问津。
归因通过单独运行每个测试来测量,检测则通过运行完整测试套件来测量。这两类运行不共享 fixture 或执行顺序,因此某个测试单独运行时可能变红,放进完整套件却可能变绿,反过来也一样。遇到不一致,要把它当作需要调查的信号,而不是测试工具有 bug。
空 fixture 会让所有下游断言失去实际意义,而这种空洞的断言,看起来和正常通过的断言一模一样。要守住输入,不能只检查输出。
# tests/conftest.py
import pytest
REQUIRED = {"id", "total_cents", "status"}
@pytest.fixture
def orders(load_fixture):
rows = load_fixture("orders.json")
assert rows, "orders.json is empty; downstream assertions would be vacuous"
missing = REQUIRED - set(rows[0])
assert not missing, f"fixture drifted, missing keys: {sorted(missing)}"
return rows
fixture 哨兵本身也是检查。如果某个故障只触发了哨兵,归因表就会如实呈现,你也就知道,真正的断言根本没有执行到。
对于不稳定测试,是否允许冻结,通常只看症状:某个测试在 CI 中失败了两次。有了矩阵,你就能算清冻结的代价。如果准备隔离的测试是某个故障的唯一检测者,冻结它就会悄悄地把这个故障变成漏检故障。
我的规则是:冻结必须有明确的负责人、到期日期,以及一份凭据,证明另一项稳定的检查仍能捕获这个被冻结测试原本负责捕获的故障。没有凭据,就不能冻结。如果没有替代检查,这个不稳定测试就必须留在套件里,并且要修好它,因为一个不稳定的唯一检测者,比明确亮起的红灯更糟糕。
最后这一行结果让我保持清醒:如果故障目录是空的,矩阵再干净,也只能说明我没有认真思考这个补丁。
这个循环里,有两件事只是烦琐,并不困难:从 diff 中拟定合理的故障,以及连续运行数百次测试套件。MonkeyCode 的免费模型访问对第一件事有帮助——让模型根据 Agent 自己的 diff,提出候选不变量和故障补丁,几分钟就能得到一份初始目录。对于模型起草的任何故障或断言,我都必须亲自应用,并查看矩阵中对应的那一行,才会接受;模型负责提议,运行脚本负责裁决。
免费服务器选项解决的是第二件事:矩阵中不同故障的运行几乎可以毫无依赖地并行执行,因此我可以把任务放到笔记本之外运行,自己继续编辑。这两项都是运营方提供的可用性声明,并非基准测试结果——在接入 CI 之前,先测量你自己的运行耗时。
披露:本文是 MonkeyCode 产品推广活动的一部分。
归因只能覆盖你想到的故障。矩阵干净,只能说明你的故障目录所要求的检查都已满足,绝不意味着补丁正确。目录本身决定了这套方法的上限。
只要测试之间共享 fixture、执行顺序或模块级状态,单独运行的结果就可能与完整套件不同。参数化断言和动态生成的断言,也很难与测试节点 ID 清晰对应。遇到这些棘手情况,要做好手动归因的准备。
如果你的套件每次运行都要花上好几分钟,而且无法分片;如果仓库没有可靠的还原路径;或者补丁涉及的模块没有现成 fixture 可供添加哨兵检查,那就跳过这套方法。也不要把矩阵转换成覆盖率百分比——它是针对每个故障逐一给出的论证,取平均值会毁掉它所承载的唯一有用信息。
先从五个故障开始,只针对 Agent 实际修改过的文件运行。如果第一次运行就发现了一个漏检故障,这套工具就已经值回成本了。如果你想先试试再配置资源,免费套餐足够运行五个故障并查看结果表。
如需进一步采取行动,可以考虑屏蔽此人和/或举报滥用行为。