Jest 30 中的 ECMAScript ModulesESM支持激活步骤、模块 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-runtime、jest-resolve、jest-config揭示底层实现原理。读完本文你将掌握node --experimental-vm-modules的完整启动姿势、jest.unstable_mockModule与jest.unstable_unmockModule的正确用法、extensionsToTreatAsEsm的配置技巧以及 ESM 与 CommonJS 混用时的全部关键差异。警告Jest 的 ESM 支持是实验性的在使用任何 ESM 功能之前必须先了解一个事实Jest 对 ECMAScript ModulesESM的支持是实验性的experimental。原文档明确给出了两条警示该实现可能存在 bug 且缺少功能最新状态请跟踪 jestjs/jest issue #9430 以及 issue 跟踪器上的 ES Modules label。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 支持只需要以下几步确保禁用代码转换transform或配置 transformer 输出 ESM 而非默认的 CommonJSCJS。最直接的方式是在 Jest 配置中传入transform: {}transform配置项的完整形态参见 Configuration.md 中的transform一节[transform: {[key: string]: PathToTransformer | PathToTransformer[]}]。使用--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使用 Yarnyarn node --experimental-vm-modules $(yarn bin jest)该命令同样适用于 Yarn PlugnPlay在 Windows 上可以使用cross-env来设置环境变量。如果你的代码库包含从*.wasm文件导入 ESM 的场景不需要额外传--experimental-wasm-modules——Jest 当前的 WebAssembly 导入实现已经基于实验性 VM 模块不过这一点未来可能改变。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函数中判定顺序如下扩展名为.mjs→ 直接判定为 ESM扩展名为.cjs→ 直接判定为 CJS扩展名不是.js如.ts、.jsx→ 看它是否命中extensionsToTreatAsEsm列表扩展名是.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/globals的SyntheticModule通过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);使用时有几个重要限制顶层awaitTLA限制对包含顶层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 模块所导入的传递依赖。包导出条件包通过require和module-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 中的模块 Mockjest.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基本一致但有两点不同工厂函数factory是必传的工厂函数可以是同步的也可以是异步的。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 中的模块 Unmockjest.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 !!! Dont 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); // 继续编写测试...这里有几个值得注意的实操要点在.cjs或 ESM测试文件中先用createRequire(import.meta.url)创建一个指向当前文件的require再通过它加载被jest.mock注册的 CJS 模块jest.mock(electron, () ({...}))的工厂返回对象需要覆盖被测代码实际用到的导出如app.on、app.whenReady、BrowserWindow也可以通过(await import(electron)).default获取 CJS 模块的module.exports对象——CJS 模块被动态import()时其导出会出现在default上由于 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(...)).defaultCJSrequire()ESM 文件Node ≥ v24.9 支持含顶层await时抛ERR_REQUIRE_ASYNC_MODULE改用await import()深入阅读ECMAScriptModules.md本文所依据的官方文档原文JestGlobals.tsjest对象与unstable_mockModule/unstable_unmockModule的实现MockState.tsCJS/ESM mock 注册、unmock 的状态管理shouldLoadAsEsm.tsESM 判定逻辑.mjs/.cjs/type字段/extensionsToTreatAsEsmDefaults.tsextensionsToTreatAsEsm等配置默认值runtime_esm_sync_graph.test.tsESM 运行时相关测试含import.meta.jest用例Configuration.mdtransform、extensionsToTreatAsEsm等配置项完整说明JestObjectAPI.mdjest对象完整 API 参考【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
