Vitestを読む
リポジトリ
CONTRIBUTING.md
- pnpmワークスペースを使用したmonorepo
- VSCode使ってデバッグできる
- https://code.visualstudio.com/docs/editor/debugging
- Run and DebugからJavaScript Debug Terminalを選択
pnpm run test
動作確認
rootでpnpm run dev
しつつ、pnpm run test
でコアなテストケースは開発しながら確認できる。
また、任意のexamplesフォルダに移動し、pnpm run test
を実行すればexamlesのテストも確認できる。
rootのtestコマンドではtest/core
のテストを行なっており、その中でもbasic.test.ts
がシンプルなテストになっているので動作確認するのに良さそう
cd test/core
pnpm run test test/basic.test.ts
# single run する場合
pnpm run test run test/basic.test.ts
デバッグ
VSCodeの場合、ブレークポイントを設定しRun and DebugからJavaScript Debug Terminalを選択、そのターミナル上でpnpm run test
を実行するとブレークポイントに設定した部分で止まる。また1ステップずつ処理を確認できる
デバッグ中に変数の中身がVSCode上で確認できる。便利。
packages
cd test/core
pnpm run test test/basic.test.ts
test/coreのbasic.test.tsを実行しつつ、packageが読み込まれる順に実装を眺める
vitest
vitest
./vitest.mjs
がbinには指定されている
./vitest.mjs
では./dist/cli-wrapper.js
がimportされてる
ビルドの設定をrollup.config.js
から見ると./dist/cli-wrapper.js
の元は'src/node/cli/cli-wrapper.ts'
っぽい
'src/node/cli/cli-wrapper.ts'
ではmain()
が実行されている
手元で実行してみるとimport('../cli')
してる
'../cli'ではcreateCLI().parse()
を実行してる
createCLI()
createCLI()
ではcac
というパッケージを利用している
cacはCommand And Conquerの略で、CLIアプリを作るためのライブラリぽい
Vitestの場合、シンプルにテストを実行するならvitest run
コマンドを実行する
このvitest run
ではrun
が使われてそう
run
の実装は以下で、start
関数が呼ばれてる
引数であるcliFilters
には指定したテストファイルのパスが配列で格納されていた
['test/basic.test.ts']
start
関数も同ファイルで定義されている
start
関数の中ではstartVitest()
が実行されている
startVitest()
の実装は以下
まずcreateVitest()
で context を作ってそう
createVitest()
の実装は以下
Vitest クラス
VitestPackageInstaller クラス
なんかサーバー作ってるけど、listen はしてない
ctx.start(cliFilters)
の実装は以下
git からファイルの変更差分を検知してそう
テストの実行はここら辺かな?
テストを実行してそうthis.pool.runTests(paths, invalidates)
this.pool.runTests(paths, invalidates)
の実装は以下
filesByPool.threads
の中にテストファイルのパスが格納されている
thread はcreateThreadsPool
で作られる。createThreadsPool
の実装は以下
pools[pool]!.runTests(specs, invalidate)
が実行され
pools[pool]!.runTests(specs, invalidate)
は以下の部分が実行される?
実態はここかな?
await pool.run(data, { transferList: [workerPort], name })
この pool は Tinypool
Node.js Worker Thread Pool をミニマムで作ったもの。pool を作った際にワーカーの内容を定義している。
worker の指定は以下でやっており、filename の値は'/Home/vitest/packages/vitest/dist/worker.js'
worker の実装はここ
worker(threads?) の中で何が実装されているかを定義しているのは
この中で Tinypool にはpackages/vitest/src/workers.ts
が渡されてる
以下 Claude3
Worker はプロセス間の並列処理、Threads はプロセス内の並列処理を行うためのモデル
プロセス間で並列処理をすることで同じメモリ領域を参照しない、しかし、プロセス間通信のオーバーヘッドは発生する。
逆に threads はスレッド間でメッセージングを使って直接通信できるが、メモリが共有されるため、データの競合が発生する可能性がある。
CPU バウンドと I/O バウンド
ディスクとの入出力は行わないが、計算が重いプログラムを CPU バウンド
ディスクとの入出力が多いプログラムを I/O バウンド
Worker Pool を使うことで複数のスレッドを効率的に並列実行できる
- 指定した数のスレッドをあらかじめ作成する。
- 実行したいタスクをキューに追加する。
- スレッドが空いている場合、キューからタスクを取り出して実行する。
- タスクが完了するとスレッドはプールに戻る。
- pool のスレッド数は動的に増減できる
Tinypool を作成するときもスレッド数を指定しており、ローカルで実行した場合、この数はどちらも 6 だった
TODO: Node.js の worker と threads を実装してみる
workers/threads.js
これはsrc/runtime/workers/threads.ts
に実装がある
ThreadsBaseWorker
が export されている
この worker も data に詰め込まれた状態で pool.run が実行される
pool.run が実行されると
packages/vitest/src/runtime/worker.ts のrun
が実行され、この中のworker.runTests(state)
が実行される
worker.runTests(state)
はの実装は以下でrunBaseTests
が実行される
runBaseTests
ではimport('../runBaseTests'),
で相対パスの位置にあるpackages/vitest/src/runtime/runBaseTests.ts
が import され、その中のrun
が実行される
runBaseTests
の中でrun
の中のawait startTests([file], runner)
部分が実際にテストを実行していそう
@vitest/runner
テストを実行していそうなstartTests
は@vitest/runner
で定義されている
VitestRunner で公開されている API はドキュメントに乗ってる
startTests
→runFiles
→runSuite
runSuite
→runSuiteChild
→runTest
runSuiteChild
→runTest
runTest
の中ではgetFn
でテスト対象の fn ?を取得して実行している
getFn
は WeakMap に格納されている
以下 Claude3
WeakMap を使うことで Map とは異なり、キーとなるオブジェクトが参照されなくなると GC によりメモリが解放される
WeakMap を使うことでメモリりーすが低くなる。今回の Vitest の Test や Suite のように一時的に使われるものを WeakMap で格納することでメモリリークを防ぎつつオブジェクトの生存期間を気にしないで良くなる
getFn
の返り値
fn.toString()
'(...args) => {
return Promise.race([fn(...args), new Promise((resolve, reject) => {
var _a;
const timer = setTimeout(() => {
clearTimeout(timer);
reject(new Error(makeTimeoutMsg(isHook, timeout)));
}, timeout);
(_a = timer.unref) == null ? void 0 : _a.call(timer);
})]);
}'
これはwithTimeout
の実装
sefFn
しているのはcreateSuiteCollector
の中
setFn
するタイミングはデバッグの call stack を見ると
- packages/vitest/src/runtime/runBaseTests.ts の
run()
時のstartTests()
- packages/runner/src/collect.ts の
collectTests()
- runner.importFile
- VitestTestRunner.importFile
- ViteNodeRunner.executeId
- ViteNodeRunner.cachedRequest
- ViteNodeRunner.directRequest
- ViteNodeRunner.runModule
テストコードで使うsuite
は createSuiteCollector
を実行してる。
createSuiteCollector、createSuite、suite
このcreateSuiteCollector
の中でsetFn
しており、withTimeout
の第一引数に実行されるテスト関数が渡されている
第一引数はwithFixtures(handler, context)
で
手元で実行してみると以下の部分のように handler(fn)を実行している
この handler(fn) は以下のようになっている
handler.toString()
'async () => {
__vite_ssr_import_0__.assert.equal(Math.sqrt(4), __vite_ssr_import_1__.two);
__vite_ssr_import_0__.assert.equal(Math.sqrt(2), Math.SQRT2);
__vite_ssr_import_0__.expect(Math.sqrt(144)).toStrictEqual(12);
}'
ここまでくるとテストが単体で実行できるようになっていそう
このhandler
はcreateSuiteCollector
の中で定義されている
TODO: handler
を定義している方法を調べるところから
テスト実行時(startTests
)に、対象のテストを取得するcollectTests
collectTests
の実装箇所
pathe
あらゆる環境で使えるファイルパスモジュールで UnJS の一つ
UnJS は、Nuxt 開発チームが中心となって開発・メンテナンスされている、あらゆる JavaScript フレームワーク上で統一的に動作するユーティリティーツール・ライブラリ群
対象のテストファイルをrunner.importFile(filepath, 'collect')
してる
importFile
ではthis.__vitest_executor.executeId(filepath)
が実行される
this.__vitest_executor.executeId(filepath)
は手元ではViteNodeRunner
のexecuteId
が実行される
Runner の定義はここ
TODO: どのように Runner を定義しているのか
executeId
ではcachedRequest
を実行
循環参照を防ぐ処理とかしてそう
directRequest
が実行される
directRequest
の定義場所
directRequest
の中で対象のテストファイルパスに対して、await this.options.fetchModule(id)
を実行してる
this.options.fetchModule()
の実装場所
this.options.fetchModule()
の返り値のcode
の値
const __vite_ssr_import_0__ = await __vite_ssr_import__(
"/@fs/Users/nus3/dev/fork/vitest/packages/vitest/dist/index.js",
{ importedNames: ["assert", "expect", "it", "suite", "test"] }
);
const __vite_ssr_import_1__ = await __vite_ssr_import__("/src/submodule.ts", {
importedNames: ["two"],
});
const __vite_ssr_import_2__ = await __vite_ssr_import__("/src/timeout.ts", {
importedNames: ["timeout"],
});
__vite_ssr_import_0__.test("Math.sqrt()", async () => {
__vite_ssr_import_0__.assert.equal(Math.sqrt(4), __vite_ssr_import_1__.two);
__vite_ssr_import_0__.assert.equal(Math.sqrt(2), Math.SQRT2);
__vite_ssr_import_0__.expect(Math.sqrt(144)).toStrictEqual(12);
});
TODO: vm.runInContext
を使ってみる
テスト対象のファイルやテストで使われるモジュールを実行できる形にトランスパイルしてそう
this.options.fetchModule()
がトランスパイルしてそうなので中身をもっとみる
this.options.fetchModule()
は何回か呼ばれてそう
サブモジュールをthis.options.fetchModule()
した時の返り値
const two = 1 + 1;
Object.defineProperty(__vite_ssr_exports__, "two", {
enumerable: true,
configurable: true,
get() {
return two;
},
});
rpc().fetch(id, getTransformMode())
の返り値にはトランスパイルされた後のコードが生成されてる?
rpc()
は何なのか
rpc は Proxy オブジェクト
rpc 周りの実装はここにありそう
vitest/packages/vitest/src/runtime/rpc.ts
以下 Claude3
rpc は、Remote Procedure Call(リモートプロシージャコール)の略です。
これは、プログラム間でデータを交換したり、他のプログラムの機能を利用したりするための技術の一種です。
Vitest において rpc は Worker と Runner の間での通信に使われています。
Worker: テストを実行する役割を持つプロセス
Runner: Worker の管理やテスト実行の調整をする親プロセス
rpc を使うことで、Runner から Worker にテストの実行を指示したり、Worker から Runner にテスト結果を送ったりと、プロセス間でデータのやり取りができます。
具体的には、createRuntimeRpc という関数で rpc のインスタンスを作成し、それを介して Worker と Runner が通信を行なっているようです。rpc の実装には birpc というライブラリが使われているみたいですね。
rpc はクライアント・サーバー形式の分散システムでよく使われる技術概念ですが、Vitest ではプロセスレベルでのメッセージングにも活用されているということになります。
birpc
というライブラリが使われている
このrpc
を使ってる箇所でコメントが記載されており
Claude さんが言ってるように、ワーカー(Worker、threads、子プロセス)とメインスレッド間の通信に使用される
が、トランスパイルが実施されるタイミングがわからぬ...
トランスパイル後のコードで使われている__vite_ssr_import__
を全文検索すると以下の部分でのみ使われていた
TODO: packages/vite-node/src/client.ts の部分を読めば、どこでトランスパイルしてるのかわかるかも
vite-node
Node.jsでのテスト実行時にトランスパイルをやってそうなパッケージ
Vite でサーバー作ってその中で fetchModule を定義している?
vite-node
では server と client があって、server 側でトランスパイル、client 側で対象ファイルを実行してるんか?
トランスパイルをしてそうな箇所
ViteDevServer の transformRequest を使っている
/**
* プログラムで URL を解決、読込、変換して、HTTP リクエストパイプラインを
* 経由せずに結果を取得します。
*/
transformRequest(
url: string,
options?: TransformOptions,
): Promise<TransformResult | null>
手元で確かめたトランスパイル後のコード
const __vite_ssr_import_0__ = await __vite_ssr_import__(
"/@fs/Users/nus3/dev/fork/vitest/packages/vitest/dist/index.js",
{ importedNames: ["assert", "expect", "it", "suite", "test"] }
);
const __vite_ssr_import_1__ = await __vite_ssr_import__("/src/submodule.ts", {
importedNames: ["two"],
});
const __vite_ssr_import_2__ = await __vite_ssr_import__("/src/timeout.ts", {
importedNames: ["timeout"],
});
__vite_ssr_import_0__.test("Math.sqrt()", async () => {
__vite_ssr_import_0__.assert.equal(Math.sqrt(4), __vite_ssr_import_1__.two);
__vite_ssr_import_0__.assert.equal(Math.sqrt(2), Math.SQRT2);
__vite_ssr_import_0__.expect(Math.sqrt(144)).toStrictEqual(12);
});
/src/submodule.ts
側
const two = 1 + 1;
Object.defineProperty(__vite_ssr_exports__, "two", {
enumerable: true,
configurable: true,
get() {
return two;
},
});
s;
サブモジュールでは__vite_ssr_exports__
が使われていて、トップレベルのモジュールでは__vite_ssr_import__
を使ってモジュールを import している。
ViteNodeRunner 側では context に import や export のために必要なものを格納してから ViteNodeServer 側でトランスパイルされた関数を vm で実行している
TODO: ViteのtransformRequestを使いつつ、vite-nodeの部分を自作してみる