用 Node 内置测试运行器替代 Jest
对比 Node test runner 与 Jest:稳定功能、watch mode、snapshot、fake timers、coverage、TypeScript 支持,以及迁移时的限制。
对于大多数服务端测试套件而言,Node 内置的测试运行器完全可以取代 Jest。运行器本身自 Node 20.0.0 起就已进入稳定(Stable)状态,而 watch 模式、快照测试和伪造定时器(fake timers)如今也都已具备,尽管一些较旧的迁移指南仍将它们列为缺失功能。
推动这类迁移的痛点很常见:你的服务是纯 ESM,但测试命令却要拖上一整套转译流水线、一个配置文件,以及一棵在主版本升级时随时会崩的依赖树——而这一切只是为了执行函数并对结果做断言。真正的问题不在于 node:test 是否存在,而在于它的哪些部分足够稳定,可以接入 CI。下文将按顺序梳理其功能面,依据 Node 测试运行器文档给出每一部分的稳定性等级,并说明相较 Jest 和 Vitest 你会失去什么。
要点速览
- Node 测试运行器自 v20.0.0 起已稳定,但覆盖率仍需
--experimental-test-coverage实验性标志,watch 模式同样被标记为实验性。 - 快照测试在 v22.3.0 落地,v23.4.0 转为稳定;基于
mock.timers的伪造定时器自 v23.1.0 起稳定,并可 mockDate。 - ES 模块的导出是冻结的,因此
mock.method无法替换具名导出;改为导出一个对象,或使用--experimental-test-module-mocks标志下的实验性mock.module()。 .ts测试文件无需 loader 即可运行,因为类型擦除(type stripping)默认开启,自 v24.12.0 起稳定。- 离开 Jest 后失去的不是功能,而是人体工学:丰富的匹配器(matcher)词汇表、jsdom 环境,以及像
mockResolvedValue这样的一行式打桩辅助函数。
零依赖能得到什么?
零依赖的基线组合是:用 node:test 组织结构、用 node:assert 做断言,通过 node --test 执行。你可以获得 describe/it(suite/test 的别名)、before/after/beforeEach/afterEach 钩子、子测试(subtests)、skip 与 todo,以及失败时的非零退出码。
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
稳定性是按功能标注的,而非按模块,这一区别在你决定把它接入流水线之前至关重要。各部分的现状如下:
| 功能 | 标志 / API | 状态 | 版本 |
|---|---|---|---|
| 运行器核心 | node --test | 稳定 | 自 v20.0.0 起稳定 |
| Watch 模式 | --watch | 实验性 | v19.2.0 加入 |
| 快照 | t.assert.snapshot() | 稳定 | v22.3.0 加入,v23.4.0 稳定 |
| 伪造定时器 | mock.timers | 稳定 | 自 v23.1.0 起稳定 |
| 覆盖率 | --experimental-test-coverage | 实验性 | - |
| 模块 mock | mock.module() | 早期开发阶段 | v22.3.0 / v20.18.0 加入 |
| 测试标签 | --experimental-test-tag-filter | 早期开发阶段 | v26.2.0 加入,回移植至 v24.19.0 |
| TypeScript 类型擦除 | 默认开启 | 稳定 | 自 v24.12.0 起稳定 |
使用 Node 测试运行器运行与过滤测试
不带参数时,node --test 会发现匹配 **/*.test.{cjs,mjs,js}、**/*-test.{cjs,mjs,js}、**/*_test.{cjs,mjs,js}、**/test-*.{cjs,mjs,js}、**/test.{cjs,mjs,js} 和 **/test/**/*.{cjs,mjs,js} 的文件,此外还包括这六种模式对应的 {cts,mts,ts} 版本——除非你用 --no-strip-types 关闭类型擦除。你也可以将显式的 glob 作为参数传入。
过滤方式与 Jest 的使用习惯直接对应:
node --test --test-name-pattern="parses headers" # like jest -t
node --test --test-skip-pattern="integration" # inverse filter
node --test --test-only # honor { only: true }
--test-only 是 Jest 用户最先感到不适应的地方:将测试标记为 { only: true } 后,若不传该标志则毫无作用。测试标签随 v26.2.0 的 --experimental-test-tag-filter 到来,并在 v24.19.0 回移植到 LTS 线,两者均处于早期开发稳定性。两条版本线上的过滤语法并不一致:v26 支持布尔表达式和通配符,而 24.x 只匹配字面标签名。无论哪种,早期开发阶段都太不成熟,不宜用来把控流水线。
Watch 模式
Watch 模式已经存在,通过 node --test --watch 调用。它会监视测试文件及其引入的模块,然后重新运行受改动影响的部分。文档仍将 watch 模式标注为 Stability 1(实验性),自 v19.2.0 加入。实际含义是:它适合作为本地开发循环,但应排除在 CI 脚本之外——反正 CI 也不需要它。
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
覆盖率仍在标志后面
代码覆盖率仍需要 --experimental-test-coverage,因此一条以覆盖率为门禁的流水线等于主动接受了一个不稳定的功能面。可用 --test-coverage-include 和 --test-coverage-exclude 的 glob 来限定统计范围,并借助 lcov reporter 为 CI 输出机器可读的结果:
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
阈值也可以强制执行,通过 --test-coverage-lines、--test-coverage-branches 和 --test-coverage-functions,或通过编程式 run() API 中等价的 lineCoverage、branchCoverage 和 functionCoverage 选项。其他内置报告器包括 spec(默认)、tap、dot 和 junit。
Mock:间谍、定时器,以及冻结导出这堵墙
node:test 提供的 mock 对象涵盖了间谍函数(mock.fn)、方法打桩(mock.method)和伪造定时器(mock.timers)。这里没有 mockResolvedValue;异步结果需要通过异步的 mockImplementation 来打桩。断言不再依赖匹配器,而是读取 mock.callCount() 和 mock.calls[n].arguments:
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
伪造定时器自 v23.1.0 起稳定,可以 mock setTimeout、setInterval、setImmediate 和 Date,并用 tick() 或 runAll() 推进时间。有一个值得知道的缺口:如果通过解构从模块中取出定时器,例如 import { setTimeout } from 'node:timers',mock 将不会作用于它。
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
真正的限制在于模块 mock。ES 模块的导出是冻结的,因此 mock.method 无法替换具名导出;可靠的变通方案是导出一个对象,然后 mock 其上的方法:
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
Node 确实提供了一个官方替代方案 mock.module(),它可以 mock ESM、CJS、JSON 和内置模块,但它位于 --experimental-test-module-mocks 标志之后,处于早期开发稳定性。用它做实验可以,但不要把 CI 套件押在上面。
无需 Loader 的 TypeScript
Node 通过类型擦除直接运行 .ts、.mts 和 .cts 测试文件。该特性默认启用(自 v23.6.0 与 v22.18.0 起),并自 v24.12.0 起稳定,也就是说在 24.x LTS 线上已经稳定。除非传入 --no-strip-types,否则测试运行器会自动匹配 TypeScript 文件模式。至于旧做法——像 Mehul Kar 那篇 Node 20 时代的迁移文章所描述的那样接入 tsx 之类的 loader——就测试执行而言已成历史。不过类型擦除只会抹除类型,因此 enum 等具有运行时语义的 TS 语法仍然需要转译。
相比 Jest 和 Vitest,你放弃了什么?
坦率地说,这笔交易牺牲的是人体工学,而非能力。有三项损失是实打实的。第一,匹配器生态:Jest 的 expect 提供了 toHaveBeenNthCalledWith 以及数百个社区匹配器,而 node:assert 只能让你用 deepStrictEqual 和 mock.calls 自行拼装断言。它的扩展点是 assert.register()(v23.7.0 与 v22.14.0 加入),可在测试上下文上定义自定义断言。第二,类浏览器环境:没有 jsdom 或 happy-dom 的等价物,因此涉及 DOM 的组件测试仍应留在 Vitest 或 Jest 上。第三,打桩的便利性:没有 mockResolvedValue,没有 test.each(一个 for...of 循环即可代劳),逐次调用的打桩要通过 mockImplementationOnce 而非链式辅助方法完成。Erick Wendel 的迁移指南逐条对照了这些转换关系,不过其中关于伪造定时器的章节早于 mock.timers API 落地,读起来更像是一份草案提案。
那么,一个正在迁移的测试套件该如何取舍?
对于从不接触 DOM 的 Node 服务、CLI 或库来说,内置运行器已经覆盖了 Jest 所做工作的稳定核心,且零依赖、无转译层;剩下的实验性边缘地带是覆盖率、watch 模式、模块 mock 和标签。一条低风险路径是:先转换一个包,在标志转正之前继续用现有工具做覆盖率门禁,并在改动到相关代码时顺手重写那些重度依赖匹配器的断言。先对单个已转换的文件跑一遍 node --test,看看你的配置目录能删掉多少。
常见问题
node --test 会并行运行测试文件吗?
会。进程隔离是默认行为,每个测试文件都会获得独立的子进程,而 --test-concurrency 决定同时运行的进程数量。在单个文件内部,测试仍然是依次执行的,除非你在 test 或 describe 上设置了 concurrency 选项。如果你的套件共享数据库、端口或全局状态,--test-concurrency=1 可以保证一次只跑一个文件。
迁移期间可以让 Jest 和 node:test 并存吗?
可以。两个运行器彼此独立,你可以保留各自的 npm 脚本并逐个文件迁移。需要注意的是文件发现范围的重叠:两者默认都会匹配 *.test.js 之类的文件,因此要用显式 glob、独立目录或 Jest 的 testMatch 设置来限定各自的范围,以免已转换的文件被运行两次,或未转换的文件在 node --test 下失败。
node:test 能用于 CommonJS 项目吗?
能。运行器与模块系统无关:require('node:test') 和 require('node:assert') 在 CommonJS 文件中可以正常工作,默认的发现模式也明确包含了 .cjs 以及 .mjs 和 .js。唯一的要求是必须使用 node: 前缀,因此 require('test') 或 import test from 'test' 会失败。混合代码库可以在同一次 node --test 调用中同时运行 ESM 和 CJS 测试文件。
要在 CI 中采用 node:test,应该以哪个 Node 版本为目标?
Node 24 LTS 覆盖了稳定核心:运行器(自 v20.0.0 起稳定)、快照测试、mock.timers 伪造定时器,以及默认开启的 TypeScript 类型擦除。覆盖率和 watch 模式在所有发布线上仍是实验性的。有两项较新的测试运行器功能是通过回移植进入 24.x 而非仅停留在 current 线上的——v24.19.0 的测试标签和 v24.16.0 的运行顺序随机化——但两者都处于早期开发阶段,暂时不要把 CI 门禁建立在它们之上。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k