AI 快速生成大量测试覆盖每行代码,但缺乏有意义的断言;测试通过覆盖率高,bug 仍能上线,称为"覆盖率虚高"。
你的 CI pipeline 显示测试覆盖率为 83%。AI assistant 在不到两分钟内生成了 27 个新的测试函数,新模块中的每个函数都有一个对应测试。PR 看起来很干净,覆盖率门禁也顺利通过。
但 bug 还是上线了。
所谓「覆盖率表演」(Coverage Theater),是指 AI 生成的测试虽然提高了代码行执行率,却没有对任何有意义的行为进行断言。指标看起来没问题,测试也确实运行了,但最终你会发现,这套测试几乎什么问题都捕获不到。
AI coding assistant 的目标往往是可量化的指标:当你让它为某个函数编写测试时,它生成的测试用例会覆盖每一行代码,却不一定覆盖每一种行为。BrassCoders 并不评估测试质量;无论测试套件覆盖了什么,它都会扫描生产代码中的安全和正确性 bug——不管覆盖率有没有达到 80%,SQL 注入 bug 都可能被发布出去。
如果把一个 hash_password 函数交给 AI,它会读取函数的结构:分支、参数类型和返回值。随后,它可能生成 assert hash_password("") is not None 和 assert hash_password("secret") is not None。这两行代码都会执行,Coverage.py 也会把它们计入覆盖率。但这些断言都无法告诉你,该函数是否使用了安全的算法,或者是否应用了 salt。即使实现从 bcrypt 换成 MD5,这两个测试也不会失败。
出现这种情况,是因为 AI assistant 根据实现推断测试,而不是根据 specification 编写测试。拿到代码后,它生成的测试只是在匹配代码当前的行为。这不叫测试,只是给 bug 拍了一张快照。
BrassCoders 不会扫描测试质量——这超出了静态分析的范围——但覆盖率表演产生的四种模式值得明确命名:循环断言(测试断言某个函数返回的结果,正是你编写测试时它所返回的结果)、mock 被测对象、测试实现细节而不是结果,以及按照每个函数生成一个测试,而不是按照每种行为生成测试。
循环断言是其中最严重的问题。AI 在测试 calculate_tax(income, rate) 时,可能会生成:expected = calculate_tax(1000, 0.2); assert calculate_tax(1000, 0.2) == expected。除非函数抛出异常,否则这个测试无论如何都会通过。每次调用都会被记为覆盖了一行代码,但它什么也没有验证。
mock 被测对象的情况通常是这样的:你有一个数据库查询函数,于是 mock 了数据库连接,然后断言这个 mock 是否使用了正确的参数被调用。这样一来,你测试的其实是 Python 的 unittest.mock 库。真正的查询逻辑——它是否构造了参数化语句、是否正确处理 null 值——完全没有得到验证。
测试实现细节,是指针对函数内部的调用关系编写断言,而不是验证它向调用方承诺的 contract。当你重构内部实现时,即使行为没有变化,测试也会失败;而真正的回归问题仍然可能被发布出去。
第四种模式——每个函数只写一个测试——之所以能够通过覆盖率门禁,是因为覆盖率工具追踪的是代码行是否执行,而不是不同的行为路径。一个函数可能包含五种错误条件、两个边界情况和一条 happy path,却只有一个测试:测试 happy path。其余条件都没有经过测试,但这个函数仍会显示为已覆盖。
Coverage.py 衡量的是测试套件运行期间执行了哪些代码行——它没有任何机制判断测试中的断言是否真的能够捕获 bug。BrassCoders 可以捕获覆盖率指标遗漏的生产代码 bug:例如数据库 handler 中存在 SQL 注入,即使它拥有 100% 的代码行覆盖率,只要没有断言检查是否使用了参数化查询,这个问题依然不会被测试发现。
Martin Fowler 的 TestCoverage 文章直截了当地指出:覆盖率适合作为一种负向指标。低覆盖率肯定意味着存在问题;高覆盖率只能说明某些代码运行过,却无法说明这些运行是否能够捕获 regression。
问题源于团队对覆盖率含义的错误假设。大家默认认为:如果某一行代码被覆盖,就意味着有人编写了能够验证它的断言。在人工编写的测试中,这通常成立。但对于以通过覆盖率门禁为目标的 AI 生成测试,这个假设经常不成立——AI 编写的是能够让这行代码运行起来的断言,而不是能够捕获 regression 的断言。
Mutation testing 可以让这种差距暴露出来。Mutmut 和 Cosmic Ray 会故意向生产代码中引入 bug,然后让测试套件针对修改后的代码运行。如果存在 bug 时测试仍然通过,就说明这些测试并没有验证对应的行为。Coverage.py 文档也指出,覆盖率数据最好与其他质量信号结合使用,而不是单独作为门禁。Mutation testing 正是这样一种补充信号。
BrassCoders 的 12 个 scanner 针对生产代码运行,而不是针对测试代码——无论测试断言了什么,它们都会捕获实现中的安全和正确性 bug。无论测试覆盖率有没有达到 80%,数据库查询中的 SQL 注入 bug 都可能被发布;而 BrassCoders 会以确定性的方式将它标记出来。
这些 scanner 运行在实现代码上。BrassCoders 编排的上游工具包括 Bandit、detect-secrets、Pyre/Pysa、Semgrep、ast-grep,以及六个自定义 detector。这些自定义 detector 覆盖 secret 格式模式、隐私与 PII 处理、AI 生成代码中的 phantom API 调用、性能反模式、内容审核信号,以及 JavaScript/TypeScript。即使一个数据库 handler 拥有 100% 的测试覆盖率,BrassCoders 仍会在第一次扫描时标记出 SQL 注入问题。
发布在 /blog/ai-coder-bug-benchmark/ 的一项 benchmark,从 Copilot、Cursor 和 Claude Code 等 AI coding assistant 中选取了 12 个真实 bug,并分别使用 Bandit 和 BrassCoders 进行测试。Bandit 捕获了 12 个 bug 中的 6 个,BrassCoders 则捕获了 11 个。Coverage.py 很可能会显示其中大多数 bug 所在的代码都已被覆盖,因为包含这些 bug 的函数全部都有测试。
Coverage.py 和 BrassCoders 回答的是两个不同的问题。Coverage.py 回答:测试套件执行了哪些代码行?BrassCoders 回答:生产代码中包含哪些安全和正确性模式?一个代码库在第一个问题上可能拿到 95% 的分数,但其中仍然可能存在可被利用的注入漏洞或硬编码凭据。这两个答案并不重叠,所以你需要同时使用二者。
运行 pip install brasscoders,然后运行 brasscoders scan .,即可查看哪些 scanner 会在你的生产代码上触发。它的 OSS core 免费提供,采用 Apache 2.0 许可证,并且不会将任何数据发送到你的机器之外。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。