Jest 测试环境(Test Environment)完全指南:node、jsdom 与自定义环境
【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest
Jest 通过testEnvironment配置决定测试代码运行在何种宿主环境(Node.js 还是浏览器模拟环境 jsdom)中,并允许通过testEnvironmentOptions精细调节环境行为。本文以 Jest 官方文档 website/versioned_docs/version-30.4/TestEnvironment.md 为骨架,结合仓库内jest-environment-node、jest-environment-jsdom、jest-environment-jsdom-abstract、@jest/environment等包的源码实现,系统讲解内置环境的使用、单文件级别的 docblock 覆盖、内置环境扩展、从零编写自定义环境以及底层生命周期原理,帮助你在真实项目中按需选择与定制测试运行环境。
什么是测试环境
Jest 在运行测试文件时,会为每个测试套件创建一个独立的"沙箱"运行环境。测试代码中的global对象、浏览器 API(如document、window)、定时器与模块模拟等能力,都由这个环境提供。testEnvironment配置选项用于选择环境的类型,testEnvironmentOptions则用于向环境传递初始化参数。
Jest 默认内置两种开箱即用的环境:
node——默认环境。运行在 Node.js 运行时中,提供process、Buffer等 Node 全局对象,适合测试后端逻辑、工具函数、模块系统等不依赖浏览器 API 的代码。jsdom—— 通过 jsdom 包模拟浏览器环境,提供window、document、navigator等 Browser API,适合测试涉及 DOM 操作、事件、localStorage等浏览器特性的前端代码。
从源码结构看,内置环境由独立包实现:node环境对应 packages/jest-environment-node,jsdom环境对应 packages/jest-environment-jsdom,二者都实现了 packages/jest-environment/src/index.ts 中定义的JestEnvironment接口。其中jsdom环境本身基于抽象的 packages/jest-environment-jsdom-abstract 实现,具体见 packages/jest-environment-jsdom/src/index.ts 中的JSDOMEnvironment类,它只做了一件事:把 JSDOM 模块传递给父类构造器:
import * as JSDOM from 'jsdom'; import BaseEnv from '@jest/environment-jsdom-abstract'; export default class JSDOMEnvironment extends BaseEnv { constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { super(config, context, JSDOM); } }配置方式:全局生效与按文件覆盖
在 Jest 配置中全局设置
在jest.config.js/jest.config.ts(或package.json的jest字段)中设置testEnvironment,该值会作用于项目中的所有测试文件。完整说明见 docs/Configuration.md:
// jest.config.js const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: 'node', // 默认值,可省略 });// jest.config.ts import {defineConfig} from 'jest'; export default defineConfig({ testEnvironment: 'jsdom', // 切换到浏览器模拟环境 });testEnvironment的取值可以是:
- 内置环境名:
'node'或'jsdom'; - 指向自定义环境文件的路径(相对或绝对),如
'./custom-node-environment.js'; - 一个可解析的 npm 包名,如
'jest-environment-puppeteer'。
当使用非内置环境时,Jest 会尝试按文件路径或包名加载该模块,并期望其默认导出一个符合JestEnvironment形态的对象(见 docs/Configuration.md 第 2070 行附近的说明)。
使用 docblock pragma 按文件覆盖
全局配置粒度较粗,如果项目里大多数测试跑node,只有个别 DOM 相关的测试文件需要jsdom,可以在测试文件顶部的注释(docblock)中声明@jest-environmentpragma,实现单文件级别的覆盖:
/** * @jest-environment jsdom */ test('use jsdom in this test file', () => { const element = document.createElement('div'); expect(element).not.toBeNull(); });/** * @jest-environment jsdom */ test('use jsdom in this test file', () => { const element = document.createElement('div'); expect(element).not.toBeNull(); });同样地,docblock 也可以指向自定义环境文件:
/** * @jest-environment ./my-custom-environment.js */ test('use custom environment in this test file', () => { const element = document.createElement('div'); expect(element).not.toBeNull(); });/** * @jest-environment ./my-custom-environment.ts */ test('use custom environment in this test file', () => { const element = document.createElement('div'); expect(element).not.toBeNull(); });在 docs/Configuration.md 中可以找到同样的用法示例,它与本指南中的示例完全一致,可以互相印证。
通过 docblock 传递环境选项
除了选择环境,docblock 还支持用@jest-environment-options传入 JSON 字符串形式的环境选项,字符串必须能被JSON.parse解析(见 docs/Configuration.md 中的说明):
/** * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ test('use jsdom and set the URL in this test file', () => { expect(window.location.href).toBe('https://jestjs.io/'); });多个选项可以合并:
/** * @jest-environment jsdom * @jest-environment-options {"url": "https://example.com/", "userAgent": "Custom Agent"} */ test('use custom URL and user agent', () => { expect(window.location.href).toBe('https://example.com/'); expect(window.navigator.userAgent).toContain('Custom Agent'); });testEnvironmentOptions:环境初始化参数
testEnvironmentOptions默认值为{},会被整体传入环境的构造函数,具体支持哪些键取决于所用的环境(完整参考见 docs/Configuration.md)。
Node 环境选项
使用node环境时,可配置以下选项:
globalsCleanup('on'|'soft'|'off'):控制测试之间全局变量的清理策略,默认值为'soft'。该选项在 packages/jest-environment-node/src/index.ts 的readGlobalsCleanupConfig函数中被读取和校验,非法值会通过jest-validate输出警告;- 其余选项会直接透传给 Node.js 的
vm.runInContext(见 Node.jsvm模块文档)。
在源码中可以看到,packages/jest-environment-node/src/index.ts 的构造函数会执行runInContext('this', Object.assign(this.context, projectConfig.testEnvironmentOptions)),即把testEnvironmentOptions合并进 vm 上下文后求值this作为global,这正是这些选项能被runInContext消费的实现依据。
JSDOM 环境选项
使用jsdom环境时,常用选项包括:
url—— 页面 URL,影响window.location和相对 URL 解析,默认值为"http://localhost";userAgent—— user agent 字符串,默认是通用值;html—— 初始 HTML 内容;- 其余选项透传给 jsdom 的
JSDOM构造器(见 jsdom 文档)。
典型配置示例:
const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: 'jsdom', testEnvironmentOptions: { html: '<html lang="en-US"></html>', url: 'https://jestjs.io/', userAgent: 'Agent/007', }, });import {defineConfig} from 'jest'; export default defineConfig({ testEnvironment: 'jsdom', testEnvironmentOptions: { html: '<html lang="en-US"></html>', url: 'https://jestjs.io/', userAgent: 'Agent/007', }, });这些参数的实际消费逻辑在 packages/jest-environment-jsdom-abstract/src/index.ts:构造器以projectConfig.testEnvironmentOptions.html作为JSDOM的第一个参数(默认'<!DOCTYPE html>'),并将userAgent交给ResourceLoader、其余选项整体展开传给JSDOM构造器,同时固定开启pretendToBeVisual: true与runScripts: 'dangerously',并用VirtualConsole把 jsdom 的 console 输出转发到context.console。
customExportConditions:控制包的 exports 解析
testEnvironmentOptions还支持customExportConditions,用于控制从依赖包的package.jsonexports字段加载哪个版本。内置环境的默认值如下:
jest-environment-jsdom默认['browser'];jest-environment-node默认['node', 'node-addons']。
例如为 React Native 场景覆盖 jsdom 的导出条件:
const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: 'jsdom', testEnvironmentOptions: { customExportConditions: ['react-native'], }, });在源码中,两个环境分别把默认值定义在类的customExportConditions字段上(packages/jest-environment-node/src/index.ts 为['node', 'node-addons'],packages/jest-environment-jsdom-abstract/src/index.ts 为['browser']),若用户在testEnvironmentOptions中提供了customExportConditions,则通过exportConditions()方法返回用户配置,否则返回默认值;非字符串数组的非法配置会直接抛出错误。
扩展内置环境(推荐做法)
Jest 允许继承内置的NodeEnvironment或JSDOMEnvironment来定制行为,例如在测试前注入全局对象、读取 docblock pragma 做条件逻辑、监听 circus 测试事件等。这是扩展环境的首选方式——无需从零实现全部接口。
继承 NodeEnvironment 的完整示例
// An example of a custom Node environment const NodeEnvironment = require('jest-environment-node'); /** * @implements {import('jest-environment-node').NodeEnvironment} */ class CustomNodeEnvironment extends NodeEnvironment { constructor(config, context) { super(config, context); console.log(config.globalConfig); console.log(config.projectConfig); this.testPath = context.testPath; this.docblockPragmas = context.docblockPragmas; } async setup() { await super.setup(); await someSetupTasks(this.testPath); this.global.someGlobalObject = createGlobalObject(); // Will trigger if docblock contains @my-custom-pragma my-pragma-value if (this.docblockPragmas['my-custom-pragma'] === 'my-pragma-value') { // ... } } async teardown() { this.global.someGlobalObject = destroyGlobalObject(); await someTeardownTasks(); await super.teardown(); } getVmContext() { return super.getVmContext(); } async handleTestEvent(event, state) { if (event.name === 'test_start') { // ... } } } module.exports = CustomNodeEnvironment;// An example of a custom Node environment import NodeEnvironment from 'jest-environment-node'; export default class CustomNodeEnvironment extends NodeEnvironment { constructor(config, context) { super(config, context); console.log(config.globalConfig); console.log(config.projectConfig); this.testPath = context.testPath; this.docblockPragmas = context.docblockPragmas; } async setup() { await super.setup(); await someSetupTasks(this.testPath); this.global.someGlobalObject = createGlobalObject(); // Will trigger if docblock contains @my-custom-pragma my-pragma-value if (this.docblockPragmas['my-custom-pragma'] === 'my-pragma-value') { // ... } } async teardown() { this.global.someGlobalObject = destroyGlobalObject(); await someTeardownTasks(); await super.teardown(); } getVmContext() { return super.getVmContext(); } async handleTestEvent(event, state) { if (event.name === 'test_start') { // ... } } }然后在 Jest 配置中声明使用它:
const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: './custom-node-environment.js', });import {defineConfig} from 'jest'; export default defineConfig({ testEnvironment: './custom-node-environment.ts', });环境生命周期与关键接口的源码印证
上面示例中出现的constructor(config, context)、setup()、teardown()、getVmContext()、handleTestEvent()等接口,正是 packages/jest-environment/src/index.ts 中JestEnvironment基类声明的契约。结合内置实现可以理解每个环节的真实行为:
setup():默认空实现。在 packages/jest-environment-node/src/index.ts 中只是空方法,扩展时在其中执行环境初始化逻辑;teardown():负责释放资源。Node 环境会dispose两套 fake timers、清空context、并通过内部GlobalProxy.clear()删除测试期间设置到全局对象上的属性,防止内存泄漏(packages/jest-environment-node/src/index.ts);jsdom 环境则额外移除error事件监听并调用this.global.close()(packages/jest-environment-jsdom-abstract/src/index.ts);getVmContext():返回测试代码运行的 vm 上下文。Node 环境返回this.context,jsdom 环境返回this.dom.getInternalVMContext();handleTestEvent():可选实现。在 jest-circus 运行器中,如果环境提供了该方法,会被注册为事件处理器:见 packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts 中的addEventHandler(environment.handleTestEvent.bind(environment))。
关于 handleTestEvent 的同步事件说明
文档特别提醒:handleTestEvent是可选方法;当它返回 Promise 时,jest-circus 会等待其 settle 之后再继续——但以下同步事件除外:
start_describe_definitionfinish_describe_definitionadd_hookadd_testerror
这 5 个事件不会等待返回的 Promise,因此在处理这些事件时应避免依赖异步操作的结果。
关于 docblock pragmas 的传递
任何测试文件中的 docblock pragma(例如@my-custom-pragma my-value)都会以context.docblockPragmas的形式传入环境构造函数。在 packages/jest-environment/src/index.ts 中可以看到EnvironmentContext的完整定义,包含console、docblockPragmas和testPath三个字段,这也解释了为什么上面的示例能通过context.testPath拿到当前测试文件路径、通过context.docblockPragmas['my-custom-pragma']读取自定义 pragma。
基于 jsdom 的组合式扩展
Jest 还提供 packages/jest-environment-jsdom-abstract 包,方便你基于 jsdom 组合出自定义环境,或使用你自己安装的 jsdom 版本:
const JSDOMEnvironment = require('@jest/environment-jsdom-abstract'); const jsdom = require('jsdom'); class CustomJSDOMEnvironment extends JSDOMEnvironment { constructor(config, context) { super(config, context, jsdom); } // Override methods to customize behavior } module.exports = CustomJSDOMEnvironment;import JSDOMEnvironment from '@jest/environment-jsdom-abstract'; import jsdom from 'jsdom'; export default class CustomJSDOMEnvironment extends JSDOMEnvironment { constructor(config, context) { super(config, context, jsdom); } // Override methods to customize behavior }配置方式相同:
const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: './custom-jsdom-environment.js', });import {defineConfig} from 'jest'; export default defineConfig({ testEnvironment: './custom-jsdom-environment.ts', });这个抽象包的设计动机从源码可见:BaseJSDOMEnvironment的受保护构造器接收第三个参数jsdomModule(packages/jest-environment-jsdom-abstract/src/index.ts),内部从该模块解构出JSDOM、ResourceLoader、VirtualConsole再组装环境。官方内置的jest-environment-jsdom正是用同样的方式把自己捆绑的 jsdom 传进去的,因此社区包完全可以依赖这个抽象层并注入自己的 jsdom 版本。
从零编写自定义环境(Custom Environment)
当继承内置环境仍无法满足需求时,可以创建自己的环境包或指定一个合法的 JS/TS 文件路径。该文件应导出一个符合JestEnvironment形态的对象,也就是实现 packages/jest-environment/src/index.ts 中声明的接口:构造函数接收(config, context),至少提供global、setup()、teardown()、getVmContext()等成员。
/** * @implements {import('@jest/environment').JestEnvironment} */ class CustomEnvironment { // Implement the required methods here // Example of a method getVmContext() { return null; } } module.exports = CustomEnvironment;import type {JestEnvironment} from '@jest/environment'; export default class CustomEnvironment implements JestEnvironment { // Implement the required methods here // Example of a method getVmContext() { return null; } }声明使用:
const {defineConfig} = require('jest'); module.exports = defineConfig({ testEnvironment: './environment.js', });import {defineConfig} from 'jest'; export default defineConfig({ testEnvironment: './environment.ts', });更完整的自定义环境骨架可以参考 docs/Configuration.md 中给出的版本,其中构造函数接收config并解构出projectConfig使用。
命名规范
如果以 npm 包形式发布自定义环境,官方建议使用jest-environment-前缀命名(例如jest-environment-puppeteer),这样能被清晰地识别为 Jest 环境包。从加载机制上看,testEnvironment配置既支持文件路径也支持包名,命名规范能让你的包在生态中被其他开发者一眼认出。
生命周期与隔离保证
文档中有一条重要提示:每个测试套件运行在独立的TestEnvironment实例中,setup和teardown每个套件只调用一次。这意味着:
- 环境是按"测试文件(套件)"为单位创建的,而不是每个
test()用例一个; - 在
setup()中注入的全局对象、在构造函数中读取的配置,会在整个套件期间保持; teardown()只在套件结束时执行一次,用于释放资源、清理全局对象。
这一设计也解释了为何环境类需要fakeTimers、fakeTimersModern、moduleMocker等实例字段:它们都随环境实例化,为每个套件提供独立的定时器模拟与模块模拟状态,避免跨套件相互污染。
总结
- 默认
node、浏览器模拟选jsdom,通过testEnvironment全局配置,或通过@jest-environmentdocblock 按文件覆盖; - 环境行为通过
testEnvironmentOptions微调:Node 环境透传vm.runInContext选项并支持globalsCleanup,jsdom 环境透传 jsdom 构造选项并支持url/userAgent/html等,二者都支持customExportConditions; - 需要定制时优先继承
NodeEnvironment/JSDOMEnvironment,或在@jest/environment-jsdom-abstract基础上注入自己的 jsdom 版本;极端场景再实现完整的JestEnvironment接口; - 每个测试套件拥有独立的
TestEnvironment实例,setup/teardown每套件一次,环境通过handleTestEvent可接入 jest-circus 事件流。
相关参考资料:docs/Configuration.md 中的testEnvironment与testEnvironmentOptions章节、packages/jest-environment/src/index.ts(环境接口定义)、packages/jest-environment-node/src/index.ts 与 packages/jest-environment-jsdom-abstract/src/index.ts(内置实现)、packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts(环境事件接入点)。
【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考