大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试 node:test
这一课你将能做到
- 用 Node 20 内置的
node:test写出单元测试并运行,全程零第三方依赖 - 使用
assert断言普通值、抛错(assert.throws)和异步拒绝(assert.rejects) - 用
describe/it组织用例,测异步函数,并跑出覆盖率报告
为什么要学
没有测试的代码,改一行就要「凭感觉」上线——感觉是不可靠的。测试人话:把「我改完应该还能跑」变成「机器跑一遍用例确认还能跑」,重构、升级依赖、接手别人代码时全靠它兜底。
Node 20 把测试框架 node:test 升为稳定内置模块:不用装 Jest/Mocha,node --test 一条命令就能跑。小工具、脚本、库项目用它完全够;理解了它的概念(用例、断言、夹具、覆盖率),换 Jest 等框架也只是换语法外壳。
先建立图像
一个测试文件的结构(AAA 模式):
test('用例描述', () => {
// Arrange 准备:造输入数据
// Act 执行:调用被测函数
// Assert 断言:assert.strictEqual(实际, 期望)
});
运行链路:
node --test → 扫描 test/ 目录(或 *.test.js)→ 逐个执行 → 汇总报告
✓ 用例通过(绿) ✗ 断言失败(红,显示实际 vs 期望)
测试的本质就一句话:用代码描述「给定什么输入、期望什么输出」,让机器反复验证。
跟着做
步骤 1:写一个被测模块
新建 node-test 目录,npm init -y,新建 math.js:
// math.js
function add(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) {
throw new RangeError('除数不能为 0');
}
return a / b;
}
async function sumRemote(values) {
// 模拟一个异步计算(现实中可能是查库/请求)
await new Promise((r) => setTimeout(r, 10));
return values.reduce((s, n) => s + n, 0);
}
module.exports = { add, divide, sumRemote };
步骤 2:第一个测试文件
按约定新建 test/math.test.js(或与源文件同级叫 math.test.js,node --test 都认):
// test/math.test.js
const test = require('node:test');
const assert = require('node:assert');
const { add, divide } = require('../math');
test('add 两数相加', () => {
assert.strictEqual(add(1, 2), 3);
});
test('add 处理负数', () => {
assert.strictEqual(add(-1, -2), -3);
});
test('divide 正常相除', () => {
assert.strictEqual(divide(10, 4), 2.5);
});
test('divide 除以 0 时抛 RangeError', () => {
assert.throws(() => divide(1, 0), {
name: 'RangeError',
message: '除数不能为 0',
});
});
运行:
node --test
# ▶ add 两数相加
# ✔ 0ms
# ▶ divide 除以 0 时抛 RangeError
# ✔ 1ms
# ℹ tests 4
# ℹ pass 4
# ℹ fail 0
零配置、零安装,直接出报告。把 npm test 接上(package.json scripts 默认的 test 值改掉):
"scripts": {
"test": "node --test"
}
以后就是标准的 npm test。
步骤 3:故意让测试失败——看失败长什么样
把某个断言改成错的跑一次:
test('故意失败示范', () => {
assert.strictEqual(add(1, 2), 99); // 期望值故意写错
});
node --test
# ✖ failing tests:
# ✖ 故意失败示范
# Error [ERR_ASSERTION]: Expected inputs to be identical.
# + actual: 3
# - expected: 99
报告明确告诉你「实际 3、期望 99」。永远先看测试失败一次(确认测试真的在检查),再改对——没红过的测试不可信。
步骤 4:describe/it 组织用例 + 测异步
用例多了要分组。describe 建分组,it 是 test 的别名(读起来像句子):
// test/math-group.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { add, divide, sumRemote } = require('../math');
describe('math.add', () => {
it('正数相加', () => {
assert.strictEqual(add(2, 3), 5);
});
it('浮点相加', () => {
assert.strictEqual(add(0.1, 0.2), 0.30000000000000004); // 二进制浮点事实
});
});
describe('math.sumRemote(异步)', () => {
it('async 函数直接 await', async () => {
const result = await sumRemote([1, 2, 3]);
assert.strictEqual(result, 6);
});
it('拒绝的场景用 assert.rejects', async () => {
await assert.rejects(
() => Promise.reject(new Error('网络错误')),
{ message: '网络错误' }
);
});
});
node --test
异步用例两条规则:测试函数写成 async,内部 await 被测函数;测「应该拒绝」用 assert.rejects(对应同步的 assert.throws)。忘了 await 是异步测试的头号 bug——断言在 Promise 落定前就执行了,测试假绿。
步骤 5:测抛错与边界值的完整姿势
// test/boundary.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { divide } = require('../math');
describe('divide 边界', () => {
it('NaN 直接抛 RangeError(不返回 NaN 更好排查)', () => {
assert.throws(() => divide('a', 2), RangeError);
});
it('结果可用 Math.abs 验证符号', () => {
assert.ok(Math.sign(divide(-10, 2)) === -1);
});
});
assert.throws(fn, RangeError) 可只按类型匹配;传对象则要求字段全部吻合。常用断言速查:
assert.strictEqual(a, b); // 严格相等(===,用于原始值)
assert.deepEqual(obj1, obj2); // 深比较对象结构
assert.ok(value); // 真值断言
assert.match(str, /正则/); // 字符串匹配
assert.throws(fn, ErrorType); // 同步抛错
await assert.rejects(fn, ErrorType); // 异步拒绝
步骤 6:覆盖率——哪些代码没被测到
node --test --experimental-test-coverage
# ℹ start of coverage report
# ℹ -------------------------------------------------------------
# ℹ file | line % | branch % | funcs % | uncovered lines
# ℹ -------------------------------------------------------------
# ℹ math.js | 100.00 | 100.00 | 100.00 |
# ℹ test/math... | 100.00 | | |
# ℹ -------------------------------------------------------------
# ℹ all files | 100.00 | 100.00 | 100.00 |
报告列出每个文件的行/分支/函数覆盖率和未覆盖行号。覆盖率是「找漏」工具不是 KPI:80% 有意义的断言,好过 100% 的 assert.ok(true)。接线到 scripts:
"scripts": {
"test": "node --test",
"test:cov": "node --test --experimental-test-coverage"
}
讲清楚要点
node:test是 Node 20 内置的稳定测试运行器:node --test自动发现test/目录和*.test.js文件;断言用同样内置的node:assert,一条第三方依赖都不用装。- AAA 是用例的组织纪律:Arrange 准备数据、Act 执行被测函数、Assert 断言结果。一个用例只验证一个行为,描述写成「能当文档读」的句子。
- 每个用例必须独立:不依赖执行顺序、不共享可变状态。测试文件之间并行执行,共享可变状态会导致「单独跑能过、一起跑就炸」。
- 同步用
assert.throws,异步用assert.rejects:Promise 拒绝不会触发同步 try/catch,测异步错误必须用 rejects + await。 - 失败优先原则:新写测试先让它们红一次,确认断言真的在工作;只见过绿的测试可能从第一行起就是假测试。
你可能会懵的地方
-
不用 Jest 会不会太寒酸? 不会。node:test 已内置且稳定,小到中型的库、脚本、CLI 用它最轻快。Jest/Vitest 的优势在快照、mock 生态和组件测试——等需求出现再引入,概念完全可迁移(describe/it/断言都是同套词汇)。
-
测试文件放哪、怎么命名? 默认两处都被扫描:项目里任何
*.test.js、或test/目录下的文件。团队约定一种:小项目test/目录 +xxx.test.js与被测文件同名,一眼对应。 -
怎么测「会抛错」的函数? 同步:
assert.throws(() => fn(), RangeError)。注意传函数引用而不是调用结果——写成assert.throws(fn(), ...)是在断言函数返回值,抛错会直接炸掉测试而不是被捕获。 -
测试里的 setTimeout 真的要等吗? 真的会等,所以别在用例里写长 sleep。被测函数里的延时用可注入的方式设计(把 delay 或「时钟」作为参数传入),测试传 0 或假实现——这就是「可测试性设计」的入门。
-
覆盖率 100% 就安全了吗? 不。覆盖率只说明「这行代码执行过」,不说明「断言了正确行为」。
assert.ok(true)刷满行覆盖毫无意义。看覆盖率的正确姿势是找「完全没被碰过」的模块。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
assert.equal(0, '') 借宽松比较 | == 语义,0 == '' 为 true,假绿 | 用 assert.strictEqual / deepStrictEqual |
测试函数里忘 await 异步调用 | 断言先于结果执行,测试假绿 | 测试函数标 async,逐个 await |
assert.throws(fn()) 传调用结果 | 抛错直接炸测试而非被捕获 | 传箭头函数 () => fn() |
| 用例间共享可变的全局变量 | 一起跑时互相污染 | 每个用例自建数据,或用 beforeEach 重置 |
| 一个用例里塞五六个不相关断言 | 失败时无法定位是哪个行为坏了 | 一用例一行为,描述即文档 |
| 断言失败前先打 console.log 看看 | 手工目测,不可重复 | 直接断言,报告会打印 actual/expected |
卡点急救
node --test说找不到测试:确认文件名匹配*.test.js或位于test/目录;在项目根目录运行命令。require('../math')报 Cannot find module:相对路径是相对测试文件的,test/math.test.js引用上级用../math;核对文件名。- ESM 项目里
require is not defined:package.json 有"type": "module"时测试文件也用 import:import test from 'node:test'、import assert from 'node:assert/strict'。 - 测试全部卡住不结束:某个用例开了定时器/服务没关闭(server.listen、setInterval)。用例结束时清理资源,或
--test-force-exit强制收尾再排查根因。 - 本地过、CI 挂:多半是时区、路径分隔符或端口占用的环境差异。测试数据里别写死本地路径,监听端口用 0(系统随机分配)。
面试常问
-
单元测试的 AAA 模式是什么? Arrange 准备输入与依赖、Act 执行被测行为、Assert 断言结果。每个用例聚焦一个行为、相互独立、描述可读。这样失败时能立刻定位到具体行为,用例本身就是活文档。
-
怎么测试异步函数和错误场景? 测试函数声明为 async 并 await 被测调用;错误场景同步用
assert.throws(() => fn()),异步用await assert.rejects(() => fn()),可附带类型与 message 匹配。关键是别丢失 Promise(不 await 不 catch 就是假测试)。 -
覆盖率是什么?100% 意味着什么? 行/分支/函数三个维度统计测试执行到的代码比例。100% 只说明所有代码被执行过,不等于验证了正确行为;覆盖率用于发现遗漏(未覆盖模块),不是质量承诺。
-
node:test相比 Jest 的取舍? 零依赖、官方维护、启动快,适合库/脚本/中小服务;Jest 生态在 mock、快照、React 组件测试上更成熟。核心概念(describe/it/断言/覆盖率)相通,迁移成本低。
自测三问
- AAA 三个字母分别代表什么?一个用例里应包含几组 AAA?
- 测「divide(1,0) 会抛 RangeError」和「sumRemote 会 reject」,分别用什么断言?
assert.throws(divide(1,0))和assert.throws(() => divide(1,0))有什么本质区别?
小结一句话
node:test 让测试零成本起步:node --test 发现用例、assert 断言成败、AAA 组织结构、覆盖率找漏——先红后绿,才是可信的测试。
延伸
- Node.js 官方文档 test runner
- Node.js assert 文档
- 下一课:综合练习与选型——把 12 课的积木拼成一个真实小项目,并回答「什么时候用 Node、什么时候用 Java」。