Jest 30 中的 ECMAScript Modules(ESM)支持:激活步骤、模块 Mock 与 CJS 互操作实战指南
2026/9/19 2:48:40 网站建设 项目流程

Jest 30 中的 ECMAScript Modules(ESM)支持:激活步骤、模块 Mock 与 CJS 互操作实战指南

【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest

导读:本文基于 Jest 30.4 文档 ECMAScriptModules.md 展开,系统讲解如何在 Jest 中启用实验性的 ESM 支持、如何在 ESM 环境下正确 mock/unmock 模块、如何让 CJS 代码require()ESM 模块,并结合本仓库源码(jest-runtimejest-resolvejest-config)揭示底层实现原理。读完本文,你将掌握node --experimental-vm-modules的完整启动姿势、jest.unstable_mockModulejest.unstable_unmockModule的正确用法、extensionsToTreatAsEsm的配置技巧,以及 ESM 与 CommonJS 混用时的全部关键差异。

警告:Jest 的 ESM 支持是实验性的

在使用任何 ESM 功能之前,必须先了解一个事实:Jest 对 ECMAScript Modules(ESM)的支持是实验性的(experimental)。原文档明确给出了两条警示:

  1. 该实现可能存在 bug 且缺少功能,最新状态请跟踪 jestjs/jest issue #9430 以及 issue 跟踪器上的 "ES Modules" label。
  2. Jest 用于实现 ESM 支持的底层 API 在 Node 中同样被视为实验性的——即node:vm模块中的vm.SourceTextModule/vm.SyntheticModule等 VM 模块 API(截至 Node18.8.0仍为实验状态)。

从源码看,这种"实验性"的痕迹非常直接:在 packages/jest-runtime/src/internals/JestGlobals.ts 中,unstable_mockModule方法名以unstable_开头,且其注册的 mock 工厂被要求必须传入(否则直接抛出TypeError: 'unstable_mockModule' must be passed a mock factory')。此外,packages/jest-resolve/src/shouldLoadAsEsm.ts 中用typeof SyntheticModule === 'function'来探测当前 Node 运行时是否支持 VM 模块,若不支持则一律按非 ESM 处理。因此在使用前,请务必确认你的 Node 版本满足要求,并把 ESM 支持视作"可用但可能演进"的能力。

三步激活 ESM 支持

按原文档的说明,在你的测试中激活 ESM 支持只需要以下几步:

  1. 确保禁用代码转换(transform),或配置 transformer 输出 ESM 而非默认的 CommonJS(CJS)。最直接的方式是在 Jest 配置中传入transform: {}transform配置项的完整形态参见 Configuration.md 中的transform一节([transform: {[key: string]: PathToTransformer | PathToTransformer[]}])。
  2. 使用--experimental-vm-modules标志启动 Node,因为 Jest 的 ESM 实现依赖 Node 的 VM 模块 API。原文档给出的三种启动方式:
    • 直接指定二进制路径:node --experimental-vm-modules node_modules/jest/bin/jest.js
    • 通过环境变量:NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npx jest
    • 使用 Yarn:yarn node --experimental-vm-modules $(yarn bin jest)(该命令同样适用于 Yarn Plug'n'Play)
    • 在 Windows 上,可以使用cross-env来设置环境变量。
    • 如果你的代码库包含从*.wasm文件导入 ESM 的场景,不需要额外传--experimental-wasm-modules——Jest 当前的 WebAssembly 导入实现已经基于实验性 VM 模块(不过这一点未来可能改变)。
  3. Jest 会尽可能遵循 Node 自身激活 "ESM mode" 的逻辑(例如查看package.json中的type字段或.mjs扩展名),详见 Node 官方文档。

如何把.jsx.ts等扩展名当作 ESM 处理

如果想将其他文件扩展名(例如.jsx.ts)也按 ESM 处理,请使用extensionsToTreatAsEsm配置项(参见 Configuration.md 中的extensionsToTreatAsEsm一节),例如:

{ "extensionsToTreatAsEsm": [".jsx", ".ts"] }

从源码可以确认该配置的默认行为:在 packages/jest-config/src/Defaults.ts 中,extensionsToTreatAsEsm: []为空数组,即默认只有.mjs(以及在package.json中声明"type": "module".js)会被当作 ESM。

ESM 判定的底层逻辑

Jest 如何决定一个文件是否按 ESM 加载?核心实现在 packages/jest-resolve/src/shouldLoadAsEsm.ts 的shouldLoadAsEsm函数中,判定顺序如下:

  1. 扩展名为.mjs→ 直接判定为 ESM;
  2. 扩展名为.cjs→ 直接判定为 CJS;
  3. 扩展名不是.js(如.ts.jsx)→ 看它是否命中extensionsToTreatAsEsm列表;
  4. 扩展名是.js→ 向上查找最近的package.json,若"type": "module"则判定为 ESM(对应 cachedPkgCheck)。

此外,该文件还针对缓存做了两个值得一提的细节:运行时若SyntheticModule不可用(旧版 Node)则直接返回false;并且缓存 key 同时包含extensionsToTreatAsEsm列表(用JSON.stringify序列化),避免不同项目因配置不同而读到彼此的错误缓存(见 cachedShouldLoadAsEsm)。在运行时侧,jest-runtime的 index.ts 通过unstable_shouldLoadAsEsm(modulePath)暴露该判定,并在加载文件时(index.ts)据此决定模块 ID 的归属。

ESM 与 CommonJS 的主要差异

ESM 与 CJS 之间的大部分差异在 Node 官方文档 中已有说明,但 Jest 额外注入了一个关键变量:Jest 会向所有被执行的测试文件注入jest对象。在 CJS 中它是全局变量,但在 ESM 中,你无法直接访问注入到全局的jest,需要@jest/globals模块导入,或通过import.meta.jest访问

import {jest} from '@jest/globals'; jest.useFakeTimers(); // 等价的替代写法 import.meta.jest.useFakeTimers(); // jest === import.meta.jest => true

从源码看,这一机制在 packages/jest-runtime/src/internals/JestGlobals.ts 中实现:cjsGlobals(from)为 CJS 测试环境注入带jest字段的全局对象;esmGlobalsModule(from, context)则把同样的全局对象包装成一个名为@jest/globalsSyntheticModule(通过syntheticFromExports构造),使 ESM 测试文件可以import {jest} from '@jest/globals'import.meta.jest的可用性在测试中也有覆盖,例如 runtime_esm_sync_graph.test.ts 中有 "provides import.meta.jest and import.meta.resolve" 的用例。

require()加载 ESM 模块

Node v24.9 及以后

在 Node v24.9 及以上版本,Jest 支持在 CJS 代码中require()一个 ES 模块,与 Node 自身的require(esm)行为对齐:

const {value, default: defaultExport} = require('./esm-module.mjs');

使用时有几个重要限制:

  • 顶层await(TLA)限制:对包含顶层await(或其依赖图中包含 TLA)的 ESM 文件调用require()会抛出ERR_REQUIRE_ASYNC_MODULE。这类文件请改用await import(...)
  • mock 不生效:当被解析文件是 ESM 时,jest.mock不会生效——jest.mock只针对 CJS 目标。若想 mock 一个你通过require()加载的 ESM 文件,需要通过jest.unstable_mockModule注册 mock(该 mock 会作用于该 ESM 模块所导入的传递依赖)。
  • 包导出条件:包通过requiremodule-sync条件解析(与 Node 行为一致)。如果某个包只在import条件下暴露 ESM 入口,Node 会以ERR_PACKAGE_PATH_NOT_EXPORTED拒绝require();而module-sync入口的依赖图中若包含顶层await,则同样抛出ERR_REQUIRE_ASYNC_MODULE

早于 Node v24.9 的版本

在旧版本 Node 上,require()一个 ESM 文件仍然会抛出ERR_REQUIRE_ESM,此时只能通过await import(...)加载。

ESM 中的模块 Mock:jest.unstable_mockModule

为什么 ESM 里jest.mock的自动提升失效

由于 ESM 会在执行任何代码之前先求值静态import语句,CJS 中依赖的 "jest.mock调用被提升到模块顶部" 机制在 ESM 中不再成立。因此,在 ESM 中 mock 模块时,必须在jest.mock调用之后,再使用require或动态import()加载被 mock 的模块(这一规则同样适用于加载了被 mock 模块的其他模块)。

ESM 的 mock 通过jest.unstable_mockModule实现。正如方法名所示,该 API 仍在开发中,请关注 issue #10025 获取更新。

用法与jest.mock的两点差异

jest.unstable_mockModule的用法与jest.mock基本一致,但有两点不同:

  1. 工厂函数(factory)是必传的
  2. 工厂函数可以是同步的,也可以是异步的
import {jest} from '@jest/globals'; jest.unstable_mockModule('node:child_process', () => ({ execSync: jest.fn(), // 其余要 mock 的导出... })); const {execSync} = await import('node:child_process'); // 继续编写测试...

从源码确认"工厂必传"的约束:在 JestGlobals.ts 中,mockModule(即unstable_mockModule的实现)会先检查typeof mockFactory !== 'function',不满足则抛出TypeError;随后通过setModuleMockBridge把注册转发到运行时。在 MockState.ts 的setModuleMock中,工厂函数会被存入esmFactories,并标记explicitEsmMock;传入{virtual: true}时还会记录到virtualEsmMocks。注册时解析被 mock 名称的行为意味着:如果 mock 的模块在磁盘上不存在,会立即抛出 "Cannot find module",此时可通过virtual: true选项 mock 不存在的模块(错误提示里也会给出该指引,见 MockState.ts)。

注意:mock 注册是一次性的,覆盖无效

unstable_mockModule的注册是"单次生效"的:一旦注册,之后再次对同一模块调用unstable_mockModule覆盖 mock 是无效的(后注册的 mock 不会生效)。如果需要还原,请使用jest.unstable_unmockModule(见下文)。

ESM 中的模块 Unmock:jest.unstable_unmockModule

要还原被unstable_mockModulemock 掉的 ESM 模块,使用jest.unstable_unmockModule。下面的完整示例来自原文档,演示了 mock → unmock → 尝试覆盖的完整生命周期:

export default () => { return 'default'; }; export const namedFn = () => { return 'namedFn'; };
import {jest, test} from '@jest/globals'; test('test esm-module', async () => { jest.unstable_mockModule('./esm-module.js', () => ({ default: () => 'default implementation', namedFn: () => 'namedFn implementation', })); const mockModule = await import('./esm-module.js'); console.log(mockModule.default()); // 'default implementation' console.log(mockModule.namedFn()); // 'namedFn implementation' jest.unstable_unmockModule('./esm-module.js'); const originalModule = await import('./esm-module.js'); console.log(originalModule.default()); // 'default' console.log(originalModule.namedFn()); // 'namedFn' /* !!! WARNING !!! Don`t override */ jest.unstable_mockModule('./esm-module.js', () => ({ default: () => 'default override implementation', namedFn: () => 'namedFn override implementation', })); const mockModuleOverride = await import('./esm-module.js'); console.log(mockModuleOverride.default()); // 'default implementation' console.log(mockModuleOverride.namedFn()); // 'namedFn implementation' });

注意示例中的最后一部分:即使unmock之后再次调用unstable_mockModule尝试覆盖,也不会生效——mockModuleOverride输出的仍然是上一次 mock 的实现('default implementation' / 'namedFn implementation'),这正是上面提到的"注册一次性"特性,属于该 API 当前已知的行为限制,写测试时务必避开这种覆盖写法。

从源码看,unstable_unmockModule的实现(JestGlobals.ts)会调用mockState.unmockEsm(from, moduleName),而 unmockEsm 会把对应的explicitEsmMock标记置为false,从而在后续动态import()时走真实模块加载路径。

Mocking CJS 模块:继续使用jest.mock

在 ESM 测试文件中 mockCJS模块时,规则不变——继续使用jest.mock。原文档给出了一个非常典型的 Electron 场景示例:

const {BrowserWindow, app} = require('electron'); // 其他代码... module.exports = {example};
import {createRequire} from 'node:module'; import {jest} from '@jest/globals'; const require = createRequire(import.meta.url); jest.mock('electron', () => ({ app: { on: jest.fn(), whenReady: jest.fn(() => Promise.resolve()), }, BrowserWindow: jest.fn().mockImplementation(() => ({ // 部分 mock。 })), })); const {BrowserWindow} = require('electron'); const exported = require('./main.cjs'); // 或者使用动态 import 的替代写法 const {BrowserWindow} = (await import('electron')).default; const exported = await import('./main.cjs'); // 继续编写测试...

这里有几个值得注意的实操要点:

  1. .cjs(或 ESM)测试文件中,先用createRequire(import.meta.url)创建一个指向当前文件的require,再通过它加载被jest.mock注册的 CJS 模块;
  2. jest.mock('electron', () => ({...}))的工厂返回对象需要覆盖被测代码实际用到的导出(如app.onapp.whenReadyBrowserWindow);
  3. 也可以通过(await import('electron')).default获取 CJS 模块的module.exports对象——CJS 模块被动态import()时,其导出会出现在default上;
  4. 由于 ESM 的静态import会先于代码执行,这里必须使用require(...)或动态import()来保证 mock 注册之后才加载模块。

与 Node 行为的一些差异(Jest 模块系统的特性)

在同时包含 ESM 与 CJS 的混合依赖图中,Jest 的模块系统与 Node 存在若干行为差异,了解它们可以避免在排查问题时走弯路:

  • 执行顺序:在混合图中,CJS 依赖会在图构建期间执行,因此 CJS 模块可能比它在 Node 中的 ESM 兄弟模块更早运行。
  • 命名导出:从 ESM 导入 CJS 模块时,其命名导出是 Node 的超集:除了静态分析发现的导出外,模块求值后module.exports上的键也会被暴露。
  • require.cache:对require.cache的写入与删除会被静默忽略。
  • require('module')的静态成员(如Module._resolveFilename)来自宿主 Node,而非 Jest 的模块系统。
  • 循环依赖require()一个正处于加载图中的 ES 模块会抛出ERR_REQUIRE_CYCLE_MODULE,即使被 require 的模块并不是 require 方模块的祖先。
  • 'module.exports'命名导出:在所有 Node 版本上都会暴露(包括 Node 23 之前 Node 自身不提供该导出的版本)。
  • JSON 导入:不带with {type: 'json'}导入 JSON 会发出警告而不是抛错(该行为在未来大版本中会变成错误)。
  • 错误信息细节application/wasm的 data: URI 必须带;base64参数,否则给出描述性错误;裸核心说明符带 query/fragment(如import 'fs?q')抛出ERR_UNKNOWN_BUILTIN_MODULE;堆栈跟踪显示文件路径而非file://URL。

小结:ESM 场景速查

场景正确做法
启用 ESM 支持配置transform: {}(或让 transformer 输出 ESM),用--experimental-vm-modules启动 Node
.ts/.jsx按 ESM 处理配置extensionsToTreatAsEsm: ['.ts', '.jsx'](默认[]
在 ESM 中访问jest对象import {jest} from '@jest/globals'import.meta.jest
在 ESM 中 mock ESM 模块jest.unstable_mockModule(name, factory)+ 之后的require/动态import();factory 必传,可同步或异步
还原 ESM mockjest.unstable_unmockModule(name)(再次unstable_mockModule覆盖无效)
mock CJS 模块继续用jest.mock,配合createRequire(import.meta.url)(await import(...)).default
CJSrequire()ESM 文件Node ≥ v24.9 支持;含顶层await时抛ERR_REQUIRE_ASYNC_MODULE,改用await import()

深入阅读

  • ECMAScriptModules.md:本文所依据的官方文档原文
  • JestGlobals.ts:jest对象与unstable_mockModule/unstable_unmockModule的实现
  • MockState.ts:CJS/ESM mock 注册、unmock 的状态管理
  • shouldLoadAsEsm.ts:ESM 判定逻辑(.mjs/.cjs/type字段/extensionsToTreatAsEsm
  • Defaults.ts:extensionsToTreatAsEsm等配置默认值
  • runtime_esm_sync_graph.test.ts:ESM 运行时相关测试(含import.meta.jest用例)
  • Configuration.md:transformextensionsToTreatAsEsm等配置项完整说明
  • JestObjectAPI.md:jest对象完整 API 参考

【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询