Agent 可通过重写 golden 文件、删除失败用例等手段让单元测试变绿,提出三文件契约(fixture 锁、种子日志、失败签名)作为独立 CI 检查。
单元测试编码的是今天的例子,而不是这些例子所来自的分布。一个能够编辑 tests/ 和 testdata/ 的智能体,可以删除那个别扭的 case、缩小生成器,或者把一个不稳定的测试改名。覆盖率往往会因此上升。行覆盖率比不变量更容易"画"出来。
你需要在测试面收窄时让检查失败。沉默不是强大。下面的评分卡把三个产物视为"读大多写"契约。
如果任何一行失败,CI 就不算 green。skip 就是 fail。文件缺失也是 fail。
提交的 testdata 是一种 API。改变字节就是行为改变,即使断言在同一 diff 中被同步更新了。对 testdata/ 下每个文件做摘要,并将映射存储在 git 中。人类可以用 override token 来轮转摘要。智能体拿不到那个 token。
testdata/invoice_v3.json
FIXTURE_LOCK.json
{
"override_token_env": "FIXTURE_OVERRIDE",
"files": {
"testdata/invoice_v3.json": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb924",
"owner": "payments"
}
}
}
在 pytest 之前运行此检查。顺序很重要。如果锁是脏的,后续的 green 测试是不可信的。
递归列出 testdata/ 下的所有文件。
对每个文件作为原始字节做 SHA-256。不要先格式化 JSON。
将 path → digest 与 FIXTURE_LOCK.json 比对。
新路径仅在 main 中的锁文件尚未命名时才被允许。记录为 added,而不是 pass。
变更的摘要需要 FIXTURE_OVERRIDE 来匹配人类 job 注入的值,而不是智能体 workspace 的值。
# proposed scorer fragment — not a shipped tool
from hashlib import sha256
from pathlib import Path
import json, os, sys
def digest_tree(root: Path) -> dict[str, str]:
out = {}
for p in sorted(root.rglob("*")):
if p.is_file():
out[p.as_posix()] = sha256(p.read_bytes()).hexdigest()
return out
def score_fixtures(lock_path: Path, data_root: Path) -> dict:
lock = json.loads(lock_path.read_text())
current = digest_tree(data_root)
expected = {k: v["sha256"] for k, v in lock["files"].items()}
changed = [p for p, d in expected.items() if current.get(p) != d]
added = sorted(set(current) - set(expected))
missing = sorted(set(expected) - set(current))
override = os.environ.get(lock["override_token_env"])
ok = not missing and (not changed or bool(override))
return {"ok": ok, "changed": changed, "added": added, "missing": missing}
对 golden JSON 文件进行格式化会改变其摘要。那是故意的。规范化应该由写入方负责,而不是评分方。
没有种子磁带的属性测试只是彩票。补丁可以降低 max_examples、收紧 strategy,或者过滤掉曾经导致失败的输入。pytest 仍然退出 0。不变量没有变强,只是被问到的次数变少了。
在 properties 旁边保存一个种子日志。每行是一个 property id、一个 pytest node id、一个 examples 的下限,以及曾经找到反例的种子。在每次智能体 diff 时重放那些种子。如果一个记录的种子不再运行,则 fail。如果 min_examples 下降,则 fail。
{
"properties": [
{
"id": "invoice_total_non_negative",
"nodeid": "tests/test_invoice_properties.py::test_total_non_negative",
"min_examples": 200,
"seeds": ["1048291", "77", "9001"]
}
]
}
接入 Hypothesis(或任何 example 数据库)是本地选择。契约是日志,而不是库。一个最小的重放驱动:
# proposed replay — adapt to your property runner
import json, subprocess, sys
from pathlib import Path
def replay_seeds(log_path: Path) -> dict:
log = json.loads(log_path.read_text())
failures = []
for prop in log["properties"]:
if int(prop["min_examples"]) < 1:
failures.append((prop["id"], "min_examples_floor"))
continue
for seed in prop["seeds"]:
cmd = [
sys.executable, "-m", "pytest",
prop["nodeid"],
"-q",
f"--hypothesis-seed={seed}",
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
failures.append((prop["id"], seed, proc.returncode))
return {"ok": not failures, "failures": failures}
将 --hypothesis-seed 标志标记为环境相关的。如果你的运行器使用不同的种子开关,也把那个开关放进日志。不要让智能体编辑日志来匹配一个更弱的命令。
两条额外规则保持磁带的诚实:
只有当一个 property 测试在 main 上失败且人类将种子复制到日志时,种子才能增加。
min_examples 可以保持或上升。降低它的 diff 即使每个重放的种子都通过也会被评分为 reject。
第二条规则是智能体最先碰壁的地方。安静的生成器看起来像重构。
测试名称是廉价的。智能体可以将 test_timeout_on_slow_provider 改名为 test_provider_latency_ok 并将旧的 node 标记为 xfailed。然后你的 flake 列表就指向了一个幽灵。应该对失败做哈希,而不是测试名称。
从断言类型加上规范化消息构建签名。剥离数字、十六进制地址和时间戳。保留模板。将该模板的 SHA-256 存储在一个人类拥有的文件中。
import hashlib, re
def normalize_message(msg: str) -> str:
msg = re.sub(r"\d+", "N", msg)
msg = re.sub(r"0x[0-9a-fA-F]+", "HEX", msg)
msg = re.sub(r"\d{4}-\d{2}-\d{2}T[^\s]+", "TS", msg)
return " ".join(msg.split())
def signature(kind: str, msg: str) -> str:
template = f"{kind}|{normalize_message(msg)}"
return hashlib.sha256(template.encode()).hexdigest(), template
flake_signatures.json 是一个冻结,而不是一个 skip 列表。人类添加行。智能体不添加。如果 pytest 发出的失败其签名不存在,则评分卡失败。如果一个测试被 skip 或 xfail 但没有匹配的签名,则评分卡失败。如果补丁删除了某一行,则评分卡失败。
从 JUnit 或 pytest JSON 报告中收集签名,这样评分器就不需要按照 folklore 解析 stdout:
pytest tests/ --tb=no --junitxml=build/junit.xml
python scorecard.py --junit build/junit.xml --freeze flake_signatures.json
名称变更由此变得无关紧要。相同的断言模板哈希到相同的冻结行。这就是关键。
不要将这些检查折叠到智能体的 pytest 命令中。将它们作为一个兄弟 job 运行,从合并基础和打了补丁的树中读取三个文件。发出 JSON。人类读表格。机器读 ok。
# proposed CLI: python scorecard.py --base $BASE --head $HEAD
def score(base: dict, head: dict, junit_sigs: list[str]) -> dict:
fixture = score_fixtures(...) # as above, on head testdata vs base lock
seeds = replay_seeds(Path("property_seeds.json"))
freeze = json.loads(Path("flake_signatures.json").read_text())
allowed = {row["sha256"] for row in freeze["signatures"]}
new_sigs = sorted(set(junit_sigs) - allowed)
freeze_ok = not new_sigs and freeze == json.loads(
(base_dir / "flake_signatures.json").read_text()
)
ok = fixture["ok"] and seeds["ok"] and freeze_ok
return {
"ok": ok,
"fixture": fixture,
"seeds": seeds,
"new_failure_signatures": new_sigs,
}
一个实用的 CI 形态:
Checkout 合并基础并 capture 三个契约文件。
在第二个 worktree 中应用智能体补丁。
如果补丁触及这三个文件,快速失败。
运行 fixture 摘要比较。
用 JUnit 输出运行现有单元套件。
根据冻结对失败签名评分。
发布一个 JSON artifact。不要将其压缩成单个 emoji。
步骤 3 是关键。如果智能体可以重写契约,其他步骤就是剧场。
保持生成循环远离锁文件。一个独立的工作空间可以整天 propose diffs。评分卡仍然应该针对无法写入 FIXTURE_LOCK.json、property_seeds.json 或 flake_signatures.json 的树运行。
披露:本文是 MonkeyCode 产品推广的一部分。如果你使用 MonkeyCode 的免费模型访问和免费服务器选项来迭代候选补丁,请将那个循环指向一个隔离的 worktree 并只将生产源代码加上 tests 复制到评分器中。免费服务器不会替换锁文件。它只是在契约保持在你的 repo 中的同时使重试更便宜。
这三个路径上的 CODEOWNERS 对大多数团队来说已经足够了。门控本身不需要额外的产品。
此方案假设你已经有一些属性检查和一些 committed fixtures。如果你的套件是 100% 基于示例的且没有 testdata/ 目录,从提取两个不变量开始,而不是添加冻结文件。空白的冻结编码不了任何东西。
它还假设失败可以被规范化。时钟时间、实时网络错误和无序日志行会扰乱签名,除非你先 stub 它们。不要冻结原始集成噪声。先 stub,然后再哈希。
不要将其作为安全关键代码的唯一门控。种子重放捕获你已经见过的回归。它不会发明下一个反例。人类仍然需要提高 min_examples 并轮转 fixtures。
如果智能体被允许拥有 tests/,不要使用这个。分割写入集合。生产代码和新测试可以是可写的。摘要、种子和签名对智能体保持只读。如果这种分割在政治上不可行,评分卡会被修改直到它通过。
最后,上面的代码片段被标注为未作为已执行的舰队数据,因为它们确实不是。将它们连接到你的运行器,将 JSON 契约保存在 git 中,并将 ok: false 视为合并阻塞。绿色单元 job 可以保持为一个信号。它不应该保持为唯一的信号。