Read OSS

Vitest アーキテクチャ概観:Vite ベースのテストフレームワークはどう構成されているか

中級

前提知識

  • Vite の基本的な理解(dev サーバー、プラグイン、モジュール変換)
  • npm/pnpm ワークスペースとモノレポの概念に慣れていること
  • TypeScript の実務的な知識

Vitest アーキテクチャ概観:Vite ベースのテストフレームワークはどう構成されているか

Vitest は「たまたま Vite を使っているテストランナー」ではありません。dev サーバーをフルテストプラットフォームへと変換する Vite プラグインそのものです。この根本的な設計判断を理解すると、コードベース全体の見え方が変わります。なぜ設定が Vite のプラグインフックを通じて流れるのか、なぜワークスペースの各プロジェクトが独自の ViteDevServer インスタンスを持つのか、なぜテストファイルがアプリケーションコードと同じ変換パイプラインで処理されるのか、すべての答えがここにあります。

この記事では全体の地図を描きます。モノレポの 17 パッケージを概観し、Node オーケストレーション層とワーカーランタイムの境界線を追い、中心となる Vitest クラスを解剖し、テスト実行時とプログラマティック用途を明確に分けるデュアル API 設計を理解していきましょう。

モノレポ構造とパッケージマップ

Vitest は pnpm ワークスペースとして構成されており、パッケージは packages/ 配下に置かれ、ドキュメント・サンプル・結合テスト用のディレクトリが別途用意されています。ワークスペースの定義はシンプルです。

pnpm-workspace.yaml#L20-L24

packages:
  - docs
  - packages/*
  - examples/*
  - test/*

全パッケージの一覧は以下のとおりです。

パッケージ 役割
vitest コアフレームワーク:CLI、設定、オーケストレーション、プール、レポーター、ランタイムワーカー
@vitest/runner フレームワーク非依存のテストランナー:DSL(describe/test/it)、収集、実行、フック、フィクスチャ
@vitest/expect Chai をベースに Jest 互換マッチャーを加えたアサーションライブラリ
@vitest/spy tinyspy をベースにしたモック/スパイシステム(vi.fn()vi.spyOn()
@vitest/snapshot スナップショットテスト:インラインおよびファイルベースのスナップショット
@vitest/mocker モジュールモックの基盤(vi.mock()vi.hoisted()
@vitest/utils 共有ユーティリティ:エラー処理、ソースマップ、シリアライズ、差分
@vitest/pretty-format スナップショットと差分のための値シリアライザ(Jest からフォーク)
@vitest/browser ブラウザテストのオーケストレーション
@vitest/browser-playwright Playwright ブラウザプロバイダー
@vitest/browser-webdriverio WebDriverIO ブラウザプロバイダー
@vitest/browser-preview ブラウザプレビュー UI コンポーネント
@vitest/coverage-v8 V8 コードカバレッジプロバイダー
@vitest/coverage-istanbul Istanbul コードカバレッジプロバイダー
@vitest/ui Vue ベースのダッシュボード UI
@vitest/web-worker Node テスト環境向け Web Worker ポリフィル
@vitest/ws-client UI 通信用 WebSocket クライアント
graph TD
    subgraph "Core"
        V[vitest]
        R["@vitest/runner"]
        E["@vitest/expect"]
        S["@vitest/spy"]
        SN["@vitest/snapshot"]
        M["@vitest/mocker"]
        U["@vitest/utils"]
        PF["@vitest/pretty-format"]
    end

    subgraph "Browser"
        B["@vitest/browser"]
        BP["@vitest/browser-playwright"]
        BW["@vitest/browser-webdriverio"]
        BPR["@vitest/browser-preview"]
    end

    subgraph "Coverage"
        CV8["@vitest/coverage-v8"]
        CIS["@vitest/coverage-istanbul"]
    end

    subgraph "UI & Tools"
        UI["@vitest/ui"]
        WW["@vitest/web-worker"]
        WS["@vitest/ws-client"]
    end

    V --> R
    V --> E
    V --> S
    V --> SN
    V --> M
    V --> U
    E --> U
    E --> PF
    SN --> PF
    B --> V
    UI --> WS

ここで重要なのは、厳格な階層構造です。@vitest/runnervitest への依存を一切持たず、テスト DSL と実行エンジンをフレームワーク非依存の形で定義しています。vitest パッケージはこれらのサブパッケージを合成し、Vite のモジュール変換パイプラインと Node.js ワーカー管理を組み合わせて動作させます。

ヒント: コードベースを読み始めるなら packages/vitest/src/public/ が最適な出発点です。ここには明示的な API 境界が集まっており、任意のユースケースに関係する内部モジュールをすぐに把握できます。

2 つの実行ドメイン:Node とランタイム

Vitest のアーキテクチャで最も重要な境界線は、2 つの実行ドメインの分離です。

  1. Node ドメインpackages/vitest/src/node/)— すべてを統括するメインプロセス。CLI パース、設定解決、Vite サーバー管理、プール生成、レポーター呼び出し、ファイル監視、WebSocket API を担います。

  2. ランタイムドメインpackages/vitest/src/runtime/)— テストファイルを実行するワーカースレッドまたは子プロセス。環境セットアップ、Vite の変換パイプラインを通じたモジュール読み込み、テスト収集、テスト実行を担います。

flowchart LR
    subgraph "Node Domain (Main Process)"
        CLI[CLI / Programmatic API]
        Core[Vitest Class]
        Config[Config Resolution]
        Pool[Pool Manager]
        Reporters[Reporters]
        State[StateManager]
    end

    subgraph "Runtime Domain (Workers)"
        Worker[Worker Entry]
        Env[Environment Setup]
        Runner[Test Runner]
        Modules[Module Loading via Vite]
    end

    CLI --> Core
    Core --> Config
    Core --> Pool
    Core --> Reporters
    Core --> State
    Pool -- "birpc over MessagePort" --> Worker
    Worker --> Env
    Worker --> Runner
    Runner --> Modules
    Modules -- "RPC fetch()" --> Core
    Runner -- "RPC events" --> State

この分離は、隔離と並列実行のために存在します。テストファイルは jsdomhappy-dom などグローバル状態を操作する環境を使う場合があります。それぞれを別ワーカーで動かすことで干渉を防いでいます。2 つのドメイン間の通信ブリッジは birpc ——MessagePort(スレッド)または process.send(フォーク)上に透過的な関数呼び出しインターフェースを構築する双方向 RPC ライブラリです。

Vitest クラス——中央オーケストレーター

packages/vitest/src/node/core.tsVitest クラスは、フレームワーク全体の重心です。主要なサブシステムをすべて所有しています。

classDiagram
    class Vitest {
        +version: string
        +logger: Logger
        +projects: TestProject[]
        +watcher: VitestWatcher
        +vcs: VCSProvider
        -pool: ProcessPool
        -_vite: ViteDevServer
        -_state: StateManager
        -_cache: VitestCache
        -_snapshot: SnapshotManager
        -_testRun: TestRun
        +config: ResolvedConfig
        +vite: ViteDevServer
        +state: StateManager
        +snapshot: SnapshotManager
        +cache: VitestCache
        +_setServer(options, server)
        +start(cliFilters)
        +close()
    }

    Vitest --> ViteDevServer
    Vitest --> "1..*" TestProject
    Vitest --> ProcessPool
    Vitest --> StateManager
    Vitest --> SnapshotManager
    Vitest --> VitestCache
    Vitest --> TestRun
    Vitest --> VitestWatcher

コンストラクターは意図的に軽量に設計されており、LoggerVitestSpecificationsVitestWatcher の生成だけを行います。実際の初期化は Vite の configureServer フックが発火した後に呼ばれる _setServer() で行われます。

packages/vitest/src/node/core.ts#L207-L231

このメソッドは設定を解決し、StateManagerVitestCacheSnapshotManagerTestRun を生成したうえで、モジュールランナーと VCS プロバイダーをセットアップします。この設計により、設定ファイルの変更時に Vitest を再初期化できます——_setServer() は再構築の前にすべての状態を丁寧にリセットします。

マルチプロジェクトワークスペースアーキテクチャ

Vitest は複数プロジェクトの並列実行をサポートしており、各プロジェクトは独自の設定・Vite サーバー・モジュールリゾルバーを持ちます。packages/vitest/src/node/project.ts#L45-L93TestProject クラスが、単一のワークスペースプロジェクトを表します。

flowchart TD
    V[Vitest] --> P1[TestProject 'unit']
    V --> P2[TestProject 'integration']
    V --> P3[TestProject 'e2e-chromium']

    P1 --> VS1[ViteDevServer]
    P1 --> C1[ResolvedConfig]
    P1 --> R1[VitestResolver]

    P2 --> VS2[ViteDevServer]
    P2 --> C2[ResolvedConfig]
    P2 --> R2[VitestResolver]

    P3 --> VS3[ViteDevServer]
    P3 --> C3[ResolvedConfig]
    P3 --> R3[VitestResolver]

TestProject は独自の ViteDevServer インスタンスを持つため、プロジェクトごとに異なる Vite プラグイン・エイリアス・解決設定を使えます。packages/vitest/src/node/projects/resolveProjects.ts#L28-L34 のプロジェクト解決ロジックは、3 種類の定義スタイルに対応しています。

  1. 設定ファイルパス — glob で解決される vitest.config.ts または vite.config.ts
  2. ディレクトリパス — 設定ファイルをスキャンするディレクトリ
  3. インラインオブジェクト — ワークスペース定義内に直接記述する設定オブジェクト

プロジェクト名はユニークである必要があり、返却前にシステムが検証します。ブラウザプロジェクトは特別な扱いを受けます——browser.instances が設定された単一プロジェクトは、ブラウザごとに複数の子プロジェクトを生成します。

ビルドエントリとデュアル API 設計

rollup.config.js を見ると、慎重に設計されたデュアル API が見えてきます。

flowchart TD
    subgraph "Test-time API (vitest)"
        IDX["src/public/index.ts"]
        IDX --> describe & test & it & expect & vi
    end

    subgraph "Programmatic Node API (vitest/node)"
        NODE["src/public/node.ts"]
        NODE --> createVitest & startVitest & reporters & config_types
    end

    subgraph "Worker Entries"
        WT["workers/threads"]
        WF["workers/forks"]
        WVM["workers/vmThreads"]
        WVF["workers/vmForks"]
    end

    subgraph "Other Entries"
        CLI["cli"]
        COV["coverage"]
        SNAP["snapshot"]
    end

テスト実行時 API(src/public/index.ts)は、テストファイルに必要なもの——describetestitexpectvibench、フック、型ユーティリティ——をすべて再エクスポートします。@vitest/runner@vitest/expect、および vitest 統合モジュールからインポートされます。

プログラマティック Node API(src/public/node.ts)は、Vitest をプログラムから操作するために必要なもの——createViteststartVitest、レポータークラス、プールワーカー、設定型、シーケンサー——をエクスポートします。IDE 拡張やカスタムツールが使う API です。

この分離は意図的なものです。テストファイルが Node 側のオーケストレーションコードをインポートすべきではなく、ビルドツールがテストグローバルをインポートすべきでもありません。ワーカーエントリはパフォーマンスのために個別にバンドルされており、workers/threads をインポートしてもフレームワーク全体が読み込まれることはありません。

Vite をバックボーンとして

Vitest は Vite を利用するスタンドアロンツールではなく、それ自体が Vite プラグインの配列です。packages/vitest/src/node/plugins/index.ts#L26-L291VitestPlugin 関数は、Vite をテストランナーへと変換するプラグインの配列を返します。

flowchart TD
    VP["VitestPlugin()"] --> Core["vitest (core plugin)"]
    VP --> ME["MetaEnvReplacerPlugin"]
    VP --> CSS["CSSEnablerPlugin"]
    VP --> COV["CoverageTransform"]
    VP --> RES["VitestCoreResolver"]
    VP --> MOCK["MocksPlugins"]
    VP --> OPT["VitestOptimizer"]
    VP --> NORM["NormalizeURLPlugin"]
    VP --> MRT["ModuleRunnerTransform"]

    Core -- "config()" --> MergeDefaults["Merge configDefaults with user config"]
    Core -- "configResolved()" --> SetupVitest["Store config, set env variables"]
    Core -- "configureServer()" --> Init["vitest._setServer()"]

コアの vitest プラグインは 3 つの Vite フックを使います。

  • config() — Vitest の configDefaults とユーザー設定をマージし、サーバーオプション(HMR の無効化、API ポートの設定)をセットアップし、esbuild/oxc のターゲットを調整します。
  • configResolved() — すべてのプラグインが実行された後に最終的な設定マージを行い、UI プラグインの注入を処理し、解決済みの設定を Vite 設定オブジェクトに保存します。
  • configureServer() — Vite サーバーの準備が整った後、vitest._setServer() を呼び出して初期化を完了させます。

サブプラグインはそれぞれ専門的な役割を担います。CoverageTransform はカバレッジのためにコードをインストルメント化します。MocksPlugins は変換レベルで vi.mock() 呼び出しをインターセプトし、VitestOptimizer は依存関係の最適化を管理します。MetaEnvReplacerPlugin は実行時の再代入を可能にするため import.meta.envprocess.env に置き換えます。

ヒント: 設定の問題をデバッグするなら、plugins/index.ts の 44 行目にある config() フックが最初の調査地点です。Vitest のデフォルト値とユーザー設定が最初にマージされる場所であり、解決済み設定の最終的な値の出どころはここにあります。

次のステップ

アーキテクチャ全体の地図が描けたので、次は実際の実行パスを追いましょう。次の記事では、vitest run コマンドがバイナリエントリから始まり、CLI パース、設定ファイルの探索、Vite サーバーの生成、プラグインフックのライフサイクル全体、ワークスペースの解決、ワーカーへの設定シリアライズまでをたどります。このブートシーケンスを理解しておくと、設定の問題のデバッグや、Vitest のプログラマティック API を活用したカスタムツールの構築がはるかにスムーズになります。