对于 Jest,你是否很好奇:
- 底层流程、原理是什么样的?
- mock 和断言里面有哪些神奇操作?
- 如何针对 Jest 做自己的扩展?
- 横向对比 Vitest 框架有什么优劣?
这一篇关于 Jest 的原理解析。本文将和大家一起深入拆解 Jest monorepo ,查找这些问题的答案。
Jest monorepo at a glance
如何使用jest:
// *test.js
const sum = require('./sum');
describe('用例集', () => {
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
}
yarn jest xxx.ts (匹配文件,实际也是正则)
yarn jest */**/*.ts(匹配正则)
yarn jest(读取配置文件jest.config.js)
CLI & Test File 收集
获取各项目的根目录
// jest-cli/src/cli/index.ts
// 获取各项目的根目录
const getProjectListFromCLIArgs = (argv: Config.Argv, project?: string) => {
// 命令行读取参数
const projects = argv.projects ? argv.projects : [];
// 没有设置则置入当前工作目录
if (!projects.length) {
projects.push(process.cwd());
}
return projects;
};
根据各项目根目录读取配置文件
// jest-config/src/index.ts
// 根据命令行参数及提取的 projects 配置项读取配置文件
export async function readConfigs(
argv: Config.Argv,
projectPaths: Array<string>,
): Promise<{
globalConfig: Config.GlobalConfig;
configs: Array<Config.ProjectConfig>;
hasDeprecationWarnings: boolean;
}> {
// 仅设置一个 project 或没有设置时
if (projectPaths.length === 1) {
const parsedConfig = await readConfig(argv, projects[0]);
// 读取该 project 下配置文件中的 projects 配置项进行替换。所以应该只有一个 projects 配置会生效?
if (globalConfig.projects && globalConfig.projects.length) {
projects = globalConfig.projects;
}
}
// 根据 projects 配置读取配置项
if (projects.length > 0) {
const parsedConfigs = await Promise.all(
projects
// 分别执行 readConfig
.map((root, projectIndex) => {
return readConfig(/*...*/);
}),
);
// configs 包括了所有 projects 的 projectConfig
configs = parsedConfigs.map(({projectConfig}) => projectConfig);
}
// ...
return {
configs,
/*...*/
};
}
// 根据项目路径和命令行参数处理全局配置和项目配置
export async function readConfig(
argv: Config.Argv,
packageRootOrConfig: string | Config.InitialOptions, // projects 中的元素
): Promise<ReadConfig> {
let rawOptions: Config.InitialOptions;
let configPath = null;
// ...如果 project 为 JSON 则直接使用
if (typeof packageRootOrConfig !== 'string') {
rawOptions = packageRootOrConfig;
// 如果 config 配置直接传了 JSON 配置
} else if (isJSONString(argv.config)) {
let config;
config = JSON.parse(argv.config); // 直接转换 JSON 为配置
rawOptions = config;
// 如果 config 配置了文件路径
} else if (!skipArgvConfigOption && typeof argv.config == 'string') {
// 读取 config 设置的配置文件
configPath = resolveConfigPath(
argv.config,
process.cwd(),
skipMultipleConfigError,
);
rawOptions = await readConfigFileAndSetRootDir(configPath);
// 否则从当前工作目录中寻找配置文件
} else {
configPath = resolveConfigPath(
packageRootOrConfig,
process.cwd(),
skipMultipleConfigError,
);
rawOptions = await readConfigFileAndSetRootDir(configPath);
}
// 参数处理,如命令行无标识参数转为文件路径和正则表达式等
const {options, hasDeprecationWarnings} = await normalize(
rawOptions,
argv,
configPath,
projectIndex,
);
// 拆分配置
const {globalConfig, projectConfig} = groupOptions(options);
return {
configPath,
globalConfig,
hasDeprecationWarnings,
projectConfig,
};
}
根据各项目配置生成测试项目上下文
// jest-core/src/cli
const _run10000 = async (
globalConfig: Config.GlobalConfig,
configs: Array<Config.ProjectConfig>,
hasDeprecationWarnings: boolean,
outputStream: NodeJS.WriteStream,
onComplete: OnCompleteCallback,
) => {
// ...
// 根据项目配置构建测试项目上下文和依赖图
// contexts: TestContext[]
const {contexts, hasteMapInstances} = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream,
);
// ...
};
根据各测试项目上下文运行搜索器
// jest-core/src/runJest
export default async function runJest({
contexts,
globalConfig,
/*...*/
}: {
globalConfig: Config.GlobalConfig;
contexts: Array<TestContext>;
/*...*/
}): Promise<void> {
// 排序器
const Sequencer: typeof TestSequencer = await requireOrImportModule(
globalConfig.testSequencer,
);
const sequencer = new Sequencer();
let allTests: Array<Test> = [];
// 对各测试上下文构建搜索器
const searchSources = contexts.map(context => new SearchSource(context));
const testRunData: TestRunData = await Promise.all(
// 对各测试上下文调用 getTestPaths 方法
contexts.map(async (context, index) => {
const searchSource = searchSources[index];
const matches = await getTestPaths(/*...*/);
allTests = allTests.concat(matches.tests);
return {context, matches};
}),
);
// 排序
allTests = await sequencer.sort(allTests);
}
const getTestPaths = async (
globalConfig: Config.GlobalConfig,
source: SearchSource,
outputStream: NodeJS.WriteStream,
changedFiles: ChangedFiles | undefined,
jestHooks: JestHookEmitter,
filter?: Filter,
) => {
// 调用搜索器的 getTestPaths 方法
const data = await source.getTestPaths(globalConfig, changedFiles, filter);
};
具体搜索逻辑
// jest-core/src/SearchSource
export default class SearchSource {
private _context: TestContext; // 保存创建时输入的测试项目上下文
async getTestPaths(
globalConfig: Config.GlobalConfig,
changedFiles?: ChangedFiles,
filter?: Filter,
): Promise<SearchResult> {
const searchResult = await this._getTestPaths(globalConfig, changedFiles);
// 过滤
return searchResult;
}
private async _getTestPaths(
globalConfig: Config.GlobalConfig,
changedFiles?: ChangedFiles,
): Promise<SearchResult> {
// 命令行无标识参数,即测试文件匹配正则式
let paths = globalConfig.nonFlagArgs;
// 注意搜索会拼接测试项目上下文中的工作目录
// 如果配置了 --runTestsByPath,则使用精准匹配
if (globalConfig.runTestsByPath && paths && paths.length) {
return this.findTestsByPaths(paths);
// 如果配置了 --findRelatedTests,则在一定范围内搜索
} else if (globalConfig.findRelatedTests && paths && paths.length) {
return this.findRelatedTestsFromPattern(
paths,
globalConfig.collectCoverage,
);
// 默认模式,在项目根目录下搜索所有测试文件,根据正则表达式匹配
// 正则表达式包括命令行无标识参数以及--testPathIgnorePatterns配置项
} else if (globalConfig.testPathPattern != null) {
return this.findMatchingTests(globalConfig.testPathPattern);
} else {
return {tests: []};
}
}
}
测试用例收集与执行
准备好环境
前置动作搞一搞
Step 0 首先,得有个 worker pool
为了简(偷)化(懒),我们默认走当前线程执行
// 只有一个 test case 时的优化,默认你在 debug,在当前线程执行,同样也会帮你启用 verbose reporter
const runInBand = shouldRunInBand(tests, timings, this._globalConfig);
...
// somewhere in jest-runner
async runTests(
tests: Array<Test>,
watcher: TestWatcher,
options: TestRunnerOptions,
): Promise<void> {
return await (options.serial
? this.#createInBandTestRun(tests, watcher)
: this.#createParallelTestRun(tests, watcher));
}
Step 1 Runner 要准备开始了
// 执行环境,比如 node 环境的一些东西,jest-dom 就会提供一些 document 之类的 globals
const TestEnvironment: typeof JestEnvironment =
await transformer.requireAndTranspileModule(testEnvironment);
// 执行框架,提供 describe / it,默认的是 jest-circus
const testFramework: TestFramework =
await transformer.requireAndTranspileModule(
process.env.JEST_JASMINE === '1'
? require.resolve('jest-jasmine2')
: projectConfig.testRunner,
);
// 运行时,负责 VM 和 模块管理,还有 jest.xxx 相关的 api,比如 jest.mock
const Runtime: typeof RuntimeClass = interopRequireDefault(
projectConfig.runtime
? require(projectConfig.runtime)
: require('jest-runtime'),
).default;
Test File 解析与执行
类比于 Node.js 的执行,一个老生常谈的面试题,node 中的 module
/require
/__dirname
这些变量哪来的?
当然是 Node.js 帮你注入的,那么怎么注入的?
(function (module, exports, __dirname, __filename) {
// your code
// var testingLibrary = require('@testing-library')
// describe(() => {
//
// })
})(module, exports, __dirname, __filename)
就是上面这样拼装一下形成一个 IIFE,那么此时这段代码还是个字符串,怎么执行呢?接下来该 VM 上场了。
const filename = module.filename;
module.children = [];
Object.defineProperty(module, 'parent', {
enumerable: true,
get() {
const key = from || '';
return moduleRegistry.get(key) || null;
},
});
module.paths = this._resolver.getModulePaths(module.path);
Object.defineProperty(module, 'require', {
value: this._createRequireImplementation(module, options),
});
// 当然先要编译一下,不能有 esm
const transformedCode = this.transformFile(filename, options);
let compiledFunction: ModuleWrapper | null = null;
// 就在这把外层的 IIFE 包裹好
const script = this.createScriptFromCode(transformedCode, filename);
let runScript: RunScriptEvalResult | null = null;
const vmContext = this._environment.getVmContext();
// vmContext 即 jest-env + jest-circus
if (vmContext) {
// 想像成 eval
runScript = script.runInContext(vmContext, {filename});
}
if (runScript !== null) {
compiledFunction = runScript[EVAL_RESULT_VARIABLE];
}
if (compiledFunction === null) {
this._logFormattedReferenceError(
'You are trying to `import` a file after the Jest environment has been torn down.',
);
process.exitCode = 1;
return;
}
// jest.mock 等相关 API
const jestObject = this._createJestObjectFor(filename);
this.jestObjectCaches.set(filename, jestObject);
if (!this._mainModule && filename === this._testPath) {
this._mainModule = module;
}
Object.defineProperty(module, 'main', {
enumerable: true,
value: this._mainModule,
});
// 执行
try {
compiledFunction.call(
module.exports,
module, // module object
module.exports, // module exports
module.require, // require implementation
module.path, // __dirname
module.filename, // __filename
);
} catch (error: any) {
this.handleExecutionError(error, module);
}
Describe & It
这个时候你代码里的 describe 和 it 该执行了,他们主要是起到收集用例的作用,实现的机制就像 readme 里写的 flux-based,dispatch 一些 event 然后由 event handler 统一处理并存放于 state
// it 的本来面目
const _addTest = (
testName: Circus.TestNameLike,
mode: Circus.TestMode,
concurrent: boolean,
fn: Circus.TestFn | undefined,
testFn: (
testName: Circus.TestNameLike,
fn: Circus.TestFn,
timeout?: number,
) => void,
timeout?: number,
failing?: boolean,
) => {
return dispatchSync({
asyncError,
concurrent,
failing: failing === undefined ? false : failing,
fn,
mode,
name: 'add_test',
testName,
timeout,
});
};
// 处理事件,存放进单一 store
case 'add_test': {
const {currentDescribeBlock, currentlyRunningTest, hasStarted} = state;
const {
asyncError,
fn,
mode,
testName: name,
timeout,
concurrent,
failing,
} = event;
const test = makeTest(
fn,
mode,
concurrent,
name,
currentDescribeBlock,
timeout,
asyncError,
failing,
);
if (currentDescribeBlock.mode !== 'skip' && test.mode === 'only') {
state.hasFocusedTests = true;
}
currentDescribeBlock.children.push(test);
currentDescribeBlock.tests.push(test);
break;
}
收集完相关的 testFn,接下来要做的就是根据 state 依次去执行 testFn,再通过各种事件,把测试结果收集起来
const _runTestsForDescribeBlock = async (
describeBlock: Circus.DescribeBlock,
isRootBlock = false,
) => {
await dispatch({describeBlock, name: 'run_describe_start'});
const {beforeAll, afterAll} = getAllHooksForDescribe(describeBlock);
const isSkipped = describeBlock.mode === 'skip';
if (!isSkipped) {
// beforeAll
for (const hook of beforeAll) {
await _callCircusHook({describeBlock, hook});
}
}
for (const child of describeBlock.children) {
switch (child.type) {
// 执行 test
case 'describeBlock': {
await _runTestsForDescribeBlock(child);
break;
}
case 'test': {
const hasErrorsBeforeTestRun = child.errors.length > 0;
await _runTest(child, isSkipped);
break;
}
}
}
for (遍历) {
let numRetriesAvailable = retryTimes;
while (xxx) {
// Clear errors so retries occur
await dispatch({name: 'test_retry', test});
// 执行
await _runTest(test, isSkipped);
}
}
if (!isSkipped) {
for (const hook of afterAll) {
// afterAll
await _callCircusHook({describeBlock, hook});
}
}
await dispatch({describeBlock, name: 'run_describe_finish'});
};
describe('describe name', () => {
it("setTimeout won't impact test result", (done) => {
setTimeout(() => {
done(); // 不调用 done,jest 无法确定要不要等你的 setTimeout
expect(1 + 1).toEqual(3);
}, 0);
});
});
Test 文件的依赖收集 – jest-haste-map
Jest 内部使用 jest-haste-map
作为执行时的文件系统,主要记录了 test 文件及其依赖的文件路径,当我们修改了文件后,能按需的测试文件变动相关的测试用例。
// packages/jest-core/src/cli/index.ts/_run10000
// 小故事: _run10000 这个方法名是作者纪念第 10000个 PR 专门留下的
const {contexts, hasteMapInstances} = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream,
);
globalConfig.watch || globalConfig.watchAll
? await runWatch(
contexts,
configs,
hasDeprecationWarnings,
globalConfig,
outputStream,
hasteMapInstances,
filter,
)
: await runWithoutWatch(
globalConfig,
contexts,
outputStream,
onComplete,
changedFilesPromise,
filter,
);
从 runWatch
和 runWithoutWatch
的参数可以推测 hasteMapInstaces
是针对 watch
选项有特殊用途的。
Q&A
Q1: 为什么runWithoutWatch
也需要传 changedFilesPromise
A1: 因为这个是用于另外一个功能,而不是 watch
功能。一个很好用,但是平常很少用的功能?
Transformer – jest-transfrom
Translate – 翻译
Test 文件已经收集完成了,可是我们都是用 typescript
编写的测试用例,jest 是怎么把这些文件运行在 nodejs
中呢?
答案就是使用 transformer
,它的作用就是将不能直接在nodejs
(严格来说是 TestEnvironment
)运行的代码翻译为可以运行的代码。比如 ts -> js, esNext -> es5。
- babel-jest: 将 esNext -> es5
- ts-jest: 将 typescript -> es5
- esbuild-jest: 使用 esbuild 将 typescript or esNext -> es6
- @swc/jest: 使用 swc 将 将 typescript or esNext -> es6
Instrument – 插桩
transformer 还有另外一个作用,就是插桩(Instrument),插桩是为了进行覆盖率统计的。
这里又有一个小故事:
主角是著名的编译工具 babel
和著名的覆盖率检测工具 instanbul
Adanvce: Make a transformer yourself
- 将新晋网红语言
carbon
引入到 jest 单元测试中 - Docs: Code Transformation · Jest
- Implement: jest transfrom demo – StackBlitz
Mocker – jest-mock
原理
Mock 的原理大概有两种,一种是 Object.defineProperty
劫持对象,一种是劫持 require
方法。两种方式对应不同的 mock,前者主要是 mock 某个属性,后者主要是 mock 某个模块。
jest.fn
- 这个方法严格来说并不是 mock,而是对
function
进行包装,在方法对象里添加了许多 mock 相关的参数用于后续断言,经常在作为被测试函数的 callback,可以断言调用参数,调用次数等
// 简单实现
function jestFn(implement) {
const fn = (...args) => {
fn.called = true;
fn.args = args;
fn.count += 1;
implement && implement(...args)
}
fn.called = false;
fn.args = undefined;
fn.count = 0;
return fn
}
jest.spyOn – Object.defineProperty
- spyOn 一般来说只能用来 mock
function
, 但是如果一个对象属性使用getter
或者setter
来定义,也能通过 spyOn 来 mock。
// 简单实现
function jestSpyOn(object, methodName, getterOrSetterType) {
if (getterOrSetterType) {
return getterOrSetterSpecialLogic(object, methodName, getterOrSetterType)
}
let descriptor = Object.getOwnPropertyDescriptor(object, methodName);
let proto = Object.getPrototypeOf(object);
// 如果不是对象自己的属性,则从原型链上面找
while (!descriptor && proto !== null) {
descriptor = Object.getOwnPropertyDescriptor(proto, methodName);
proto = Object.getPrototypeOf(proto);
}
let mock = jestFn();
if (descriptor && descriptor.get) {
Object.defineProperty(object, methodName, descriptor);
} else {
// @ts-expect-error overriding original method with a Mock
object[methodName] = mock;
}
}
return object[methodName];
}
jest.mocked
- 这个方法其实专门为 typescript 准备的,可以让 mock 的模块带有 jest Mock 类型
jest.mock – require hijack
- 这个方法是需要劫持 require 的,所以使用的时候它要求我们先调用
jest.mock
才引入对应的模块
// 简单实现,实际上是有很多状态需要处理的
const mockModuleRegistry = new Map();
function jestMock(modulePath, implement) {
const moduleId = getModuleId(modulePath);
mockModuleRegistry.set(moduleId, implement() || autoMock)
}
function requireModuleOrMock(modulePath) {
const moduleId = getModuleId(modulePath);
if (mockModuleRegistry.has(moduleId)) {
return requireMock(modulePath);
} else {
return requireModule(modulePath);
}
}
// 最后将 requireModuleOrMock 这个方法当成 require 的实现注入到 vm 中
// 就能实现模块级别的mock
断言
前世今生:
底层基于 mjackson/expect 库,后来这个库捐赠给了 jest 维护,新地址 ?? jest/packages/expect
Facebook 为 expect 库写了一些 jest 定制扩展(jest-snapshot),合起来又变成了 @jest/expect
Jest-snapshot 扩展了啥呀!?
- expect(xxx).toMatchSnapshot()
- expect(xxx).toMatchInlineSnapshot()
主要的断言逻辑都在 jest/packages/expect 中。
接下来简单实现下 expext(xxx).not.toBe()
。
实现一个 expect 函数 ?? source
const expect = (actual) => {
// 存放匹配器
const expectation: any = {
not: {},
rejects: {not: {}},
resolves: {not: {}},
};
const allMatchers = getMatchers(); // [toBe、toBeNull...] 等匹配器
allMatchers.forEach(name => {
const matcher = allMatchers[name];
expectation[name] = matcher;
expectation.not[name] = matcher; // 在这里会有一个包装函数,注入isNot = true 和 actual,先省略了
expectation.rejects[name] = matcher;
expectation.rejects.not[name] = matcher;
......
})
return expectation;
}
// 到这里,我们可以用两种方式去调用 toBeNull
expect(xxx).toBeNull();
expect(xxx).not.toBeNull();
实现一个匹配器(matcher)?? source
const matchers = {
... // 其他匹配器
toBe(received, expected) {
let pass = Object.is(received, expected);
pass = isNot ? !pass : pass;
return {actual: received, expected, message, pass}
}
// 外层包了个函数,pass == false 时会 throw Error
}
测试结果收集
收集时机
太长不看,给点总结:边跑测试边收集结果 ?? source
scheduleTests() {
const allTestResult = createAllTestResult(tests.length);
// 把所有测试都跑一遍
testRunners.forEach(testRunner => {
testRunner.on('failure', () => {
allTestResult.add(curTestResult);
_dispatcher.onTestFileResult(curTestResult, allTestResult)
})
testRunner.on('success', () => {
allTestResult.add(curTestResult);
_dispatcher.onTestFileResult(curTestResult, allTestResult)
})
testRunner.runTests();
})
await updateSnapshotState(); // 在这里更新快照
_dispatcher.onRunComplete(testContexts, aggregatedResults)
}
// 本质是一个简单的发布订阅
const _dispatcher = {
_reporters: [],
// 注册
register: (reporter) => this._reporters.push(reporter)
// hook
onTestFileResult: (...rest) => {
_reporters.forEach(reporter => {
reporter.onTestResult(...rest); // 这个 reporter 是何方神圣啊???
})
}
onRunComplete: () => {
_reporters.forEach(reporter => {
reporter.onRunComplete(...rest);
})
}
}
Reporter
用某不愿意透露姓名的 DefaultReporter 举例, @jest/reporters
如果你在命令行中配置了
--verbose
,那就会用 DefaultReporter 的好兄弟 VerboseReporter(提供更详细的报告)
// 专门负责log输出
Class DefaultReporter {
......
log(message) {
process.stderr.write(`${message}\n`);
}
// 每执行完一个测试就会调用,负责console.log当前测试结果
onTestResult() {
// 有很多组装字符串的逻辑,不是很重要,可以忽略
this.log(result.path);
this.log(result.message);
}
// 执行完所有测试后调用,也是负责 log
onRunComplete() {
clearLog();
this.log(result.XXXmessage)
}
}
如果想加餐,了解 testResult 具体是个什么东东、里面有些啥内容,可以看看这里。
生态 & 可扩展性
create-jest-runner
这么粗暴、直接的名字正代表它的功能。
简单使用一下:
// index.js
const { createJestRunner } = require('create-jest-runner');
const runner = createJestRunner(require.resolve('./run'));
module.exports = runner;
createJestRunner
接受两个参数
-
第一个是
Run file
,每个测试文件都会执行一遍这个Run file
,里面存放我们的 runner 逻辑 -
第二个参数是
config
写一下Run file
,希望限制每个测试文件中都应该注明“批准进行测试”。
// run.js
const fs = require('fs');
const { pass, fail } = require('create-jest-runner');
const runTest = ({ testPath, config, extraOptions }) => {
const contents = fs.readFileSync(testPath, 'utf8');
if (contents.includes('批准进行测试')) {
return pass({ startTime, endTime, test: { path: testPath } });
}
return fail({ startTime, endTime, test: { path, errorMessage, title }});
};
module.exports = runTest;
一个 Jest-ShuaiB-Runner
到这里就已经完成了!????
接下来去看看竞品是怎么做的!
jest-runner-eslint
比较有创意,借用了jest的翻译、文件遍历功能,去校验文件是否符合eslint规范
底层还是依赖了老大哥 create-jest-runner
,那就直接看它的Run file
吧!
源码在这里,只有135行!
const runESLint = async ({ testPath, config, extraOptions }) => {
const lint = new ESLint(cliOptions);
// 校验lint
const report = await lint.executeOnFiles(testPath).result;
// 返回结果
if (report[0]?.errorCount > 0) {
return fail({ startTime, endTime, test: { path, errorMessage }});
}
return pass({ startTime, endTime, test: { path: testPath } });
}
jest-extended
上面讲了两个扩展 runner 的库,再来了解下怎么扩展 matcher。
jest-extended
对 jest 进行了大量的断言扩展,比如 toBeArray()
、toBeNumber()
。
如果你用腻了自带的 toBe 断言,也想给 jest 扩展一个自己的toBeOrNotToBe()
,可以参考下。
这个库是怎么用的:
// jest config
"jest": {
"setupFilesAfterEnv": ["./testSetup.js"]
}
// testSetup.js
import { toBeArray, toBeSealed } from 'jest-extended';
expect.extend({ toBeArray, toBeSealed });
expect.toBeArray
划重点:setupFilesAfterEnv,这个配置会在【测试框架就绪后,测试启动前】,执行我们的自定义初始化代码(这时候框架已经就绪了,可以在代码里使用全局变量和 jest 对象了)。
expect.extend 支持我们往里面塞自定义 matcher,关于怎么写matcher可以参考??断言章节,也可以学习下toBeArray是怎么写的~
jest-canvas-mock
这个库能够模拟 canvas,给 jest 提供 canvas 测试能力
举个?:
ctx.beginPath();
ctx.arc(1, 2, 3, 4, 5);
ctx.moveTo(6, 7);
ctx.rect(6, 7, 8, 9);
ctx.closePath();
const path = ctx.__getPath();
expect(path).toMatchSnapshot();
其实还是借助了 setupFilesAfterEnv
配置,在 setup 阶段 mock 掉 window。
// jest config
"jest": {
"setupFilesAfterEnv": ["./testSetup.js"]
}
// testSetup.js
if (typeof window !== 'undefined') {
if (!window.Path2D) window.Path2D = mockPath2D;
if (!window.CanvasGradient) window.CanvasGradient = mockCanvasGradient;
if (!window.CanvasPattern) window.CanvasPattern = mockCanvasPattern;
}
总结一下扩展方式
- Runner & Reporters
// jest.config.js
module.exports = {
runner: '/path/to/my-runner',
reporters: [
"default",
['/path/to/my-reporter']
]
};
- Setup
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['<rootDir>/setup-jest.js'],
};
对生态感兴趣可以看看 awesome-jest。
分析下竞品 Vitest
vitest 是一个由Vue团队出品的测试框架。
一些概念理解
File: 测试文件 *.test.*
、*.spec.*
,每个测试文件内有很多 Suite
Suite: 等于 jest describe,内部有一个 Tasks 队列(执行一个 suite 实际是执行这个 tasks 队列)
Test: 等于 jest it, 运行 Test 对象即运行我们编写的测试代码。Test 对象一般存放在 Suite.tasks 中
关系如下图:
Runtime流程
感兴趣可以看看一些关键源码
source | 解释 | |
---|---|---|
run | entry.ts | Runtime 入口函数 |
startTestsNode | run.ts | 在 node 环境下执行测试,对比 startTestsBrowser 多了快照、覆盖率功能的代码 |
collectTests | collect.ts | 遍历文件,收集所有的 describe / it |
runSuite | run.ts | 遍历 Suite 下的 Tasks 队列,输出报告 |
runTest | run.ts | 运行测试代码 |
Vitest 支持在真实浏览器(不是Browser-like,目前在开发中)和Node环境中运行,两者的主流程大同小异(Vitest内部处理了Api差异)。
但是 Node 环境下功能会更丰富一些,支持快照、覆盖率收集。
和 Jest 比较
vite 及 vitest 宣称自己最大的特点,就是原生的 ESM 支持和快。第一点,由于 vm 对于 esm 的支持还比较差,所以 vitest 也并非原生支持 esm,而是通过编译将 esm 转换为 cjs,这点和 jest 无异;
关于第二点,我们可以看到 vitest 整体流程和 Jest 一模一样,都是 File收集
-> 用例收集
-> 代码转译
-> 执行测试
-> 输出报告
。
那么它到底快在哪呢?值得关注的是 代码转译
这一步,常规 Jest 使用 Babel 去转码,而 vitest 使用了 package/vite-node
来做代码运行环境(直接把文件当做 esm 执行)。
综上,Vitest 的优势在于在使用 Vite 的项目中,比配置 Jest 更方便。如果你是一个从 0 开始使用 vite或者从 0 使用 vitest 的项目,那么使用 vitest 是一个好的选择,毕竟它有了更加先进的设计理念以及开箱即用的 esm support,就像官网中所说的:
Vitest aims to position itself as the Test Runner of choice for Vite projects, and as a solid alternative even for projects not using Vite.
Vitest 旨在将自己定位为 Vite 项目的首选测试框架,即使对于不使用 Vite 的项目也是一个可靠的替代方案。
但如果你试图将上百个用例从 jest 迁移至 vitest,或许使用 esbuild 或者 swc 来替换 babel 是一个更好的选择。关于 vitest 性能以及 isolate 相关的讨论,更多信息可参考 github.com/vitest-dev/…