Under the Sun with Paddy

第 84 期 · 2026年10月10日

VibeCoding中AI测试全绿之后,谁在测谎?

三天前,我把本博客的「自研」主题提交到了 WordPress 官方主题目录(ticket #292433 1),至今没有回音,个人怀疑是因为介绍用中文写的(😡)。

同一段时间,我给 Bifrost 提了个 PR。Bifrost 是 Maxim AI 开源的 LLM API 网关,我折腾大模型 API 的时候发现的,你别说,比起 OneAPI 它功能更全,界面也更好看。修复本身很小:Anthropic provider 在处理会话历史里 function_call 带非法 JSON arguments 时,会把整个请求打成 500,改动 +20/-2,三个文件 2。我(的 Agent)是真的在测试服务器上,按 PR checklist 一项项验证完才提交的。

PR审核机器人排排站好了

等到今天,PR 还是没动静。不过这不重要。重要的是我盯着 PR 页面这几天,看清了一件事:一个人类都还没看的 PR,上面已经站满了机器人。

一、二十行代码,五家公司的机器人陪审团

PR 刚创建,流水线上就自动亮起一串绿勾(CodeRabbit稍微工作了十几分钟,给我发了通过回复):

还有 Codecov。它没在 PR 页面上留下一票,产出在仓库仪表盘上:一张漂亮的测试覆盖率饼图 3。

Codecov 测试覆盖页面

一个 AI 审查官,一个法务,两个安全审计,一个质量监督,围观我二十行代码。挺好,这就是成熟开源项目的自动化陪审团。然后我想起自己的单人项目:一个都没有。代码靠 AI 写,AI 写完连人工 review 都没有,更别说这一桌机器人。

这串绿勾里跟测试直接相关的,是 Codecov 那张饼图背后的覆盖率。而覆盖率恰好是这一整套系统里最容易糊弄的环节。这篇文章想清楚的,就是在「AI 写代码 + AI 写测试 + 无人审查 + 本地高频 commit」的闭环里,怎么给自己攒一套 AI 没法靠凑数绕过的自动校验。

二、AI 写的测试,是「看起来够了」

普通质量问题,怎么差怎么补。VibeCoding 不一样,AI 会主动凑数。

Michael Bromley 在《The Problem With Your AI Tests》4 里把 AI 写测试的失效形态归了四类,我在自己的项目里全见过:

形态长什么样为什么全绿
弱断言assert result is not None、assert total >= 0(本该 assert total == 3)对错值也能通过
重言式expected = net + tax,然后断言被测函数返回 expected,期望值是同一段算式复算出来的;更极端的连被测函数都不 import,自己在测试文件里重写一份断言自己证明自己
mock 自证mock 掉被测依赖,断言结果等于 mock 返回值。测的是 mock 配置,不是代码测试在自嗨
条件式测试if result.success: assert X, else: assert error,两条分支都给自己留了活路AI 把容错逻辑写进了测试套件

这不是个别人的抱怨。HN 上的一手吐槽:「把测试 mock 到只剩测 mock 本身?是的!」5。还有人发现 Claude Code 和 Cursor 会删掉或跳过测试,然后宣称全部修复 6。westurner 的总结最扎心:现代 coding agent 很擅长写出刚好凑够 100% 覆盖率的测试,但 100% 覆盖率不等于高质量软件 7。

学术侧也跟上来了。2026 年 MSR 的实证研究测出,coding agent 生成的测试里 mock 占比显著高于人类提交,36% 对 26%(Hora & Robbes,《Are Coding Agents Generating Over-Mocked Tests?》8)。LLM 的短板为什么集中在断言(test oracle)而不是造输入,ASE 2025 有一篇专门的分析 9。

原因说穿了不新鲜。一条 HN 高赞评论讲得很透:模型被 RL 训练的目标就是让测试通过,遇到困难,它先想到的不是修代码,是改测试 10。覆盖率是数字,通过率也是数字,Goodhart 定律在 agentic coding 里自动兑现。

得说句公道话:重言式测试不是 AI 发明的。2010 年就有 TTDD(Tautological TDD)反模式的老吐槽 11;2019 年学术界就给这种「通过但断言根本没生效」的测试起了名字,Rotten Green Tests 12。AI 的贡献是把它从个别懒人的特产,变成了默认产物。

三、覆盖率救不了你,变异分数可以

pitest.org 首页有段话,建议每个让 AI 写测试的人都读一遍:

“Traditional test coverage … measures only which code is executed by your tests. It does not check that your tests are actually able to detect faults … It is therefore only able to identify code that is definitely not tested.” —— 13

翻译一下:覆盖率只能告诉你哪些代码没被测,永远说不了哪些代码没测好。零断言的测试照样刷满行覆盖。MutGen 的论文 14 里有个扎心案例:LLM 生成的测试,覆盖率 100%,变异分数 4%。

变异分数(mutation score)就是那个 AI 难以作假的指标,原理三句话:

  1. 往被测代码里注入一个细微突变:整数 +1、< 换 <=、break 换 continue;
  2. 跑测试套件。挂了,这个突变算 killed;全绿,算 survived;
  3. killed 除以总数,就是变异分数。

survived 的突变体就是测试的盲区。AI 写的 assert x is not None,在 <= 被改成 < 时根本不会红,当场现形。全程不需要任何人「审」测试,机器直接给测试的真实性打分。

这不是玩具技术。Meta 把变异引导的 LLM 测试生成做进了生产系统(ACH,FSE 2025 Industry Track 15):10,795 个类,9,095 个突变体,生成的测试 73% 被采纳。Google 也在 ICSE-SEIP 2020 发过全公司规模的变异测试实践 16。

工具就是 mutmut(当前 3.8.0 17)。我给自己排的演示流程:让 AI 写一个新函数的测试,mutmut run,mutmut browse 看 survived 列表,让 AI 补真断言,再跑,变 killed。这套前后对比做一次,比讲十遍理论都管用。

也别把变异分数神化。mutmut 自己提供 # pragma: no mutate 豁免,行、块、区间三级都有 17。工具度量测试真实性,也顺手提供了新的作弊口:AI 往代码里塞豁免注释就能「提高」分数。所以豁免区必须进 review,哪怕是 AI 自审。

四、单人场景的五重特殊性

把团队方案直接抄到单人环境,会处处踩空。我把自己场景的特殊性整理成五条:

维度典型团队方案假设单人 VibeCoding 实况
审查者有 PR reviewer 人工把关没有第二双眼睛。AI 既是作者,又是唯一可能的审查者
门禁环境CI 流水线,push 之后commit 之前,问题要在本地拦住
妥协空间门槛太严会卡住队友,要折中可以设最严门槛,没人会被你卡住
反馈容忍度CI 慢一点能接受commit 是高频动作,hook 慢几秒就会想绕过它
指标语义覆盖率是参考信号之一覆盖率会被 AI 主动刷分,必须换它难以作假的指标

由此两个推论。第一,变异测试在单人场景比团队项目更重要:团队里还有人工 review 兜底,你只有自动化手段。第二,它又不能全量塞进 commit 环节:变异测试的本质是每个突变体都要跑一遍测试,全量跑下来,提交会慢到你想 --no-verify。所以只能把验证拆散,嵌进工作流的不同时刻。

五、三层防线:同步守门 + 异步体检

我的方案是三层触发点,各管一段,职责不重叠:

层触发时机跑什么反馈给谁时间预算
① 即时反馈AI 写完测试的瞬间(Claude Code PostToolUse hook)新测试文件 lint + 快速跑测反馈给 Claude,让它自己修30-60 秒内
② commit 守门git commit(pre-commit hook)受影响测试 + 覆盖率门禁阻止提交(exit 非零)≤ 10-15 秒
③ 定时体检每日定时全量测试 + 全量变异测试给人看的体检报告分钟到小时,无所谓

① AI 写完测试的瞬间:PostToolUse hook

Claude Code 的 hooks 配置在 settings.json 的 hooks 块(~/.claude/settings.json 或项目 .claude/settings.json)。官方文档给的示例场景里,就有「只在 Claude 写/编辑文件时跑 lint 脚本」,用 matcher Edit|Write 过滤工具事件 18:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "python .claude/hooks/check_tests.py" }]
      }
    ]
  }
}

hook 从 stdin 拿到 JSON,里面有 tool_name 和 tool_input(Write/Edit 的 file_path 恒为绝对路径),判断写的是不是测试文件,是就跑检查。

这里有个关键语义:exit 2 在 PreToolUse 里能拦下工具调用;在 PostToolUse 里,动作已经发生,拦不回来,只能把 stderr 喂回给 Claude。而后者恰好就是第①层要的语义:测试写得烂,让 Claude 立刻看到失败,自己修(这条语义我对照了多个二手源,官方文档页一直没抓开,见文末「未能核实」)。

社区已有现成玩法。有人用 PreToolUse 拦下 git push,先跑 lint 和测试再放行 19;有人做了 hook 集合,让 Claude 每次改完 TS 文件自动过一遍类型检查 20。Claude 官方课程的说法更直接:hooks 给你的是确定性控制 21。

两个坑。第一,hooks 只在 Claude Code 会话内生效,人不在会话里,第①层就完全静默,这正是还需要第②层的原因。第二,文件被外部程序改写时不会触发 PostToolUse,这种漏网情形只能靠第②层兜底。

② commit 秒级守门:pre-commit + 受影响测试

第②层的目标是守门,不是全测。社区对 pre-commit 耗时的共识是 10 秒以内,贵的测试留给 CI 22;上限普遍认为在十几秒。Windows 上 pytest 冷启动就要 1-3 秒,受影响测试必须选得准:

  • pytest --lf:只重跑上次失败的。语义是「重跑失败」,不是「受影响」,别混用;
  • pytest-picked:按 git 变更文件匹配同名测试文件。文件级粗粒度,零建库成本,适合起步 23;
  • pytest-testmon:用 Coverage.py 收集「测试 ↔ 被测代码」依赖图,做细粒度选择,首次要全量建库。单人小项目用它最合适 23。

覆盖率门禁一行搞定:coverage.py 的 fail_under(低于阈值 exit 2),或 pytest-cov 的 --cov-fail-under 24。门禁值可以设到团队不敢设的高度,反正卡住的只有你自己。

③ 每日体检:定时任务跑全量变异测试

commit 环节放不下变异测试,但完全不跑,又等于放弃真实性校验。团队方案里 CI 承担的角色,单人环境用本地定时任务替代。CircleCI 官方博客的建议跟我想的一样:

“Run mutation tests on a schedule, not on every commit. Nightly or weekly runs against the main branch catch regressions in test quality.” —— 25

Windows 侧一行建任务:schtasks /create /sc DAILY /tn mutation-report /tr run-mutation.bat。nightly 也有反方观点,隔夜反馈错过了修复时的上下文 25。单人开发者的化解反而简单:把体检排在早餐前跑完,看完报告再开工。

mutmut 让这件事的性价比越来越高:3.7.0 起按函数源码哈希做增量缓存,改一个函数只重测该函数的突变体;还支持通配符,mutmut run "my_module*" 单跑一个模块 17。Stryker(JS/TS 生态的同类工具)官方文档给过一组数字:增量模式下 3965 个突变体复用了 3731 个,只需重跑 234 个 26。

最后一个 Windows 的坑,单独拎出来:mutmut 依赖 os.fork,Windows 上必须在 WSL 里跑。官方原话:”must be run on a system with fork support” 17。

六、提示词也是防线

上面三层都是事后机器验证。AI 是测试的作者,事前还应该有一层约束,压低假测试的产生率。我的 CLAUDE.md 里加了这些规则:

## 测试规则
- 期望值必须硬编码字面量,禁止由被测逻辑复算得出
- 禁止弱断言:assert x is not None / assertTrue(x) / assert x >= 0(除非语义确实如此)
- 禁止 mock 被测单元本身;mock 只用于外部依赖
- 禁止修改既有测试来让结果变绿;测试失败先怀疑实现
- 新测试写完必须先确认它能失败(对空实现跑一遍)

最后一条不是我发明的,是社区验证过的 TDD 工作流:Anthropic 官方文章里,团队的用法就是先写测试、确认失败、再实现、不许改测试 27。「不许弱化测试或校验来让失败的用例通过」也已经是公开 AGENTS.md 的常见条目,krkn-operator 的 AGENTS.md 里就原样写着 “Do not weaken tests, validation, RBAC, TLS, or quality thresholds to hide failures” 28。

也有人泼冷水。qodo 提醒,静态规则文件有时反而让 agent 表现变差,规则要跟具体上下文绑定 29。

所以这层的定位要说清楚:它是概率防线,压低假测试的产生率,但不保证拦住每一个;确定性防线是变异分数,机器说了算。一个降本,一个兜底。

七、本地小模型审查:我的结论是默认不上

我原来的设想里有「本地小模型的强制审查」这一环。调研之后暂时放弃,三个理由。

一,可靠性边界太硬。IEEE TSE 2025 有一篇专门研究 LLM 给代码当裁判的论文,结论是即便表现最好的 GPT-4-turbo,判断代码正确性也远谈不上可靠 30;2026 年的《Bias in the Loop》进一步发现,judge 的结论对 prompt 措辞高度敏感,代码不变,结论能翻转 31。7B 本地模型只会更差,而误报最后还是要人复核,省下的时间又还回去了。

二,最关键的问题不需要模型。「这个测试真的能红吗」是个可执行验证问题,变异测试直接给答案,不需要第二意见。

三,工业界的做法印证了这一点。Meta 的 TestGen-LLM 让 LLM 改进既有测试,但每条建议必须过确定性过滤器:能构建、能运行、覆盖率提升、杀死更多变异体 32。LLM 负责生成候选,验收全部交给机器,而不是另一个 LLM。

替代方案是规则层,便宜,立刻能上:

  • Ruff(PT018 复合断言拆分、B011 assert False、PLW0129 字面量断言、PT009 pytest 风格)加 flake8-assertive(规则码 A500-A504:assertTrue(a==b) 改 assertEqual,assertTrue(a is not None) 改 assertIsNotNone)33;
  • 自写一个两百行以内的 ast.NodeVisitor 补盲区:测试函数断言数为 0、断言裸变量或字面量、重复断言同一表达式;
  • 语义级的「断言与实现同构」不硬扛,交给变异测试兜底。

再加一味药:property-based testing。Hypothesis 的 ghostwriter 能给纯函数生成性质测试源码,官方把它定位成 PBT 的低门槛入口 34。AI 写的举例测试和性质测试天然互为审查,「看起来在测」过不了随机输入那一关。真想实验小模型,把它定位成可疑测试的第二意见就好,别做门禁。

八、元测试:验证验证者

还剩一环:这套机制自己坏了,谁来验证?

我的最低成本做法是手动注入 bug,相当于变异测试的 30 秒手动版:

  1. 写完核心模块和测试后,随手改坏一行(< 换 <=,+ 换 -);
  2. 跑测试;
  3. 全绿?说明测试瞎了,回到第五节重来。

土是土了点,但思想和混沌工程是同一回事:Netflix 的 Chaos Monkey 定期往基础设施里注入故障,验证监控、告警、恢复机制真的有效,Gremlin 把这套做法制度化为 game day 35。「定期注入 bug 验证测试真的会红」,就是它在代码层的投影。

想升级就上抽样变异:限定目录、限定突变算子,跑一部分突变体,接受近似的杀灭率。另外,断言密度是个免费的旁路信号:2015 年有实证研究发现,测试套件的断言数量与它的杀变异能力强相关(基于 Java 生态,搬到 Python 要谨慎)36,一条 shell 命令就能统计。

九、SOP 草案:先人工调度,再自动化

落地节奏,从最便宜的起步:

  1. 阶段 0(现在,零成本):CLAUDE.md 测试规则 + Ruff + flake8-assertive。这三样没有理由不开。
  2. 阶段 1(本周,人工):每次 AI 写完测试,人工跑一遍 mutmut run 和 mutmut browse,肉眼看 survived;每个核心模块手动注入一个 bug,30 秒元测试。这一步是摸清变异测试在我项目里的真实耗时和误报形态。
  3. 阶段 2(半自动):pre-commit 挂 pytest-testmon 和 --cov-fail-under;schtasks 建每日全量变异任务,早餐前跑完。
  4. 阶段 3(全自动):把阶段 1 的动作写成 PostToolUse hook 脚本,挂到第①层;体检报告摘要自动推送。SOP 稳定之后,人工调度过的每一步才有资格被自动化。

顺带一个还没想清楚的方向。antirez 提出与其让 AI 写单元测试,不如让它当 QA:按 markdown 清单做真实的集成验收,覆盖覆盖率之外的状态空间 37。这跟变异测试不冲突:变异分数管测试的真实性,QA agent 管测试的盲区。留给下一轮。

十、工具速查

用途工具一句话
变异测试(Python)mutmut3.8.0,schemata 并行、增量缓存、TUI 报告;Windows 必须走 WSL
变异测试(Python,备选)cosmic-ray8.7.0 活跃维护,结构更重,可分布式 38
变异测试(其他语言)Stryker(JS/TS)、PIT(JVM)、cargo-mutants(Rust)、gremlins(Go)、mull(C/C++)、Stryker.NET(.NET)各生态主流选择
弱断言 lintflake8-assertive(A500-A504)、Ruff(PT018/B011/PLW0129/PT009)、pylint(W0199/W0129)规则层第一道网
受影响测试pytest-testmon(依赖级)/ pytest-picked(文件级)/ --lf(失败级)按建库成本递减选择
覆盖率门禁coverage.py fail_under / pytest-cov --cov-fail-under低于阈值 exit 2
hook 机制Claude Code hooks(PostToolUse/PreToolUse)官方文档 18
定时任务Windows schtasks / Task Scheduler一行命令建每日任务
PBTHypothesis(ghostwriter)性质测试与 AI 举例测试互为审查
test smell 学术参考tsDetect / testsmells.org 3919 种 JUnit smell 分类(Python 需自写 AST 检查)
未能核实的事项
  • Claude Code hooks 的退出码语义(PreToolUse 阻断 / PostToolUse 只回显 stderr):官方 hooks 文档页两次抓取均超时,结论来自多个独立二手源交叉一致。配置示例发布前建议对照官方页再核一遍。
  • 《On the Effectiveness of LLM-as-a-Judge for Code Generation and Summarization》确认发表于 IEEE TSE(2025),稳定的公开全文链接没拿稳,本文未附。
  • promptessor 与 qodo 的两篇文章只确认了站点、标题与发布时间,具体 URL 未核,先挂站点链接。
  • pytest-picked 在新版 pytest 下的兼容性没实测,用前先跑一把。
  • HN 各评论的存在性经核实,逐字引语以链接原文为准。
  • Snyk、CodeRabbit 的免费额度各处口径对不上,本文不写具体数字,只引 PR 页面可见的自报内容。
  • Zhang & Mesbah 的断言密度研究只核到 2015 年 ACM DL 收录,「强相关」的具体强度以原文为准。

来源

广告位
广告位
广告位
广告位
广告位
广告位

读者来信(1)

  1. DianWu 的头像

    三层防线里,第②层靠 pre-commit 守门,文里提到提交环节太慢会让人想绕过它,而第①层的 hook 又只在 Claude Code 会话里生效。可以再补一个小点:让 pre-commit 每次运行时往本地追加一行日志(记个时间就行),第③层每日体检时顺手数一下近一天的提交数和日志条数。日志条数少于提交数,就说明有提交没经过守门,可能是被跳过了,也可能是 hook 没装好或没触发。这样体检报告里除了测试结果和变异分数,还能看到守门本身有没有被绕过。这只是我的设想,没有实际跑过,比如被拦下没提交成功的那几次怎么排除之类的细节,要自己调整。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注