测试全绿仍有漏洞:断言别照搬实现
文章用三个小案例讨论测试通过却遗漏业务错误的问题,其中折扣测试照搬实现条件,漏掉了恰好购买 10 件时的边界错误。AI 生成代码外观整洁,更需要核对测试是否独立验证需求。
AI 深度解读
测试全绿,代码也整齐,需求却可能一条都没验证到。作者用三个能运行的 Node.js 小例子说明:测试通过,只能证明被断言的事情成立,不能自动证明业务要求成立。AI 生成的代码外观规整,反而容易让这类偏差混过去。
这三个例子分别涉及折扣边界、故障开关和分页删除。它们不是线上事故复盘,而是作者构造的演示。值得看的是:每个测试到底检查了什么,又漏掉了什么。
覆盖了边界,为什么仍然测错折扣
需求是「超过 10 件的订单打九折」,实现却把恰好 10 件也算进去了:
// Ticket: "10% off orders of more than 10 units."
export function lineTotalCents(unitCents, qty) {
const rate = qty >= 10 ? 0.9 : 1;
return Math.round(unitCents * qty * rate);
}
要看的是 qty >= 10:需求要求严格大于,实现用了大于等于。
配套测试覆盖了 1、5、10、25 件,看起来连边界都照顾到了:
for (const qty of [1, 5, 10, 25]) {
const expected = Math.round(999 * qty * (qty >= 10 ? 0.9 : 1));
assert.equal(lineTotalCents(999, qty), expected);
}
问题仍在 qty >= 10。测试用同一个错误条件计算 expected,再拿它检查实现,两边自然一致。Math.round 和金额乘法可以都正确,折扣资格依然可能错。
覆盖到某个输入,不等于验证了这个输入应有的业务结果。
这里需要的是独立于实现的预期:按需求,单价 999 分、数量恰好 10 件时,总价应为 9990 分。这个答案应从需求推导,而不是复制实现里的判断式。
不抛异常、任务成功,都不等于事情办对了
第二个需求是:发生事故时,运维必须能关闭结账。
作者的 isCheckoutEnabled(fetchFlags) 正常情况下返回 flags.checkout !== false;调用失败后则执行 console.warn,并返回 true,继续开放结账。测试用 down 模拟抛出 Error("timeout"),通过 assert.doesNotReject(isCheckoutEnabled(down)) 验证函数不会拒绝。
它确实检查了错误有没有向外抛,却没有检查结账是否关闭。
如果同一次故障也打垮了配置服务,这个默认值就会让结账继续开放。这里的风险叫「故障时放行」(fail open):依赖不可用时,系统选择允许操作。它是否合适,必须结合开关的业务目的判断。
第三个需求是删除所有过期会话。purgeExpired(store, pageSize = 100) 从 offset = 0 开始,通过 store.listExpired({ offset, limit: pageSize }) 取数据,再调用 store.deleteMany(page.map((row) => row.id)) 删除,随后执行 offset += pageSize,直到取到空页。
陷阱在于:删除之后,剩余数据的位置变了。
原来的第 101 条已经移到最前面,下一次却从偏移量 100 开始,直接跳过了一批。测试只有 3 行数据,不会进入第二页;换成作者演示的 250 行,就有 100 条过期会话留下,任务仍然报告成功。
这两个例子共同提醒我们:没有异常,只说明控制流程走完了;要证明业务完成,还得检查返回值或最终数据状态。
我的判断
作者建议按「需求、测试、代码差异」的顺序审查,我赞同。先明确业务承诺,再看测试能不能推翻错误实现,比较容易发现那些跟着代码一起写错的预期值。
不过,「测试和实现用了相同表达式,所以它只能同意实现」需要限定范围。折扣测试仍可能抓到乘法、取整或返回值方面的错误,只是无法识别两边共享的折扣边界错误。其他两个测试也有价值,只是验证范围比需求窄。
作者还提出:谁提交 PR,谁就对每一行负责,不管代码来自哪里。这条规则免去了识别 AI 代码的争论。但书面解释也能生成,因此作者认为现场追问才是责任要求真正经受检验的环节。
这仍是经验主张。原文明确说,没有人测量过它是否加快审查、减少事故;例子和测试可以运行,但并非真实事故证据。文章本身也由 AI 起草,经作者审核后发布。
实现上也不能机械套答案。结账依赖失败后,是关闭、沿用上次状态,还是继续开放,需要团队明确约定。分页删除可以考虑持续读取首批、使用游标或按稳定标识推进,具体取舍取决于存储接口与并发条件,原文没有展开。
能拿来做什么
- 审查 AI 生成的业务代码时,先从需求独立写出关键预期,再检查测试是否复制了实现条件。折扣场景至少要区分恰好 10 件与超过 10 件。
- 给 Node 服务里的配置开关补失败场景断言,明确依赖超时后应返回什么。
assert.doesNotReject可以保留,但还要验证业务状态。 - 检查分页删除任务时,让数据跨过至少两页,并在执行后核对剩余过期记录,避免只凭函数返回或成功日志判断完成。
- 作者提供的例子无需安装依赖,可在 examples 文件夹运行
node --test。复查时逐个追问:这个测试要遇到什么错误才会失败?