プールシステム:VitestがワーカーへテストをどのようにDistributeし実行するか
前提知識
- ›第1回:アーキテクチャとプロジェクト構成
- ›第2回:CLIの起動とConfig解決
- ›Node.jsのworker_threadsおよびchild_process API
- ›RPC(Remote Procedure Call)パターンの基本理解
プールシステム:VitestがワーカーへテストをどのようにDistributeし実行するか
Vitestは設定の解決とプロジェクトの初期化を終えると、次の核心的な課題に直面します。何千ものテストファイルを並列に、互いに独立した環境で実行しながら、その結果をリアルタイムにReporterへストリームし続けることです。この処理を担うのがプールシステムです。
Vitestのプールアーキテクチャは三層構造になっています。Poolがタスクキューを管理し、複数のPoolRunnerにワークをスケジューリングします。各PoolRunnerは実際のスレッドやプロセスを所有するPoolWorkerをラップしています。この抽象化により、worker_threads・child_process.fork()・VMによるIsolatedバリアント・ブラウザセッション・TypeScript型チェックといった異なる実行方式を、すべて同一のスケジューリングロジックで統一的に扱えます。
プールの種類と生成
Vitestは6種類のビルトインプールをサポートしており、packages/vitest/src/node/pool.ts#L38-L45で定義されています。
export const builtinPools: BuiltinPool[] = [
'forks',
'threads',
'browser',
'vmThreads',
'vmForks',
'typescript',
]
| プール | 仕組み | 用途 |
|---|---|---|
threads |
worker_threads |
デフォルト。高速な起動と共有メモリ。 |
forks |
child_process.fork() |
より強固なIsolation。一部のネイティブモジュールに必要。 |
vmThreads |
worker_threads + node:vm |
スレッド内でファイルごとにVMコンテキストをIsolate。 |
vmForks |
child_process.fork() + node:vm |
子プロセス内でVMをIsolate。 |
browser |
ブラウザオーケストレーション | Playwright/WebdriverIO経由で実ブラウザ上でテストを実行。 |
typescript |
型チェッカー | 型レベルのテストのためにtsc/vue-tscを実行。 |
54行目のcreatePool()関数がPoolインスタンスを一つ生成し、解決済みのオプションで設定します。executeTests()関数はTestSpecificationオブジェクトをプールの種類ごとにグループ化し、それぞれのグループを適切なプール実装へディスパッチします。
flowchart TD
ET["executeTests(specs)"] --> Seq["sequencer.sort(specs)"]
Seq --> Group["Group by pool type + project + environment"]
Group --> G1["threads group"]
Group --> G2["forks group"]
Group --> G3["browser group"]
G1 --> Pool["Pool.run(task)"]
G2 --> Pool
G3 --> BrowserPool["Browser Pool (separate)"]
三層プールアーキテクチャ
プールシステムは、それぞれ明確な責務を持つ三つの層で構成されています。
classDiagram
class Pool {
-queue: QueuedTask[]
-activeTasks: ActiveTask[]
-maxWorkers: number
+run(task, method): Promise
+cancel(): Promise
-schedule(): Promise
-getPoolRunner(task): PoolRunner
}
class PoolRunner {
+poolId: number
+project: TestProject
+environment: ContextTestEnvironment
-_state: RunnerState
-_rpc: BirpcReturn
+start(): Promise
+stop(options?): Promise
+waitForTerminated(): Promise
}
class PoolWorker {
<<interface>>
+name: string
+on(event, callback): void
+off(event, callback): void
+send(message: WorkerRequest): void
+start(): Promise
+stop(): Promise
+canReuse?(task): boolean
+deserialize(data): unknown
}
Pool --> "0..*" PoolRunner
PoolRunner --> "1" PoolWorker
Pool(packages/vitest/src/node/pools/pool.ts#L31-L50)はタスクキューとアクティブタスクのセットを管理します。run()が呼ばれるとタスクがエンキューされ、schedule()がワーカーの空きを確認します。アクティブなタスク数がmaxWorkersを下回っていれば、タスクをデキューしてPoolRunnerを取得または生成し、処理をディスパッチします。メモリ使用量も監視されており、ワーカーが上限を超えるとRunnerを停止して再生成します。
PoolRunner(packages/vitest/src/node/pools/poolRunner.ts#L42-L77)は単一ワーカーのライフサイクルを管理します。状態遷移(IDLE → STARTING → STARTED → STOPPING → STOPPED)を追跡し、birpcチャンネルを保持し、開始・停止のタイムアウトを処理します。Isolationが無効でワーカーのcanReuse()がtrueを返す場合、複数のテストファイルにわたってRunnerを再利用できます。
PoolWorker(packages/vitest/src/node/pools/types.ts#L22-L41)は、実際のスレッドやプロセスの実装が満たすべきinterfaceです。そのコントラクトはシンプルに保たれています。イベント用のon/off、メッセージ送信用のsend、ライフサイクル管理用のstart/stop、そしてワーカー再利用判断のためのオプショナルなcanReuseです。
ワーカーの実装
packages/vitest/src/node/pools/workers/threadsWorker.tsのThreadsPoolWorkerは、PoolWorker interfaceの実装例として分かりやすいものです。
export class ThreadsPoolWorker implements PoolWorker {
public readonly name: string = 'threads'
constructor(options: PoolOptions) {
this.entrypoint = resolve(options.distPath, 'workers/threads.js')
}
async start(): Promise<void> {
this._thread ||= new Worker(this.entrypoint, {
env: this.env,
execArgv: this.execArgv,
stdout: true,
stderr: true,
})
this._thread.stdout.pipe(this.stdout)
this._thread.stderr.pipe(this.stderr)
}
send(message: WorkerRequest): void {
this.thread.postMessage(message)
}
async stop(): Promise<void> {
await this.thread.terminate()
}
}
エントリーポイントはrollupのビルド成果物であるworkers/threads.jsに解決されます。これがNode側のオーケストレーションとワーカー側のテスト実行をつなぐ橋渡しです。
Tip: ワーカーの問題をデバッグする際は
execArgv配列を確認しましょう。--inspectや--max-old-space-sizeといったNodeフラグはここで注入されます。envオブジェクトはワーカーが参照できる環境変数を制御します。
birpc通信ブリッジ
NodeとワーカーはThroughの通信をbirpcで行い、双方向のRPCチャンネルを確立します。Node側のRPCメソッドはpackages/vitest/src/node/pools/rpc.ts#L17-L29で定義されています。
sequenceDiagram
participant Worker as Worker (Runtime)
participant RPC as birpc Channel
participant Node as Node (Orchestration)
Worker->>RPC: rpc.fetch(url, importer, env)
RPC->>Node: createMethodsRPC().fetch()
Node->>Node: project._fetcher(url, ...)
Node-->>RPC: TransformResult
RPC-->>Worker: Module source code
Worker->>RPC: rpc.resolve(id, importer, env)
RPC->>Node: pluginContainer.resolveId()
Node-->>Worker: Resolved path
Worker->>RPC: rpc.onTaskUpdate(packs, events)
RPC->>Node: StateManager + Reporters
Worker->>RPC: rpc.snapshotSaved(snapshot)
RPC->>Node: SnapshotManager
createMethodsRPC()関数が公開する主な操作は以下のとおりです。
fetch()— ViteのpipelineでModuleをfetchしてトランスパイルします。テストファイルとその依存関係がここで変換されます。resolve()— Viteのplugin containerを通じてModuleのSpecifierを解決します。onTaskUpdate()— テストの進捗と結果をStateManagerへ報告します。- スナップショット操作 —
snapshotSaved・resolveSnapshotPathなどを処理します。
ワーカー側からはこれらをpackages/vitest/src/runtime/rpc.tsのrpc()呼び出しを通じてアクセスします。ワーカー内でのModuleインポートはすべて最終的にfetch()を通じてNodeプロセスへコールバックされ、ViteのPlugin pipelineがソースを変換します。
ワーカーの起動シーケンス
threadsのワーカーエントリーポイントは驚くほどシンプルです。packages/vitest/src/runtime/workers/threads.tsのわずか4行がすべてです。
import { runBaseTests, setupBaseEnvironment } from './base'
import workerInit from './init-threads'
workerInit({ runTests: runBaseTests, setup: setupBaseEnvironment })
init-threadsのworkerInit関数がメッセージハンドリングループをセットアップします。WorkerRequestメッセージ(start・run・collect・stop・cancel)を受け取り、適切にディスパッチします。startメッセージを受け取るとsetupBaseEnvironmentが呼ばれます。
packages/vitest/src/runtime/workers/base.ts#L72のsetupBaseEnvironmentが重要な処理を担います。
sequenceDiagram
participant PM as Pool Manager
participant W as Worker Thread
participant Base as base.ts
participant MR as Module Runner
participant Env as Environment
PM->>W: { type: 'start', config, environment }
W->>Base: setupBaseEnvironment(context)
Base->>MR: startModuleRunner(options)
MR->>MR: Override process.exit
MR->>MR: Set up error listeners
MR->>MR: Create VitestModuleRunner or NativeModuleRunner
Base->>Env: loadEnvironment(name)
Env->>Env: Setup (jsdom/happy-dom/node/edge-runtime)
Base-->>W: Return teardown function
W-->>PM: { type: 'started' }
Module Runnerはワーカーごとに一度だけ生成され、複数ファイルにわたって再利用されます。実験的なviteModuleRunnerフラグがfalseの場合、ViteのModuleRunnerではなくNodeネイティブのModuleロードを使うNativeModuleRunnerが使われます。
ワーカーでのテスト実行:runBaseTests
プールが{ type: 'run' }メッセージを送ると、ワーカーはpackages/vitest/src/runtime/runBaseTests.ts#L21-L92のrun()を呼び出します。
flowchart TD
Run["run(method, files, config, moduleRunner, environment)"] --> Setup["Parallel setup:<br>1. Resolve test runner<br>2. Setup global env<br>3. Start coverage<br>4. Resolve snapshot env"]
Setup --> Loop["For each file"]
Loop --> Isolated{config.isolate?}
Isolated -- "yes" --> Reset["Reset mocker + modules"]
Isolated -- "no" --> Skip["Skip reset"]
Reset & Skip --> SetPath["workerState.filepath = file"]
SetPath --> Method{method?}
Method -- "run" --> StartTests["startTests([file], testRunner)"]
Method -- "collect" --> CollectTests["collectTests([file], testRunner)"]
StartTests & CollectTests --> PostFile["vi.resetConfig()<br>vi.restoreAllMocks()"]
PostFile --> Loop
Loop -- "all files done" --> Coverage["stopCoverageInsideWorker()"]
四つのセットアップ処理はPromise.allで並列実行されます。TestRunnerの解決・グローバル環境のセットアップ・Coverageの初期化・スナップショット環境の解決が同時に行われる点がポイントです。続くファイルごとのループは順次処理されます。Isolationが有効な場合はファイル間でModuleがリセットされ、TestRunnerが実行され、Mockが復元されます。
startTests()とcollectTests()は@vitest/runnerから提供されます。このFramework非依存のテストRunnerについては、次の記事で詳しく掘り下げます。
Tip: テストの起動が遅い場合は、まず並列セットアップフェーズを疑いましょう。CoverageのinitializationとEnvironmentのセットアップはコストが高くなりがちです。
experimental.openTelemetryのトレースでボトルネックを特定できます。
結果の収集:StateManagerとTestRun
テスト結果はワーカーからRPCブリッジを通じてNode側のStateManagerとTestRunへ流れ込みます。
packages/vitest/src/node/state.ts#L19-L26 — StateManagerがテスト状態全体を保持します。
export class StateManager {
filesMap: Map<string, File[]> = new Map()
pathsSet: Set<string> = new Set()
idMap: Map<string, Task> = new Map()
taskFileMap: WeakMap<Task, File> = new WeakMap()
errorsSet: Set<unknown> = new Set()
leakSet: Set<AsyncLeak> = new Set()
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
}
packages/vitest/src/node/test-run.ts#L27-L56 — TestRunがReporterイベントのライフサイクルを統括します。
sequenceDiagram
participant TR as TestRun
participant SM as StateManager
participant R as Reporters
TR->>SM: collectPaths(filepaths)
TR->>R: onTestRunStart(specifications)
loop For each file
TR->>SM: collectFiles(project, [file])
TR->>R: onTestModuleQueued(testModule)
Note over TR: Tests collected
TR->>SM: collectFiles(project, files)
TR->>R: onTestModuleCollected(testModule)
Note over TR: Tests executing...
TR->>R: onTestCaseReady(testCase)
TR->>R: onTestCaseResult(testCase)
end
TR->>R: onTestRunEnd(modules, errors, reason)
TestRun.start()はファイルパスをStateManagerに収集し、onTestRunStartを発火します。ワーカーがRPC経由で結果を報告するたびに、enqueued()とcollected()が状態を更新してReporterへディスパッチします。個々のテスト結果はタスク更新イベントとして流れます。すべてのワーカーが完了すると、TestRunは最終的な理由('passed'・'failed'・'interrupted')とともにonTestRunEndを発火します。
次回の内容
ここまでで、プールスケジューラーからワーカーの起動、結果の収集に至るまでの流れを追いました。次回は@vitest/runnerの内部へと踏み込みます。describe/test/itがどのようにChain可能なAPIを構築するか、ファイルがどのようにタスクツリーに収集されるか、フックがどの順番で実行されるか、そしてPlaywright流のFixtureがどのようにDependency Injectionを実現するかを詳しく見ていきましょう。