Skip to content

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:

js
// legacy.cjs
module.exports = { twice: value => value * 2 };
js
// use.mjs
import legacy from './legacy.cjs';
console.log(legacy.twice(4)); // 8

不要把 CommonJS 命名导出的自动识别当作任意动态写法都可靠的保证。从 CommonJS 使用 ESM 时,import() 是清楚的异步入口;require(esm) 的支持范围与 Node 版本以及依赖是否含顶层 await 有关。本书不依赖这种互操作捷径。

先定义行为,再写断言 ​

新建 price.mjs,金额使用整数“分”,避免在这个实验中混入浮点金额问题。

js
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:

js
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:

js
// window.mjs
export function isOpen(deadline, now = Date.now) {
  if (!Number.isFinite(deadline)) throw new TypeError('截止时间必须有限');
  return now() < deadline;
}
js
// 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 是命令的固定入口 ​

json
{
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --test price.test.mjs"
  }
}

保存为同目录 package.json 后,可运行 npm test。这里没有依赖,不需要先安装一套工具链。脚本只是给命令起了一个稳定名字,不会自动提升测试质量。

普通业务函数可以在 Node 中测试;依赖 DOM 的行为则需要浏览器环境或适当的测试环境。用一个自造的空 document 对象糊弄运行错误,并不能证明事件传播正常。

测试应覆盖会变错的地方 ​

正常值说明主要功能可用;零、空值、上界说明边界;错误类型说明失败契约。再加回归用例:某次发现“数量 1.5 被悄悄接受”,就留下这条输入,防止以后重新出现。

避免只断言私有函数被调用几次。内部实现从循环改成其他方式,只要公开行为没变,测试就应继续通过。时间、网络与随机数会让测试结果不稳定,必要时把它们作为参数传入,用可控的小实现替代真实外部环境。

练习 ​

  1. 为 total 补充一个字符串价格测试,说明预期是转换还是拒绝。

  2. 为什么 assert.throws(async () => total(-1, 2)) 不是验证异步失败的正确方法?

  3. 把isOpen的比较从<改成<=,哪条测试会失败?为什么不让测试真正等待到截止时刻?

查看本节答案

用心记录每一步成长