通过故意破坏代码不变量来验证测试有效性,可发现 AI 生成代码中测试未覆盖的逻辑漏洞。
AI 助手返回了一个 diff 和配套的测试,所有测试都通过了。尴尬的时刻在于,你仍然无法用自己的话解释这些测试究竟在保护什么。
一种审查技巧是选择一个不变量,故意打破它,然后检查是否有测试失败。失败的预期会指向它在实现中保护的条件。
本文将 SquadNote 真实的通知路由器及现有测试提取到一个临时副本中。删除一个删除条件后,8 个通过的测试变成 7 个通过加 1 个失败。检查于 2026 年 9 月 11 日执行,使用 Node.js v24.15.0 和 Vitest 4.1.4。
这个实验的目的不是判断代码的作者身份。它展示的审查流程同样适用于从 AI 助手那里收到的代码。这里描述的是已验证过的流程,不声称持续使用会带来什么好处。
示例涉及删除设备的推送令牌。假设一个设备从账户 A 切换到账户 B,随后收到来自 A 的延迟注销请求。
"登出正常"这种表述留给了太多解读空间。这里的不变量是:
来自前所有者 A 的请求不得删除已转移到 B 的设备令牌。
这明确了执行者、目标和必须保留的数据。相比"正确处理授权",它更容易转化为测试。
相关的删除谓词是:
and(
eq(pushTokens.userId, ctx.session.user.id),
eq(pushTokens.token, input.token),
)
令牌必须匹配,且其当前所有者必须与会话用户相匹配。注册和所有权转移是一个更大的话题;本次审查聚焦于删除谓词。
从操作序列开始:
A 注册了 ios-1 和 android-1。
B 注册了 android-1,将该令牌的所有权转移给 B。
A 请求注销 android-1。
B 的 android-1 和 A 的 ios-1 必须保留。
现有测试已经遵循了这个序列。它在之后读取数据库行,而不是仅检查是否调用了 API。其最终预期是:
expect(await rows()).toEqual([
{ user_id: "user-b", token: "android-1", platform: "android" },
{ user_id: "user-a", token: "ios-1", platform: "ios" },
]);
rows() 从哪里读取也很重要。这些测试使用真实的路由器和 libSQL 测试数据库,外部 HTTP 被 mock 了。如果 unregisterPushToken 本身被替换为一个总是成功的 mock,这个测试就不会执行这个谓词。
实验将提交 0cda1e8 提取到临时目录并运行了原始测试。然后仅在该副本中将谓词改为:
eq(pushTokens.token, input.token)
这是一个为了检验测试而注入的故障。这不是提议的生产变更。仅按令牌删除应该允许 A 的过期请求移除 B 的注册。
失败的测试是那个断言切换账户只转移该设备且前所有者无法删除它的测试。它对存储行的预期不再匹配。
一个已经失败的测试无法确立这种差异。一个阻止测试套件启动的语法错误也无法确立预期的不变量被检测到。这里,失败发生在测试的数据预期上。
实验脚本提取修复的提交,运行基准,移除条件,再次运行测试,且不修改原始仓库。假设源项目的依赖已安装,从文章仓库运行:
node experiments/article-stock-2026-09/mutation-check.mjs ../circle-hub-multi-device-push
这个反例被检测到了。如果不同的变更导致测试套件仍然通过,先检查反例是否到达了目标代码,再考虑增加更多测试。
例如,一个总是提供用户 A 的 mock 无法建立到 B 的转移。仅仅检查成功响应而不在之后读取行的断言可能会漏掉过度删除。测试也可能执行了与你修改的路由器不同的路由器。
按顺序阅读输入设置、调用的函数、外部边界的 mock 和最终预期。一旦你确定了哪些组件实际运行,就添加缺失的反例。
这个流程确定了一个选定的测试能够检测到一个选定的故障。它不能证明不存在每个授权 bug、每个有问题的并发顺序,或对真实设备的投递失败。这个实验没有使用外部投递或物理设备。
当向 AI 助手请求解释时,"如果移除这个条件,哪些数据会消失,哪个预期会失败?"能产生一个可以与执行结果对比的答案。记录不变量、反例和实际失败的预期。这份记录帮助下一个审查者判断相同的条件是否可以在清理期间被移除。
对于进一步的操作,你可以考虑屏蔽此人或举报滥用。