[控场AI]
· 12 分钟阅读· 6,451 字

AI补丁测试审计:别被42 passed骗了

AI补丁测试审计:别被42 passed骗了

AI代理报告的「42 passed」只证明被选中的测试通过,不等于整套900个测试通过。

文章指出,AI代理补丁报告「42 passed」时只证明了这42个测试跑过,仓库里未被执行的858个测试只是尚未被定价的假设。为此提出一套可审计的证据包与四道闸门:分别记录 collected、selected、executed 三种计数并做包含关系检查(executed ⊆ selected ⊆ collected);用逐测试覆盖率上下文把变更行映射到归属测试,找出无归属测试的不可达变更;维护 fixture 读取集注册表做闭包检查;对 flaky 隔离台账做哈希冻结,新增隔离或延长 expires 需人工签字。文章还给出 conftest.py 记录器、reachability.py 等参考实现,并将证据打包供第二模型做对抗性审查,最后说明了行级可达性的局限与不适用场景。

一个AI代理提交的补丁报告「42 passed」时,它只证明了这42个测试跑过且通过,而不是整个测试套件通过。当仓库实际收集了900个测试,而补丁只执行了42个,剩余858个测试并非证据——它们只是尚未被定价的假设。

这种范围声明需要变得可审计。核验思路是构建一个小型证据包配合四道闸门:选择/执行台账、从变更行到归属测试的可达性检查、fixture读取集的闭包检查,以及哈希冻结的隔离台账,确保补丁无法悄悄扩大它被允许跳过的测试集合。

rss source: Test Selection Is a Claim: Audit Which Tests an Agent Patch Actually Ran

三类计数:补丁通常只引用最后一个

一次测试运行至少会产生三个不同的数字,而补丁通常只报告最后一个。

计数产生阶段能证明什么
collected收集阶段测试仍存在于代码树中
selected-k、--lf、impacted-test工具补丁声称覆盖的范围
executed(call阶段)运行报告测试跑到了裁决结果
skipped / xfail运行报告在分类前,什么都证明不了

核心闸门是一个包含关系检查:executed ⊆ selected ⊆ collected。如果某个测试被执行了却从未被选中,说明测试框架在撒谎,该次运行的任何裁决都不可信。如果某个变更行在executed中没有对应的归属测试,补丁就存在不可达变更,需要书面说明理由。

pytest 的 collect / call 阶段:pytest 的测试运行生命周期由若干钩子阶段组成,其中 pytest_collection_modifyitems 属于收集(collect)阶段,此时测试项已被发现并整理成 items 列表,但尚未执行;而 pytest_runtest_logreport 中的 report.when == "call" 代表测试函数本体真正被调用并产生裁决结果的阶段。两者之间还有 setup 和 teardown 阶段。之所以要区分,是因为 -k、--lf(last-failed)、--deselect 等选择机制都作用在 collected 与 selected 之间,而跳过(skip)可能发生在收集期(如 pytest.mark.skip 或模块级 skip),也可能发生在 setup 期。若不把 collected、selected、executed 分文件记录,就无法判断某个测试是「从未被收集」还是「被选中但被条件跳过」,也就无法审计「补丁声称覆盖的范围」与实际执行范围之间的差值。

第一步:分别记录选择与执行

收集阶段和call阶段是两个不同事件,载荷不同,应写入不同文件。

# conftest.py — 证据记录器(参考实现,需适配路径)
import json, os, pathlib

ART = pathlib.Path(os.environ.get("EVIDENCE_DIR", ".evidence"))
ART.mkdir(parents=True, exist_ok=True)
COLLECTED = ART / "collected.jsonl"
EXECUTED  = ART / "executed.jsonl"

def pytest_collection_modifyitems(config, items):
    with COLLECTED.open("w") as fh:
        for item in items:
            fh.write(json.dumps({
                "nodeid": item.nodeid,
                "path": str(item.path),
                "markers": [m.name for m in item.iter_markers()],
            }) + "\n")

def pytest_runtest_logreport(report):
    if report.when != "call":
        return
    with EXECUTED.open("a") as fh:
        fh.write(json.dumps({
            "nodeid": report.nodeid,
            "outcome": report.outcome,
            "skipped": report.skipped,
            "duration": report.duration,
        }) + "\n")

运行补丁自身的命令后,先检查包含关系再读取通过数。跳过测试应归入executed,但必须单独计数。补丁把失败测试转成跳过测试,只是改变了裁决词汇,并未改变行为。

第二步:将变更行映射到归属测试

补丁的diff是输入,逐测试的覆盖率上下文是索引。为每个测试节点记录上下文,导出JSON后求交集。

coverage run -m pytest -q
coverage json -o .evidence/coverage.json

**逐测试覆盖率上下文(contexts)**:coverage.py 默认只记录行是否被执行,而 `--cov-context=test` 或 `dynamic_context = test_function` 会为每一行附加「在哪个测试上下文中被命中」的信息,其产物在 `coverage json` 输出的 `files.<path>.contexts` 字段中呈现,形如 `{"12": ["tests/test_x.py::test_a|run"]}`。这是把「变更行」关联到「归属测试」的关键索引。需要注意:`contexts` 键仅在该文件确实被某测试执行过时才存在,未被任何测试触碰的文件不会有 contexts 条目;且不同 coverage.py 版本对 context 字符串的格式(是否带 `|run`、是否含 setup/teardown)存在差异,因此在 diff 行归属计算前应先验证实际 JSON 的形状,避免把版本差异误判为不可达变更。
# reachability.py — 参考实现;先验证JSON schema
import json, re, subprocess, sys

diff = subprocess.run(["git", "diff", "-U0", "origin/main...HEAD"],
                      capture_output=True, text=True, check=True).stdout
HUNK = re.compile(r"^\+\+\+ b/(?P<path>.+)$"
                  r"|^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")

changed, path = {}, None
for line in diff.splitlines():
    m = HUNK.match(line)
    if not m:
        continue
    if m.group("path"):
        path = m.group("path")
        changed.setdefault(path, set())
    elif path:
        start = int(m.group("start"))
        count = int(m.group("count") or 1)
        changed[path].update(range(start, start + count))

cov = json.load(open(".evidence/coverage.json"))
unowned = []
for path, lines in changed.items():
    contexts = cov["files"].get(path, {}).get("contexts", {})
    for ln in sorted(lines):
        if not contexts.get(str(ln)):
            unowned.append(f"{path}:{ln}")

print(json.dumps({"changed_lines": sum(map(len, changed.values())),
                  "lines_with_no_owning_test": unowned}, indent=2))
sys.exit(1 if unowned else 0)

两个注意事项:contexts键仅在记录了上下文时存在,其形状在不同coverage.py版本间有变化,需检查实际生成的JSON。全新文件中的全新行没有上下文,因此应将「新文件」视为「需要至少一个执行测试来导入该模块」,而不是自动判定失败。

第三步:Fixture读取集闭包

补丁在此有两个失败方向:重写fixture使测试通过,或重写fixture却从未运行读取它的测试。若维护一个以fixture名称索引的注册表,两者都可检测。

pytest --fixtures-per-test -q > .evidence/fixture_usage.txt
# .evidence/fixture_readsets.yaml — 由人工维护,非自动生成
# 仅将磁盘输入足够稳定可哈希的fixture列入。
db_session:
  reads:
    - tests/fixtures/schema.sql
    - tests/fixtures/seed_users.json
clock:
  reads:
    - tests/fixtures/clock.json

闭包规则:若补丁触及了某fixture的reads列表中的任何文件,则每个声明该fixture的测试(依据fixture_usage.txt)都必须出现在executed.jsonl中。否则闸门阻止,补丁作者需在.evidence/scope_exceptions.yaml中写入条目,注明测试名、原因和过期日期。例外需被审查,而非静默通过。

第四步:冻结flaky隔离台账

这是价值最高的闸门,因为受压的代理有一个廉价逃生口:把失败测试标记为跳过。隔离是合理的——flaky测试会摧毁信号——但列表不能被它所保护的补丁编辑。


**quarantine 哈希冻结机制的落地方式**:在 CI 中对 `.evidence/quarantine.yaml` 计算内容哈希(如 `sha256sum`),并与基线分支上一提交的哈希比对,是这套闸门里实现成本最低的一环。策略设计的要害在于「编辑方向」而非「编辑本身」:新增条目或延长 `expires` 会让补丁获得更多可跳过的测试,必须阻断并要求人工签字;而移除条目(在连续多次绿色运行之后)或仅修改 `reason`/`tracker` 不影响隔离集合大小,可自动通过。把哈希写进 CI 步骤后,代理就无法通过在同一提交里悄悄扩充隔离列表来让失败测试「合法消失」。若团队已有 ISSUE 跟踪系统,可要求 `tracker` 字段指向真实工单,并让过期日期触发提醒,防止隔离台账无限膨胀成为永久性信号黑洞。
# .evidence/quarantine.yaml — 在CI中进行内容哈希
- nodeid: tests/test_ingest.py::test_retry_budget
  reason: timing-dependent under parallel execution
  added: 2026-09-02
  expires: 2026-11-02
  tracker: ISSUE-4412
台账变更策略
新增隔离测试阻止;需人工签字
延长现有expires阻止;需人工签字
连续N次绿色运行后移除条目自动允许
仅编辑reason或tracker自动允许

在CI中对文件哈希,与基线分支存储的哈希比较。新增导致构建失败,移除则通过。这一条规则消除了代理补丁隐藏其引入的回归的最常见方式。

第五步:打包证据(可选第二读者)

以上所有步骤都会写入文件,因此证据包是机械化的:

文件生产者供给的闸门
collected.jsonl, executed.jsonl记录器插件包含关系
coverage.json覆盖率上下文可达性
fixture_usage.txtpytest CLI闭包
quarantine.yaml + hashCI冻结

证据包就绪后,第二个模型可作为对抗性读者对JSON而非散文进行审查。问它固定问题——「列出每个没有归属测试的变更行」「如果这行被回滚,哪个执行测试会失败?」——将其答案视为线索,而非裁决。

在干净环境中运行该审查步骤,需要一个不按token计费的模型。MonkeyCode的免费模型访问和免费服务器选项是在临时克隆上运行审计的低承诺方式,免费限制随时间变化,依赖前请检查当前条款。披露:本文是作为MonkeyCode产品外联的一部分准备的。

局限性与不适用场景

  • 可达性是行级的。被覆盖区域内的变更行在逻辑上仍可能未被测试;该闸门缩小搜索范围,并不得出结论。
  • 选择审计不说明断言是否有意义,也不说明收集清单是否缩小。需搭配套件清单diff和可证伪性检查。
  • 逐测试上下文增加运行时和存储。在大型套件上,仅对变更的测试文件记录上下文。
  • 如果完整套件运行只需几分钟以内,跳过此审计,直接运行全部测试、完全跳过选择。审计只在存在选择的地方才值得。
  • 如果无人负责fixture注册表,跳过。过时的fixture_readsets.yaml会以错误理由阻止补丁,团队会学会绕过狼来了的闸门。

本周最廉价可加的闸门是隔离哈希,因为它不需要覆盖率工具,只需一个CI步骤。包含关系检查次之。可达性和fixture闭包只在前两者变得无聊且自动后才值得投入。

#AI补丁测试审计#测试选择范围#pytest审计#Agent Patch测试#测试覆盖率可达性#flaky测试隔离#fixture闭包检查
分享:

相关推荐