English Version | 中文版

深入淺出 Jest 框架的實現原理

https://github.com/Wscats/jest-tutorial

什麼是 Jest

Jest 是 Facebook 開發的 Javascript 測試框架,用於建立、執行和編寫測試的 JavaScript 庫。

Jest 作為 NPM 包釋出,可以安裝並執行在任何 JavaScript 專案中。Jest 是目前前端最流行的測試庫之一。

測試意味著什麼

在技 ​​ 術術語中,測試意味著檢查我們的程式碼是否滿足某些期望。例如:一個名為求和(sum)函式應該返回給定一些運算結果的預期輸出。

有許多型別的測試,很快你就會被術語淹沒,但長話短說的測試分為三大類:

  • 單元測試
  • 整合測試
  • E2E 測試

我怎麼知道要測試什麼

在測試方面,即使是最簡單的程式碼塊也可能使初學者也可能會迷惑。最常見的問題是“我怎麼知道要測試什麼?”。

如果您正在編寫網頁,一個好的出發點是測試應用程式的每個頁面和每個使用者互動。但是網頁其實也需要測試的函式和模組等程式碼單元組成。

大多數時候有兩種情況:

  • 你繼承遺留程式碼,其自帶沒有測試
  • 你必須憑空實現一個新功能

那該怎麼辦?對於這兩種情況,你可以通過將測試視為:檢查該函式是否產生預期結果。最典型的測試流程如下所示:

  • 匯入要測試的函式
  • 給函式一個輸入
  • 定義期望的輸出
  • 檢查函式是否產生預期的輸出

一般,就這麼簡單。掌握以下核心思路,編寫測試將不再可怕:

輸入 -> 預期輸出 -> 斷言結果。

測試塊,斷言和匹配器

我們將建立一個簡單的 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 模組進行斷言,當然還有很多更復雜的斷言方法,本質上原理都差不多。

CLI 和配置

編寫完測試之後,我們則需要在命令列中輸入命令執行單測,正常情況下,命令類似如下:

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 execachalk 等來解析執行並列印命令。

模擬

在複雜的測試場景,我們一定繞不開一個 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;
},
};

執行環境

有些同學可能留意到了,在測試框架中,我們並不需要手動引入 testexpectjest 這些函式,每個測試檔案可以直接使用,所以我們這裡需要創造一個注入這些方法的執行環境。

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);

在程式碼執行的前後可以使用時間差算出單測的執行時間,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;
try {
await fn.apply(this);
log("\x1b[32m%s\x1b[0m", `√ ${name} passed`);
} catch {
log("\x1b[32m%s\x1b[0m", `× ${name} error`);
}
});

鉤子函式

我們還可以在單測執行過程中加入生命週期,例如 beforeEachafterEachafterAllbeforeAll 等鉤子函式。

在上面的基礎架構上增加鉤子函式,其實就是在執行 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());
});

beforeAllafterAll 就可以放在,testBlock 所有測試執行完畢前和後。

+beforeAllBlock.forEach(async (beforeAll) => await beforeAll());
testBlock.forEach(async (item) => {}) +
afterAllBlock.forEach(async (afterAll) => await afterAll());

至此,我們就實現了一個簡單的測試框架了,我們可以在此基礎上,豐富斷言方法,匹配器和支援引數配置,下面附讀原始碼的個人筆記。

jest-cli

下載 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" });

執行成功會顯示如下,它會幫你把 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

就會執行 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

當獲取各種命令引數後,就會執行 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.tsrequireOrImportModule 方法呼叫封裝好的原生 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 使用的模組系統,它還有一個叫做 HasteContext 的東西,因為它有 HastFS(Haste 檔案系統),HastFS 只是系統中檔案的列表以及與之關聯的所有依賴項,它是一種地圖資料結構,其中鍵是路徑,值是元資料,這裡生成的 contexts 會一直被沿用到 onRunComplete 階段。

const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream
);

jest-runner

_run10000 方法中會根據配置資訊 globalConfigconfigs 獲取 contextscontexts 會儲存著每個區域性檔案的配置資訊和路徑等,然後會帶著回撥函式 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.tsscheduleTests 方法。

const results = await new TestScheduler(
globalConfig,
{ startRun },
testSchedulerContext
).scheduleTests(allTests, testWatcher);

scheduleTests 方法會做很多事情,會把 allTests 中的 contexts 收集到 contexts 中,把 duration 收集到 timings 陣列中,並在執行所有單測前訂閱四個生命週期:

  • 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.tsrunTests 方法。

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 裡面執行單測。

  • _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 中能得到這些作用域的方法。

// 本質上是使用 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);
}

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 檔案,這裡執行之前已經使用 initialize 預設好了執行環境 globalssnapshotState,並改寫 beforeEach,如果配置了 resetModulesclearMocksresetMocksrestoreMockssetupFilesAfterEnv 則會分別執行下面幾個方法:

  • runtime.resetModules
  • runtime.clearAllMocks
  • runtime.resetAllMocks
  • runtime.restoreAllMocks
  • runtime.requireModule 或者 runtime.unstable_importModule

當執行完 initialize 方法初始化之後,由於 initialize 改寫了全域性的 describetest 等方法,這些方法都在 /packages/jest-circus/src/index.ts 這裡改寫,這裡注意 test 方法裡面有一個 dispatchSync 方法,這是一個關鍵的方法,這裡會在全域性維護一份 statedispatchSync 就是把 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 檔案裡面按規範寫,會有 testdescribe 等程式碼塊,所以這個時候所有的 testdescribe 接受的回撥函式都會被存到全域性的 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 字尾檔案,執行 readFile 讀取文字,用 transformJson 和 JSON.parse 轉格輸出內容。
  • 判斷是否 node 字尾檔案,執行 require 原生方法引入模組。
  • 不滿足上述兩個條件的檔案,執行 _execModule 執行模組。

_execModule 中會使用 babel 來轉化 fs 讀取到的原始碼,這個 transformFile 就是 packages/jest-runtime/src/index.tstransform 方法。

const transformedCode = this.transformFile(filename, options);

_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,
});

當上面複寫全域性方法和儲存好 state 之後,會進入到真正執行 describe 的回撥函式的邏輯裡面,在 packages/jest-circus/src/run.tsrun 方法裡面,這裡使用 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 測試框架的核心實現和原理,感謝大家耐心的閱讀,如果文章和筆記能帶您一絲幫助或者啟發,請不要吝嗇你的 Star 和 Fork,文章同步持續更新,你的肯定是我前進的最大動力