跳转到内容

4. 看一次全绿怎样掩盖错误

约 30 分钟 难度:新手 动手章

测试全绿,通常被当成「这段代码没问题」的证据。这一章要拆穿一种常见情况:全绿只说明测试通过了自己规定的检查,不说明检查规定得对不对。

AI 写代码时经常先给出实现,再补一批测试。补测试的方式往往是运行一次当前代码,把输出记下来,写成断言。这批测试看起来完整,实际检查的只是「代码现在输出了什么」,不是「输入本身应该得到什么」。两者一致时看不出区别,实现出错时问题就藏进了全绿里。

需求很简单:从同一种物料的多条报价里,选出价格最低的一行。把这句话交给 AI,请它连实现和测试一起给出,拿到的结果大致是这样:

  • 文件夹quote-tools/
    • lowest-price.js
    • lowest-price.test.js
lowest-price.js
export function lowestPrice(rows) {
// 先假设第一行最低,然后逐行比较,遇到更低的价格就换上它
let lowest = rows[0];
for (const row of rows) {
if (row.price < lowest.price) {
lowest = row;
}
}
return lowest;
}
lowest-price.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lowestPrice } from './lowest-price.js';
// 两条报价,价格更低的那条排在第一行
test('从多条报价里选出价格最低的一行', () => {
const rows = [
{ name: 'M6 螺母', unit: '个', price: 0.18 },
{ name: 'M6 螺母', unit: '个', price: 0.22 },
];
assert.deepEqual(lowestPrice(rows), { name: 'M6 螺母', unit: '个', price: 0.18 });
});
// 只有一条报价,没有别的行可以比较
test('只有一行时,直接返回这一行', () => {
const rows = [{ name: 'M8 螺栓', unit: '个', price: 0.55 }];
assert.deepEqual(lowestPrice(rows), { name: 'M8 螺栓', unit: '个', price: 0.55 });
});

把这两个文件保存到同一个文件夹,在该目录下运行:

Terminal window
node --test

Node.js 内置的测试运行器(node:test)会自动找出当前目录里符合 *.test.js 命名的文件并逐个执行。终端会输出两条测试都通过,退出码为 0。

现在换上另一个对照版本,看它是不是也能通过同一批测试:

lowest-price.js
export function lowestPrice(rows) {
// 不比较任何价格,直接给出第一行
return rows[0];
}

测试文件不动,重新运行 node --test。

两条测试仍然全部通过。

两版实现的注释已经把差别写清楚:一版逐行比较价格,另一版什么都不比较,直接给出第一行。测试却分不出这两者。先回答一句:测试到底检查了「价格最低的一行被选出来」,还是检查了「返回了第一行」?

两条测试的输入里,价格最低的那一行恰好都排在数组第一位。第一版实现依次比较大小,正好也会选出第一行;第二版实现什么都不比较,直接返回第一行。两版代码在这两组输入下产生同一个结果,测试因此分不出谁对谁错。

刚才发生的事可以先用自己的话描述一遍:一份测试通过了,但它检查的范围小于它看起来覆盖的范围,因为期望值来自当前实现的输出,不是来自对输入的独立判断。

给这种测试起一个临时名字,写在旁边。

本书后面把这类测试统一叫回声测试:它把实现已经产生的声音原样録回去,再拿这段录音去核对同一个实现,核对结果自然一致。这是本书为了方便讨论使用的说法,不是软件测试行业统一采用的术语,换一本书或者换一个团队,可能听到别的叫法,甚至没有专门的叫法。

把测试文件里的第一组数据改成价格最低的一行排在最后:

lowest-price.test.js
const rows = [
{ name: 'M6 螺母', unit: '个', price: 0.22 },
{ name: 'M6 螺母', unit: '个', price: 0.18 },
];

期望值也改成价格 0.18 的那一行,再用两版实现分别跑一次 node --test。

比较大小的那一版仍然通过;直接返回第一行的那一版会失败。同一份测试,换了数据排列之后,终于能分辨两版实现的差别。

自己再补一组:三行数据,最低价排在中间;或者两行价格相同。哪一种排列能让「直接返回第一行」的版本露出破绽,哪一种排不出来,运行一次就有答案。

这一章没有产出新功能,留下的是一条能立住的判断:旧测试的两组数据都让「最低价」和「第一行」重合,所以它没能检查出实现被换成只返回第一行。判断依据是刚才两次运行的实际输出,不是「感觉这测试不够严谨」。

下一章会正式处理「例子该怎么选才不会漏掉这类情况」,这一章先把「全绿不能单独当证据」这件事,用一次可复现的实验立住。

这是一份测试文件和它对应的实现。检查每一条断言的期望值:如果不看这份实现,只看输入本身,这个期望值能不能独立算出来?指出哪些断言只是在复述当前实现的输出,再给我补一条能让「返回第一行」这种错误实现失败的用例。

这一节有错或讲不清? 提个 Issue 直接改文档 请作者催更