architecture
リポジトリ: vitest-dev/vitest 分析日: 2026-03-05
概要
Vitest のレイヤー構成 CLI -> Node (Vitest core) -> Pool -> PoolRunner -> Worker (Runtime) と、その間の通信パターンを分析する。テストフレームワークを「メインプロセスでオーケストレーション、ワーカープロセスで実行」という二分割にし、birpc による双方向 RPC でつなぐ設計が、拡張性・分離性・パフォーマンスの三点で優れたプラクティスを生み出している。
背景にある原則
プロセス境界による強制的な関心分離: Node 側(設定解決・テスト発見・レポーティング・カバレッジ集約)とランタイム側(環境セットアップ・モジュール実行・テスト実行)を物理的に別プロセス/スレッドに配置する。これにより、テストコードがメインプロセスの状態を汚染できない。根拠:
packages/vitest/src/node/pool.tsで Pool を作成し、packages/vitest/src/runtime/worker.tsでワーカー側の実行を完全に分離している。抽象インターフェースによるプール戦略の交換可能性:
PoolWorkerインターフェース (packages/vitest/src/node/pools/types.ts:22-41) がstart/stop/send/on/off/deserializeだけを要求し、forks/threads/vmForks/vmThreads/typescript/custom をすべて同一のPoolRunnerで扱う。実装の切り替えがコンフィグ一行で完結する。メッセージパッシングによる疎結合: Node 側とワーカー側は
WorkerRequest/WorkerResponseの型付きメッセージのみでやり取りし、birpc で RPC を構成する。直接のメソッド呼び出しが存在しないため、通信経路(IPC/postMessage)の差異を吸収できる (packages/vitest/src/node/pools/types.ts:68-108)。段階的初期化と明示的ライフサイクル管理: PoolRunner は
IDLE -> STARTING -> STARTED -> STOPPING -> STOPPEDの状態遷移を持ち、操作ロック (_operationLock) で並行 start/stop を防止する (packages/vitest/src/node/pools/poolRunner.ts:14-21)。
実例と分析
レイヤー1: CLI -> Vitest Core
CLI エントリポイントは極めて薄い。packages/vitest/src/node/cli.ts は3行で、cac パーサーに委譲するだけである。
// packages/vitest/src/node/cli.ts:1-3
import { createCLI } from './cli/cac'
createCLI().parse()実際のブートストラップは startVitest() (packages/vitest/src/node/cli/cli-api.ts:56-62) が担い、createVitest() -> Vitest インスタンス生成 -> Vite サーバー起動 -> ctx.start() の順で進む。この分離により、CLI を経由しないプログラマティック利用(startVitest() 直接呼び出し)が容易になっている。
レイヤー2: Vitest Core -> Pool
Vitest.start() はテストスペック収集後、createPool(ctx) (packages/vitest/src/node/pool.ts:54) で Pool インスタンスを生成し、pool.runTests(specs) を呼ぶ。
Pool 内部では groupSpecs() がテストファイルを environment/project/sequenceOrder でグループ化し、各グループを maxWorkers の並列度で実行する。この grouping ロジックが、環境の異なるテスト(jsdom vs node vs happy-dom)を別バッチで走らせることを可能にしている。
レイヤー3: Pool -> PoolRunner -> PoolWorker
Pool クラス (packages/vitest/src/node/pools/pool.ts:31) はワーカーキューを管理する。pool.run(task) が呼ばれると:
getPoolRunner(task)で適切な PoolWorker を選択(switch 文でforks/threads/vmForks/vmThreads/typescriptを分岐)PoolRunnerを生成し、PoolWorker をラップrunner.start()->runner.request('run', context)-> ワーカー側で実行testfileFinishedメッセージを受信して resolve
非分離モード (isolate: false) では sharedRunners に PoolRunner を保持し、同一環境のテストを再利用する最適化が入っている (packages/vitest/src/node/pools/pool.ts:149-157)。
レイヤー4: Worker Runtime
ワーカー側のエントリポイントは transport に応じて異なる:
threads.ts:worker_threadsのparentPort経由forks.ts:child_processのprocess.send経由
いずれも init() (packages/vitest/src/runtime/workers/init.ts:22) を呼び、メッセージハンドラを登録する。init() は start/run/collect/stop の4つのメッセージタイプを処理する状態マシンとして機能する。
レイヤー5: TestRunner (実行エンジン)
@vitest/runner パッケージの startTests() / collectTests() がテストの実際の実行を担う。runBaseTests() (packages/vitest/src/runtime/runBaseTests.ts:22-92) が環境セットアップ済みの状態で resolveTestRunner() -> startTests() を呼ぶ。
resolveTestRunner() (packages/vitest/src/runtime/runners/index.ts:32-156) は TestRunner インスタンスを生成し、onTaskUpdate / onCollected / onAfterRunFiles 等のライフサイクルフックを RPC 呼び出しで装飾(パッチ)する。カスタムランナーが RPC を意識せずに済むよう、フレームワークがフックを自動的にラップしている。
RPC 通信の設計
birpc を使い、Node 側は RuntimeRPC インターフェース (packages/vitest/src/types/rpc.ts:7-33) を公開し、ワーカー側は RunnerRPC (onCancel のみ) を公開する。非対称な RPC 設計で、情報の流れを「ワーカー -> ノード」方向に制約している。
ワーカー側の RPC は createSafeRpc() (packages/vitest/src/runtime/rpc.ts:94-118) で Proxy ラップされ、全呼び出しが withSafeTimers() を経由する。これはテストが setTimeout 等をモックした場合でも RPC 通信が壊れないようにする防御策である。
コード例
// packages/vitest/src/node/pools/pool.ts:239-254
// PoolWorker の選択 - Strategy パターンの switch 実装
switch (task.worker) {
case 'forks':
return new PoolRunner(options, new ForksPoolWorker(options))
case 'vmForks':
return new PoolRunner(options, new VmForksPoolWorker(options))
case 'threads':
return new PoolRunner(options, new ThreadsPoolWorker(options))
case 'vmThreads':
return new PoolRunner(options, new VmThreadsPoolWorker(options))
case 'typescript':
return new PoolRunner(options, new TypecheckPoolWorker(options))
}// packages/vitest/src/node/pools/poolRunner.ts:14-21
// PoolRunner のライフサイクル状態
enum RunnerState {
IDLE = 'idle',
STARTING = 'starting',
STARTED = 'started',
START_FAILURE = 'start_failure',
STOPPING = 'stopping',
STOPPED = 'stopped',
}// packages/vitest/src/runtime/rpc.ts:94-118
// Safe RPC Proxy - テストのモック環境から RPC を保護
export function createSafeRpc(rpc: WorkerRPC): WorkerRPC {
return new Proxy(rpc, {
get(target, p, handler) {
if (p === '$rejectPendingCalls') {
return rpc.$rejectPendingCalls
}
const sendCall = get(target, p, handler)
const safeSendCall = (...args: any[]) =>
withSafeTimers(async () => {
const result = sendCall(...args)
promises.add(result)
try {
return await result
}
finally {
promises.delete(result)
}
})
safeSendCall.asEvent = sendCall.asEvent
return safeSendCall
},
})
}// packages/vitest/src/runtime/workers/init-forks.ts:9-14
// テストによるグローバル汚染からプロセス関数を保護
const processExit = process.exit.bind(process)
const processSend = process.send.bind(process)
const processOn = process.on.bind(process)
const processOff = process.off.bind(process)
const processRemoveAllListeners = process.removeAllListeners.bind(process)パターンカタログ
Strategy パターン (分類: 振る舞い)
- 解決する問題: 複数のワーカー実行戦略(threads/forks/vmThreads/vmForks)を統一的に扱う
- 適用条件: 実行時にアルゴリズムを切り替える必要がある場合
- コード例:
packages/vitest/src/node/pools/pool.ts:239-261の switch +PoolWorkerインターフェース - 注意点: switch 文は GoF の典型実装とは異なるが、
PoolWorkerインターフェースが Strategy の役割を果たしている。カスタムプールもPoolRunnerInitializerで拡張可能。
Mediator パターン (分類: 振る舞い)
- 解決する問題: PoolRunner が Node 側とワーカー側の通信を仲介し、双方が直接依存しない
- 適用条件: 複数のコンポーネント間の通信を集約したい場合
- コード例:
packages/vitest/src/node/pools/poolRunner.ts:42-412 - 注意点: PoolRunner は EventEmitter を内包し、メッセージの振り分け・状態管理・RPC 生成を一手に担う
Decorator パターン (分類: 構造)
- 解決する問題: カスタムランナーが RPC を意識せずに済むよう、フックを自動的にラップする
- 適用条件: 既存オブジェクトの振る舞いを透過的に拡張したい場合
- コード例:
packages/vitest/src/runtime/runners/index.ts:66-153でonTaskUpdate等をパッチ - 注意点: Proxy ではなく直接メソッド上書きなので、原本のメソッドは
original*変数に退避する必要がある
Good Patterns
- Safe Timers による RPC 保護: テストが
setTimeout/setImmediateをモックしても、フレームワークの RPC 通信はgetSafeTimers()で取得した本物のタイマーを使う。テストコードとフレームワークコードの実行コンテキストを分離する汎用テクニック。
// packages/vitest/src/runtime/rpc.ts:11-52
function withSafeTimers(fn: () => void) {
const { setTimeout, clearTimeout, nextTick, setImmediate, clearImmediate }
= getSafeTimers()
// ... グローバルを一時退避・復元
}- グローバル関数の事前バインド: ワーカー初期化時に
process.exit.bind(process)等を変数に保存し、テストがprocess.exitを上書きしてもワーカーの制御フローが壊れない。
// packages/vitest/src/runtime/workers/init-forks.ts:9-14
const processExit = process.exit.bind(process)
const processSend = process.send.bind(process)- 操作ロックによる状態遷移の保護: PoolRunner の
start()/stop()は_operationLock(DeferPromise) を使い、同時呼び出しを直列化する。非同期ライフサイクル管理で競合状態を防ぐ実践的なパターン。
// packages/vitest/src/node/pools/poolRunner.ts:166-181
async start(options: { workerId: number }): Promise<void> {
if (this._operationLock) {
await this._operationLock
}
// ...
this._operationLock = createDefer()- 型付きメッセージプロトコル:
WorkerRequest/WorkerResponseをタグ付きユニオンで定義し、__vitest_worker_request__: true/__vitest_worker_response__: trueフラグで自プロトコルのメッセージを識別する。
// packages/vitest/src/node/pools/types.ts:68-108
export type WorkerRequest
= { __vitest_worker_request__: true } & (
| { type: 'start'; ... }
| { type: 'stop'; ... }
| { type: 'run'; ... }
| { type: 'collect'; ... }
)Anti-Patterns / 注意点
- ホットパスでの switch によるワーカー選択:
getPoolRunner()は switch 文で5つの組み込みプールを分岐している。プール数が増えると保守コストが上がる。ただし、カスタムプールはPoolRunnerInitializerインターフェースで対応済みなので、現状は実用上問題ない。
Bad (仮に全てを switch で処理する場合):
switch (task.worker) {
case 'forks': ...
case 'threads': ...
case 'customA': ...
case 'customB': ...
// 無限に増える
}Better (実際の実装 - 組み込み以外は Registry パターンへ):
// packages/vitest/src/node/pools/pool.ts:256-261
const customPool = task.project.config.poolRunner
if (customPool != null && customPool.name === task.worker) {
return new PoolRunner(options, customPool.createPoolWorker(options))
}- メソッドパッチによるフック装飾:
resolveTestRunner()でtestRunner.onTaskUpdateを直接上書きしている。これはカスタムランナーが同じフックを上書きした場合に順序依存になりうる。Proxy や EventEmitter ベースの方が安全だが、パフォーマンスとのトレードオフ。
Bad:
// フック上書きの連鎖が追跡困難
testRunner.onTaskUpdate = async (task, events) => {
const p = rpc().onTaskUpdate(task, events)
await originalOnTaskUpdate?.call(testRunner, task, events)
return p
}Better (概念的):
// イベントベースで順序を明示
testRunner.on('taskUpdate', (task, events) => rpc().onTaskUpdate(task, events))
testRunner.on('taskUpdate', (task, events) => originalHandler(task, events))導出ルール
[MUST]メインプロセスとワーカープロセスの境界には型付きメッセージプロトコル(タグ付きユニオン + 識別フラグ)を定義する- 根拠: Vitest は
WorkerRequest/WorkerResponseに__vitest_worker_request__フラグを付与し、birpc メッセージと自プロトコルメッセージを安全に区別している (packages/vitest/src/node/pools/types.ts:68-108)
- 根拠: Vitest は
[MUST]テストコードが上書きしうるグローバル関数(タイマー、process.exit等)は、フレームワーク初期化時に bind で事前キャプチャする- 根拠:
init-forks.ts:9-14でprocess.exit/send/on/offを事前保存し、rpc.tsでwithSafeTimers()を経由させることで、テストのモック環境からフレームワーク通信を保護している
- 根拠:
[SHOULD]マルチプロセスアーキテクチャでは、ワーカーのライフサイクルを明示的な状態マシン(IDLE/STARTING/STARTED/STOPPING/STOPPED)で管理し、操作ロックで並行遷移を防止する- 根拠:
PoolRunnerはRunnerStateenum と_operationLockで start/stop の競合を排除している (packages/vitest/src/node/pools/poolRunner.ts:14-21, 166-243)
- 根拠:
[SHOULD]実行戦略が複数ある場合は、共通インターフェースを定義し Strategy パターンで切り替える。組み込み戦略は switch/factory で、カスタム戦略は Registry パターンで拡張できるようにする- 根拠:
PoolWorkerインターフェースが forks/threads/vm 系を統一し、カスタムプールはPoolRunnerInitializerで拡張可能 (packages/vitest/src/node/pools/types.ts:7-10, 22-41)
- 根拠:
[SHOULD]CLI エントリポイントは薄く保ち、コア機能はプログラマティック API として独立させる- 根拠:
cli.tsは3行でパーサーに委譲し、startVitest()/createVitest()が独立した API として利用可能 (packages/vitest/src/node/cli.ts,packages/vitest/src/node/cli/cli-api.ts:56)
- 根拠:
[AVOID]ワーカープロセス内でメインプロセスの状態に直接アクセスする設計。RPC 経由で必要なデータのみ受け渡す- 根拠:
RuntimeRPCはfetch/resolve/transform等の必要最小限のメソッドのみを公開し、ワーカーがメインプロセスの内部状態(Vite サーバー、ファイルシステムキャッシュ等)に直接触れない設計になっている (packages/vitest/src/types/rpc.ts:7-33)
- 根拠:
適用チェックリスト
- [ ] メインプロセスとワーカー間の通信にタグ付きユニオン型のメッセージプロトコルを定義しているか
- [ ] ワーカーのライフサイクル(初期化・実行・停止)が明示的な状態遷移として管理されているか
- [ ] テストコードやプラグインが上書きしうるグローバルを事前にキャプチャしているか
- [ ] CLI と API が分離されており、プログラマティック利用が可能か
- [ ] ワーカー実行戦略(threads/forks/custom)が共通インターフェースで交換可能か
- [ ] RPC の非対称設計(ワーカー -> メインへの情報フローの制約)が意図的に行われているか
- [ ] 非分離モード(isolate: false)でのワーカー再利用条件が明確に定義されているか