从Node 18开始,Node.js内置测试运行器(也就是node:test模块)就已经开门营业,到了Node 20之后更是直接变成稳定能力。我最初也只是抱着“试试看”的心态在一个内部小工具里用它写单元测试,结果一套用完就回不去了:不用装依赖、不用写配置文件、不用管Babel和模块映射,写测试的成本低到有点不真实。这篇文章就按最直接的实操路线来,带你在三分钟内跑通第一个基于node:test的单元测试,然后再把断言、异步、mock、覆盖率这些日常必用的点一次说透,最后附上我在实际项目里踩过的一些坑和排查方法。适合刚想给Node.js项目补测试但又不想引入一堆工具链的同学,也适合已经在用第三方测试框架、想给项目减负的从业者。
1. 为什么该用Node.js内置测试运行器,而不是再装一套框架
1.1 内置测试运行器解决了什么痛点
我见过太多团队在项目初期兴致勃勃搭了一套测试框架,结果两年过去,测试代码还在,跑测试的环境先坏了。最典型的场景就是Node.js版本升级之后,第三方测试框架和ESM的兼容问题开始冒出来,要么需要额外装Babel去转译,要么必须改一堆配置文件才能让测试文件正常import。而内置测试运行器的逻辑很简单:Node.js自己就是一个运行时,测试代码也是在这个运行时里跑的,模块怎么加载、事件循环怎么调度,全都原生支持,不存在“测试环境和运行环境两套世界”的问题。
另一个痛点是依赖安装。为一个测试环境专门引入一整个框架、插件、类型声明包,这在中小型项目里是很不划算的。node:test核心能力全都挂在Node.js内部,只要你的项目用了一个还过得去的Node版本,打开终端敲node --test就能开始跑测试,连node_modules里都不会多出一个包。对于维护npm库的人来说,这意味着使用者的安装成本降低,项目本身的依赖树也更干净。坦白讲,第三方测试框架的生态确实更丰富,但对不需要快照、不需要特殊DOM环境的Node.js项目来说,内置运行器已经完全够用。
1.2 该选择内置运行器还是第三方框架
我自己现在的取舍标准很简单:先看项目复杂度,再看团队成员熟悉程度。下面这张表是我在几个项目里对比后整理的参考维度。
| 维度 | 内置测试运行器 | 第三方测试框架 |
|---|---|---|
| 安装复杂度 | 零安装,随Node.js提供 | 需要安装框架本体和配套插件 |
| 配置文件 | 无,命令行直接跑 | 通常需要配置测试环境、转译规则 |
| ESM / CJS 支持 | 原生支持,无转译 | 老牌框架当年在ESM上吃了不少苦头 |
| 断言库 | 自带node:assert/strict | 各有专属断言API,风格不一 |
| 快照测试 | 暂无内置方案 | 部分第三方框架支持得比较完善 |
| 覆盖率 | 支持,一条命令输出 | 支持,可能需要额外插件 |
| 适用场景 | Node.js服务、库、脚本 | 前端组件、大型全栈项目、复杂断言场景 |
如果你只是给Node.js后端服务、命令行工具、内部库补测试,优先选内置运行器。它可能没有那么多花哨能力,但胜在稳定、省心、零摩擦。如果你要测的是UI组件、需要模拟浏览器环境或者依赖快照做回归校验,那再考虑重型框架不迟。对大多数人来说,内置运行器不是替代品,而是“够用且免费”的方案。
2. 三分钟写出第一个Node.js单元测试:从零搭建最小Demo
2.1 建项目、写被测函数
先准备一个干净的目录来跑这份最小Demo。
mkdir node-test-demo cd node-test-demo npm init -y初始化完成后,把package.json里的type字段改成module,顺便加一个test脚本。这里有一个小技巧:所有后续操作都围绕这个脚本入口展开,团队成员不用记命令,直接npm test就可以。
{ "name": "node-test-demo", "type": "module", "scripts": { "test": "node --test" } }然后写一个待测模块src/price.js。我故意挑了一个带校验逻辑和浮点计算的函数,因为这种函数在真实业务里最常见,测试起来也有代表性。
export function calculateTotal(items, discount = 0) { if (!Array.isArray(items)) { throw new TypeError('items 必须是数组'); } if (discount < 0 || discount > 1) { throw new RangeError('discount 必须在 0 ~ 1 之间'); } const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0); return Number((subtotal * (1 - discount)).toFixed(2)); }这个函数做的事情很简单:接收购物车商品数组,每个商品有price和quantity,外加一个可选的折扣比例,返回计算后的总价。之所以要校验items类型和discount范围,是为了让你在测试里能看到异常断言的用法。价格小数用toFixed处理掉浮点精度问题,这是电商类逻辑里常见的做法。
2.2 写第一个断言测试
创建test/price.test.js文件。测试文件放test目录是Node.js内置运行器的默认约定,也可以把测试文件命名为price.test.js,让CLI自动扫描到。
import { test } from 'node:test'; import assert from 'node:assert/strict'; import { calculateTotal } from '../src/price.js'; test('正常计算多件商品总价', () => { const items = [ { price: 10, quantity: 2 }, { price: 3.5, quantity: 4 }, ]; assert.equal(calculateTotal(items), 34); }); test('空购物车总价为 0', () => { assert.equal(calculateTotal([]), 0); }); test('折扣参数会把总价按比例折掉', () => { const items = [{ price: 100, quantity: 1 }]; assert.equal(calculateTotal(items, 0.2), 80); });注意这里有两个import来源:test函数从node:test导入,断言对象从node:assert/strict导入。我推荐直接使用strict版本,它会用严格相等(===)做比较,避免常规assert.equal在判断时发生偷懒的类型转换,减少“测试通过但行为不对”的假阳性。
三个测试分别覆盖正常计算、空数据边界和折扣逻辑。第一个用例里10乘以2加3.5乘以4,结果是34,这个数字手工可验,方便你确认测试没有自欺欺人。
2.3 运行测试并查看报告
在项目根目录执行:
npm test因为package.json里配的是node --test,它会自动扫描测试目录和符合命名约定的测试文件。如果你只想跑某一个文件,可以显式指定:
node --test test/price.test.js跑完的终端输出大致长这样:
✔ 正常计算多件商品总价 ✔ 空购物车总价为 0 ✔ 折扣参数会把总价按比例折掉 ℹ tests 3 ℹ suites 0 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 4.3684字体带颜色的对勾和汇总数据会一起打印。pass就是通过的用例数,fail就是失败的用例数,missing一项如果出现,说明有断言没走完。如果你在CI或者普通终端里看到这份报告,恭喜,第一个内置测试就跑通了。
3. 单元测试必修:断言方法、describe组织与数据驱动写法
3.1 断言快速查表
node:assert/strict提供的断言方法足够覆盖日常大部分场景,我把高频的几个整理成了一张速查表,方便你用之前先扫一眼。
| 断言方法 | 作用 | 典型场景 |
|---|---|---|
| assert.strictEqual(a, b) | 严格相等比较 | 数字、字符串、布尔值的结果校验 |
| assert.notStrictEqual(a, b) | 严格不相等比较 | 确认两个值确实不同 |
| assert.deepStrictEqual(a, b) | 深比较对象或数组的完整结构 | 比较接口返回的JSON对象 |
| assert.throws(fn, errorClass) | 确认同步函数抛错 | 校验非法参数的异常路径 |
| assert.rejects(promise, errorClass) | 确认Promise被拒绝 | 测试异步接口的失败分支 |
| assert.match(str, regex) | 正则匹配字符串 | 检查错误信息或返回内容的格式 |
| assert.ok(value) | 判断值是否为真 | 快速校验存在性 |
这里最需要注意的是deepStrictEqual和strictEqual的区别。前者会递归比较对象里每个键值对,并且会检查原型和属性描述符,所以适合比较接口返回的完整JSON;后者只比较基本类型的值和引用,比较对象时如果指向不同内存地址就会失败。我见过不少同学一上来拿deepStrictEqual去比基本类型,没报错但语义不对,写多了容易混乱。
3.2 用describe和it组织测试套件
随着测试数量增长,所有test平铺在一起会越来越难读。node:test提供了describe和it两个方法,可以把相关用例归到一个套件里。逻辑上类似于其它测试框架里的suite和testcase,只是命名换成了更贴近BDD风格的形式。
import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { calculateTotal } from '../src/price.js'; describe('calculateTotal 商品总价计算', () => { it('应该返回多件商品的小计和', () => { const items = [ { price: 10, quantity: 2 }, { price: 3.5, quantity: 4 }, ]; assert.equal(calculateTotal(items), 34); }); it('折扣参数生效时会按比例减少总价', () => { const items = [{ price: 100, quantity: 1 }]; assert.equal(calculateTotal(items, 0.2), 80); }); it('非法参数应该抛出异常', () => { assert.throws(() => calculateTotal('not array'), TypeError); assert.throws(() => calculateTotal([], 1.5), RangeError); }); });套件名称建议直接写成被测函数或模块名,it的描述用“应该xxx”的句式。真正跑测试出现问题的时候,终端输出的失败信息会变成类似“calculateTotal 商品总价计算 > 折扣参数生效时应该按比例减少总价”这样的完整路径,定位效率和可读性都高很多。
describe还支持嵌套。一个模块如果包含多个子功能,可以先建一个外层套件,再按子功能建内层套件,层级不要太深,两到三层就足够。过深的嵌套会让文件变得笨重,也容易让人分不清用例所属边界。
3.3 表格驱动测试,减少重复代码
实际项目中,同一个函数经常需要验证十几组输入输出。如果每个用例都复制粘贴一遍test包裹,代码会变得极其冗余。表格驱动测试是一个好出路:把用例数据放到数组里,用循环批量生成测试。
import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { calculateTotal } from '../src/price.js'; const cases = [ { desc: '空购物车', items: [], discount: 0, expected: 0 }, { desc: '单件原价商品', items: [{ price: 59.9, quantity: 1 }], discount: 0, expected: 59.9 }, { desc: '两件商品打八折', items: [{ price: 100, quantity: 2 }], discount: 0.2, expected: 160 }, { desc: '多件商品无折扣', items: [{ price: 1.5, quantity: 3 }, { price: 2, quantity: 2 }], discount: 0, expected: 8.5 }, ]; describe('calculateTotal 数据驱动用例', () => { for (const c of cases) { it(`${c.desc}:期望结果为 ${c.expected}`, () => { assert.equal(calculateTotal(c.items, c.discount), c.expected); }); } });这里第四组数据计算一下:1.5乘以3是4.5,2乘以2是4,加在一起是8.5,所以期望值没有问题。数据驱动写法的好处是,新增一组用例只需要在cases数组里加一行,测试名称也自动生成。如果某一组数据失败,终端会明确打出“多件商品无折扣:期望结果为 8.5”这样的完整desc,一眼就知道是哪条数据出了问题。这是我在项目中用得最多的组织方式,强烈推荐给有大量边界值的业务函数。
4. 异步测试、mock和钩子,一次说清
4.1 异步函数与Promise怎么测
Node.js项目里的单元测试几乎绕不开异步:读文件、查数据库、调接口,全部是Promise。node:test对异步的支持非常自然,测试函数直接声明为async,内部用await等待结果即可。
import { test } from 'node:test'; import assert from 'node:assert/strict'; async function fetchUserName(id) { await new Promise((resolve) => setTimeout(resolve, 10)); return `user-${id}`; } test('异步获取用户名的结果符合预期', async () => { const name = await fetchUserName(1); assert.equal(name, 'user-1'); });如果被测的异步函数会返回一个被拒绝的Promise,则应该用assert.rejects来断言,而不是try/catch包一圈又手动fail。assert.rejects会等Promise真正被拒绝后再做校验,还支持传正则去匹配错误消息。
test('异步接口失败时返回错误信息', async () => { await assert.rejects(fetchUserName(999), /not found/); });一个需要注意的点是时间敏感型测试。如果测试里确实要等setTimeout,控制好等待时长,别依赖很长的真实睡眠。遇到定时器逻辑,更稳妥的做法是在代码设计阶段把定时器依赖抽成可注入的参数,或者用mock的方法替换它,后面一节会讲。
4.2 用test上下文自带的mock做函数替身
写单元测试时最烦的事情之一,就是被测函数内部调用了一个网络请求或数据库操作。你不希望测试真的发请求,也不想去连一个测试库,这时就需要把那个依赖“替身化”。node:test提供了内置的mock能力,而且最顺手的使用方式是利用测试函数自带的t参数,在每个测试内创建mock实例。
// src/user.js export async function updateUser(userId, payload, save) { if (!payload.name) { throw new Error('name 不能为空'); } const after = { id: userId, ...payload }; await save(after); return after; }对应的测试文件:
import { test } from 'node:test'; import assert from 'node:assert/strict'; import { updateUser } from '../src/user.js'; test('updateUser 会调用保存函数并返回更新后的对象', async (t) => { const save = t.mock.fn(async (data) => data); const result = await updateUser(3, { name: '路人甲' }, save); assert.equal(save.mock.callCount(), 1); assert.deepEqual(save.mock.calls[0].arguments[0], { id: 3, name: '路人甲' }); assert.deepEqual(result, { id: 3, name: '路人甲' }); });这里t.mock.fn创建一个被mock的函数,同时会把调用信息记录下来。save.mock.callCount()表示函数被调用次数,save.mock.calls数组里能拿到每一次调用的参数、返回值和this指向。这种用法本质上是手动依赖注入,被测函数通过参数拿到外部依赖,测试里塞一个替身进去就行,比去mock整个模块要清爽得多,可读性和维护成本都更友好。
mock模块本身还支持替换对象方法、模拟模块导出,配合t.mock使用时,测试结束会自动恢复原始实现,避免污染其他用例。这一点非常重要,我在后面章节的避坑记录里会单独展开。
4.3 钩子函数在集成测试里的正确打开方式
钩子用来解决测试环境的准备和清理问题。node:test提供了before、after、beforeEach、afterEach四类钩子,分别作用于套件生命周期和每个用例生命周期。
import { beforeEach, afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; describe('用户资料库测试', () => { beforeEach(async () => { // 每个用例执行前初始化一些测试数据 await setupTestData(); }); afterEach(async () => { // 每个用例执行后清理数据,避免互相干扰 await cleanupTestData(); }); it('应该能按 id 查找到用户', () => { assert.equal(1 + 1, 2); }); it('应该能更新用户资料', () => { assert.equal(2 + 2, 4); }); });第一个用例和第二个用例之间完全不共享可变状态,因为beforeEach会把环境重置一遍。如果你的测试用例需要真正操作数据库或写临时文件,这个模式几乎是必杀技。before和after则适合整个套件只执行一次的操作,例如创建数据库连接池或删除临时目录——注意连接池是复用的,但每个用例前还是要确保数据状态干净。还有一个细节:钩子函数可以写在describe外部,那样它的作用范围就是整个测试文件,通常用于全局环境变量和跨套件的公共资源。
5. 覆盖率和CI流水线整合,让测试真正有约束力
5.1 内置覆盖率统计怎么用
测试写了,但心里没底,这时候覆盖率是很好的参照。node:test提供了一条命令直接输出覆盖率报告。以我的Node版本为例,运行:
node --test --experimental-test-coverage给package.json加一个更加语义化的脚本:
{ "scripts": { "test:coverage": "node --test --experimental-test-coverage" } }执行npm run test:coverage之后,终端会在测试结果后面追加一个覆盖率报告。它的基本格式类似下面这样:
ℹ start of coverage report ℹ file | line % | branch % | funcs % | uncovered lines ℹ src/price.js | 100.00 | 83.33 | 100.00 | 12 ℹ src/user.js | 92.59 | 100.00 | 100.00 | 20 ℹ end of coverage report这份报告能告诉我们哪些行没被执行,哪些分支没被覆盖。不过我要提醒一句:覆盖率数值高不代表测试质量高,它只代表代码行被执行过一遍。真正的价值在于,当你加了新逻辑却忘了补测试时,覆盖率会诚实地把这个漏洞登记在案。我会把它定位成一个负向约束工具,而不是团队的KPI。
5.2 通过npm scripts统一入口
测试命令统一收口到npm scripts是很值得养成的好习惯。除了上面提到的test和test:coverage,我通常还会给package.json加一个只跑单个文件的方式:在命令行直接写node --test文件路径。这样团队成员在紧急定位某个测试问题时,不需要把整个测试套件都跑一遍。
node --test test/user.test.js如果项目里已经有大量测试文件,单个文件排查会节省非常多的等待时间。另一个建议是在仓库根目录放一个.nvmrc或者在package.json的engines字段里声明Node.js版本范围,确保本地和流水线用的Node版本一致。
5.3 CI跑测试需要注意的Node版本和输出格式
把npm test接进CI流水线是测试发挥效果的关键一步。提交代码后流水线自动执行测试,一旦失败立即反馈,才能倒逼大家认真对待质量。常见流程是:安装依赖 -> 锁定Node版本 -> 执行npm test。
内置测试运行器默认输出的是人类可读的文本报告,正常在CI日志里直接看没有问题。如果你需要把结果解析成机器可读的格式,可以指定reporter输出TAP协议或者JUnit XML。比如:
node --test --test-reporter=junit --test-reporter-destination=stdout流水线里能不能正确跑出覆盖率,很大程度上取决于Node版本。较早版本支持带experimental前缀的覆盖率flag,较新版本可能已经调整成稳定参数。所以CI里的Node版本一定不要“随缘”,锁定在项目声明的稳定版本上。另一个容易被忽略的地方是超时:如果流水线里某个项目测试特别多,整体任务时间较长,需要留意CI平台的总超时时长设置,别让测试在最后一步被平台强制中断。
6. 踩过的坑与排查技巧实录
6.1 ESM模块导入报错
最早用内置测试运行器时,我卡得最久的一个问题是import语句直接报错。
SyntaxError: Cannot use import statement outside a module排查下来原因就两类:第一,package.json里没有设置"type": "module",导致.js文件被当作CommonJS解析;第二,相对路径导入时漏写了扩展名,ESM规则要求相对导入必须带完整文件名。解决方法很简单:在package.json里补上"type": "module",并且把import从'../src/price'改成'../src/price.js'。用惯了某些打包工具的人很容易忽略这一步,因为打包器会自动帮你解析扩展名,而Node.js原生ESM不会。
6.2 测试之间互相干扰
有一次我遇到一个很玄学的问题:单独跑某个测试文件全过,npm test一起跑却偶发失败。查看到最后,发现是测试文件里有人直接改了process.env,而另一个测试恰好依赖这个环境变量。node --test在较新版本里会按可用CPU数量并发执行多个测试文件,文件与文件之间不在同一个进程里隔离,全局状态就成了隐患。
解决办法有几个:不在测试里修改全局变量;如果确实要改环境变量,用完后在finally里恢复;测试涉及临时文件时,每个用例使用独立的临时目录并在钩子里清理。把测试写成“与顺序无关、与并发无关”是一门基本功。一旦做到这一点,后续增加用例和并行加速都没有心理负担。
6.3 mock没有复位导致的连带问题
在我自己早期的代码里,直接在文件顶层用mock.fn替代某个模块方法,结果第二个测试文件里这个方法的真实行为已经不存在了,排查了半天才发现是mock没有复位。这就是动态替身的副作用:你替了别人忘记换回来,后面的代码全都在跟替身打交道。
所以我现在一律推荐用测试上下文参数t来创建mock。t.mock创建的替身会在当前测试结束之后自动恢复,不需要手动写restore。如果确实需要在非测试函数里维护mock,记得在after钩子中调用mock.restoreAll()或具体方法的mock.restore(),把公共环境恢复干净。这个习惯能避免掉一大类诡异的连带失败。
6.4 测试文件命名和自动扫描的边界
node --test的自动扫描规则并不是“目录里所有js文件都当测试文件”。它会去找test目录、以及文件名符合*.test.js、-test.js、_test.js等约定的文件。这意味着如果同事把一个辅助脚本命名为xxx.test.js放进src里,它也会被当成测试跑一遍,轻则多跑几个无关用例,重则因为脚本启动副作用拖慢整个流水线。
我通常会在项目里定一个约定:所有测试文件统一放test目录,文件名统一为被测模块名加.test.js。有了这条约定,CLI的自动扫描结果就是确定的,也不会出现测试文件散落各处的情况。如果哪天发现node --test扫描出的文件数量和自己预期不一致,先去看文件名是否符合约定永远是最快的排查入口。
6.5 给我的几个实用建议
最后分享一点我在实际项目中沉淀下来的习惯。第一,断言库统一用node:assert/strict,别混用普通assert,减少比较行为不一致带来的困惑。第二,新写的业务函数尽量先写边界校验和异常分支,再写主流程用例,因为真正暴露问题的大多不是那条顺利路径。第三,跑测试用npm test,跑覆盖率用npm run test:coverage,把命令固化下来之后,团队里任何一个同学都不会因为记错参数而卡住。内置测试运行器没有那么多花哨功能,但它的克制反而让测试代码回归到最简单的本质:写清楚输入,断言好输出,然后让它在每次改动之后替你守住底线。