4. 看一次全绿怎样掩盖错误
测试全绿,通常被当成「这段代码没问题」的证据。这一章要拆穿一种常见情况:全绿只说明测试通过了自己规定的检查,不说明检查规定得对不对。
AI 写代码时经常先给出实现,再补一批测试。补测试的方式往往是运行一次当前代码,把输出记下来,写成断言。这批测试看起来完整,实际检查的只是「代码现在输出了什么」,不是「输入本身应该得到什么」。两者一致时看不出区别,实现出错时问题就藏进了全绿里。
先跑一遍全绿测试
Section titled “先跑一遍全绿测试”需求很简单:从同一种物料的多条报价里,选出价格最低的一行。把这句话交给 AI,请它连实现和测试一起给出,拿到的结果大致是这样:
文件夹quote-tools/
- lowest-price.js
- lowest-price.test.js
export function lowestPrice(rows) { // 先假设第一行最低,然后逐行比较,遇到更低的价格就换上它 let lowest = rows[0]; for (const row of rows) { if (row.price < lowest.price) { lowest = row; } } return lowest;}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 });});把这两个文件保存到同一个文件夹,在该目录下运行:
node --testNode.js 内置的测试运行器(node:test)会自动找出当前目录里符合 *.test.js 命名的文件并逐个执行。终端会输出两条测试都通过,退出码为 0。
换一份实现,颜色不变
Section titled “换一份实现,颜色不变”现在换上另一个对照版本,看它是不是也能通过同一批测试:
export function lowestPrice(rows) { // 不比较任何价格,直接给出第一行 return rows[0];}测试文件不动,重新运行 node --test。
两条测试仍然全部通过。
全绿检查了什么,遗漏了什么
Section titled “全绿检查了什么,遗漏了什么”两版实现的注释已经把差别写清楚:一版逐行比较价格,另一版什么都不比较,直接给出第一行。测试却分不出这两者。先回答一句:测试到底检查了「价格最低的一行被选出来」,还是检查了「返回了第一行」?
两条测试的输入里,价格最低的那一行恰好都排在数组第一位。第一版实现依次比较大小,正好也会选出第一行;第二版实现什么都不比较,直接返回第一行。两版代码在这两组输入下产生同一个结果,测试因此分不出谁对谁错。
现在可以给它一个名字
Section titled “现在可以给它一个名字”刚才发生的事可以先用自己的话描述一遍:一份测试通过了,但它检查的范围小于它看起来覆盖的范围,因为期望值来自当前实现的输出,不是来自对输入的独立判断。
给这种测试起一个临时名字,写在旁边。
本书后面把这类测试统一叫回声测试:它把实现已经产生的声音原样録回去,再拿这段录音去核对同一个实现,核对结果自然一致。这是本书为了方便讨论使用的说法,不是软件测试行业统一采用的术语,换一本书或者换一个团队,可能听到别的叫法,甚至没有专门的叫法。
换一批数据,重新戳一次
Section titled “换一批数据,重新戳一次”把测试文件里的第一组数据改成价格最低的一行排在最后:
const rows = [ { name: 'M6 螺母', unit: '个', price: 0.22 }, { name: 'M6 螺母', unit: '个', price: 0.18 },];期望值也改成价格 0.18 的那一行,再用两版实现分别跑一次 node --test。
比较大小的那一版仍然通过;直接返回第一行的那一版会失败。同一份测试,换了数据排列之后,终于能分辨两版实现的差别。
自己再补一组:三行数据,最低价排在中间;或者两行价格相同。哪一种排列能让「直接返回第一行」的版本露出破绽,哪一种排不出来,运行一次就有答案。
留下什么判断
Section titled “留下什么判断”这一章没有产出新功能,留下的是一条能立住的判断:旧测试的两组数据都让「最低价」和「第一行」重合,所以它没能检查出实现被换成只返回第一行。判断依据是刚才两次运行的实际输出,不是「感觉这测试不够严谨」。
下一章会正式处理「例子该怎么选才不会漏掉这类情况」,这一章先把「全绿不能单独当证据」这件事,用一次可复现的实验立住。
这一章可以对 AI 说的一句话
Section titled “这一章可以对 AI 说的一句话”这是一份测试文件和它对应的实现。检查每一条断言的期望值:如果不看这份实现,只看输入本身,这个期望值能不能独立算出来?指出哪些断言只是在复述当前实现的输出,再给我补一条能让「返回第一行」这种错误实现失败的用例。