什么是Jest
测试意味着什么
我怎么知道要测试什么
测试块,断言和匹配器
如何实现测试块
如何实现断言和匹配器
CLI 和配置
模拟
怎么模拟一个函数
执行环境
作用域隔离
V8 虚拟机
运行单测回调
钩子函数
生成报告
jest-cli
jest-config
jest-haste-map
jest-runner
jest-environment-node
jest-circus
jest-runtime
最后&源码
本文主要给大家深入了解 Jest 背后的运行原理,并从零开始简单实现一个 Jest 单元测试的框架,方便了解单元测试引擎是如何工作的,Jest 编写单测相信我们已经很熟悉了,但 Jest 是如何工作的我们可能还很陌生,那让我们一起走进 Jest 内心,一同探究单元测试引擎是如何工作的。
先附上 Jest 核心引擎的代码实现给有需要的同学,欢迎关注和交流:https://github.com/Wscats/jest-tutorial
Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。
Jest 作为 NPM 包发布,可以安装并运行在任何 JavaScript 项目中。Jest 是目前前端最流行的测试库之一。
在技术术语中,测试意味着检查我们的代码是否满足某些期望。例如:一个名为求和(sum
)函数应该返回给定一些运算结果的预期输出。
有许多类型的测试,很快你就会被术语淹没,但长话短说的测试分为三大类:
在测试方面,即使是最简单的代码块也可能使初学者也可能会迷惑。最常见的问题是“我怎么知道要测试什么?”。
如果您正在编写网页,一个好的出发点是测试应用程序的每个页面和每个用户交互。但是网页其实也需要测试的函数和模块等代码单元组成。
大多数时候有两种情况:
那该怎么办?对于这两种情况,你可以通过将测试视为:检查该函数是否产生预期结果。最典型的测试流程如下所示:
一般,就这么简单。掌握以下核心思路,编写测试将不再可怕:
输入 -> 预期输出 -> 断言结果。
我们将创建一个简单的 Javascript 函数代码,用于 2 个数字的加法,并为其编写相应的基于 Jest 的测试
const sum = (a, b) => a + b;
现在,为了测试在同一个文件夹中创建一个测试文件,命名为 test.spec.js
,这特殊的后缀是 Jest 的约定,用于查找所有的测试文件。我们还将导入被测函数,以便执行测试中的代码。Jest 测试遵循 BDD 风格的测试,每个测试都应该有一个主要的 test
测试块,并且可以有多个测试块,现在可以为 sum
方法编写测试块,这里我们编写一个测试来添加 2 个数字并验证预期结果。我们将提供数字为 1 和 2,并期望输出 3。
test
它需要两个参数:一个用于描述测试块的字符串,以及一个用于包装实际测试的回调函数。expect
包装目标函数,并结合匹配器 toBe
用于检查函数计算结果是否符合预期。
这是完整的测试:
test("sum test", () => {
expect(sum(1, 2)).toBe(3);
});
我们观察上面代码有发现有两点:
test
块是单独的测试块,它拥有描述和划分范围的作用,即它代表我们要为该计算函数 sum
所编写测试的通用容器。expect
是一个断言,该语句使用输入 1 和 2 调用被测函数中的 sum
方法,并期望输出 3。toBe
是一个匹配器,用于检查期望值,如果不符合预期结果则应该抛出异常。测试块其实并不复杂,最简单的实现不过如下,我们需要把测试包装实际测试的回调函数存起来,所以封装一个 dispatch
方法接收命令类型和回调函数:
const test = (name, fn) => {
dispatch({ type: "ADD_TEST", fn, name });
};
我们需要在全局创建一个 state
保存测试的回调函数,测试的回调函数使用一个数组存起来。
global["STATE_SYMBOL"] = {
testBlock: [],
};
dispatch
方法此时只需要甄别对应的命令,并把测试的回调函数存进全局的 state
即可。
const dispatch = (event) => {
const { fn, type, name } = event;
switch (type) {
case "ADD_TEST":
const { testBlock } = global["STATE_SYMBOL"];
testBlock.push({ fn, name });
break;
}
};
断言库也实现也很简单,只需要封装一个函数暴露匹配器方法满足以下公式即可:
expect(A).toBe(B)
这里我们实现 toBe
这个常用的方法,当结果和预期不相等,抛出错误即可:
const expect = (actual) => ({
toBe(expected) {
if (actual !== expected) {
throw new Error(`${actual} is not equal to ${expected}`);
}
}
};
实际在测试块中会使用 try/catch
捕获错误,并打印堆栈信息方面定位问题。
在简单情况下,我们也可以使用 Node 自带的 assert
模块进行断言,当然还有很多更复杂的断言方法,本质上原理都差不多。
编写完测试之后,我们则需要在命令行中输入命令运行单测,正常情况下,命令类似如下:
node jest xxx.spec.js
这里本质是解析命令行的参数。
const testPath = process.argv.slice(2)[0];
const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString();
复杂的情况可能还需要读取本地的 Jest 配置文件的参数来更改执行环境等,Jest 在这里使用了第三方库 yargs``execa
和 chalk
等来解析执行并打印命令。
在复杂的测试场景,我们一定绕不开一个 Jest 术语:模拟(mock
)
在 Jest 文档中,我们可以找到 Jest 对模拟有以下描述:”模拟函数通过抹去函数的实际实现、捕获对函数的调用,以及在这些调用中传递的参数,使测试代码之间的链接变得容易“
简而言之,可以通过将以下代码片段分配给函数或依赖项来创建模拟:
jest.mock("fs", {
readFile: jest.fn(() => "wscats"),
});
这是一个简单模拟的示例,模拟了 fs 模块 readFile 函数在测试特定业务逻辑的返回值。
接下来我们就要研究一下如何实现,首先是 jest.mock
,它第一个参数接受的是模块名或者模块路径,第二个参数是该模块对外暴露方法的具体实现
const jest = {
mock(mockPath, mockExports = {}) {
const path = require.resolve(mockPath, { paths: ["."] });
require.cache[path] = {
id: path,
filename: path,
loaded: true,
exports: mockExports,
};
},
};
我们方案其实跟上面的 test
测试块实现一致,只需要把具体的实现方法找一个地方存起来即可,等后续真正使用改模块的时候替换掉即可,所以我们把它存到 require.cache
里面,当然我们也可以存到全局的 state
中。
而 jest.fn
的实现也不难,这里我们使用一个闭包 mockFn
把替换的函数和参数给存起来,方便后续测试检查和统计调用数据。
const jest = {
fn(impl = () => {}) {
const mockFn = (...args) => {
mockFn.mock.calls.push(args);
return impl(...args);
};
mockFn.originImpl = impl;
mockFn.mock = { calls: [] };
return mockFn;
},
};
有些同学可能留意到了,在测试框架中,我们并不需要手动引入 test
、expect
和 jest
这些函数,每个测试文件可以直接使用,所以我们这里需要创造一个注入这些方法的运行环境。
由于单测文件运行时候需要作用域隔离。所以在设计上测试引擎是跑在 node 全局作用域下,而测试文件的代码则跑在 node 环境里的 vm 虚拟机局部作用域中。
global
context
两个作用域通过 dispatch
方法实现通信。
dispatch
在 vm 局部作用域下收集测试块、生命周期和测试报告信息到 node 全局作用域 STATE_SYMBOL
中,所以 dispatch
主要涉及到以下各种通信类型:
测试块
ADD_TEST
生命周期
BEFORE_EACH
BEFORE_ALL
AFTER_EACH
AFTER_ALL
测试报告
COLLECT_REPORT
既然万事俱备只欠东风,我们只需要给 V8 虚拟机注入测试所需的方法,即注入测试局部作用域即可。
const context = {
console: console.Console({ stdout: process.stdout, stderr: process.stderr }),
jest,
expect,
require,
test: (name, fn) => dispatch({ type: "ADD_TEST", fn, name }),
};
注入完作用域,我们就可以让测试文件的代码在 V8 虚拟机中跑起来,这里我传入的代码是已经处理成字符串的代码,Jest 这里会在这里做一些代码加工,安全处理和 SourceMap 缝补等操作,我们示例就不需要搞那么复杂了。
vm.runInContext(code, context);
在代码执行的前后可以使用时间差算出单测的运行时间,Jest 还会在这里预评估单测文件的大小数量等,决定是否启用 Worker 来优化执行速度
const start = new Date();
const end = new Date();
log("\x1b[32m%s\x1b[0m", `Time: ${end - start} ms`);
V8 虚拟机执行完毕之后,全局的 state
就会收集到测试块中所有包装好的测试回调函数,我们最后只需要把所有的这些回调函数遍历取出来,并执行。
testBlock.forEach(async (item) => {
const { fn, name } = item;
await fn.apply(this);
});
我们还可以在单测执行过程中加入生命周期,例如 beforeEach
,afterEach
,afterAll
和 beforeAll
等钩子函数。
在上面的基础架构上增加钩子函数,其实就是在执行 test 的每个过程中注入对应回调函数,比如 beforeEach
就是放在 testBlock
遍历执行测试函数前,afterEach
就是放在 testBlock
遍历执行测试函数后,非常的简单,只需要位置放对就可以暴露任何时期的钩子函数。
testBlock.forEach(async (item) => {
const { fn, name } = item;
beforeEachBlock.forEach(async (beforeEach) => await beforeEach());
await fn.apply(this);
afterEachBlock.forEach(async (afterEach) => await afterEach());
});
而 beforeAll
和 afterAll
就可以放在,testBlock
所有测试运行完毕前和后。
beforeAllBlock.forEach(async (beforeAll) => await beforeAll());
testBlock.forEach(async (item) => {})
afterAllBlock.forEach(async (afterAll) => await afterAll());
当单测执行完后,可以收集成功和捕捉错误的信息集,
try {
dispatch({ type: "COLLECT_REPORT", name, pass: 1 });
log("\x1b[32m%s\x1b[0m", `√ ${name} passed`);
} catch (error) {
dispatch({ type: "COLLECT_REPORT", name, pass: 0 });
log("\x1b[32m%s\x1b[0m", `× ${name} error`);
}
然后劫持 log
的输出流,让详细的结果打印在终端上,也可以配合 IO 模块在本地生成报告。
const { reports } = global["STATE_SYMBOL"];
const pass = reports.reduce((pre, next) => pre.pass + next.pass);
log("\x1b[32m%s\x1b[0m", `All Tests: ${pass}/${reports.length} passed`);
至此,我们就实现了一个简单的 Jest 测试框架的核心部分,以上部分基本实现了测试块、断言、匹配器、CLI配置、函数模拟、使用虚拟机及作用域和生命周期钩子函数等,我们可以在此基础上,丰富断言方法,匹配器和支持参数配置,当然实际 Jest 的实现会更复杂,我只提炼了比较关键的部分,所以附上本人读 Jest 源码的个人笔记供大家参考。
下载 Jest 源码,根目录下执行
yarn
npm run build
它本质跑的是 script 文件夹的两个文件 build.js 和 buildTs.js:
"scripts": {
"build": "yarn build:js && yarn build:ts",
"build:js": "node ./scripts/build.js",
"build:ts": "node ./scripts/buildTs.js",
}
build.js 本质上是使用了 babel 库,在 package/xxx 包新建一个 build 文件夹,然后使用 transformFileSync 把文件生成到 build 文件夹里面:
const transformed = babel.transformFileSync(file, options).code;
而 buildTs.js 本质上是使用了 tsc 命令,把 ts 文件编译到 build 文件夹中,使用 execa 库来执行命令:
const args = ["tsc", "-b", ...packagesWithTs, ...process.argv.slice(2)];
await execa("yarn", args, { stdio: "inherit" });
image
执行成功会显示如下,它会帮你把 packages 文件夹下的所有文件 js 文件和 ts 文件编译到所在目录的 build 文件夹下:
image
接下来我们可以启动 jest 的命令:
npm run jest
# 等价于
# node ./packages/jest-cli/bin/jest.js
这里可以根据传入的不同参数做解析处理,比如:
npm run jest -h
node ./packages/jest-cli/bin/jest.js /path/test.spec.js
就会执行 jest.js
文件,然后进入到 build/cli
文件中的 run 方法,run 方法会对命令中各种的参数做解析,具体原理是 yargs 库配合 process.argv 实现
const importLocal = require("import-local");
if (!importLocal(__filename)) {
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "test";
}
require("../build/cli").run();
}
当获取各种命令参数后,就会执行 runCLI
核心的方法,它是 @jest/core -> packages/jest-core/src/cli/index.ts
库的核心方法。
import { runCLI } from "@jest/core";
const outputStream = argv.json || argv.useStderr ? process.stderr : process.stdout;
const { results, globalConfig } = await runCLI(argv, projects);
runCLI
方法中会使用刚才命令中解析好的传入参数 argv 来配合 readConfigs
方法读取配置文件的信息,readConfigs
来自于 packages/jest-config/src/index.ts
,这里会有 normalize 填补和初始化一些默认配置好的参数,它的默认参数在 packages/jest-config/src/Defaults.ts
文件中记录,比如:如果只运行 js 单测,会默认设置 require.resolve('jest-runner')
为运行单测的 runner,还会配合 chalk 库生成 outputStream 输出内容到控制台。
这里顺便提一下引入 jest 引入模块的原理思路,这里先会 require.resolve(moduleName)
找到模块的路径,并把路径存到配置里面,然后使用工具库 packages/jest-util/src/requireOrImportModule.ts
的 requireOrImportModule
方法调用封装好的原生 import/reqiure
方法配合配置文件中的路径把模块取出来。
const { globalConfig, configs, hasDeprecationWarnings } = await readConfigs(
argv,
projects
);
if (argv.debug) {
/*code*/
}
if (argv.showConfig) {
/*code*/
}
if (argv.clearCache) {
/*code*/
}
if (argv.selectProjects) {
/*code*/
}
jest-haste-map 用于获取项目中的所有文件以及它们之间的依赖关系,它通过查看 import/require
调用来实现这一点,从每个文件中提取它们并构建一个映射,其中包含每个文件及其依赖项,这里的 Haste 是 Facebook 使用的模块系统,它还有一个叫做 HasteContext 的东西,因为它有 HastFS(Haste 文件系统),HastFS 只是系统中文件的列表以及与之关联的所有依赖项,它是一种地图数据结构,其中键是路径,值是元数据,这里生成的 contexts
会一直被沿用到 onRunComplete
阶段。
const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream
);
_run10000
方法中会根据配置信息 globalConfig
和 configs
获取 contexts
,contexts
会存储着每个局部文件的配置信息和路径等,然后会带着回调函数 onComplete
,全局配置 globalConfig
和作用域 contexts
进入 runWithoutWatch
方法。
接下来会进入 packages/jest-core/src/runJest.ts
文件的 runJest
方法中,这里会使用传过来的 contexts
遍历出所有的单元测试并用数组保存起来。
let allTests: Array<Test> = [];
contexts.map(async (context, index) => {
const searchSource = searchSources[index];
const matches = await getTestPaths(
globalConfig,
searchSource,
outputStream,
changedFilesPromise && (await changedFilesPromise),
jestHooks,
filter
);
allTests = allTests.concat(matches.tests);
return { context, matches };
});
并使用 Sequencer
方法对单测进行排序
const Sequencer: typeof TestSequencer = await requireOrImportModule(
globalConfig.testSequencer
);
const sequencer = new Sequencer();
allTests = await sequencer.sort(allTests);
runJest
方法会调用一个关键的方法 packages/jest-core/src/TestScheduler.ts
的 scheduleTests
方法。
const results = await new TestScheduler(
globalConfig,
{ startRun },
testSchedulerContext
).scheduleTests(allTests, testWatcher);
scheduleTests
方法会做很多事情,会把 allTests
中的 contexts
收集到 contexts
中,把 duration
收集到 timings
数组中,并在执行所有单测前订阅四个生命周期:
接着把 contexts
遍历并用一个新的空对象 testRunners
做一些处理存起来,里面会调用 @jest/transform
提供的 createScriptTransformer
方法来处理引入的模块。
import { createScriptTransformer } from "@jest/transform";
const transformer = await createScriptTransformer(config);
const Runner: typeof TestRunner = interopRequireDefault(
transformer.requireAndTranspileModule(config.runner)
).default;
const runner = new Runner(this._globalConfig, {
changedFiles: this._context?.changedFiles,
sourcesRelatedToTestsInChangedFiles: this._context?.sourcesRelatedToTestsInChangedFiles,
});
testRunners[config.runner] = runner;
而 scheduleTests
方法会调用 packages/jest-runner/src/index.ts
的 runTests
方法。
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
return await (options.serial
? this._createInBandTestRun(tests, watcher, onStart, onResult, onFailure)
: this._createParallelTestRun(
tests,
watcher,
onStart,
onResult,
onFailure
));
}
最终 _createParallelTestRun
或者 _createInBandTestRun
方法里面:
_createParallelTestRun
里面会有一个 runTestInWorker
方法,这个方法顾名思义就是在 worker 里面执行单测。
image
_createInBandTestRun
里面会执行 packages/jest-runner/src/runTest.ts
一个核心方法 runTest
,而 runJest
里面就执行一个方法 runTestInternal
,这里面会在执行单测前准备非常多的东西,涉及全局方法改写和引入和导出方法的劫持。await this.eventEmitter.emit("test-file-start", [test]);
return runTest(
test.path,
this._globalConfig,
test.context.config,
test.context.resolver,
this._context,
sendMessageToJest
);
在 runTestInternal
方法中会使用 fs
模块读取文件的内容放入 cacheFS
,缓存起来方便以后快读读取,比如后面如果文件的内容是 json 就可以直接在 cacheFS
读取,也会使用 Date.now
时间差计算耗时。
const testSource = fs().readFileSync(path, "utf8");
const cacheFS = new Map([[path, testSource]]);
在 runTestInternal
方法中会引入 packages/jest-runtime/src/index.ts
,它会帮你缓存模块和读取模块并触发执行。
const runtime = new Runtime(
config,
environment,
resolver,
transformer,
cacheFS,
{
changedFiles: context?.changedFiles,
collectCoverage: globalConfig.collectCoverage,
collectCoverageFrom: globalConfig.collectCoverageFrom,
collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom,
coverageProvider: globalConfig.coverageProvider,
sourcesRelatedToTestsInChangedFiles: context?.sourcesRelatedToTestsInChangedFiles,
},
path
);
这里使用 @jest/console
包改写全局的 console,为了单测的文件代码块的 console 能顺利在 node 终端打印结果,配合 jest-environment-node
包,把全局的 environment.global
全部改写,方便后续在 vm 中能得到这些作用域的方法,本质上就是为 vm 的运行环境提供的作用域,为后续注入 global
提供便利,涉及到改写的 global
方法有如下:
testConsole
本质上是使用 node 的 console 改写,方便后续覆盖 vm 作用域里面的 console 方法
testConsole = new BufferedConsole();
const environment = new TestEnvironment(config, {
console: testConsole,
docblockPragmas,
testPath: path,
});
// 真正改写 console 地方的方法
setGlobal(environment.global, "console", testConsole);
runtime
主要用这两个方法加载模块,先判断是否 ESM 模块,如果是,使用 runtime.unstable_importModule
加载模块并运行该模块,如果不是,则使用 runtime.requireModule
加载模块并运行该模块。
const esm = runtime.unstable_shouldLoadAsEsm(path);
if (esm) {
await runtime.unstable_importModule(path);
} else {
runtime.requireModule(path);
}
紧接着 runTestInternal
中的 testFramework
会接受传入的 runtime 调用单测文件运行,testFramework
方法来自于一个名字比较有意思的库 packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts
,其中 legacy-code-todo-rewrite
意思为遗留代码待办事项重写,jest-circus
主要会把全局 global
的一些方法进行重写,涉及这几个:
image
这里调用单测前会在 jestAdapter
函数中,也就是上面提到的 runtime.requireModule
加载 xxx.spec.js
文件,这里执行之前已经使用 initialize
预设好了执行环境 globals
和 snapshotState
,并改写 beforeEach
,如果配置了 resetModules
,clearMocks
,resetMocks
,restoreMocks
和 setupFilesAfterEnv
则会分别执行下面几个方法:
当运行完 initialize
方法初始化之后,由于 initialize
改写了全局的 describe
和 test
等方法,这些方法都在 /packages/jest-circus/src/index.ts
这里改写,这里注意 test
方法里面有一个 dispatchSync
方法,这是一个关键的方法,这里会在全局维护一份 state
,dispatchSync
就是把 test
代码块里面的函数等信息存到 state
里面,dispatchSync
里面使用 name
配合 eventHandler
方法来修改 state
,这个思路非常像 redux 里面的数据流。
const test: Global.It = () => {
return (test = (testName, fn, timeout) => (testName, mode, fn, testFn, timeout) => {
return dispatchSync({
asyncError,
fn,
mode,
name: "add_test",
testName,
timeout,
});
});
};
而单测 xxx.spec.js
即 testPath 文件会在 initialize
之后会被引入并执行,注意这里引入就会执行这个单测,由于单测 xxx.spec.js
文件里面按规范写,会有 test
和 describe
等代码块,所以这个时候所有的 test
和 describe
接受的回调函数都会被存到全局的 state
里面。
const esm = runtime.unstable_shouldLoadAsEsm(testPath);
if (esm) {
await runtime.unstable_importModule(testPath);
} else {
runtime.requireModule(testPath);
}
这里的会先判断是否 esm 模块,如果是则使用 unstable_importModule
的方式引入,否则使用 requireModule
的方式引入,具体会进入下面吗这个函数。
this._loadModule(localModule, from, moduleName, modulePath, options, moduleRegistry);
\_loadModule 的逻辑只有三个主要部分
\_execModule 中会使用 babel 来转化 fs 读取到的源代码,这个 transformFile
就是 packages/jest-runtime/src/index.ts
的 transform
方法。
const transformedCode = this.transformFile(filename, options);
image
\_execModule 中会使用 createScriptFromCode
方法调用 node 的原生 vm 模块来真正的执行 js,vm 模块接受安全的源代码,并用 V8 虚拟机配合传入的上下文来立即执行代码或者延时执行代码,这里可以接受不同的作用域来执行同一份代码来运算出不同的结果,非常合适测试框架的使用,这里的注入的 vmContext 就是上面全局改写作用域包含 afterAll,afterEach,beforeAll,beforeEach,describe,it,test,所以我们的单测代码在运行的时候就会得到拥有注入作用域的这些方法。
const vm = require("vm");
const script = new vm().Script(scriptSourceCode, option);
const filename = module.filename;
const vmContext = this._environment.getVmContext();
script.runInContext(vmContext, {
filename,
});
image
当上面复写全局方法和保存好 state
之后,会进入到真正执行 describe
的回调函数的逻辑里面,在 packages/jest-circus/src/run.ts
的 run
方法里面,这里使用 getState
方法把 describe
代码块取出来,然后使用 _runTestsForDescribeBlock
执行这个函数,然后进入到 _runTest
方法,然后使用 _callCircusHook
执行前后的钩子函数,使用 _callCircusTest
执行。
const run = async (): Promise<Circus.RunResult> => {
const { rootDescribeBlock } = getState();
await dispatch({ name: "run_start" });
await _runTestsForDescribeBlock(rootDescribeBlock);
await dispatch({ name: "run_finish" });
return makeRunResult(getState().rootDescribeBlock, getState().unhandledErrors);
};
const _runTest = async (test, parentSkipped) => {
// beforeEach
// test 函数块,testContext 作用域
await _callCircusTest(test, testContext);
// afterEach
};
这是钩子函数实现的核心位置,也是 Jest 功能的核心要素。
希望本文能够帮助大家理解 Jest 测试框架的核心实现和原理,感谢大家耐心的阅读,如果文章和笔记能带您一丝帮助或者启发
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8