Read OSS

@vitest/runner の内側:テスト収集・実行・フックシステムの仕組み

上級

前提知識

  • 第1回:アーキテクチャとプロジェクト構成
  • 第2〜3回:起動プロセスとワーカー実行コンテキスト
  • テストフレームワークの基本概念(スイート、フック、フィクスチャ、アサーション)への理解

@vitest/runner の内側:テスト収集・実行・フックシステムの仕組み

@vitest/runner パッケージは、テストコードに実際の意味を与える場所です。開発者が書く describe/test/it API の定義から始まり、それらの宣言を階層的なタスクツリーへと収集します。フックの実行順序を制御しながらテストを走らせ、フィクスチャを管理し、並行数の制限やリトライにも対応します。これらすべてを Vitest の Node オーケストレーション層に一切依存せず実現しており、スタンドアロンで動作するフレームワーク非依存のテストエンジンです。

第3回で見たように、ワーカー内の runBaseTests 関数はこのパッケージの startTests()collectTests() を呼び出します。本記事では、それらの関数の内側で何が起きているかを掘り下げていきます。

スイート/テスト DSL:チェーン可能な API の構築

公開 API の出発点は packages/runner/src/suite.ts です。ここで suitetest が生成されます。

export const suite: SuiteAPI = createSuite()
export const test: TestAPI = createTest(...)
export const describe: SuiteAPI = suite
export const it: TestAPI = test

この仕組みの中心にあるのが createChainable() です。オブジェクトを Proxy でラップし、修飾子のチェーンをサポートするユーティリティです。test.skip.each([1, 2])('adds %i', ...) と書くと、プロパティへのアクセスごとに新しい Proxy が返され、状態が蓄積されていきます。

flowchart LR
    test["test"] -- ".skip" --> skip["{ skip: true }"]
    skip -- ".each([1,2])" --> each["{ skip: true, each: [1,2] }"]
    each -- "('name', fn)" --> Call["createTest with accumulated context"]
    
    test -- ".only" --> only["{ only: true }"]
    test -- ".concurrent" --> conc["{ concurrent: true }"]
    test -- ".todo" --> todo["{ todo: true }"]
    test -- ".for([...])" --> for_["{ for: [...] }"]

サポートされている修飾子は .only.skip.todo.each().for().concurrent.sequential.fails です。これらは任意の順序でチェーンできます。このパターンの核となる createTaskCollector 関数が修飾子の状態を蓄積し、最終的に関数が呼び出されたタイミングで適切なタスクを生成します。

ファイルのインポート時に test('name', fn) が呼ばれても、すぐには何も実行されません。代わりに、context.tscollectTask() 関数を通じて、現在のスイートコレクターにタスクが登録されます。このコレクターベースのパターンこそが、「まず宣言を収集し、それから実行する」という二段階アプローチを可能にしています。

テスト収集:ファイルのインポートからタスクツリーへ

収集フェーズを担うのは packages/runner/src/collect.ts#L23-L26collectTests() 関数です。

flowchart TD
    CT["collectTests(specs, runner)"] --> ForEach["For each file spec"]
    ForEach --> CreateFile["createFileTask(filepath, root, name)"]
    CreateFile --> Clear["clearCollectorContext(file, runner)"]
    Clear --> Setup["Run setup files"]
    Setup --> Import["runner.importFile(filepath, 'collect')"]
    Import --> Collect["getDefaultSuite().collect(file)"]
    Collect --> Tasks["Process tasks:<br>tests → file.tasks<br>suites → collect recursively<br>collectors → await collect"]
    Tasks --> Hash["calculateSuiteHash(file)"]
    Hash --> Filter["Apply filters:<br>- testLocations<br>- testNamePattern<br>- testIds<br>- tags"]
    Filter --> Modes["interpretTaskModes:<br>.only handling<br>.skip propagation"]

重要なのは、packages/runner/src/context.ts#L21-L24 にある collectorContext の役割です。

export const collectorContext: RuntimeContext = {
  tasks: [],
  currentSuite: null,
}

ランナーが runner.importFile(filepath, 'collect') を呼ぶと、テストファイルが同期的(トップレベル await がある場合は非同期)に実行されます。describe() の呼び出しごとに SuiteCollector が生成され、collectorContext.tasks に積まれます。describe のコールバック内で呼ばれた test() は、そのスイートのコレクターに登録されます。runWithSuite() 関数がスイートのスタックを管理します。

export async function runWithSuite(suite, fn) {
  const prev = collectorContext.currentSuite
  collectorContext.currentSuite = suite
  await fn()
  collectorContext.currentSuite = prev
}

このスタック機構によって、ネストした describe ブロックがツリー構造を形成します。インポートが終わった後、getDefaultSuite().collect(file) が蓄積されたタスクを処理し、File → Suite → Test の最終的な階層を構築します。各タスクには、配置位置と名前をもとにした決定論的なハッシュ値が付与されます。

packages/runner/src/collect.ts#L54-L100

テスト実行:startTests() と実行ループ

実行フェーズのトリガーは startTests() 関数です。まず collectTests() を呼んで収集を行い(収集は常に最初のステップです)、その後に収集済みのタスクツリーを実行します。packages/runner/src/run.ts の実行ループは、ツリーを深さ優先で走査します。

flowchart TD
    ST["startTests(files, runner)"] --> ForFile["For each file"]
    ForFile --> RunFile["runSuite(file)"]
    RunFile --> BA["Run beforeAll hooks"]
    BA --> ForTask["For each task in suite"]
    ForTask --> IsTest{Task type?}
    IsTest -- "test" --> RunTest["runTest(test)"]
    IsTest -- "suite" --> RunSuite["runSuite(suite) (recursive)"]
    RunTest --> BE["Run beforeEach hooks (all ancestor suites)"]
    BE --> AE_around["Run aroundEach wrappers"]
    AE_around --> Fn["Execute test function"]
    Fn --> AEH["Run afterEach hooks (reversed)"]
    AEH --> Retry{Failed + retries left?}
    Retry -- "yes" --> RunTest
    Retry -- "no" --> ForTask
    ForTask -- "done" --> AA["Run afterAll hooks"]

並行処理のモデルは config.maxConcurrency と、スイートごとの concurrent/sequential 修飾子によって制御されます。スイート内で .concurrent が指定されたテストは並列実行されますが、concurrency limiter によって同時実行数が制限されます。partitionSuiteChildren() ユーティリティがスイートのタスクを逐次グループと並行グループに分割し、それぞれ適切に実行します。

リトライはテストレベルで処理されます。テストが失敗してリトライ回数が残っている場合、テスト関数全体(beforeEach/afterEach フックも含む)が再実行されます。リトライ設定はシンプルな回数指定(retry: 3)のほか、遅延時間や条件マッチングを含むオブジェクト形式もサポートしています。

function getRetryCount(retry) {
  if (typeof retry === 'number') return retry
  return retry.count ?? 0
}

ヒント: sequence.hooks 設定でフックの実行順序を制御できます。デフォルトの 'parallel' は各フェーズ内でフックを並行実行します。フック間に順序依存がある場合は 'stack' を使いましょう。afterAll/afterEach が登録の逆順で実行されるようになり、Jest と同じ挙動になります。

フックシステム

フック関数は packages/runner/src/hooks.ts で定義されています。各フックは、現在のスイートの SuiteHooks オブジェクトにリスナーとして登録されます。

sequenceDiagram
    participant File as Test File
    participant Suite as Current Suite
    participant Runner as Run Loop

    File->>Suite: beforeAll(fn, timeout)
    File->>Suite: beforeEach(fn)
    File->>Suite: afterEach(fn)
    File->>Suite: afterAll(fn)
    File->>Suite: aroundAll(fn)
    File->>Suite: aroundEach(fn)

    Note over Runner: Execution phase
    Runner->>Runner: aroundAll wraps entire suite
    Runner->>Runner: beforeAll hooks (in order)
    loop Each test
        Runner->>Runner: aroundEach wraps test
        Runner->>Runner: beforeEach hooks (parent → child order)
        Runner->>Runner: Test function
        Runner->>Runner: afterEach hooks (child → parent, reversed)
    end
    Runner->>Runner: afterAll hooks (reversed order)

すべてのフックは context.tswithTimeout() でラップされており、タイマーベースのガードが設けられています。フックがタイムアウトを超えた場合、実行箇所ではなく登録箇所のスタックトレースを示すエラーがスローされます。これが、new Error('STACK_TRACE_ERROR') を登録時にキャプチャしている理由です。

aroundAllaroundEach は強力なフック追加です。それぞれスイートまたはテストの実行全体をラップし、呼び出し必須の runSuite/runTest 関数を受け取ります。

aroundAll(async (runSuite) => {
  await tracer.trace('test-suite', runSuite)
})

aroundEach(async (runTest) => {
  await database.transaction(() => runTest())
})

複数の aroundAll/aroundEach フックはネスト構造になり、最初に登録されたものが最も外側のラッパーになります。

テストフィクスチャ:Playwright スタイルの依存性注入

packages/runner/src/fixture.ts のフィクスチャシステムは、Playwright スタイルの依存性注入を実現しています。フィクスチャは test.extend() で宣言し、分割代入で注入します。

const myTest = test.extend({
  db: async ({}, use) => {
    const connection = await connect()
    await use(connection)  // test runs here
    await connection.close()  // teardown
  },
  page: async ({ db }, use) => {
    const page = await createPage(db)
    await use(page)
  },
})

TestFixtures クラスがフィクスチャの登録・スコープ管理・ライフサイクル全体を担います。

flowchart TD
    Extend["test.extend({ db, page })"] --> Parse["parseUserFixtures()"]
    Parse --> Analyze["Analyze function parameters<br>to detect dependencies"]
    Analyze --> Register["Register in FixtureRegistrations map"]
    
    Run["Test execution"] --> Resolve["Resolve fixture dependencies"]
    Resolve --> Topo["Topological sort<br>(detect cycles)"]
    Topo --> Init["Lazy initialization:<br>call fixture fn, await use()"]
    Init --> Test["Run test with injected values"]
    Test --> Cleanup["Cleanup in reverse order"]

フィクスチャの依存関係は、関数パラメーター名を解析することで検出されます(filterOutComments() と正規表現を使用)。依存グラフを構築してサイクルを検出し、トポロジカル順で初期化を行います。スコープはライフサイクルを制御します——'test' スコープはテストごとに新しいフィクスチャを生成し、'file' スコープはファイル全体で共有し、'worker' スコープはワーカーの生存期間中保持されます。

組み込みフィクスチャとして、tasksignalonTestFailedonTestFinishedskipannotate が常に利用可能です。

packages/runner/src/fixture.ts#L28-L38

テストコンテキスト・タイムアウト・キャンセル

すべてのテスト関数は、packages/runner/src/context.ts で定義された TestContext オブジェクトを受け取ります。コンテキストが提供するものは以下のとおりです。

  • task — メタデータを持つ現在のテストタスクオブジェクト
  • signal — タイムアウトやキャンセル時に発火する AbortSignal
  • expect — スコープ付きのアサーション関数
  • skip() — テストを動的にスキップする
  • onTestFailed() — 失敗時のハンドラーを登録する
  • onTestFinished() — クリーンアップハンドラーを登録する

タイムアウト管理には withTimeout() を使います。非同期関数を setTimeout ガードでラップするユーティリティですが、実装には工夫があります——マイクロタスク解決後に performance.now() とタイムアウトを照合し、マイクロタスク内で長い同期処理が走った場合に setTimeout が遅延するケースも検出します。

function resolve(result) {
  clearTimeout(timer)
  if (now() - startTime >= timeout) {
    rejectTimeoutError()
    return
  }
  resolve_(result)
}

キャンセルは AbortController を通じて伝播します。テスト実行がキャンセルされると(ユーザーが Ctrl+C を押すなど)、プールがワーカーに cancel メッセージを送り、ワーカーの状態にキャンセルフラグが立ちます。テストランナーはテストの合間にこのフラグを確認し、現在のテストのコンテキストシグナルを中断します。

VitestRunner インターフェースと TestRunner の実装

@vitest/runner はフレームワークが実装すべき VitestRunner インターフェースを定義しています。packages/vitest/src/runtime/runners/test.ts#L36-L53 にある Vitest 自身の TestRunner は、このインターフェースを拡張して Vitest 固有の処理を追加しています。

classDiagram
    class VitestRunner {
        <<interface>>
        +config: VitestRunnerConfig
        +pool: string
        +importFile(filepath, source): unknown
        +onCollectStart?(file): void
        +onBeforeRunFiles?(): void
        +onAfterRunFiles?(): void
        +onBeforeRunSuite?(suite): void
        +onAfterRunSuite?(suite): void
        +onBeforeRunTask?(test): void
        +onAfterRunTask?(test): void
        +cancel?(reason): void
    }

    class TestRunner {
        -snapshotClient: SnapshotClient
        -workerState: WorkerGlobalState
        -moduleRunner: ModuleRunner
        +pool: string
        +viteEnvironment: string
        +importFile(filepath, source)
        +onCollectStart(file)
        +onBeforeRunTask(test)
        +onAfterRunTask(test)
        +cancel(reason)
    }

    VitestRunner <|-- TestRunner

TestRunner.importFile() は Vite の ModuleRunner.import() に処理を委譲し、モジュール変換のために Node プロセスへの RPC fetch() 呼び出しをトリガーします。onBeforeRunTask フックはスコープ付きの expect 状態とスナップショット追跡をセットアップします。onAfterRunTask はアサーション数の検証とスナップショットの解決を行います。

テスト環境は packages/vitest/src/integrations/env/index.ts で設定されています。

export const environments = {
  node,
  jsdom,
  'happy-dom': happy,
  'edge-runtime': edge,
}

各環境はグローバルスコープを設定する setup()teardown() 関数を提供します。jsdom 環境は JSDOM インスタンスを生成してそのグローバルをマッピングし、happy-dom は Happy DOM で同様の処理を行い、edge-runtime は Web API 互換の環境を提供します。node 環境は何もしない——Node のネイティブグローバルをそのまま使います。

ヒント: Environment インターフェースを実装し、environment 設定オプションにモジュールパスを指定することで、カスタム環境を作れます。Cloudflare Workers や Deno のような特殊なランタイムでテストしたい場合に役立ちます。

次回予告

@vitest/runner の内側で、テストがどのように定義・収集・実行されるかを見てきました。最終回では、結果がレポーターシステムを通じてユーザーに届くまでの流れや、カバレッジプロバイダーの統合方法を解説します。さらに、Vitest UI を支える WebSocket API や @vitest/expect@vitest/spy@vitest/snapshot といったサブパッケージの連携についても探ります。