深入淺出 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
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
這些函數,每個測試文件可以直接使用,所以我們這里需要創造一個注入這些方法的運行環境。
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`);
}
});
鈎子函數
我們還可以在單測執行過程中加入生命周期,例如 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());
至此,我們就實現了一個簡單的測試框架了,我們可以在此基礎上,豐富斷言方法,匹配器和支持參數配置,下面附讀源碼的個人筆記。
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.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 使用的模塊系統,它還有一個叫做 HasteContext 的東西,因為它有 HastFS(Haste 文件系統),HastFS 只是系統中文件的列表以及與之關聯的所有依賴項,它是一種地圖數據結構,其中鍵是路徑,值是元數據,這里生成的 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
數組中,並在執行所有單測前訂閱四個生命周期:
- 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 里面執行單測。
_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
預設好了執行環境 globals
和 snapshotState
,並改寫 beforeEach
,如果配置了 resetModules
,clearMocks
,resetMocks
,restoreMocks
和 setupFilesAfterEnv
則會分別執行下面幾個方法:
- 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
之后會被引入並執行,注意這里引入就會執行這個單測,由於單測 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);
}
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.ts
的 transform
方法。
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.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 測試框架的核心實現和原理,感謝大家耐心的閱讀,如果文章和筆記能帶您一絲幫助或者啟發,請不要吝嗇你的 Star 和 Fork,文章同步持續更新,你的肯定是我前進的最大動力 😁