我最近接手一个老服务,第一件事不是看业务代码,而是先摸一遍它的测试链路:Mocha 负责跑,Chai 管断言,Sinon 做 mock,再加一个 nyc 统计覆盖率。功能倒是都能满足,但每次新同事 clone 项目光装依赖就要等两分钟,锁文件里躺着一百多个跟业务无关的包。后来我给另一个新模块写单测时换成了 Node.js 内置测试运行器(node:test),一条node --test命令全搞定,依赖数直接归零。这篇文章我想用三分钟带你从零写一个能跑的单元测试,再把 describe/it、钩子、mock、覆盖率和 CI 接入这些真实项目里绕不开的能力全部过一遍,最后把我在换框架过程中踩过的坑也摊开讲。适合还没上测试的老项目快速补课,也适合新项目想少装一个测试框架的团队参考。
1. 为什么新项目我第一个想装的是它:内置测试运行器的真实价值
1.1 零依赖背后省的其实是一整条维护链路
先说个不算冷的知识:node:test从 Node 18 起就进标准库了,20 之后整体体验已经很顺,到了 22 我基本敢在正式项目里全量使用。也就是说,从某个版本开始,写 Node 单元测试这件事本身不需要再装任何第三方测试框架。
很多人对"内置"的第一反应是"功能肯定很简陋",我当初也这么想,直到我把一个小服务的测试从 Mocha + Chai + Sinon 三件套换成node:test之后才发现,真正值钱的不是省了几个包,而是省掉了一整条维护链路:
- 不用再维护
mocharc、jest.config这类配置文件; - 不用再纠结团队里谁用 Jest 25、谁用 Jest 29,锁文件升级引发的兼容性问题直接消失;
- 不用再记"断言用 Chai 的
expect还是should"这种风格之争; - 删掉依赖之后,
npm install的速度肉眼可见地变快,CI 里拉依赖的时间也跟着缩短。
这套东西对个人项目和中小型后端服务尤其划算。你真正需要的往往就三样:能跑用例、能比断言、能 mock 掉外部依赖。而这三点,内置运行器现在都占了。
1.2 一张表说清它的能力和边界
先把内置运行器和我们常用的方案放在一起对比一下,方便你判断要不要换:
| 对比项 | Jest | Mocha + Chai + Sinon | node:test |
|---|---|---|---|
| 额外依赖 | 一大堆 | 至少 3 个主包 | 0 |
| 配置文件 | jest.config | .mocharc 等 | 无 |
| 断言能力 | 内置 | 需 Chai | node:assert / assert/strict |
| Mock 能力 | 内置 | 需 Sinon | t.mock 内置 |
| 覆盖率 | 内置 | 需 nyc/istanbul | 内置开关 |
| 组件/快照测试 | 强 | 一般 | 不支持 |
| 适用场景 | 前端、大型工程 | 老项目习惯 | 纯 Node 服务、公共库 |
公道话也得说一句:如果你的项目是前端组件库,重度依赖 Jest 的快照测试和 DOM 环境模拟,那继续用 Jest 依然是更省心的选择。这不是"谁比谁好"的争论,而是"这个项目到底需要什么"。node:test的目标不是取代 Jest,而是让那些本来只需要简单断言的 Node 项目,不再被迫背上一个重型框架。
2. 三分钟跑通第一个用例:从空目录到 node --test
2.1 一个最小但完整的 Demo
直接实操。先建一个空目录,初始化一下:
mkdir test-demo && cd test-demo npm init -y node -v我这里假设你的 Node 版本在 20 以上。然后新建一个名为math.test.js的文件:
const { test } = require('node:test') const assert = require('node:assert/strict') function add(a, b) { return a + b } test('add 函数能正确做加法', () => { assert.equal(add(1, 2), 3) })然后运行:
node --test你会看到类似这样的输出:
✔ add 函数能正确做加法 (1.1ms) ℹ tests 1 ℹ pass 1 ℹ fail 0 ℹ cancelled 0 ℹ duration 20.3ms到这里,你的第一个单元测试就已经跑起来了。整个过程没有任何一行配置文件,没有安装任何依赖,这就是内置运行器最直观的体验。你也可以用node --test math.test.js指定只跑某一个文件,平时调试单个用例的时候这样更快。
2.2 文件发现规则:知道这些才能不被"漏跑"
node --test不是随便扫描整个项目,它有自己的一套文件匹配规则,我列一下关键点:
- 文件名以
.test.js、.test.mjs、.test.cjs结尾的文件; - 文件名以
-test.js、_test.js结尾的文件; - 文件名叫
test.js,或者以test-开头的文件; - 任何位于
test目录下的.js、.mjs、.cjs文件。
默认情况下它会跳过node_modules,这点放心。我建议团队统一采用xxx.test.js这种命名,放在被测模块旁边或者统一放test目录都可以,关键是规则要对齐,不然就会出现"我明明写了测试,CI 里却没跑"这种尴尬。
2.3 断言到底该用 node:assert 还是 node:assert/strict
这是新手最容易忽视的一个选择。node:assert是宽松模式,node:assert/strict是严格模式。光说概念不够直观,看代码:
const assert = require('node:assert') const strict = require('node:assert/strict') assert.deepEqual({ a: '1' }, { a: 1 }) // 通过,宽松比较 strict.deepEqual({ a: '1' }, { a: 1 }) // 抛异常,因为类型不同宽松模式下'1'和1会被认为是相等的,这在单元测试里非常危险——你可能以为校验了数据结构,实际上脏数据悄悄溜过去了。所以我的建议很简单:一律从node:assert/strict引入断言,让==和===、浅比较和深比较这些语义都是严格的。如果你有团队规范,直接写死这一条。
3. 写真实业务测试时真正会用到的能力
3.1 describe / it + 钩子:把测试按模块组织起来
单一test()函数写多了之后,最需要的是分组能力。内置运行器提供了describe和it,用起来就是熟悉的味道:
const { describe, it, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const users = [] function addUser(name) { users.push({ name, createdAt: Date.now() }) } describe('用户模块', () => { beforeEach(() => { users.length = 0 }) it('添加用户后列表长度增加', () => { addUser('A同学') assert.equal(users.length, 1) }) it('添加的用户包含 name 字段', () => { addUser('B同学') assert.equal(users[0].name, 'B同学') }) })钩子的执行顺序是:before在整个 describe 块开始前执行一次,beforeEach在每个用例前执行,afterEach在每个用例后执行,after在整个块结束后执行。顶层声明的钩子作用于整个文件里的所有用例,describe内部的钩子只作用于当前块。这个作用域机制和大多数框架一致,老手几乎零学习成本。
3.2 异步测试:await 和 assert.rejects 的正确姿势
现在的业务代码十个里有九个是异步的,所以异步测试是重中之重。node:test对 async 函数的处理很直接:测试函数返回 Promise,运行器会等它完成。
test('异步获取用户列表', async () => { const data = await fetchUserList() assert.equal(data.length, 2) })但这只是第一层。真正容易写错的是"断言一个 Promise 会失败"的场景。很多人下意识用assert.throws,这是错的——assert.throws只适合同步抛错,异步的 Promise 要用assert.rejects:
// 正确写法:断言 Promise 被 reject,并校验错误信息 await assert.rejects( getUserById(-1), /用户不存在/ )如果你拿assert.throws去测 async 函数,测试往往会"生效但不彻底",有时候能抓到错误,有时候抓不到,非常不稳定。我后面第五节还会展开讲这个连带问题。
3.3 参数化测试、skip 与 todo:让用例数量有意义的三个小工具
单元测试里有一类很常见的工作:同一个纯函数,喂不同输入,验证不同输出。node:test没有内置参数化语法,但直接用循环生成用例就行,反而更灵活:
const cases = [ [1, 2, 3], [2, 3, 5], [0, 0, 0], [-1, 1, 0], ] for (const [a, b, expected] of cases) { test(`add(${a}, ${b}) 应该等于 ${expected}`, () => { assert.equal(add(a, b), expected) }) }这么写之后,每次运行都能清楚看到哪组边界数据挂了,比在一个用例里写一堆assert.equal然后只报一个笼统的失败信息要直观得多。
另外两个实用标记:test.skip和test.todo。前者用于临时代码里暂时不跑的用例,后者用于标记"还没实现但应该补"的用例。它们会让你的测试输出里明确出现 skip 或 todo 的统计,而不是默默删掉一行代码。做代码审查的时候,这类标记也能提醒后来人:"这里还有账没还清"。
3.4 Mock:用 t.mock.method 管住外部依赖
单元测试的核心是隔离,你要测的是自己的逻辑,不是数据库、不是 HTTP 接口,所以 mock 能力是否好用直接决定了这套框架能不能落地。node:test的 mock 在测试上下文里取:
const { test } = require('node:test') const assert = require('node:assert/strict') async function saveUser(db, user) { await db.insert(user) return { saved: true } } test('saveUser 会调用 db.insert', async (t) => { const db = { insert: async (user) => ({ id: user.id }) } const spy = t.mock.method(db, 'insert') const result = await saveUser(db, { id: 1, name: 'A同学' }) assert.equal(result.saved, true) assert.equal(spy.mock.callCount(), 1) assert.equal(spy.mock.calls[0].arguments[0].name, 'A同学') })这里有个很贴心的设计:通过t.mock.method创建的 mock 会在该测试用例结束时自动恢复原方法,不需要你手动清理。这一点非常关键——如果你在测试之间忘了恢复 mock,很容易污染后续用例,制造一堆"改了这一处、挂了那一处"的灵异问题。
如果你用模块级mock而不是测试上下文里的t.mock,那就要记得在afterEach里手动调用mock.restoreAll()。我建议能用t.mock就用t.mock,少一件需要操心的事。
4. 覆盖率、报告器与 CI 接入:从"能跑"到"能上线"
4.1 先给项目开一份覆盖率账单
测试写完,下一个绕不开的问题就是"我到底测了多少"。内置运行器提供覆盖率统计,不需要额外装 nyc 这种工具。在 Node 20.3 以上版本,直接加开关:
node --test --experimental-test-coverage运行结束后,终端会输出每个文件的行覆盖率、分支覆盖率、函数覆盖率、语句覆盖率这几项数据。较新版本的 Node 可能已经去掉了experimental前缀,具体以你本机node --help输出的参数为准。
覆盖率这个东西,我个人的态度是:它是一面镜子,不是一根鞭子。别盲目追求 100%,那只会催生"为了覆盖率而写的假测试"。更好的做法是盯住核心业务模块的覆盖率,比如订单计算、权限判断这类出错代价高的代码,把行覆盖和分支覆盖保证在 80% 以上,其他工具类代码宽松一些也问题不大。
4.2 报告器怎么选
node --test默认输出的是 spec 风格,人眼看着很舒服。但到了 CI 和自动化平台,系统需要的是机器可读的格式。内置报告器我给三个常用选项:
# 默认的 spec 格式,适合本地开发 node --test # TAP 格式,几乎所有测试解析工具都认 node --test --test-reporter=tap # JUnit XML 格式,很多 CI 平台原生支持 node --test --test-reporter=junit如果你既想本地看好看的输出,又想给流水线留一份 XML 存档,可以同时指定多个报告器并分开输出位置,把 JUnit 结果写到文件里:
node --test --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=junit --test-reporter-destination=./test-results.xml这些参数不复杂,但每次新接一个 CI 平台都要重新查一遍很烦,建议直接写进 package.json 脚本里,团队共享。
4.3 流水线里最小接入
CI 接入其实比想象中简单,因为node --test天然遵守"退出码非 0 即失败"的约定。你只需要在 package.json 里加一行:
{ "scripts": { "test": "node --test" } }然后在流水线里做三件事:装好 Node 20 或更高版本、拉依赖、跑测试。一个最基础的流程大概是:
steps: - 安装 Node.js(版本 >= 20) - 执行依赖安装 - 执行 npm test整个过程不需要任何额外插件。你甚至可以顺手把--test-reporter=junit的输出路径设为固定文件名,让流水线归档测试报告。相比以前要专门写一个调用脚本来跑 mocha 参数,这里真的就是"一行命令"的事。
5. 用了一段时间后,我劝你注意这几个坑
5.1 异步测试忘了 await:绿灯比红灯更可怕
我先给一段典型问题代码,你可以想想自己有没有写过类似的:
test('getUserById 返回用户', () => { getUserById(1).then((user) => { assert.ok(user) }) })这个用例大概率是绿的,但它什么也没测到——测试函数在getUserById返回的 Promise 落定之前就结束了,断言逻辑被丢到了事件循环后面。运气好时断言跑完一切正常;运气差时断言里的失败会以 unhandled rejection 的形式出现,测试结果忽绿忽红,甚至可能变成"明明代码有问题,CI 却通过了"。
修正方法就是我在 3.2 节强调的:异步测试函数写成async,对每个异步调用都await。这个坑我见过太多次,也是新手从"会写测试"到"测对东西"之间最典型的一道坎。
5.2 test 目录下的工具文件会被当成测试用例加载
这个坑来自文件发现规则:node --test会把test目录下所有 js 文件都当作测试文件加载。我一度在test/helpers.js里放了个创建数据库连接的工具函数,里面有一句初始化日志打印。结果每次跑测试,那行日志都会出现好几次,看起来没什么大危害,但你要是把一个"启动时只执行一次"的副作用代码放进去,问题就会被无限放大。
我的建议是:公共测试工具不要放在test目录里,统一放项目根目录下的test-utils.js或src/test-utils.js,因为它的文件名不匹配发现规则,不会被当作测试文件加载。
5.3 concurrency 和共享状态打架
node:test默认在一个文件内串行执行用例,这个设计其实很稳。但你可能看到文档里有{ concurrency: true }这个选项,或者--test-concurrency=N参数,于是想通过并发把跑测时间压下去。
并发本身没问题,问题出在共享状态上。假如你有两个用例都操作同一个模块级变量,比如一个全局计数器,串行时一切正常,一旦打开并发,结果就变成"有时过、有时挂",还特别难复现。我的经验是:保持测试独立性永远优先于跑测速度,真想提速,应该先砍掉多余的 IO 等待,而不是盲目开并发。如果某个 describe 块里的用例确实互不干扰,再局部开启并发都来得及。
5.4 版本差异会让同一份代码表现不同
node:test是分版本逐步长全的,这一点必须心里有数:
| 能力 | 大致出现版本 |
|---|---|
| 基础 test / assert | Node 18.0 |
| describe / it / 钩子 | Node 18.7 之后 |
| t.mock.method | Node 19.x 之后 |
| 覆盖率开关 | Node 20.3 之后 |
| mock.fn、新增实验特性 | 更新版本 |
也就是说,同一份测试代码在 Node 18 和 Node 20 上的行为可能不同——不是语法报错,而是某些 API 压根不存在。我踩过一次:同事本地 Node 18,跑一个用到mock.fn()的用例直接抛TypeError,还以为是代码问题,查了半天才发现是版本问题。
建议很简单:在 package.json 里声明engines: { "node": ">=20" },开发和 CI 统一用双数版本,至少保证大版本一致。这样能避开绝大多数版本差异带来的坑。
最后再分享一个我实际操作的体会。我把一个中等规模服务的测试从 Mocha + Chai + Sinon 换到node:test之后,最直观的变化是删掉了一整个配置文件、十来个间接依赖,npm install速度快了接近一半。刚开始我也觉得没快照测试、没一堆现成插件不太习惯,但真正写了一个月之后发现,后端单元测试反复用到的就是assert.strictEqual、assert.rejects和t.mock.method这三板斧。三分钟上手这个说法不夸张,但把它用到顺手确实需要半天;我的建议是从纯函数模块开始换,边写边整理团队自己的用例风格,这套内置工具完全撑得住。