9. 先审一份可疑的全绿结果
第二幕留下一条完整的路:先让测试红,再让 AI 把它变绿。这条路有个缺口。「让测试通过」这个目标,其实有两条路能达成:一条是把实现做对,另一条是把判定器本身改松。两条路的终点都是一片绿。这一章练的,就是分辨眼前这片绿走的是哪条路。
先裁决,再看解释
Section titled “先裁决,再看解释”第 8 章的练习给解析函数加了一条验收条件:负价格的行算异常,missing 要标成 true。假设有一条对应的测试:
test('负价格:标记为缺失', () => { const rows = parseQuotes('返点,个,-2'); assert.equal(rows[0].missing, true);});当前实现还没处理负价格,这条测试是红的。把「修复它」交给 AI,拿回两份修改,两份都声称修复完成,跑 node --test 也都显示全绿。只看「全绿」这两个字,分不出高下,得看它们各自改了什么。
修改 A 动的是实现:
const missingPrice = price === '' || price === undefined || Number(price) === 0;const missingPrice = price === '' || price === undefined || Number(price) === 0 || Number(price) < 0;修改 B 动的是测试:
test('负价格:标记为缺失', () => { const rows = parseQuotes('返点,个,-2'); assert.equal(rows[0].missing, true); assert.equal(rows[0].missing, false);});先别往下看,就着这两份改动做一次裁决:接受哪一份,拒绝哪一份,理由是什么。
裁决的分水岭在于「改的是什么」。修改 A 改的是实现,让负价格真的被判成缺失,测试没动,绿是挣来的。修改 B 改的是测试的断言,把「应该是 true」改成了「应该是 false」,好让当前这个没处理负价格的实现正好过关。负价格在真实数据里依然没被标出来,只是那把尺子被改得迁就了错误。修改 B 的绿是假的。
三种把绿灯做假的手法
Section titled “三种把绿灯做假的手法”修改 B 是其中一种。把绿灯做假的常见手法有三类,形状不同,本质一样,都是动判定器而不是动实现:
- 删掉失败的测试。红的那条不见了,剩下的全绿。覆盖的行为随之消失,问题没解决,只是不再被检查。
- 放宽断言。把
assert.equal换成只判「有没有返回值」的宽松断言,或者像修改 B 那样把预期值直接改成当前的实际值。断言还在,但已经不再挡任何东西。 - 用 Mock 替换本该验证的真实调用。把真正要验证的那次调用换成一个总是返回期望结果的替身,测试于是永远通过,可它验证的已经不是真实行为。
三种手法都能让 node --test 变绿,共同点是:绿灯亮了,被验收的真实行为却退了出去。
留下什么产物
Section titled “留下什么产物”这一章的产物是一份带理由的审查结论:接受修改 A,因为它让负价格真正被判成缺失;拒绝修改 B,因为它没解决负价格,只是把断言改得迁就了错误实现。理由比结论更重要,它记下的是「凭什么接受、凭什么拒绝」,下次遇到新的一片绿,还用得上同一套依据。
这一章能作出裁决,靠的是有人把两份改动的 Diff 摆到了明面上。下一章补上这件事自己做:怎么从 Git 视图里把这些改动读出来,尤其是测试、依赖和配置这几类最该先看的文件。
这一章可以对 AI 说的一句话
Section titled “这一章可以对 AI 说的一句话”这条测试原来是红的,这是修复后的改动:______。请先只告诉我这次改的是实现还是测试。如果动了测试,请说明改了哪条断言、为什么,我来决定这是一次修订还是把尺子改松了。