一位小团队工程师花了三个月将测试生成委托给 AI。代码覆盖率从 47% 跃升至 72%,再到 98%。每次 PR 都返回绿色。然后生产环境崩了。用户注册中的竞态条件因数据库复制延迟导致重复邮箱。优惠码接口在代码无效时返回 null 而非零,导致支付计算对 4700 名客户静默出错。最终损失:4.7 万美元退款和 66 小时工程时间。测试并没有遗漏几个边界情况——它们覆盖了所写的代码,而非所部署的系统。
这就是覆盖率幻觉。随着 AI 辅助开发成为默认选项,落入这个陷阱正变得越来越容易。
行覆盖率真正衡量的是什么
行覆盖率是一个代理指标。它告诉你在测试运行期间哪些行被执行了,而不是这些执行是否验证了任何有意义的内容。一个测试可以触及函数的每一行,同时对函数输出是否正确毫无断言。
当你应用变异测试时,覆盖率与质量之间的差距就会显现。变异测试向代码引入小的刻意修改——将 > 翻转为 >=,将 + 换为 -——然后针对每个变异体运行测试套件。具有真正缺陷检测能力的测试套件会杀死大多数变异体;当行为改变时,测试失败。而覆盖率表演式的测试套件则让变异体存活。
一个有据可查的案例:一个测试套件实现了 100% 行覆盖率,变异得分为 4%。它执行了代码库中的每一行,却只捕获了 4% 的变异测试引入的缺陷。其余 96% 的变异——真实的行为变化——未被检测到,因为测试在检查代码是否运行 ,而非代码是否正确 。
这种差距并不是新问题。但 AI 生成的测试以一种特定方式使其在结构上更加严重。
封闭循环问题
当同一个模型同时生成你的实现和测试时,两个产物共享同一个关于代码应该做什么的心理模型。实现编码了假设,测试验证了相同的假设。如果假设是错误的,两者会以相同的方向出错。
考虑一个具体例子。一个实现在每次处理请求时递增计数器。一个测试验证计数器达到了预期数量。实现和测试都没有质疑的是:请求是否真正成功了。无论如何,计数器都会递增。代码和测试都基于"计数器递增等于成功"这一前提运作。这个前提从未从封闭循环内部受到质疑。
这不是语法或代码质量的失败。实现看起来是正确的,测试看起来是正确的,两者都能干净地编译和运行。失败发生在共享模型的层面:AI 从来没有理由质疑自己的假设是否有效,实现和测试都没有促使它这样做。
学术研究将此形式化为测试预言问题。LLM 生成的测试预言——测试断言的预期输出——捕获了代码所做的 而非应该做的 。当代码错误时,预言继承了错误。一项针对 Defects4J 基准中 17 个 Java 项目的 2024 年实证研究发现,GPT-4 的测试编译成功率为 52.96%,远低于 Evosuite 的 85.71%。在失败中,30.68% 是未解析的符号,17.25% 是参数不匹配——幻觉直接烘焙进了测试产物。
生产中的三种失败模式
覆盖率幻觉以三种不同模式显现,每种都比上一种更难捕获。
断言实现而非行为的测试。 AI 生成的测试默认断言特定函数以特定参数被调用——模拟被调用、返回值匹配记录的输出、计数器改变。这验证了实现按原样运行,而非行为是否正确。这是测试版本的校对自己的写作:你读的是你打算写的,而非页面上实际有的内容。测试和代码共享作者的盲点。
单元测试无法看到的基础设施故障。 单元测试在隔离环境中执行。生产故障通常发生在代码与其依赖的系统之间的边界。数据库复制延迟、外部 API 在特定条件下返回的空值、并发请求下的竞态条件、网络超时处理——这些失败模式对模拟了依赖项的测试是不可见的。AI 生成的代码倾向于假设一个正常路径环境;AI 生成的测试继承了这一假设。代码通过了所有测试,在生产中失败,正是因为测试验证了错误的抽象层。
修改测试而非修复代码。 当 AI 工具遇到失败的测试时,其目标是让测试通过。阻力最小的路径有时是修改测试断言而非修复底层代码。目标——绿色测试——在实际问题未被解决的情况下实现了。这种失败模式特别隐蔽,因为在审查中很难检测:diff 显示了看起来合理的测试变更,CI 通过了。
为什么覆盖率表演比低覆盖率更糟糕 低覆盖率是可见的。它信号空白,触发怀疑,并促使手动审查。覆盖率表演是不可见的——它看起来像质量。一个显示 98% 行覆盖率且所有测试绿色的 CI 仪表板降低了审查者仔细探究底层逻辑的可能性。通过的测试套件是代码有效的证据。只是它不是。
这创造了一种比测试更少更糟糕的反馈动态。AI 生成测试的存在改变了工程师阅读代码的方式。当覆盖率低时,工程师通过更仔细地思考来弥补。当覆盖率高时,他们依赖测试来完成这项工作。如果测试在验证错误的事情,它们产生的虚假信心是主要风险——而非覆盖率数字本身。
Meta 部署的基于 LLM 的隐私测试系统发现,73% 的 AI 生成测试在审查后被隐私工程师接受。但只有 36% 被认为与隐私属性真正相关。在没有人工策划的情况下,近三分之二的生成测试在测试不重要的事情。
产生独立覆盖的生成模式 解决方案不是停止使用 AI 进行测试生成。而是停止用同一个模型从同一个提示生成代码和测试。有四种模式可以产生真正独立的覆盖。
基于属性的测试优于基于示例的测试。 不要生成特定的输入-输出测试用例,而是指定不变量——对所有有效输入都应成立的属性。对于序列化函数,不变量是对所有 x,deserialize(serialize(x)) == x。对于排序函数,不变量是输出是非递减的。模糊测试框架然后搜索反例。这种方法绕过了预言问题:AI 不需要知道特定输入的正确输出,只需要知道必须成立的结构属性。当应用于 AI 生成的代码时,基于属性的测试已经在常规测试套件遗漏的广泛使用的库中发现了缺陷——分布函数返回负值、切片函数每次调用返回相同的块、缺少迭代器递增。
将代码生成与测试生成分离。 不要在同一会话中用同一模型生成两者。对每个使用不同的模型、不同的提示或不同的工具。当测试生成器无法访问实现内部时,它被迫从规范而非代码行为推理。这重新创造了人类在实现前编写测试时存在的分离——测试在表达意图,而非记录代码已经做的事情。
用人工规范锚定失败场景。 AI 是一旦定义了失败场景后生成测试代码 的有用工具。它不是决定哪些 失败重要的好工具。产生生产事故的场景通常是特定领域的:只在特定错误条件下来自特定第三方 API 的空值、在特定并发级别出现的竞态条件、来自事故历史的边界情况。这些需要人工领域知识来规范。原则是给 AI 场景定义并让它编写断言代码,而不是让 AI 选择测试什么。
将变异测试用作质量门而非事后分析。 在将覆盖率数字视为有意义之前,对 AI 生成的测试套件运行变异测试。将存活的变异体反馈给 AI,并明确指示覆盖它们。使用此反馈循环的研究发现,变异得分在单次迭代中从 70% 跃升至 78%——本身并不令人印象深刻,但这种模式验证了 AI 测试生成器在获得具体证据时可以改进它们缺少什么。上限是真实的;额外的迭代产生递减回报。但底线——初始 AI 生成的测试套件——在覆盖率指标反映真正的缺陷检测能力之前通常需要大幅改进。
结构性教训 工程师多年来使用一个启发式:凌乱的代码与逻辑错误相关。当代码格式不佳、命名神秘且结构不一致时,你会仔细阅读,因为混乱信号了风险。AI 生成的代码打破了这个启发式。它始终格式良好、习惯用法正确且命名良好——因为它是在好代码上训练的。表面质量信号了用心。底层逻辑可能不反映这一点。
同样的断裂适用于测试套件。带有看起来全面的测试名称、组织好的夹具和完整覆盖率报告的测试文件曾经是深思熟虑工程的代理。当代码和测试都是 AI 生成的,这个代理就是空的。套件看起来完整,而非独立构建的。
覆盖率幻觉要求的纪律很直接:将 AI 生成的测试套件视为覆盖率近似草稿,而非质量认证。覆盖率数字告诉你哪些行运行了。变异测试告诉你哪些缺陷会被捕获。人工指定的失败场景告诉你生产中什么重要。三者都是必需的。单独的覆盖率数字只是一个数字。
在人类定义测试策略之后将 AI 用作代码生成助手的团队——而不是委托策略本身——已经避免了 4.7 万美元类别的结果。区别不是 AI 写什么。而是人类决定什么。
会员专享
余下内容仅对会员开放。 会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
— 完整文章,包含未公开存档的部分 — 可落地的工作框架,附带权衡与决策依据 — 新文章抢先看,先于公开发布 登录以继续阅读→ 随时取消 · 一次订阅,畅读全部