AI 写单测的工作流:能信得过的测试

AI 写的测试经常"通过但啥也没测"。这套对抗式流程加上 mutation testing 给你真覆盖率,并用 2026 年的工具证明它有效。

一句话总结

让 AI “给这个函数加测试”,它生成的是镜像实现的测试:永远通过、什么也抓不到。解法是把生成过程变成对抗式的——先让模型列边界 case,再写测试,最后改动函数验证测试会挂。每个非平凡函数预留 15-30 分钟。结果用 mutation 测试工具验收(JavaScript/TypeScript 用 Stryker-JS、Java 用 PIT、Python 用 mutmut),因为截至 2026 年 6 月,只有它能可靠地告诉你 AI 生成的测试到底有没有在断言任何东西。

为什么”加测试”会写出啥也没测的测试

当你对 Claude Code、Cursor 或任意 LLM IDE 说”给这个函数写测试”,模型会读实现、写出与当前行为匹配的断言。如果代码里有 bug,测试就把这个 bug 当成正确行为固定下来。套件变绿、覆盖率数字上涨,下一次回归静悄悄上线。

这不是假想。Thoughtworks 技术雷达第 34 期(2026 年 4 月)专门把 mutation testing 放进了 Trial 环,原话是”在 LLM 生成测试的时代,mutation testing 才是告诉你测试到底有没有在断言的东西”。同一期雷达还点出了相关的失败模式:被要求写测试的 agent 经常凭训练数据幻觉出你代码库里根本不存在的 selector、fixture 路径和 API。第一次跑就变绿是症状,不是目标。

下面这套流程把顺序倒过来:先逼模型把失败模式枚举出来,再让它写断言,最后证明测试真的会咬人。

这篇适合谁看

用 Claude Code(Opus 4.7 / Sonnet 4.6)、Cursor、GitHub Copilot 或 Codex 给现有未测代码补测试的开发者。最有价值的场景是工具库、解析函数、计费与定价逻辑、权限检查,以及任何”算错了也无声又昂贵”的地方。写覆盖率规范的技术 lead 可以把这套流程直接交给团队,作为一份可辩护的”好测试长什么样”。

什么时候不该用

  • 需要真 Chromium、真网络、真实时序的端到端浏览器流程。用 Playwright 手写场景(或接 Playwright MCP server,让 agent 从真实 DOM 读 selector 而不是瞎猜)。
  • property-based 测试,价值在于随机生成数千个输入。直接用 fast-check(JS)或 Hypothesis(Python)。
  • 性能基准和 UI snapshot 测试,真实信号是时序或视觉 diff,不是逻辑。

开始前准备

  • 用三行写下函数契约:输入、输出、副作用、错误模式。你写不出来,模型也写不出来。
  • 把测试框架钉进 context。否则你的仓库用 Vitest,它给你写 Jest。
  • 加一份 CLAUDE.md(Claude Code)或 .cursor/rules(Cursor)文件写约定:断言风格、fixture 路径、命名、什么 mock 什么用真的。
  • 开始前跑一次套件确认绿。你得知道哪些挂是新的、哪些是已存在的。
  • 标出隐藏副作用(文件系统、网络、Date.now())。每一种都要显式 mock 或 fake timer。

具体步骤

  1. 挑一个函数。先要失败模式,不要测试。 让模型列 10 个可能让它挂的输入:空、null、超长、负数、unicode、边界值、locale 相关、DST 切换、竞态。显式说:先不写测试。
  2. 读这份列表,补 2-4 个模型漏掉的领域 case。 通常是代价最高的那些:昨晚 UTC 0 点过期的 coupon、订阅数为 0 的客户、四舍五入到半分钱的价格。
  3. 让模型每个 case 写一个测试,arrange-act-assert,描述性命名。 例 prompt:
按上面列表每个 case 写一个测试。用 Vitest。arrange-act-assert。
测试名规则:should [行为] when [条件]。
不要重构被测函数。除非必要不要加 helper。
  1. 跑测试。 第一次跑就过的都可疑。打开看断言是否真的覆盖了那条路径。最典型的破绽是写了 expect(result).toBeDefined(),本该是 expect(result).toEqual(具体值)
  2. 每个挂的测试问一句:测试错还是函数错? 函数错——你挖到了真 bug,修函数。测试错——修测试。
  3. 跑 mutation 测试证明测试会咬人。 手动做:让模型给函数植入 3 处合理 mutation(翻一个比较、去掉一个 guard、交换两个参数),逐个检查有没有被抓到。要客观数字就跑 mutation 测试工具(见下表),看 mutation score,而不是行覆盖率百分比。
  4. 测试和函数修复分两个 commit 提交,回归历史才清楚。

mutation 测试工具(截至 2026 年 6 月)

行覆盖率告诉你哪些行跑过了,mutation score 告诉你哪些行被测试真正验证了。按语言列出仍在维护的选项:

语言工具最新版本(2026 年 6 月)备注
JS / TSStryker-JSv9.6.x(2026 年 4 月)支持 Vitest 4.x 和 Jest runner;HTML 报告默认开启;事实标准
Java / JVMPIT(pitest)Maven 插件 1.15.x在字节码上操作;JUnit 5 插件;Maven + Gradle 集成
Pythonmutmut / cosmic-ray持续维护mutmut 适合本地快跑;cosmic-ray 适合接进构建工具
.NETStryker.NET当前版本同一套 Stryker 引擎,C# mutator

关键代码的 mutation score 落在 70-90% 是现实的目标。追求 100% 会浪费时间在等价 mutant 上——没有任何测试能区分它们。

覆盖率工具提示(Vitest)

用 Vitest 就明确设置 coverage.provider。自 Vitest 3.2 起,默认的 v8 provider 改用基于 AST 的 remapping,于是你能拿到 V8 的速度加 Istanbul 级别的精度,过去切到 istanbul 的理由也就消失了。无论用哪个,都把覆盖率当副产品而非目标:60% 覆盖加强断言,胜过 95% 加弱断言;行覆盖数字很高但缺口全在错误处理路径上,比一个诚实的低数字更糟。

第一次实操怎么跑

挑你手里最小的纯函数:字符串解析、日期工具、ID 生成器。整套流程跑一遍,包括真跑一次 mutation。多数开发者会发现:模型第一遍能列出 60-80% 的边界 case,剩下真正意外的 bug 来自人工补充。第二遍只改一个变量(换模型,或加一份写了约定的 CLAUDE.md)后重跑,mutation score 的 diff 告诉你哪个因素更关键。

完成后检查清单

  • 每个测试都有非平凡断言。不能单独用 toBeTruthy()toBeDefined(),几乎任何东西都能通过。
  • 测试名描述行为,不描述实现。“空输入返回 null” 对;“调用 slice(0)” 错。
  • 没有任何测试 mock 被测函数本身。模型有时这么做就为了强行通过。
  • 记录 mutation score,不只是行覆盖率。至少那 3 个手工注入的 bug 被抓住了。
  • 测试和修复分开提交,回归可清晰追溯。

容易踩的坑

  • 跳过”列边界”直接要测试。测试很浅,镜像实现。
  • 不跑测试。模型偶尔写出意图对但断言错的测试。
  • 一条 prompt 同时要”写测试 + 修挂”。模型常常通过改测试让它过,而不是改函数。
  • 把 100% 行覆盖当目标。无强断言的覆盖率抓不到 bug。
  • 时间 / 网络相关代码忘了 fake timer 和 mock。本地过,CI 挂。
  • 生成时让模型把现有断言搬进 helper,diff 一下子读不懂了。

进阶技巧

  • 有分支的函数显式说:每个分支一个测试(每个条件的 true/false、每个 switch 分支、每条错误路径)。
  • 异步 / 时间相关代码:让模型显式设置 fake timer,并在 CLAUDE.md 里给一个跑通的示例。
  • 解析函数:在边界 case 列表里显式加一节”非法输入”。模型默认对垃圾输入测试不足。
  • 数据库相关代码:让模型用内存数据库或事务回滚模式。否则它会建议大范围 mock,证明不了什么。
  • 三个 prompt 存成片段:边界 case 列表、按 case 写测试、mutation 检查。换函数只改一行。

常见问题

  • 单测还是集成测用 AI? 单测最稳。集成测要真环境,模型经常配错(端口、fixture 路径、清理逻辑)。
  • AI 比我写得好吗? 它生成更多 case 更快,浮出你忘了的边界。最终质量看你的 review 和 mutation score,不是看模型。
  • 覆盖率工具说 95% 了,为什么这套流程还能挖到 bug? 覆盖率测的是行被执行,不是行为被验证。一个无断言的函数也能跑到 100% 行覆盖。
  • 能给无文档的遗留代码生成测试吗? 能。先让模型读函数、写出契约,确认契约对再生成测试。
  • 每个函数耗时多久? 非平凡纯函数 15-30 分钟,异步或有状态代码更久。第一次抓到真回归、没让它上线,就回本了。
  • 要在 CI 里自动跑 mutation 吗? 多数团队日常用 prompt 里的手动 mutation 就够。当测试质量被量化考核时,把 Stryker-JS 或 PIT 接进 CI,并且只跑关键路径——全仓 mutation 很慢。

相关阅读

标签: #AI 编程 #教程 #工作流