Jest 代码转换(Code Transformation)完全指南:从 babel-jest 默认配置到自定义 Transformer 实战
【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest
Jest 默认以纯 JavaScript 执行项目代码,当你的源码或测试使用了 Node 原生不支持的语法(如 JSX、TypeScript、Vue 模板)时,就需要通过代码转换(Code Transformation)将其转译为普通 JavaScript。本文以 docs/CodeTransformation.md 为骨架,结合 packages/babel-jest、packages/jest-transform、packages/babel-preset-jest 等仓库源码,系统讲解transform配置、babel-jest默认行为、Transformer 完整 API、同步/异步转换机制与缓存失效原理,并给出可复制的自定义 Transformer 实战代码,帮助你为任意文件类型(图片、CSS、模板引擎等)编写转换器并接入 Jest。
为什么需要代码转换
Jest 本身并不理解 JSX、TypeScript 类型注解、Vue 模板等语法——它的运行时环境是 Node 的 JavaScript 引擎。因此,与"为浏览器打包"类似,你需要在测试运行前把这些语法转换成 Node 可以直接执行的纯 JavaScript。Jest 通过transform配置项支持这一机制。
一个 transformer(转换器)就是"提供了转换源码文件方法的模块"。例如,当你想在模块或测试中使用 Node 尚未支持的新语言特性时,可以接入一个代码预处理器,把未来版本的 JavaScript 转译成当前版本。
值得强调的是,Jest 会缓存转换结果,并根据多种因素尝试使缓存失效,例如被转换文件的源码内容、Jest 配置的变化等。理解缓存机制(见下文"缓存与缓存键"一节)对于编写高性能转换器至关重要。
默认行为:开箱即用的 babel-jest
Jest 自带一个开箱即用的转换器 ——babel-jest(源码位于 packages/babel-jest/src/index.ts)。它的行为如下:
- 加载项目自身的 Babel 配置:
babel-jest会读取你项目根目录下的 Babel 配置文件(如babel.config.js、.babelrc),并使用这些配置转译代码; - 匹配文件范围:转换所有匹配
/\.[jt]sx?$/正则的文件,即.js、.jsx、.ts、.tsx四种扩展名; - 注入 mock 提升(hoisting)插件:
babel-jest会自动注入使jest.mock提升生效的 Babel 插件,具体机制参见 ES Module mocking。
transform配置项的默认值是{"\\.[jt]sx?$": "babel-jest"}(见 docs/Configuration.md)。这意味着只要安装了jest-cli,并执行yarn add --dev babel-jest @babel/core,JS/TS 代码就会自动经过 Babel 转换(参见 packages/babel-jest/README.md)。
关于 babel-preset-jest
默认情况下,babel-jest会额外注入babel-preset-jest。从 packages/babel-preset-jest/index.js 可以看到,这个 preset 由两部分组成:
const jestPreset = { plugins: [require.resolve('babel-plugin-jest-hoist')], presets: [require.resolve('babel-preset-current-node-syntax')], };即babel-plugin-jest-hoist(负责jest.mock等调用的提升)和babel-preset-current-node-syntax(让 Babel 输出的代码语法与当前 Node 版本能力对齐)。
在 packages/babel-jest/src/index.ts 的createTransformer中可以看到注入逻辑:
const {excludeJestPreset, ...inputOptions} = transformerConfig ?? {}; // ... presets: [ ...(inputOptions.presets ?? []), ...(excludeJestPreset === true ? [] : [jestPresetPath]), ],也就是说,excludeJestPreset: true会阻止注入该 preset。但请注意:禁用它将同时停止jest.mock的提升(hoisting),可能导致你的测试失效。配置方式如下:
"transform": { "\\.[jt]sx?$": ["babel-jest", { "excludeJestPreset": true }] }与其他预处理器并存:必须显式声明 babel-jest
如果你要同时使用多个代码预处理器,务必显式把默认的babel-jest也写在transform中,否则.js/.ts文件将不再被转换:
"transform": { "\\.[jt]sx?$": "babel-jest", "\\.css$": "some-css-transformer" }babel-jest还支持传入任意 Babel 选项,例如{"\\.js$": ["babel-jest", {rootMode: "upward"}]},或者像官方 README 示例那样指定extends与额外插件:
"transform": { "\\.[jt]sx?$": ["babel-jest", { "extends": "./babel.config.js", "plugins": ["babel-plugin-transform-import-meta"] }] }编写自定义 Transformer:完整 API
你可以编写自己的转换器。Jest 的 Transformer 接口定义在 packages/jest-transform/src/types.ts,其核心类型如下(为便于阅读做了精简,完整定义请查阅仓库源码):
interface TransformOptions<TransformerConfig = unknown> { supportsDynamicImport: boolean; supportsExportNamespaceFrom: boolean; /** * 取值为: * - 若 Jest 未以 Node ESM 标志 `--experimental-vm-modules` 运行,则为 `false` * - 若文件扩展名定义在 [extensionsToTreatAsEsm](https://link.gitcode.com/i/96937fa2ceda18dba0a1ae29b9959f19) 中, * 且 Jest 以 `--experimental-vm-modules` 运行,则为 `true` */ supportsStaticESM: boolean; supportsTopLevelAwait: boolean; instrument: boolean; /** 由 `jest-runtime` 使用的缓存文件系统,用于提升性能。 */ cacheFS: Map<string, string>; /** 当前运行项目的 Jest 配置。 */ config: ProjectConfig; /** `config` 的字符串化版本——常用于缓存失效(cache busting)。 */ configString: string; /** 用户通过 `transform` 选项传入的转换器配置。 */ transformerConfig: TransformerConfig; } type TransformedSource = { code: string; map?: RawSourceMap | string | null; }; interface SyncTransformer<TransformerConfig = unknown> { canInstrument?: boolean; getCacheKey?: (sourceText, sourcePath, options) => string; getCacheKeyAsync?: (sourceText, sourcePath, options) => Promise<string>; process: (sourceText, sourcePath, options) => TransformedSource; processAsync?: (sourceText, sourcePath, options) => Promise<TransformedSource>; } interface AsyncTransformer<TransformerConfig = unknown> { canInstrument?: boolean; getCacheKey?: (sourceText, sourcePath, options) => string; getCacheKeyAsync?: (sourceText, sourcePath, options) => Promise<string>; process?: (sourceText, sourcePath, options) => TransformedSource; processAsync: (sourceText, sourcePath, options) => Promise<TransformedSource>; } type Transformer<TransformerConfig = unknown> = SyncTransformer<TransformerConfig> | AsyncTransformer<TransformerConfig>; type TransformerCreator<X extends Transformer, TransformerConfig = unknown> = (transformerConfig?: TransformerConfig) => X; type TransformerFactory<X extends Transformer> = { createTransformer: TransformerCreator<X>; };从源码结构看,TransformOptions中的supports*系列标志来自CallerTransformOptions(在 packages/jest-transform/src/types.ts 中与Options一起被引用),它们用于告知转换器"应当输出 ESM 还是 CJS",与同步/异步本身没有直接关系。
同步与异步转换:理解 process / processAsync
Jest 按需(on demand)对文件进行代码转换:当require或import被求值时才触发。这一过程(也叫 transpilation)可能是:
- 同步的:发生在
require时; - 异步的:发生在
import或import()时(后者也能在 CommonJS 模块中使用)。
因此接口提供了成对的方法:process{Async}与getCacheKey{Async}。后者用于判断"是否真的需要调用process{Async}"——即先计算缓存键,命中磁盘缓存则直接复用,避免重复转译。
两个重要的兼容性规则:
- 异步可以回退到同步:如果
processAsync未实现,异步转译会回退到同步的process; - 同步不能调用异步:同步转译永远无法使用
processAsync。
因此,如果你的代码库只使用 ESM,实现异步变体即可;但只要有任何代码通过require加载(包括 ESM 内部通过createRequire加载),就必须实现同步的process变体。Transformer类型定义中的注释也印证了这一点(见 packages/jest-transform/src/types.ts)。
与转换方式密切相关的supports*标志含义如下(参见 ECMAScriptModules 文档):
supportsDynamicImport: true:转换器可以返回import()表达式,ESM 与 CJS 都支持;supportsStaticESM: true:顶层import语句被支持,返回的代码将按 ESM(而非 CJS)解释执行。
缓存与缓存键:为什么要实现 getCacheKey
虽然不强制,但官方强烈建议实现getCacheKey,否则每次都要重新转译,而不是从磁盘读取上一次的结果,白白浪费资源。
在 packages/jest-transform/src/ScriptTransformer.ts 中,ScriptTransformer负责协调整个转换流程:先按文件匹配transform正则,加载对应的 transformer(支持直接导出对象,或导出带createTransformer的工厂,见loadTransformers),然后计算缓存键、调用process、把结果写入磁盘缓存目录(jest-transform-cache-<id>,按缓存键前两位分子目录存放)。
缓存键的默认计算(_buildCacheKeyFromFileInfo)会综合以下要素做 SHA-1 摘要:
- 文件内容(
fileData); - 序列化后的 Jest 配置(
configString); - 是否开启插桩(
instrument); callerSupport信息(supports*标志);- 文件路径与内部
CACHE_VERSION。
babel-jest的getCacheKey实现则进一步加入了 Babel 配置本身(见 packages/babel-jest/src/index.ts):它会 SHA-1 摘要babel-jest自身的代码、序列化后的 Babel options、源码文本、相对路径、Jest 配置字符串、Babel 配置文件路径、是否插桩、NODE_ENV、BABEL_ENV以及 Node 版本号。这意味着修改 Babel 配置或环境变量都会导致缓存失效,行为完全可预期。
官方提供了一个辅助包@jest/create-cache-key-function帮助实现getCacheKey(仓库源码见 packages/jest-create-cache-key-function)。
开发期间的调试建议
在开发转换器的过程中,可以配合--no-cache运行 Jest 来绕过缓存,并在需要时手动清空缓存目录(参见 缓存问题排查)。
工厂模式:createTransformer 与转换器配置
除了直接实现Transformer接口,你还可以选择导出createTransformer—— 一个用于动态创建转换器的工厂函数。这样做的好处是可以在 Jest 配置中传入转换器配置:当transform配置的值为['path-to-transformer', {options}]这种元组形式时,ScriptTransformer.loadTransformers会检测到工厂并调用createTransformer(transformerConfig)(见 packages/jest-transform/src/ScriptTransformer.ts)。
babel-jest本身就是这一模式的范例:它在 packages/babel-jest/src/index.ts 导出createTransformer,接受{excludeJestPreset, ...}等配置;同时为了兼容 Jest 的requireOrImportModule加载方式,把工厂挂到了default导出上:
const transformerFactory = { createTransformer, }; export default transformerFactory;关于 transformIgnorePatterns:node_modules 默认不转换
请注意,默认配置下node_modules不会被转译,如需转译必须修改transformIgnorePatterns配置项(默认值为["/node_modules/", "\\.pnp\\.[^\\/\\\\]+$"],见 docs/Configuration.md)。
该配置项是一个正则字符串数组,在转换前与所有源文件路径匹配:只要匹配其中任意一个模式,该文件就不会被转换。常见的实战场景是:某些第三方模块(尤其是 React Native、TypeScript 项目)以未转译的 ES 代码发布,此时需要显式放行:
// jest.config.js module.exports = { transformIgnorePatterns: [ '/node_modules/(?!(foo|bar)/)', ], };上例会转译node_modules/foo/与node_modules/bar/下的文件。注意,相互重叠的多个模式可能导致"你以为会转换、实际却没转换"的文件——例如再加一个'/bar/'模式,node_modules/bar/会因匹配第二个模式而仍然不被转换。模式字符串匹配的是完整路径,建议使用<rootDir>令牌锚定项目根目录,避免在不同环境中误伤所有文件,例如'<rootDir>/bower_components/'、'<rootDir>/node_modules/'。
另外,如果使用pnpm,node_modules下的包是通过符号链接指向.pnpm目录的,直接写<rootDir>/node_modules/(?!(package-a)/)不会生效,需要改用<rootDir>/node_modules/.pnpm/(?!(package-a|@scope\\+pkg-b)@)之类的模式。
源码映射与覆盖率:process 返回值的关键细节
官方特别提醒:务必让process{Async}返回 source map(与转译后的代码一起),这样代码覆盖率和测试错误报告才能准确定位到源码行号。内联 source map 也可以工作,但性能更慢。
从babel-jest的实现看,它在构造 Babel 选项时固定设置了sourceMaps: 'both'(同时产出内联与外置 map,见 packages/babel-jest/src/index.ts),并在process中把{code, map}一并返回。当启用覆盖率插桩时(transformOptions.instrument为真),它还会注入babel-plugin-istanbul插件完成插桩(addIstanbulInstrumentation)。
如果你的转换器声明了canInstrument: true,Jest 会认为返回的代码已自行插桩;若为false或未声明,Jest 会在转换后用 Babel 对返回代码执行 Istanbul 插桩(参见 packages/jest-transform/src/types.ts 与 packages/jest-transform/src/ScriptTransformer.ts 中的_instrumentFile)。
实战示例一:TypeScript 类型检查(ts-jest)
babel-jest虽然能转译 TypeScript 文件,但 Babel 的转译是"语法剥离",不会做类型检查。如果你希望在测试时同步进行类型校验,可以使用ts-jest作为转换器(配置方式为将transform中 TS 文件的正则指向ts-jest)。它把转译与类型检查结合在一条流水线里。
实战示例二:把图片转换为路径(fileTransformer)
导入图片是浏览器打包中的常见做法,但图片本身不是合法 JavaScript。在 Jest 中一种优雅的处理方式是:写一个转换器,把导入的值替换为图片文件名。官方文档给出如下实现:
const path = require('path'); module.exports = { process(sourceText, sourcePath, options) { return { code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`, }; }, };module.exports = { transform: { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/fileTransformer.js', }, };这个例子展示了转换器最核心的形态:接收sourceText、sourcePath、options三个参数,返回{code}(必要时附带map)。module.exports = "xxx.jpg"这样的输出让require('./logo.png')在测试中直接得到文件名。注意:由于默认transformIgnorePatterns排除node_modules,如果你后续想把这个转换器用于node_modules内的资源,需要相应调整该配置。
实战示例三:模板引擎预编译(仓库内真实用例)
仓库的端到端测试 e2e/coverage-handlebars/transform-handlebars.js 提供了一个真实的自定义转换器:它把 Handlebars 模板预编译为 JavaScript,并返回带 source map 的代码,从而让覆盖率能正确映射回.hbs源文件:
const {build} = require('@jridgewell/build-mapping'); const Handlebars = require('handlebars/dist/cjs/handlebars.js'); const dedent = require('string-dedent'); exports.process = (code, filename) => { const pc = Handlebars.precompile(code, {srcName: filename}); return dedent(build)` const Handlebars = require("handlebars/dist/cjs/handlebars.runtime.js"); module.exports = Handlebars.template(${pc}); `; };配合该目录的 jest 配置,"\\.hbs$"会命中这个转换器。这说明:只要返回合法的 JavaScript 与可选的 source map,任何文件格式都能接入 Jest。相关的端到端测试(如 e2e/tests/coverageHandlebars.test.ts)验证了转换后的覆盖率映射行为。
总结
代码转换是 Jest 支持非 JavaScript 语法与自定义资源类型的核心机制,脉络清晰:
- 默认即用:
babel-jest开箱即用地转换.js/.jsx/.ts/.tsx,加载项目 Babel 配置并注入 mock 提升所需的 preset; - 可扩展:通过
transform配置可以叠加任意转换器,但别忘了显式保留babel-jest; - 接口明确:实现
process/processAsync(按需选配getCacheKey系列),同步/异步的回退规则决定了你的代码库必须支持哪种形态; - 性能关键:实现
getCacheKey才能充分利用磁盘缓存,避免重复转译; - 边界清晰:
transformIgnorePatterns默认排除node_modules,需要转译第三方未编译代码时按需放行; - 质量保障:返回 source map 是覆盖率与错误定位准确性的前提,
canInstrument则决定了是否由 Jest 代为插桩。
从图片路径替换到 Handlebars 预编译,再到仓库端到端测试中的真实用例,掌握了 Transformer 接口,你就能让 Jest 理解项目中任何一种文件格式。
【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考