AI 补丁挤满 CI,如何筛选必跑测试
面对大量模型生成的补丁,文章建议设置始终执行的测试底线,再选择性运行其他测试以控制验证成本。筛选器必须通过历史补丁回放测量真实故障漏检数,不能仅凭节省的运行时间判断效果。
AI 深度解读
AI 生成补丁越来越快,CI 却未必跑得过来。原文给出的办法是:根据改动选择测试,保留一组每次必跑的测试,再用历史补丁回放,检查到底漏掉了多少真实失败。能否省时间,要先过这道验证。
这套方案值得读的地方,是把「少跑测试」变成了可以审计的工程决策。不过,作者给的是 Python/pytest 原型,没有公布实际节省的时间或失败召回率。代码里的依赖盲区、冻结冲突和审计边界,也需要一起看。
补丁排队之后,为什么不能只盯着全量测试
作者设定的场景是:人类每一两个小时提交一个 PR,Agent 队列却可能每小时生成几十个候选补丁。如果测试套件耗时 12 分钟,30 个候选就要消耗 6 小时 runner 时间。这是累计计算资源开销,并不等于并行运行后的实际等待时间,但足以解释验证为什么会成为瓶颈。
这里要区分两个职责:门禁(gate)决定补丁能否接受,测试选择器(selector)决定为这个判断购买哪些证据。门禁规则再严格,也只能处理已经拿到的测试结果。
选错测试最危险的结果,是该出现的失败根本没有进入报告。
原文提到 MonkeyCode 的免费模型访问和免费服务器选项,也披露文章属于其产品推广内容。作者同时承认,这些只是运营方提供的可用性声明,不保证容量。方案是否成立,不能依赖免费资源长期充足;团队仍然要测自己的运行时间,有 SLA 时还要准备备用 runner。
依赖记录能缩小范围,必跑测试负责补洞
第一步不是只分析静态 import,而是在真实测试运行中记录依赖。原因很具体:测试还会通过 conftest.py、fixture、模块级注册表和数据加载器接触业务代码,静态导入关系覆盖不了全部路径。
作者的 conftest.py 钩子为每个测试记录 files、fixtures、mutable_fixtures 和 seconds。输出位置由环境变量 TEST_MANIFEST 指定,默认是 manifest.json;耗时保留三位小数。
但这份清单只是近似记录。它比较测试开始前后 sys.modules 中的仓库文件,已经导入的模块不会再次出现在差集中。会话启动时就加载完依赖的测试,甚至可能看起来没有依赖。因此,「清单里没关联」不能直接推导出「这条测试不受影响」。
针对会修改共享状态的 fixture,作者要求显式标记:
@pytest.mark.mutable_fixture("billing_db")
def test_invoice_roundtrip(billing_db):
...
这段标记解决的是调度冲突。要看的是 mutable_fixture("billing_db"):选择器据此找出共享同一可变资源的测试,并输出串行执行建议;它不会自己完成串行调度。
选择阶段再把改动路径映射到测试,合入必跑集合 floor,扣除冻结集合 freeze。核心计算是 (touched | floor) - freeze。这里的必跑集合,就是即使依赖记录没命中,也必须执行的安全底线。
脚本提供 --manifest、--changed、--freeze、--floor、--budget-seconds 和 --out。其中 --changed 必填,其余文件默认分别为 manifest.json、freeze.json、floor.json 和 selection.json,预算默认不设置。
报告中的 estimate_seconds 是上次耗时之和,保留两位小数。它只能辅助排期:补丁可能让测试变慢,并行与串行安排也会影响实际等待时间。超过预算时,脚本返回退出码 2,提示只跑底线集合或拆分补丁,并不会自动完成这些操作。
省下的测试有没有代价,要靠回放回答
冻结测试不能被当成已经覆盖。原文用 tests/test_export.py::test_csv_export_roundtrip 举例:如果它被冻结,修改 export.py 后本该执行却被跳过,就应出现在 skipped_frozen 中,并能找到具名负责人。
另一个字段 gap_floor_frozen 更关键,它记录冻结集合与必跑集合的交集。原文要求交集必须为空,否则应解冻测试或更换底线条目。
这里存在一个实现缺口:代码虽然报告冲突,却没有因此返回失败;计算时仍然会把冻结项从底线集合里扣掉。接入 CI 时,调用方必须把这个字段非空视为错误,否则「必跑」只是配置意图。
最后一步是历史回放:对每个历史补丁运行选择器和完整测试套件,再比较失败测试是否被选中。下面这段审计代码最能体现作者的判断标准:
#!/usr/bin/env python3
"""audit_selection.py — replay patches, measure escaped failures."""
import json, pathlib, sys
def failures(path):
text = pathlib.Path(path).read_text().splitlines()
return {l.split(" ", 1)[1].strip() for l in text if l.startswith("FAILED ")}
def main(history_dir):
rows, escaped_total = [], 0
for case in sorted(pathlib.Path(history_dir).iterdir()):
if not (case / "selection.json").exists():
continue
selected = set(json.loads((case / "selection.json").read_text())["selected"])
failed = failures(case / "full_suite.txt")
escaped = sorted(failed - selected)
escaped_total += len(escaped)
rows.append({
"case": case.name,
"failed": len(failed),
"caught": len(failed & selected),
"escaped": escaped,
"recall": round(len(failed & selected) / len(failed), 3) if failed else None,
})
print(json.dumps(rows, indent=2))
print(f"escaped failures across {len(rows)} replayed patches: {escaped_total}")
return 1 if escaped_total else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1]))
要看的是 escaped = sorted(failed - selected):完整套件发现失败,但选择器没有选中,才算一次漏检。recall 则是被选中的失败数除以全部失败数,保留三位小数;没有失败时记为 None。
这段脚本只读取已有的 selection.json 和 full_suite.txt,不会自动应用历史补丁或执行测试。失败解析也只认以 FAILED 开头的行,因此输入报告必须与它的格式匹配。
作者建议保留漏检案例,把相关测试作为底线候选,并追查会话级导入、冻结依赖或动态 fixture 等原因。回放的价值正在这里:它不仅给出一个分数,还能暴露选择规则反复遗漏的路径。
我的判断
我认同「先量漏检,再谈节约」这个顺序。比起随机抽测,这套方法有明确的选择依据;比起每次全量运行,它有机会降低候选队列的验证成本。但作者没有给对照数据,不能据此认定它已经兼顾了速度和可靠性。
必跑集合只能补已知漏洞,历史回放也只能验证历史里出现过的问题。
还有一层边界:审计把「失败测试被选中」计为捕获,却没有验证它在子集环境里是否仍然失败。如果问题依赖执行顺序或共享状态,选中测试也未必足够。它衡量的是选择召回,不能单独证明整个验证流程可靠。
原文也明确排除了收益不足的场景:少于大约 100 个快速测试时,直接全跑通常更合适;大量会话级导入会让依赖记录系统性缺失;没有回放历史时,选择器仍然只是一项猜测。对普通团队来说,维护清单、底线、冻结负责人和回放样本,本身也有成本。
能拿来做什么
- 如果团队已经积压大量 AI 候选补丁,先记录完整套件耗时和候选数量,再让选择器旁路生成报告,同时继续全量测试,积累自己的漏检数据。
- 如果前端或 Node 项目已有测试依赖信息,可以借鉴「按改动选择、合入必跑集合、审计漏检」的流程;原文实现依赖 pytest,不能直接移植后就假定有效。
- 把
gap_floor_frozen非空设为流水线错误,为每条skipped_frozen关联负责人,并让 runner 实际执行serialize中的串行约束。 - 保留每次漏检的补丁、选择报告和完整测试结果,定期重新回放;超出预算时明确选择拆补丁、调整排期或执行底线集合,避免把预算不足当成验证通过。