首页 / 文章 / Replacing Jest with Node's Native Test Runner in Node 24

本文以英文发布。

Node.jsTypeScriptTestingJestVitestPerformance

Replacing Jest with Node's Native Test Runner in Node 24

A real-world migration shows how Node 24's built-in test runner and native TypeScript support cut CI time while removing four dependencies.

1565 词

该拉取请求的标题为“chore: remove jest, vitest config”,它删除了340行代码以及四个开发依赖项。有那么一刻,人们担心持续集成系统会发出警告,但实际上并未如此。所有测试都成功执行,覆盖率报告也正常运行,整个流程的完成时间比之前快了大约三分之一。就在那时,人们意识到Node内置的测试运行器已经不再只是个试验性工具,而真正成为了一种可行的选择——只不过由于习惯问题,人们在过去几年里一直忽视了它。

这也不是一个简单的测试代码库。该项目拥有近180个测试文件,涵盖了单元测试和集成测试场景,其中还包括一些涉及定时器和fetch请求的复杂模拟功能——正是在这类测试环境中,使用功能完备的框架似乎才是最稳妥的选择。

实际发生了什么变化

node:test模块最初作为实验性功能出现在Node 18中,它并未受到太多关注是有原因的:早期的版本缺乏完善的模拟功能、可用的监视模式,以及看似经过精心设计而非临时拼凑而成的覆盖率报告。Node 24解决了这些问题中的大部分。现在覆盖率数据直接来自V8,无需再使用nyc或c8工具。监视模式足够智能,能够判断某个变更会影响哪些测试文件,而不会盲目地重新运行全部测试套件。此外,定时器、模块和函数的模拟功能也已内置,因此无需额外依赖来伪造时钟或创建函数桩。

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { fetchUser } from './users.js';
test('fetchUser 返回标准化数据', async (t) => {
 const fetchMock = mock.method(global, 'fetch', async () =>
 new Response(JSON.stringify({ id: 1, name: 'test' }))
 ); const user = await fetchUser(1); assert.equal(user.id, 1);
 assert.equal(fetchMock.mock.callCount(), 1);
});

你可以使用 node --test 来运行它——无需维护配置文件,没有 Babel 处理步骤,也没有 ts-jest 转换层为每个文件增加几秒钟的加载时间。最后这一点其实比测试运行器本身更重要。

原生 TypeScript 部分

现在 Node 可以在解析时去除类型信息,从而直接执行 .ts 文件,而无需进行传统意义上的编译。从 Node 24 开始,这一功能已从实验性选项变为大多数语法的默认设置。因此运行脚本或测试时不再需要 ts-nodetsx,也不必额外进行构建步骤。

node app.ts
node --test tests/

这里有一个重要的问题:类型剥离并不会进行任何类型检查,它只是移除注解并执行剩余的代码。如果你的代码库依赖于携带运行时值的枚举、命名空间或构造函数参数属性简写等功能,其中一些功能要么需要额外的标志,要么目前还不被支持,因为它们实际上会生成 JavaScript 输出,而无法被完全移除。这并不是要取代 TypeScript 编译器,也并非试图替代它。你仍然需要使用 tsc --noEmit 或编辑器工具来捕获真正的类型错误。它所消除的,只是为了解释执行代码而编译文件这一长期存在的冗余开销——这一负担一直存在于 TypeScript 的工作流程中。

为什么测试套件速度更快了

放弃 Jest 不仅意味着移除了一个依赖项,还同时去掉了整个转换流程。默认情况下,Jest 以两种方式之一处理 TypeScript:要么将任务交给 ts-jest,这种方式虽然检查全面但速度较慢,因为它会對每個文件进行完整的类型检查,除非用户明确禁用该功能;要么使用 babel-jest,虽然速度更快,但会增加额外的配置层,并在装饰器及新语法处理方面存在各自的特殊问题。当 Node 直接执行 .ts 文件时,运行器会完全跳过这一转换步骤。再加上据称 node:test 在类似工作负载下的运行速度比早期版本的 Node 测试运行器快约 40%,整体测试用例的执行时间缩短三分之一,这已不再仅仅是巧合,而是两种独立的加速效果共同作用的结果。

覆盖率报告原本是可能引发争议的部分,但实际上并没有。

node --test --experimental-test-coverage tests/

其格式虽不如 Istanbul 默认生成的 HTML 那样精美,但底层覆盖率数值与 c8 的结果仅相差一个百分点以内,而对于在 CI 中基于覆盖率来控制拉取请求而言,这样的精确度已经足够了。

仍存在的不足

快照测试才是这里真正的限制。那些严重依赖 Jest 快照功能的团队,无论是用于组件渲染还是捕获 API 响应结构,目前都还找不到内置的替代方案。他们不得不自行编写比较逻辑,或者引入独立的快照库来满足这类需求。对于那些通过 jest.mock('./path') 及其提升机制实现 Jest 自动模块模拟的功能,情况也是如此。Node 的 mock.methodmock.module 能处理很多相关问题,但使用它们意味着必须更明确地指定要替换什么内容以及在何时替换。

对于以 React 组件为核心且包含大量基于快照的 UI 测试的项目而言,这种过渡过程将会比本次评估中使用的后端服务要艰难得多。目前几乎可以无缝切换的领域仅限于服务器端代码和 CLI 工具。

在决定将此方法应用于规模较大的测试套件之前,还需考虑并行执行这一因素。Jest 的工作进程池架构并不会像 Node 内置的运行器那样将测试文件分配到不同进程中,因此根据测试套件的结构,在大型单体仓库中,尽管单个测试文件独立运行时速度更快,但总耗时仍可能出现不理想的变化。最好在实际的 CI 流水线所用机器上进行测试,而非使用仅有几核空闲且没有其他资源竞争的本地笔记本电脑。

迁移过程究竟是怎样的

对于那些正在考虑进行此切换的人而言,具体流程大致如下:两个测试运行器在 CI 环境中并行运行了大约一周,而非通过单个拉取请求一次性完成切换。相同的测试文件会在两个独立的 CI 任务中执行,其结果与耗时会被直接对比。通过这一过程,我们发现了两个错误——它们实际上依赖于某个早已被遗忘、专为 Jest 设计的全局变量,而在被发现后仅用一小时就得到了修复。只有当这两个 CI 任务在整整一周的时间里都给出一致的结果后,才最终提交了移除 Jest 的拉取请求。虽然这是一种缓慢且不那么光鲜的处理方式,但当涉及到用于捕捉其他地方错误的保障机制时,即便过程枯燥且可逆,也总比快速但无法撤销的做法要好。

Vitest 呢?

这确实值得直接解决,因为这是大多数人首先想到的替代方案。Vitest的确比Jest运行更快,提供了更友好的API,并能自然融入以Vite为驱动的前端工作流——这些都没有争议。不过,它仍然是一个额外的依赖项,需要单独配置,还可能与你实际使用的Node版本不一致,从而导致耗费大量时间的故障。如果前端代码库已经是基于Vite构建的,选择Vitest依然合理,因为目前还没有任何原生工具能替代jsdom风格的组件测试。但对于不涉及打包器的后端服务或命令行工具而言,既然node:test已经在模拟和覆盖率方面缩小了差距,仅仅为了更美观的断言语法而引入Vitest就失去了吸引力。它只是适合特定类型项目的工具,并非所有工具的通用替代品。

这样的设置。

现状如何

这并非要求立即重写所有现有代码库。不过从长远来看,当启动新的 Node 服务时,已不再有明确的理由必须使用外部的测试运行器——这一观点在一年前是站不住脚的。该生态系统花了近十年时间,针对运行时本身留下的漏洞开发复杂的工具,而如今这些漏洞中的一些已经得到填补,相应的一部分工具便成了不必要的负担。需要明确的是,并非整个工具链,只是比例比预期要高。

相关阅读

  • 对比Frontier AI智能体:Astra、Flash、Fable与Mythos —— 详细分析最新的GPT、Gemini和Claude模型在编码、浏览和工具使用等实际智能体任务中的表现,而不仅仅是基准测试结果。
  • 调整Next.js 16.3中Turbopack的新分块控制功能 —— 实际演示Next.js 16.3新增的turbopackChunking配置,解释maxChunkCountPerGroup和generateComponentChunks如何影响代码包大小与缓存效果。
  • TypeScript 7的Go语言重写:对React类型安全的影响 — 了解TypeScript 7基于Go的编译器如何加快构建速度、优化泛型推断,从而消除React钩子与JSX中的隐藏any类型。
  • NestJS拦截器内幕:如何大规模解决96%的延迟问题 — 了解NestJS的AOP执行流程及RxJS销毁过程中的缺陷如何导致P99延迟激增,以及如何通过构建零内存分配的审计拦截器来解决这一问题。