Read OSS

プールシステム: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_threadschild_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

Poolpackages/vitest/src/node/pools/pool.ts#L31-L50)はタスクキューとアクティブタスクのセットを管理します。run()が呼ばれるとタスクがエンキューされ、schedule()がワーカーの空きを確認します。アクティブなタスク数がmaxWorkersを下回っていれば、タスクをデキューしてPoolRunnerを取得または生成し、処理をディスパッチします。メモリ使用量も監視されており、ワーカーが上限を超えるとRunnerを停止して再生成します。

PoolRunnerpackages/vitest/src/node/pools/poolRunner.ts#L42-L77)は単一ワーカーのライフサイクルを管理します。状態遷移(IDLE → STARTING → STARTED → STOPPING → STOPPED)を追跡し、birpcチャンネルを保持し、開始・停止のタイムアウトを処理します。Isolationが無効でワーカーのcanReuse()がtrueを返す場合、複数のテストファイルにわたってRunnerを再利用できます。

PoolWorkerpackages/vitest/src/node/pools/types.ts#L22-L41)は、実際のスレッドやプロセスの実装が満たすべきinterfaceです。そのコントラクトはシンプルに保たれています。イベント用のon/off、メッセージ送信用のsend、ライフサイクル管理用のstart/stop、そしてワーカー再利用判断のためのオプショナルなcanReuseです。

ワーカーの実装

packages/vitest/src/node/pools/workers/threadsWorker.tsThreadsPoolWorkerは、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へ報告します。
  • スナップショット操作snapshotSavedresolveSnapshotPathなどを処理します。

ワーカー側からはこれらをpackages/vitest/src/runtime/rpc.tsrpc()呼び出しを通じてアクセスします。ワーカー内での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-threadsworkerInit関数がメッセージハンドリングループをセットアップします。WorkerRequestメッセージ(startruncollectstopcancel)を受け取り、適切にディスパッチします。startメッセージを受け取るとsetupBaseEnvironmentが呼ばれます。

packages/vitest/src/runtime/workers/base.ts#L72setupBaseEnvironmentが重要な処理を担います。

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-L92run()を呼び出します。

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側のStateManagerTestRunへ流れ込みます。

packages/vitest/src/node/state.ts#L19-L26StateManagerがテスト状態全体を保持します。

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-L56TestRunが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を実現するかを詳しく見ていきましょう。