Node 模块与测试:让一次正确可以反复验证
浏览器提供 document,Node 提供文件系统等能力。两者运行 JavaScript,却不等于拥有相同的全局对象、模块解析规则和权限。本节只使用 Node 内置能力,不需要安装测试框架。
明确文件使用哪套模块规则
| 文件或配置 | 含义 |
|---|---|
.mjs | 明确使用 ESM |
.cjs | 明确使用 CommonJS |
最近父级 package.json 的 "type": "module" | 该包范围中的 .js 使用 ESM |
"type": "commonjs" | 该包范围中的 .js 使用 CommonJS |
现代 Node 对没有明确标记的输入还可能进行语法检测。学习时直接声明类型,可以避免“我机器能运行”变成模块规则的一部分。ESM 的相对导入写全扩展名;不要假定 ./sum 会自动找到 sum.js。
CommonJS 使用 module.exports 暴露值,require() 取得导出。它不等同于 ESM 的静态绑定机制。ESM 引入旧 CommonJS 文件时,默认导入可对应 module.exports:
// legacy.cjs
module.exports = { twice: value => value * 2 };// use.mjs
import legacy from './legacy.cjs';
console.log(legacy.twice(4)); // 8不要把 CommonJS 命名导出的自动识别当作任意动态写法都可靠的保证。从 CommonJS 使用 ESM 时,import() 是清楚的异步入口;require(esm) 的支持范围与 Node 版本以及依赖是否含顶层 await 有关。本书不依赖这种互操作捷径。
先定义行为,再写断言
新建 price.mjs,金额使用整数“分”,避免在这个实验中混入浮点金额问题。
export function total(cents, count) {
if (!Number.isSafeInteger(cents) || cents < 0 ||
!Number.isSafeInteger(count) || count < 0) {
throw new RangeError('价格和数量必须是非负安全整数');
}
const result = cents * count;
if (!Number.isSafeInteger(result)) {
throw new RangeError('总价超出安全整数范围');
}
return result;
}新建 price.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { total } from './price.mjs';
test('按整数分计算总价', () => {
assert.equal(total(125, 3), 375);
assert.equal(total(125, 0), 0);
});
test('拒绝非法输入和溢出', () => {
assert.throws(() => total(-1, 2), RangeError);
assert.throws(() => total(10, 1.5), RangeError);
assert.throws(() => total(Number.MAX_SAFE_INTEGER, 2), RangeError);
});
test('等待异步拒绝完成', async () => {
const calculate = async () => total(-1, 2);
await assert.rejects(calculate, RangeError);
});运行 node --test price.test.mjs,应看到三个测试通过。断言不是打印结果让人用肉眼找错,而是让不符合约定的结果产生失败退出状态,方便重复检查。
assert.throws 接收函数,不能写 assert.throws(total(-1, 2)):后者在进入断言之前已经抛错。异步拒绝则使用 await assert.rejects。忘记等待会让测试结束得比验证更早,仿佛监考老师提前下班。
让会变化的环境成为可控输入
价格例子是纯计算,输入相同就容易复现。假如一项判断读取当前时间,测试刚好跨过截止时刻,就可能一会儿成功、一会儿失败。给测试多加一秒等待并没有修复问题,只是把偶然性藏远一点。
把“取得现在时间”作为依赖传入。以下两个文件与前面的价格文件独立,运行 node --test window.test.mjs:
// window.mjs
export function isOpen(deadline, now = Date.now) {
if (!Number.isFinite(deadline)) throw new TypeError('截止时间必须有限');
return now() < deadline;
}// window.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { isOpen } from './window.mjs';
test('截止前开放,到达截止时刻关闭', () => {
assert.equal(isOpen(1000, () => 999), true);
assert.equal(isOpen(1000, () => 1000), false);
assert.equal(isOpen(1000, () => 1001), false);
});三次调用不需要真的等时间流逝。测试声明的契约是严格小于截止时间,恰好相等必须关闭。若把 < 改成 <=,中间那项就会失败。这才是一个能抓住边界变化的测试,而不是把函数的计算结果再原样算一遍。
默认参数传入 Date.now 函数,而不是 Date.now() 的执行结果:前者允许函数在需要时读取,后者是已经取得的数字。依赖替身也应保持同一接口,不能真实函数返回 Promise,测试替身却随意返回别的结构然后假称异步路径通过。
真实模块可能在 import 时就读环境或联网。那样测试还没来得及注入替身,副作用已经发生。将纯判断、外部获取与调用入口分开,既能改善测试,也能使模块更容易复用。这里不需要建立庞大的依赖框架,一个函数参数就足够。
package scripts 是命令的固定入口
{
"private": true,
"type": "module",
"scripts": {
"test": "node --test price.test.mjs"
}
}保存为同目录 package.json 后,可运行 npm test。这里没有依赖,不需要先安装一套工具链。脚本只是给命令起了一个稳定名字,不会自动提升测试质量。
普通业务函数可以在 Node 中测试;依赖 DOM 的行为则需要浏览器环境或适当的测试环境。用一个自造的空 document 对象糊弄运行错误,并不能证明事件传播正常。
测试应覆盖会变错的地方
正常值说明主要功能可用;零、空值、上界说明边界;错误类型说明失败契约。再加回归用例:某次发现“数量 1.5 被悄悄接受”,就留下这条输入,防止以后重新出现。
避免只断言私有函数被调用几次。内部实现从循环改成其他方式,只要公开行为没变,测试就应继续通过。时间、网络与随机数会让测试结果不稳定,必要时把它们作为参数传入,用可控的小实现替代真实外部环境。
练习
为
total补充一个字符串价格测试,说明预期是转换还是拒绝。为什么
assert.throws(async () => total(-1, 2))不是验证异步失败的正确方法?把isOpen的比较从<改成<=,哪条测试会失败?为什么不让测试真正等待到截止时刻?