Jestを読む
リポジトリ
ディレクトリ構造
// 1階層目のディレクトリ
.
├── benchmarks
├── docs
├── e2e
├── examples
├── packages
├── scripts
└── website
以下がmonorepoの対象。
動作確認
jest
パッケージ自体はmonorepo配下で管理されている
なのでjestの実装を変更して(例えばconsole.log
の追加など)、動作確認をする場合は以下の作業をする
# リポジトリのルートで変更分をビルド
yarn watch
# 適当なテストケースを実行
# yarn jest -- {任意に作成したテストファイルのパス}
yarn jest -- packages/jest-util/src/__tests__/nus3.test.ts
もしくはmonorepoでjest自体は直接リポジトリ内のパッケージを見ているのでexamples配下のテストを実行することでも動作確認ができる。
examples/getting-started
配下でyarn test
を実行するなど
packages
jest
を実行すると、monorepoで管理されたpackages
配下のパッケージが実行されるので、依存するパッケージを出てくる順番でみてみる
パッケージの実行順
実際にテストを実行している部分を、パッケージごとにみてみる
jest
を実行した時に、呼ばれるパッケージ
2024/04/09まで読んでみてのイメージ
- テストファイルと、その対象のファイル、そのほか依存しているモジュールに対してbabelでトランスパイルし、requireしておく
- テストファイルの中で定義されたテストコードを実行する
- テストコードはtry catchの中で実行し、実行したテストコードがerrorをthrowしたら、その呼び出しもとでテストは落ちたと判定するように
- テスト結果を格納する
jest
jest
のbin
は"./bin/jest.js"
に実装されている
"./bin/jest.js"
ではjest-cli
をrequire
jest-cli
'jest-cli/bin/jest'
では、jest-cli/src/run.tsのrun()
が実行されている
このrun()
の中で実際にテストを実行している部分は@jest/core
のrunCLI()
っぽい
@jest/core
runCLI()
runCLI()
の_run10000()
のコールバック引数でテスト結果を変数に入れてそう
同じファイル上に_run10000()
が実装されている
このonComplete
が結果を変数に入れるコールバックで、runWithoutWatch()
の第4引数にそのまま渡されている
このonComplete
が引数として渡されたrunWithoutWatch()
では、runJest()
にそのまま渡している
関数名からjestを実行してそうだぞ
runJest()
は同一パッケージ(@jest/core
)内でexportされている関数
runJest()
に渡されたonComplete
はprocessResults()
で実行される
以下はprocessResults()
でonComplete
が実行されている箇所
動作確認してみると、ここではprocessResults()
の第一引数として渡されたrunResults
をonCompleteの第一引数に渡している
ということでテスト結果はprocessResults()
の第一引数であるresults
このresults
はawait scheduler.scheduleTests(allTests, testWatcher);
の返り値である
scheduler.scheduleTests
のscheduler
はcreateTestScheduler
で生成される
このscheduler
はTestScheduler
クラスのインスタンス
TestScheduler
クラスの中にscheduleTests
メソッドが定義されている
scheduleTests
はaggregatedResults
という変数を return してる
return される`aggregateResults`の中身
{
numFailedTestSuites: 0,
numFailedTests: 0,
numPassedTestSuites: 1,
numPassedTests: 1,
numPendingTestSuites: 0,
numPendingTests: 0,
numRuntimeErrorTestSuites: 0,
numTodoTests: 0,
numTotalTestSuites: 1,
numTotalTests: 1,
openHandles: [],
snapshot: {
added: 0,
didUpdate: false,
failure: false,
filesAdded: 0,
filesRemoved: 0,
filesRemovedList: [],
filesUnmatched: 0,
filesUpdated: 0,
matched: 0,
total: 0,
unchecked: 0,
uncheckedKeysByFile: [],
unmatched: 0,
updated: 0
},
startTime: 1711858222218,
success: true,
testResults: [
{
leaks: false,
numFailingTests: 0,
numPassingTests: 1,
numPendingTests: 0,
numTodoTests: 0,
openHandles: [],
perfStats: [Object],
skipped: false,
snapshot: [Object],
testFilePath: '${HOME}/jest/packages/jest-util/src/__tests__/nus3.test.ts',
testResults: [Array],
console: undefined,
displayName: undefined,
failureMessage: null,
testExecError: undefined,
coverage: undefined
}
],
wasInterrupted: false
}
をみるとtestResults
プロパティの中にテスト結果が格納されている。
このtestResults
の中にあるtestResults
プロパティを見ると次のようになっている。
`testResults`の中にある`testResults`プロパティの中身
[
{
"ancestorTitles": ["nus1"],
"duration": 3,
"failing": false,
"failureDetails": [],
"failureMessages": [],
"fullName": "nus1 nus1 testcase1",
"invocations": 1,
"location": null,
"numPassingAsserts": 1,
"retryReasons": [],
"startAt": 1711858647818,
"status": "passed",
"title": "nus1 testcase1"
}
]
最初のtestResults
がおそらくファイルごとの結果を配列で格納しており、その中のtestResults
がファイルの中にあるテストケースごとの結果を配列で格納している
aggregateResults
はcreateAggregatedResults
の返り値
createAggregatedResults
ではテスト結果の初期値を生成してそう
からのテスト結果を生成するのはmakeEmptyAggregatedTestResult
が作ってそう
makeEmptyAggregatedTestResult
をみるとAggregatedResult
型のオブジェクトを生成し、初期値を入れてる
このmakeEmptyAggregatedTestResult
は@jest/test-result
パッケージの持ち物
TestScheduler.scheduleTests
の中で定義されているonResult
この関数の中でaddResult()
を呼ぶことでAggregatedResult
にテスト結果を記してそう
実際に@jest/test-result
が提供するaddResult()
では、テスト結果を引数に受け取り AggregatedResult にテスト結果を追加している
addResult
の引数に渡すテスト結果は onResult
の引数に渡されるものでもある。実際にonResult
がTestScheduler.scheduleTests
の中で呼ばれているのは以下の 2 箇所
単純なテストファイルを手元で実行してみるとtestRunner.supportsEventEmitters
が true の場合の以下の処理が実行されていた
testRunner.on()
は実装されているパッケージがjest-runner
なので、そっちに記載する
@jest/test-result
makeEmptyAggregatedTestResult()
テスト結果の初期値入りオブジェクトを生成する関数。
TestScheduler.scheduleTests
の中で使用される
addResult()
テスト結果を受け取りAggregatedResultにその結果を追加する
jest-runner
TestRunner.on
TestScheduler.scheduleTests
の中でテストが実行されていそうな以下の部分で使われている
定義されている場所は以下
TestRunner.on
ではTestRunner
のプライベートメソッドであるthis.#eventEmitter.on
を実行してる
this.#eventEmitter
はemittery
というパッケージを require している
このemittery
は非同期でイベントの listner や emit がシンプルにできるライブラリっぽい
TestRunner.on
では第一引数で指定したイベント名の listner を登録しており、this.#eventEmitter.emit
で登録したイベントを実行してると推測
テスト結果が書き写されるイベント名はtest-file-success
の時で、このイベントをemitしているのは手元で動作確認すると、yarn jest -- packages/jest-util/src/__tests__/nus3.test.ts
のように対象のテストファイルを指定して実行する場合、#createInBandTestRun()
の中でemitしている。
this.#eventEmitter.emit('test-file-success', [test, result])
で emit の際に渡される result はrunTest
の返り値である
jest-runnter パッケージの中にrunTest
は定義されていて、runTestInternal
の返り値が result に格納されている
runTestInternal
も同一ファイルに定義されている
runTestInternal
の返り値である result
はtestFramework
の返り値である
このtestFramework
はtransformer.requireAndTranspileModule
の返り値
transformer.requireAndTranspileModule
の定義は@jest/transform
で定義されている
examples/getting-started のテストを実行した際のrequireAndTranspileModule
の第一引数は以下だった
${HOME}/jest/packages/jest-runner/build/index.js
${HOME}/jest/packages/jest-environment-node/build/index.js
${HOME}/jest/packages/jest-circus/build/runner.js
requireAndTranspileModule
の処理の中で出てくるaddHook
はモジュールを require 時に特定のコードを変換する処理を挟めるライブラリっぽい
requireAndTranspileModule
の第一引数に渡した path のファイルを require してる認識で良さそ
testFramework
はtransformer.requireAndTranspileModule
の第一引数に渡すモジュールを require してそう
testFramework
の値はrequireAndTranspileModule
の第一引数の値が${HOME}/jest/packages/jest-circus/build/runner.js
の時
ということで result の値はjest/packages/jest-circus/build/runner.js
のモジュールが実行した返り値
jest-circus
実際にテストを実行して結果を出力してそうなパッケージ
テスト結果を返すtestFramework()
の実態はpackages/jest-circus/src/runner.ts
にある。
このモジュールはpackages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts
が export するモジュールを使っている
対象のテストファイルのパスを引数に渡して、esm か cjs かをruntime.unstable_shouldLoadAsEsm(path)
で判断してる
unstable_shouldLoadAsEsm()
はjest-runtime
で定義されており、wasmファイルでなければ実態はResolver.unstable_shouldLoadAsEsm()
である
Resolver.unstable_shouldLoadAsEsm()
は./shouldLoadAsEsm
でexportされており
実態はcachedShouldLoadAsEsm
cachedShouldLoadAsEsm
の中でshouldLoadAsEsm
が実行されており、runtime.unstable_shouldLoadAsEsm(path)
ではファイルの拡張子か package.json の type を見て esm か cjs かを判定している
esm か cjs か判定して、テストファイルを require か import する
runtime.requireModuleについてはjest-runtime
パッケージに実装があるので以下に記載する
最終的に、テストファイルとテスト対象のファイル(さらにその依存も含め)をbabelでトランスパイルしてそう
対象の CJS ファイルを babel でトランスパイルしつつ、読み込み、その後runAndTransformResultsToJestFormat()
を実行し、結果を格納してる
runAndTransformResultsToJestFormat()
の実装は以下
で、run()
を実行している
run()
の実装は以下
test は以下の分岐で実行されてそう。関数で言うと_runTest
_runTest
の実装は以下
_runTest
の中で_callCircusTest
が実行されている
_callCircusTest
の実装は以下
_callCircusTest
ではcallAsyncCircusFn
が呼ばれており、その実装箇所は以下
このcallAsyncCircusFn
ではtest
変数にあるfn
の中身は以下のようになっている
function () {
expect(sum(1, 2)).toBe(3);
}
try catch の中で、このfn
を実行することでテストが落ちているかどうかを判定している。またこの実行を Promise で行い、何かしら error を throw するようであれば reject する
呼び出し元では reject されるようであればそれを catch し、テストが落ちたと判定してる
TODO: なぜfn.call(testContext)
が実行できるのか
これはテストコードのみでsum()
はどこにも定義されていないはず
引数で渡されているtestContextの空オブジェクトのようだし
fn.call
で呼ばれていることでfn
が定義されている大元のtest
からだと、テスト対象のモジュールにもアクセスできるのか?
jest-runtime
jest-circus
が定義する jestAdapter に渡される runtime 部分の実装がある
requireModule
今回、動作確認をしているのはexamples/getting-started/sum.test.js
で CJS だし、ESM はそもそも jest だと ESM 対応が experimental なので CJS のやつを見る
requireModule
は以下に定義されている
requireModule
には引数が 4 つあるが、使われているのは from
のみで、from
は該当のテストのパス
以下で、manual モックの場合に、require の対象のパスを manual モックのパスに変更してたりする?
modulePath
はthis._resolveCjsModule(from, moduleName)
の返り値
this._resolveCjsModule(from, moduleName)
では、moduleName が指定されない限りは、from
の値を返す
moduleRegistry
の値は、今回動作確認してるテストだとここを通る
this._moduleRegistry
の値は Map になっている
main
が自身の値を受け取っている(循環参照)
Map(0) {}
Map(1) {
'/$HOME/jest/examples/getting-started/sum.test.js' => <ref *1> {
children: [],
exports: {},
filename: '/$HOME/jest/examples/getting-started/sum.test.js',
id: '/$HOME/jest/examples/getting-started/sum.test.js',
isPreloading: false,
loaded: false,
path: '/$HOME/jest/examples/getting-started',
parent: [Getter],
paths: [
'/$HOME/jest/examples/getting-started/node_modules',
'/$HOME/jest/examples/node_modules',
'/$HOME/jest/node_modules',
'/$HOME/node_modules',
'/$USER/dev/node_modules',
'/$USER/node_modules',
'/Users/node_modules',
'/node_modules'
],
main: [Circular *1]
}
}
this._loadModule
でモジュールを読み込んでるのかな
this._loadModule
の実装
テストファイルの拡張子は.js なので以下の分岐に進み、this._execModule
が実行される
from
をテストパスだけにすると、console.log が 2 回実行されてる。なんでだ
テストファイルと、テストファイルが import(require)してるファイルが map に格納されてるからっぽい
this._execModule
の実装
this._execModule
に渡される localModule の filename が'/Users/nus3/dev/fork/jest/examples/getting-started/sum.test.js'
と'/Users/nus3/dev/fork/jest/examples/getting-started/sum.js'
の場合が対象のテストファイルを load してる場合だ
this.transformFile
をした時点で、テストファイルとテストファイルが require したテスト対象のファイルはトランスパイルされている
// sum.test.js
"use strict";
var sum = require("./sum");
test("adds 1 + 2 to equal 3", function () {
expect(sum(1, 2)).toBe(3);
});
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
この transform 処理はjest-transform
パッケージの以下で定義されている
呼び出し元を辿っていくと_instrumentFile()
という関数の中でbabelTransform
が呼ばれてる
このbabelTransform
はbabel-core
のtransformSync
を使っている
どうやら対象のテストファイルは babel を使ってトランスパイルされてる
jest-resolve
jest-runtime
で使っていたunstable_shouldLoadAsEsm
の大元の実装はこのパッケージにある
cachedShouldLoadAsEsm()
jest-circus
で使っていたruntime.unstable_shouldLoadAsEsm(path)
の大元の実装部分。esm の判定を拡張子でやってる
詳細は以下のリンクに記載
どのようにしてテスト関数を実行しているのか
例えば examples にある sum のテストの場合
最終的に jest の内部では、以下の部分でテスト関数を実行している
このfn
は以下のようになっている
function () {
expect(sum(1, 2)).toBe(3);
}
このfn
を実行するだけでは、expect
とsum
が require されていないため、テストが実行できないのでは?
テスト実行時にはjest-runtime
のrequireModule
をjest-circus
のjestAdapter
(projectConfig.testRunner
に指定されている path)で呼んでいる
テストファイルやテストファイルで呼び出されているモジュールはruntime.requireModule
を実行する際に babel でトランスパイルされている
runtime.requireModule
では、テストファイルで呼んでいるモジュールも localModule の exports に追加している
の部分の出力はそれぞれ以下になり、テストファイルが依存しているexamples/getting-started/sum.js
の関数であるsum
がトランスパイルされた状態でlocalModule.exports
に追加されている
if (from === "/jest/examples/getting-started/sum.test.js") {
console.log(modulePath);
console.log(localModule.exports);
// それぞれ出力は以下
// /examples/getting-started/sum.js
// [Function: sum]
// /examples/getting-started/sum.test.js
// {}
}
テストファイルで定義した関数を実行する前にjest/packages/jest-circus/build/jestAdapterInit.js
のinitialize
を実行している
これがあることで、expect
やsum
などの依存しているモジュールを localModule.exports に追加してる?
@jest/expect
のexpect
を runtime に突っ込んでそうな雰囲気はある
runtime.requireModule
の引数である from が/Users/nus3/dev/fork/jest/examples/getting-started/sum.test.js
の場合だとruntime.requireModule
は 2 回呼ばれており、それぞれ別の引数であるmoduleName
は undefined と./sum
が渡されている