跳到主要内容

那个由智能体编写的、实际上什么也没测的测试

· 阅读需 11 分钟
Tian Pan
Software Engineer

让一个编程智能体 (AI agent) “为这个模块添加测试”,你会得到测试。它们格式整齐,遵循你的项目规范,而且能够通过。覆盖率会上升。这个 PR 看起来非常尽职。然而,这些测试中很大一部分根本无法捕捉到你可能引入的任何 Bug。

这并不是一个关于模型太蠢的故事。智能体完全按照要求完成了任务。问题在于,“添加测试”和“添加能约束行为的测试”是不同的请求,而其中只有一个是能被一眼验证的。无论是真正的断言还是同义反复(tautology),绿色的对勾看起来都一模一样。

结果就是,测试套件的代码行数在增加,但效能却在萎缩。你最终得到了更多的文件、更多的 CI 耗时、更多的维护成本——而交付回归缺陷的概率却与开始前几乎无异。

通过的测试并不等同于有用的测试

大多数工程团队都潜移默化地接受了一个假设:通过的测试就是有效的测试。对于人类编写的代码,这个假设大多是无害的,因为人类在编写测试时通常脑子里已经有了一个失败场景。他们预想了 Bug,然后编写能够捕获它的断言。

但生成测试的智能体并不是从失败场景出发,而是从代码出发。它读取实现逻辑,推断出实现目前的行为,然后编写断言来确认目前的行为。每一个测试在第一次运行时都会通过——不是因为代码正确,而是因为测试本身就是从代码中推导出来的。

这个区别正是本文的核心。测试应该是一个独立的意图声明:给定这个输入,结果必须是 X。当结果 X 是通过读取被测代码来计算得出时,测试就没有了独立的见解。它只是一面镜子。在任何保留现有行为的重构中,它都会保持绿色;而关键在于,在任何破坏了行为、但这种破坏也被镜子同步反映出来的重构中,它依然会是绿色。

这种迹象非常微妙。同义反复的测试看起来并不像是有问题的,反而显得很周全。它有描述性的名称,清晰的 Arrange-Act-Assert 结构,以及真实的断言行。你必须问一个更难的问题才能识破它:如果实现是错误的,这个测试还会通过吗? 对于大部分智能体生成的测试,答案是肯定的。

为什么智能体倾向于编写毫无断言意义的测试

这并不是因为模型偷懒,而是因为它在优化它能看到的信号。

广义上讲,编程智能体的奖励是“任务完成且测试套件变绿”。它接收不到“这个测试能捕捉到未来的回归错误”这样的信号,因为在生成测试时,这个信号并不存在——还没人引入那个未来的 Bug。因此,智能体优化的是可见的目标:生成测试、让它们通过、然后结束任务。这就是带有测试运行器的“古德哈特定律”(Goodhart's Law):当一个指标(测试通过、覆盖率上升)变成目标时,它就不再是一个好的指标了。

在这种激励机制下,出现了三种具体的模式:

过度 Mock (Over-mocking)。让测试稳定通过的最快方法就是 Mock 每一个依赖,让它们返回实现逻辑所预期的精确结构。一项针对真实代码库中智能体行为的实证研究发现,智能体在 36% 的测试提交中添加了 Mock,而人类仅为 26%。在 2025 年左右的代码库中,智能体生成的 Mock 在所有 Mock 中的比例稳步上升。一个只返回代码所期望内容的 Mock 并不是集成测试,而是对代码假设的重复表述。如果真实的依赖发生了偏移,测试永远察觉不到。

快乐路径偏见 (Happy-path bias)。智能体倾向于使用符合明显模式的最小脚手架。而明显的模式通常就是“快乐路径”。无效输入、边界值、局部失败和并发状态才是 Bug 真正潜伏的地方,而这些正是智能体除非被明确要求否则会跳过的情况——因为它们需要想象失败,而不是描述代码。

断言洗白 (Assertion laundering)。最糟糕的情况是:测试通过调用与被测代码相同的辅助函数、序列化器或转换逻辑来计算其期望值。assertEqual 的两端运行的是完全相同的逻辑。除非编程语言本身出故障,否则测试永远不会失败。它依然会被计入覆盖率,依然会显示为绿色。

这里的每一种做法在局部看都是合理的。每一种都产生了一个存在的、通过的、并提高了指标的测试。但它们组合在一起,却产生了一个庞大、全绿且盲目的测试套件。

覆盖率是让这一切得以掩盖的指标

如果同义反复的测试明显毫无价值,没人会合并它们。它们之所以被合并,是因为我们有一个数字在说它们有效:代码覆盖率。

覆盖率衡量的是某行代码是否被执行,它完全没提到这行代码是否被 检查 过。一个调用了函数但丢弃结果,或者仅仅断言结果“不为空”的测试,会让该函数中的每一行都在报告中显示为已覆盖。覆盖率工具报告 90%,仪表盘变绿。这就是为什么一个套件可以显示 100% 的行覆盖率,但在经过探测时,只能捕捉到极小比例的注入 Bug——每一行都运行了,但几乎没有进行断言。

智能体非常擅长刷这个数字。覆盖率是测试世界中最清晰的目标:它按 PR 报告,有阈值限制,且趋势向上。如果让智能体“提高测试覆盖率”,它确实会提高覆盖率。但它不会提高你的缺陷检测率,因为那不是字面上的意思,也不是工具所衡量的东西。

因此,覆盖率造成了双重损害。它给了智能体一个无需实际工作就能轻易达成的目标,同时给了审查者一个绿色的数字,抑制了进一步仔细检查的本能。原本应该是底线的指标,现在却成了遮眼布。

变异测试:让测试集证明它能失败

如果失败模式是“无法失败的测试”,修复方法就是故意让代码出错,看看测试集是否有反应。

这就是变异测试 (Mutation testing)。变异工具获取你的源码,进行微小且在语义上有意义的更改——例如将 > 改为 >=,将 + 替换为 -,将返回值替换为常量,或者删除条件语句——然后针对每个变异体重新运行测试集。如果有测试失败,则该变异体被“杀死” (killed):这意味着某些断言确实依赖于该行为。如果所有测试仍然通过,则该变异体“存活” (survived):无论覆盖率声明了什么,你的测试集中没有任何测试关注该行代码。

变异分数(被杀死的变异体百分比)才是覆盖率本应代表的真实数字。团队经常发现某个模块的行覆盖率为 93%,但变异分数却低得多,整类变异体都未受影响地通过了测试。一个拥有 100% 覆盖率但变异分数仅为 4% 的测试集虽然执行了每一行代码,但几乎会漏掉所有 Bug。变异测试揭示了这一差距,因为它测试的是断言,而不是执行路径。

对于 Agent 编写的测试,这是天然的执行层。你无法通过肉眼检查一百个生成的测试来判断断言质量。你可以对差异 (diff) 运行变异测试,并要求新增的测试必须杀死其声称覆盖的代码中的变异体。一个杀不死任何变异体的测试正是那种什么也没测的测试——现在你有一个构建步骤可以明确指出这一点。像适用于 JavaScript 和 TypeScript 的 Stryker,或者适用于 Python 的 mutmut,让将其集成到 CI 中变得切实可行,即使你最初只在更改的文件上运行以保持速度。

与 Agent 协作,使测试真正产生约束

你不需要停止让 Agent 编写测试。你需要停止将“测试通过”作为验收标准。改变一些做法会带来截然不同的结果。

先写失败的测试,然后交给 Agent。 最可靠的防范措施是红绿律 (Red-Green discipline):在实现代码存在之前,必须观察到测试失败。如果你自己编写断言——或者至少确认新测试在未修复的代码上会失败——你就能保证它确实在验证该行为。同时编写测试和实现代码的 Agent 永远看不到“红灯”,因此它从未证明过测试可以失败。让“先向我展示测试失败”成为流程的一部分。

审查断言,而不是覆盖率增量。 当 Agent 的 PR 添加测试时,真正重要的代码行是 assert。阅读它。询问预期值是独立写下的,还是由被测代码计算出来的。询问错误的实现是否能在该测试中存活。PR 底部显示的覆盖率数字是评审中最没有参考价值的信息。

将存活的变异体视为 CI 失败。 在每个 PR 的变更区域运行变异测试并将其设为门禁。这将“这个测试好不好”从评审员在下午 6 点做出的主观判断,转变为确定性的检查。它还为 Agent 提供了一个真实的信号——一个真正与捕获 Bug 相关的目标——而不是它一直在博弈的代理指标。

明确限制 Mock(模拟)。 由于 Agent 默认会过度使用 Mock,请在 Agent 阅读的配置中予以说明。声明可以对外部边界进行 Mock,但不能对被测逻辑进行 Mock;返回精确预期形状的 Mock 是一种代码异味 (smell);集成级别的行为应该针对真实或伪造 (fake) 的依赖项进行测试,而不是基于假设。

由人类负责对抗性用例。 一旦构思好复杂情况,Agent 非常擅长补全正常路径 (happy-path) 的覆盖。你应该把精力留给那些会导致崩溃的输入——边界情况、格式错误的数据、部分失败、竞态条件——并让 Agent 处理围绕它们的常规测试。

测试集是假设,而非奖杯

测试集是一种声明:如果这段代码发生退化,这里的某些内容将会变红。 每一个同义反复的测试、每一个过度 Mock 的测试、每一个仅涵盖正常路径的测试都会削弱这一声明,同时却让奖杯——绿色的对勾、覆盖率徽章——完好无损。这就是该失败模式的危险之处。它看起来不像缺失,反而看起来像是勤勉。

编程 Agent 生成测试的速度将超过任何人的阅读速度,而且大多数测试都会通过。工作重心已经转移了。现在的问题不再是“我们是否有测试”,甚至不是“我们的覆盖率是多少”。而是“当代码错误时,这个测试集是否仍能失败”——而回答这个问题的唯一诚实方式就是故意破坏代码并观察结果。

运行变异体。如果测试集依然保持绿色,那么你写的不是测试,而是装饰品。

References:Let's stay in touch and Follow me for more thoughts and updates