我所理解的 JavaScript 最佳实践(15):用独立的预期值捕获实现错误
最近打开的文件按时间从新到旧排列,页面最多展示五项。这个需求很容易测。给六项输入,检查返回前五项。但如果测试直接拿生产代码里的数量上限生成预期值,那条限制即使被改坏,测试也可能跟着改答案。
实现和预期共用上限,错误会一起传过去
下例把函数和断言放在同一个文件里,可以直接用 Node.js 运行。输入是已经排好序的文件名。
import assert from 'node:assert/strict';
const recentDocumentLimit = 5;
function getRecentDocuments(documents) {
return documents.slice(0, recentDocumentLimit);
}
const documents = ['A.md', 'B.md', 'C.md', 'D.md', 'E.md', 'F.md'];
assert.deepEqual(getRecentDocuments(documents), documents.slice(0, recentDocumentLimit));
运行后断言会通过。把 recentDocumentLimit 改成 4,断言还是通过。函数和预期都在使用 4,比较结果只证明两边用了一样的截取方式。需求里规定的五项从头到尾都没有参与判断。
让预期从需求里来
用同一组输入,把断言换成明确的五项结果。这里同时约束数量和顺序。读到断言,能直接看出第六项 F.md 不该出现。
assert.deepEqual(getRecentDocuments(documents), ['A.md', 'B.md', 'C.md', 'D.md', 'E.md']);
我用同样的六项输入运行了两种断言。只把上限从 5 改成 4,结果如下。
| 实现中的上限 | 用同一个上限计算预期 | 预期写成需求里的五项 |
|---|---|---|
5 |
通过 | 通过 |
4 |
仍然通过 | 断言失败,缺少 E.md |
固定预期的价值在于它有独立依据。这里的五项来自产品规定。对于编码函数,依据也可以是协议给出的向量。对于计算逻辑,可以先手算一个小样本。预期如果长到无法人工核对,就先缩小输入,保留能区分正确实现和错误实现的那一部分。
用一个确定错误的实现检验断言
修 bug 时,可以先拿会触发问题的输入跑旧实现,看到断言因预期中的差异失败。实现已经修好时,也可以在隔离副本里临时恢复那个错误。像这里的数量上限,从 5 改成 4 就足以验证测试能抓住少一项的问题。
失败的位置也要对得上。这里应当报出缺少 E.md,模块导入失败或测试没启动都证明不了这一点。自动生成这类错误变体,再检查测试能否发现,是变异测试的基本做法,见 Stryker 的变异测试说明。
边界用例同样按职责选。如果列表只有两项,应该原样返回这两项。空列表应该仍为空。输入已经按时间排好,排序逻辑由负责生成输入的那一层验证。对这个函数来说,六项输入的关键就是第六项该被截掉、第五项必须留下。
测试代码当然可以共享夹具、类型和准备数据的工具。预期值该有自己的来处。看到两边用了同一份业务常量或同一条公式时,先想一个实际可能写错的值,再看看这条断言能不能把它抓出来。