当 AI 来写测试:它会作弊,而且很像
这是全书主线收口的一章。前面十八章绕来绕去,其实一直在为它铺垫。第 1 章说:测试是那个 AI 缺的、能反驳它的「外部真相来源」。但一个显而易见的省事想法马上冒出来 —— 既然 AI 写代码这么快,让它顺手把测试也写了不就行了?这一章要拆穿的,正是这个想法的致命之处。当同一个 AI 既写代码又写测试,那个本该独立的「外部真相来源」就塌陷回了它自己。它写的测试长得极其专业 —— AAA 齐整、命名规范、mock 到位 —— 却在用五种方式,系统性地绕开它自己代码里的 bug。
诱惑:它写得又快又像样
让 AI 写测试,产出确实惊人:一个函数,它几秒就能配上七八个测试,结构工整、名字清楚、该 mock 的 mock 了。乍一看,比很多人手写的还规范。于是很自然地,你会想把测试这件事整个交给它。
问题不在于它写得像不像样,而在于:这些测试到底在验证什么?当你逐个拆开看,会发现一个令人不安的模式 —— 它们大多在验证「代码保持了它当前的行为」,而不是「代码的行为是对的」。这两者的差别,当代码有 bug 时,就是天堂和地狱。
AI 作弊的五种方式
把前面几章散落的观察汇总,AI 写测试时,系统性地在五个地方"作弊"。它不是故意的 —— 它根本不知道什么是对,它只是在做它被训练去做的事:产出看起来最合理的测试。
这是最深的坑。AI 写测试时,会读你的实现,然后断言这个实现算出来的东西。如果实现是对的,没问题;如果实现有 bug,它就把 bug 的输出,当成了"期望值"钉进测试里。
// 实现有 bug:空列表会返回 NaN(第 1 章)
fun average(xs: List<Int>) = xs.sum().toDouble() / xs.size
// AI 配的测试:它跑了一遍,看到空列表返回 NaN,于是……
@Test fun `空列表`() {
assertTrue(average(emptyList()).isNaN()) // ← 把 bug 固化成了"规格"
}
这个测试永远绿,而且看起来考虑了边界(它测了空列表!)。但它验证的是"这段代码保持了它 NaN 的错误"。真相被彻底颠倒:测试非但没抓 bug,反而成了 bug 的守护者 —— 以后谁把它修对了(空列表抛异常),这个测试还会红。
- ② 重言式(第 18 章):当它同时写实现和测试,期望值常常用和实现一样的逻辑算出来。两边同错,互相抵消,测试骗你说对。
- ③ 只测 happy path(第 9 章):它挑的输入清一色是正常值,几乎不打边界、不喂空/零/负。而 bug 全在那儿。
- ④ 过度 mock 到只剩自己(第 11 章):把每个协作者都 mock 掉、只 verify 调用,测的其实是"我有没有按我以为的方式调用别人"——一个镜像实现的脆壳。
- ⑤ 没牙齿的断言(第 2 章):偏爱
assertNotNull、assertTrue(result.isNotEmpty())这种几乎不可能失败的断言,看起来测了,其实啥也没验。
这五种叠加起来,你会得到一套:覆盖率很高、全绿、结构专业、变异分数却低到可怜的测试(第 16 章)。它给你的所有信号都是"这段代码测得很好",而真相是它几乎没被真正验证过。
为什么必然如此:目标从根上就错位了
这不是某个模型不够强,换个更强的就好。这是一个结构性的问题:
一个好测试,本质上是对抗性的 —— 它的存在意义,是想尽办法让代码露馅(第 1 章的证伪)。它必须站在代码的对立面。
但当同一个 AI 既写代码又写测试,它没有对立面。它的目标是产出"和谐、自洽、看起来正确"的一整套东西 —— 代码和测试互相印证、皆大欢喜、全绿收工。一个想让代码和测试互相印证的作者,写不出一个想把代码搞崩的测试。它把测试的对抗性,替换成了自洽性。
说白了,让 AI 同时写代码和测试,等于让考生自己出题、自己判卷。它当然会出它会做的题,给自己打满分。那个本该来自"外部"的真相,又回到了"内部"—— 回到了那个不知道对错、只知道自己以为的对错的系统里。第 1 章的整个论证,在这里被完整地推翻又重建。
那到底怎么用 AI 写测试?
不是不能用,是要守住那条独立性的线。核心原则一句话:
可以让 AI 干测试里所有不涉及判断"对错"的活,但那个判定对错的核心 —— 期望值、该测哪些边界、什么算失败 —— 必须有一个独立于"被测代码"的来源。落地成几条:
- 首选:你写测试,AI 写实现(第 3 章的合同)。你定义正确,它满足正确,测试裁决。这是最干净的分工。
- 若让 AI 起草测试:期望值你来把关。那个 80、那个 28 天、那个"空列表该抛异常"—— 必须由你按业务/规格独立确定,而不是接受它从代码里抄来的值。
- 补它漏的边界。拿第 9 章的刁钻清单过一遍它的测试,把空、零、负、极值、失败路径补上 —— 这是它系统性缺失的。
- 用变异测试审计(第 16 章)。这是唯一能量化"AI 写的测试有没有牙齿"的硬指标,骗不过去。覆盖率不行。
- 让不同的来源写代码和测试。哪怕都用 AI,也别在同一次对话里让它一起产出——至少让它"忘掉"实现、只根据规格来写测试。分离,是关键。
一个特别好用的姿势是反过来用它的对抗性:别让它"证明代码对",而是明确命令它——「找出这段代码会出错的输入」「写一个能让它崩的测试」。当你把它的目标从"和谐"扭转为"证伪",它其实相当擅长头脑风暴边界情况——因为这时它不再是代码的作者,而是被你派去当刺客。
回到第一章
AI 让写代码变得几乎免费,于是判断对错成了唯一的瓶颈,也成了你最核心的工作。测试,是判断对错的工程化手段——它是那个"不由谁觉得对、而由事实说了算"的外部真相来源。
而这一章的补充是:正因为它如此重要,它绝不能也塌陷进 AI 自己的闭环里。你可以让 AI 写代码、可以让它帮你搭测试的架子、可以派它去找反例——但"什么是对的"这个定义,以及"它到底有没有被真正验证"这个判断,必须牢牢握在你手里。这不是对 AI 的不信任,这是验证这个动作的定义:验证之所以是验证,就是因为它独立于被验证的对象。
一次现场:AI 怎么把 bug 写成「规格」
把一号作弊演一遍,你会对它的隐蔽程度有切身体会。假设有段分页代码,漏了 ceil(第 1 章的 totalPages bug):
fun totalPages(total: Int, pageSize: Int) = total / pageSize // bug:该向上取整
你说「给它写测试」。AI 大概率产出这样一套,看起来相当专业:
@Test fun `整除的情况`() = assertEquals(2, totalPages(20, 10)) // ✓ 恰好对 @Test fun `单页`() = assertEquals(1, totalPages(10, 10)) // ✓ 恰好对 @Test fun `有余数`() = assertEquals(2, totalPages(25, 10)) // ✗ 它写了 2!
25 条数据每页 10 条,正确答案是 3 页。但 AI 写的期望值是 2 —— 因为它跑了一遍(或心算了)有 bug 的实现,看到返回 2,就把 2 当成了「期望」。这个测试全绿,还"贴心"地覆盖了「有余数」这个 case,看起来无懈可击。可它验证的是「这段代码保持了它算错的结果」。最后 5 条数据用户永远翻不到的那个 bug,被这套测试盖章认证为正确行为。
这就是一号作弊的杀伤力:它不是没测边界,它测了边界,却把边界的错误答案固化了下来。而且它极难在 review 中一眼看出 —— 除非你自己心算过 25/10 该是 3。破解只有一条:那个期望值,必须你独立算。
反过来用它:把 AI 当刺客,而非辩护律师
AI 写测试的问题,根子在「目标是和谐」。那就换个目标——别让它证明代码对,命令它把代码搞崩。同一个模型,换个提示词,产出天差地别:
- 「找出 5 个能让这个函数返回错误结果或崩溃的输入,别写测试,先只列输入和你预期它会怎么错。」—— 逼它做对抗性思考,而不是复述实现。
- 「假设这段代码有 bug。最可能的 bug 藏在哪几个边界?列出来。」—— 给它「代码是错的」这个前提,它就不再急着辩护。
- 「针对这个函数,写出 边界值和异常路径的测试:空、零、负、极大、null。期望值请按需求推导,并说明你是怎么算出每个期望值的。」—— 强制它暴露期望值的来源,你好核对。
当你把它从「作者」重新定位成「攻击者」,它其实相当擅长头脑风暴刁钻输入 —— 因为这时它调用的是「哪里可能出错」的知识,而不是「让它俩自洽」的倾向。你仍然要核对期望值,但拿到的反例质量,比「给这段代码写测试」高得多。
1. 让 AI 给一段"你已知有 bug"的代码写测试,看它是不是把 bug 的输出当成了期望值(一号作弊)。这个实验会让你终身难忘。
2. 对一套 AI 写的测试跑变异测试。覆盖率和变异分数的那个落差,就是它作弊的量化证据。
3. 换个提示词:让它"找出这段代码会出错的输入,并写测试证明"。对比它这次的产出和"给这段代码写测试"有多不同。把它当刺客,而非辩护律师。
第四幕结束。你现在不仅知道怎么写好测试,还知道怎么识破假测试 —— 包括 AI 那些以假乱真的。最后一幕,把这一切接进真实工程:Android 的测试栈怎么搭、Web 怎么对照、CI 里什么时候跑什么、怎么让测试跑得快活得久,以及一张能贴进 PR 模板的判断表。先从 Android 全景开始。