萬字詳文:徹底搞懂 Jest 單元測試框架
點擊上方 前端瓶子君,關注公眾號
回復算法,加入前端編程面試算法每日一題群

什么是Jest
測試意味著什么
我怎么知道要測試什么
測試塊,斷言和匹配器
如何實現測試塊
如何實現斷言和匹配器
CLI 和配置
模擬
怎么模擬一個函數 執(zhí)行環(huán)境
作用域隔離 V8 虛擬機 運行單測回調 鉤子函數 生成報告
jest-cli
jest-config
jest-haste-map
jest-runner
jest-environment-node
jest-circus
jest-runtime
最后&源碼
徹底搞懂 Jest 單元測試框架
本文主要給大家深入了解 Jest 背后的運行原理,并從零開始簡單實現一個 Jest 單元測試的框架,方便了解單元測試引擎是如何工作的,Jest 編寫單測相信我們已經很熟悉了,但 Jest 是如何工作的我們可能還很陌生,那讓我們一起走進 Jest 內心,一同探究單元測試引擎是如何工作的。
先附上 Jest 核心引擎的代碼實現給有需要的同學,歡迎關注和交流:https://github.com/Wscats/jest-tutorial
什么是 Jest
Jest 是 Facebook 開發(fā)的 Javascript 測試框架,用于創(chuàng)建、運行和編寫測試的 JavaScript 庫。
Jest 作為 NPM 包發(fā)布,可以安裝并運行在任何 JavaScript 項目中。Jest 是目前前端最流行的測試庫之一。
測試意味著什么
在技術術語中,測試意味著檢查我們的代碼是否滿足某些期望。例如:一個名為求和(sum)函數應該返回給定一些運算結果的預期輸出。
有許多類型的測試,很快你就會被術語淹沒,但長話短說的測試分為三大類:
單元測試 集成測試 E2E 測試
我怎么知道要測試什么
在測試方面,即使是最簡單的代碼塊也可能使初學者也可能會迷惑。最常見的問題是“我怎么知道要測試什么?”。
如果您正在編寫網頁,一個好的出發(fā)點是測試應用程序的每個頁面和每個用戶交互。但是網頁其實也需要測試的函數和模塊等代碼單元組成。
大多數時候有兩種情況:
你繼承遺留代碼,其自帶沒有測試 你必須憑空實現一個新功能
那該怎么辦?對于這兩種情況,你可以通過將測試視為:檢查該函數是否產生預期結果。最典型的測試流程如下所示:
導入要測試的函數 給函數一個輸入 定義期望的輸出 檢查函數是否產生預期的輸出
一般,就這么簡單。掌握以下核心思路,編寫測試將不再可怕:
輸入 -> 預期輸出 -> 斷言結果。
測試塊,斷言和匹配器
我們將創(chuàng)建一個簡單的 Javascript 函數代碼,用于 2 個數字的加法,并為其編寫相應的基于 Jest 的測試
const sum = (a, b) => a + b;
現在,為了測試在同一個文件夾中創(chuàng)建一個測試文件,命名為 test.spec.js,這特殊的后綴是 Jest 的約定,用于查找所有的測試文件。我們還將導入被測函數,以便執(zhí)行測試中的代碼。Jest 測試遵循 BDD 風格的測試,每個測試都應該有一個主要的 test 測試塊,并且可以有多個測試塊,現在可以為 sum 方法編寫測試塊,這里我們編寫一個測試來添加 2 個數字并驗證預期結果。我們將提供數字為 1 和 2,并期望輸出 3。
test 它需要兩個參數:一個用于描述測試塊的字符串,以及一個用于包裝實際測試的回調函數。expect 包裝目標函數,并結合匹配器 toBe 用于檢查函數計算結果是否符合預期。
這是完整的測試:
test("sum test", () => {
expect(sum(1, 2)).toBe(3);
});
我們觀察上面代碼有發(fā)現有兩點:
test塊是單獨的測試塊,它擁有描述和劃分范圍的作用,即它代表我們要為該計算函數sum所編寫測試的通用容器。expect是一個斷言,該語句使用輸入 1 和 2 調用被測函數中的sum方法,并期望輸出 3。toBe是一個匹配器,用于檢查期望值,如果不符合預期結果則應該拋出異常。
如何實現測試塊
測試塊其實并不復雜,最簡單的實現不過如下,我們需要把測試包裝實際測試的回調函數存起來,所以封裝一個 dispatch 方法接收命令類型和回調函數:
const test = (name, fn) => {
dispatch({ type: "ADD_TEST", fn, name });
};
我們需要在全局創(chuàng)建一個 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 模塊進行斷言,當然還有很多更復雜的斷言方法,本質上原理都差不多。
CLI 和配置
編寫完測試之后,我們則需要在命令行中輸入命令運行單測,正常情況下,命令類似如下:
node jest xxx.spec.js
這里本質是解析命令行的參數。
const testPath = process.argv.slice(2)[0];
const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString();
復雜的情況可能還需要讀取本地的 Jest 配置文件的參數來更改執(zhí)行環(huán)境等,Jest 在這里使用了第三方庫 yargs execa 和 chalk 等來解析執(zhí)行并打印命令。
模擬
在復雜的測試場景,我們一定繞不開一個 Jest 術語:模擬(mock)
在 Jest 文檔中,我們可以找到 Jest 對模擬有以下描述:”模擬函數通過抹去函數的實際實現、捕獲對函數的調用,以及在這些調用中傳遞的參數,使測試代碼之間的鏈接變得容易“
簡而言之,可以通過將以下代碼片段分配給函數或依賴項來創(chuàng)建模擬:
jest.mock("fs", {
readFile: jest.fn(() => "wscats"),
});
這是一個簡單模擬的示例,模擬了 fs 模塊 readFile 函數在測試特定業(yè)務邏輯的返回值。
怎么模擬一個函數
接下來我們就要研究一下如何實現,首先是 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 測試塊實現一致,只需要把具體的實現方法找一個地方存起來即可,等后續(xù)真正使用改模塊的時候替換掉即可,所以我們把它存到 require.cache 里面,當然我們也可以存到全局的 state 中。
而 jest.fn 的實現也不難,這里我們使用一個閉包 mockFn 把替換的函數和參數給存起來,方便后續(xù)測試檢查和統(tǒng)計調用數據。
const jest = {
fn(impl = () => {}) {
const mockFn = (...args) => {
mockFn.mock.calls.push(args);
return impl(...args);
};
mockFn.originImpl = impl;
mockFn.mock = { calls: [] };
return mockFn;
},
};
執(zhí)行環(huán)境
有些同學可能留意到了,在測試框架中,我們并不需要手動引入 test、expect 和 jest 這些函數,每個測試文件可以直接使用,所以我們這里需要創(chuàng)造一個注入這些方法的運行環(huán)境。
作用域隔離
由于單測文件運行時候需要作用域隔離。所以在設計上測試引擎是跑在 node 全局作用域下,而測試文件的代碼則跑在 node 環(huán)境里的 vm 虛擬機局部作用域中。
全局作用域 global局部作用域 context
兩個作用域通過 dispatch 方法實現通信。
dispatch 在 vm 局部作用域下收集測試塊、生命周期和測試報告信息到 node 全局作用域 STATE_SYMBOL 中,所以 dispatch 主要涉及到以下各種通信類型:
測試塊
ADD_TEST生命周期
BEFORE_EACHBEFORE_ALLAFTER_EACHAFTER_ALL測試報告
COLLECT_REPORT
V8 虛擬機
既然萬事俱備只欠東風,我們只需要給 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);
在代碼執(zhí)行的前后可以使用時間差算出單測的運行時間,Jest 還會在這里預評估單測文件的大小數量等,決定是否啟用 Worker 來優(yōu)化執(zhí)行速度
const start = new Date();
const end = new Date();
log("\x1b[32m%s\x1b[0m", `Time: ${end - start} ms`);
運行單測回調
V8 虛擬機執(zhí)行完畢之后,全局的 state 就會收集到測試塊中所有包裝好的測試回調函數,我們最后只需要把所有的這些回調函數遍歷取出來,并執(zhí)行。
testBlock.forEach(async (item) => {
const { fn, name } = item;
await fn.apply(this);
});
鉤子函數
我們還可以在單測執(zhí)行過程中加入生命周期,例如 beforeEach,afterEach,afterAll 和 beforeAll 等鉤子函數。
在上面的基礎架構上增加鉤子函數,其實就是在執(zhí)行 test 的每個過程中注入對應回調函數,比如 beforeEach 就是放在 testBlock 遍歷執(zhí)行測試函數前,afterEach 就是放在 testBlock 遍歷執(zhí)行測試函數后,非常的簡單,只需要位置放對就可以暴露任何時期的鉤子函數。
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());
生成報告
當單測執(zhí)行完后,可以收集成功和捕捉錯誤的信息集,
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-cli
下載 Jest 源碼,根目錄下執(zhí)行
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 庫來執(zhí)行命令:
const args = ["tsc", "-b", ...packagesWithTs, ...process.argv.slice(2)];
await execa("yarn", args, { stdio: "inherit" });

執(zhí)行成功會顯示如下,它會幫你把 packages 文件夾下的所有文件 js 文件和 ts 文件編譯到所在目錄的 build 文件夾下:

接下來我們可以啟動 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
就會執(zhí)行 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();
}
jest-config
當獲取各種命令參數后,就會執(zhí)行 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 方法配合配置文件中的路徑把模塊取出來。
globalConfig 來自于 argv 的配置 configs 來自于 jest.config.js 的配置
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
jest-haste-map 用于獲取項目中的所有文件以及它們之間的依賴關系,它通過查看 import/require 調用來實現這一點,從每個文件中提取它們并構建一個映射,其中包含每個文件及其依賴項,這里的 Haste 是 Facebook 使用的模塊系統(tǒng),它還有一個叫做 HasteContext 的東西,因為它有 HastFS(Haste 文件系統(tǒng)),HastFS 只是系統(tǒng)中文件的列表以及與之關聯的所有依賴項,它是一種地圖數據結構,其中鍵是路徑,值是元數據,這里生成的 contexts 會一直被沿用到 onRunComplete 階段。
const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream
);
jest-runner
_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 數組中,并在執(zhí)行所有單測前訂閱四個生命周期:
test-file-start test-file-success test-file-failure test-case-result
接著把 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 里面執(zhí)行單測。

_createInBandTestRun里面會執(zhí)行packages/jest-runner/src/runTest.ts一個核心方法runTest,而runJest里面就執(zhí)行一個方法runTestInternal,這里面會在執(zhí)行單測前準備非常多的東西,涉及全局方法改寫和引入和導出方法的劫持。
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,它會幫你緩存模塊和讀取模塊并觸發(fā)執(zhí)行。
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-environment-node
這里使用 @jest/console 包改寫全局的 console,為了單測的文件代碼塊的 console 能順利在 node 終端打印結果,配合 jest-environment-node 包,把全局的 environment.global 全部改寫,方便后續(xù)在 vm 中能得到這些作用域的方法,本質上就是為 vm 的運行環(huán)境提供的作用域,為后續(xù)注入 global 提供便利,涉及到改寫的 global 方法有如下:
global.global global.clearInterval global.clearTimeout global.setInterval global.setTimeout global.Buffer global.setImmediate global.clearImmediate global.Uint8Array global.TextEncoder global.TextDecoder global.queueMicrotask global.AbortController
testConsole 本質上是使用 node 的 console 改寫,方便后續(xù)覆蓋 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);
}
jest-circus
緊接著 runTestInternal 中的 testFramework 會接受傳入的 runtime 調用單測文件運行,testFramework 方法來自于一個名字比較有意思的庫 packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts,其中 legacy-code-todo-rewrite 意思為遺留代碼待辦事項重寫,jest-circus 主要會把全局 global 的一些方法進行重寫,涉及這幾個:
afterAll afterEach beforeAll beforeEach describe it test

這里調用單測前會在 jestAdapter 函數中,也就是上面提到的 runtime.requireModule 加載 xxx.spec.js 文件,這里執(zhí)行之前已經使用 initialize 預設好了執(zhí)行環(huán)境 globals 和 snapshotState,并改寫 beforeEach,如果配置了 resetModules,clearMocks,resetMocks,restoreMocks 和 setupFilesAfterEnv 則會分別執(zhí)行下面幾個方法:
runtime.resetModules runtime.clearAllMocks runtime.resetAllMocks runtime.restoreAllMocks runtime.requireModule 或者 runtime.unstable_importModule
當運行完 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 之后會被引入并執(zhí)行,注意這里引入就會執(zhí)行這個單測,由于單測 xxx.spec.js 文件里面按規(guī)范寫,會有 test 和 describe 等代碼塊,所以這個時候所有的 test 和 describe 接受的回調函數都會被存到全局的 state 里面。
const esm = runtime.unstable_shouldLoadAsEsm(testPath);
if (esm) {
await runtime.unstable_importModule(testPath);
} else {
runtime.requireModule(testPath);
}
jest-runtime
這里的會先判斷是否 esm 模塊,如果是則使用 unstable_importModule 的方式引入,否則使用 requireModule 的方式引入,具體會進入下面嗎這個函數。
this._loadModule(localModule, from, moduleName, modulePath, options, moduleRegistry);
\_loadModule 的邏輯只有三個主要部分
判斷是否 json 后綴文件,執(zhí)行 readFile 讀取文本,用 transformJson 和 JSON.parse 轉格輸出內容。 判斷是否 node 后綴文件,執(zhí)行 require 原生方法引入模塊。 不滿足上述兩個條件的文件,執(zhí)行 \_execModule 執(zhí)行模塊。
\_execModule 中會使用 babel 來轉化 fs 讀取到的源代碼,這個 transformFile 就是 packages/jest-runtime/src/index.ts 的 transform 方法。
const transformedCode = this.transformFile(filename, options);

\_execModule 中會使用 createScriptFromCode 方法調用 node 的原生 vm 模塊來真正的執(zhí)行 js,vm 模塊接受安全的源代碼,并用 V8 虛擬機配合傳入的上下文來立即執(zhí)行代碼或者延時執(zhí)行代碼,這里可以接受不同的作用域來執(zhí)行同一份代碼來運算出不同的結果,非常合適測試框架的使用,這里的注入的 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,
});

當上面復寫全局方法和保存好 state 之后,會進入到真正執(zhí)行 describe 的回調函數的邏輯里面,在 packages/jest-circus/src/run.ts 的 run 方法里面,這里使用 getState 方法把 describe 代碼塊取出來,然后使用 _runTestsForDescribeBlock 執(zhí)行這個函數,然后進入到 _runTest 方法,然后使用 _callCircusHook 執(zhí)行前后的鉤子函數,使用 _callCircusTest 執(zhí)行。
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 測試框架的核心實現和原理,感謝大家耐心的閱讀,如果文章和筆記能帶您一絲幫助或者啟發(fā),請不要吝嗇你的 Star 和 Fork,文章同步持續(xù)更新,你的肯定是我前進的最大動力 ??
https://github.com/Wscats/jest-tutorial
關于本文
來源:wscats
https://segmentfault.com/a/1190000040539268
