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


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

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

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 方法。
image

接下來會進入 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 里面執行單測。

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

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

image

這里調用單測前會在 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);

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.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,文章同步持續更新,你的肯定是我前進的最大動力 😁


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM