Read OSS

CLIから設定ファイルへ:Vitestの起動と設定解決の仕組み

中級

前提知識

  • 第1回:アーキテクチャとプロジェクト構成
  • Viteのプラグインフックシステム(config、configResolved)の基本的な理解
  • CLIの引数パースの概念に関する基礎知識

CLIから設定ファイルへ:Vitestの起動と設定解決の仕組み

ターミナルで vitest と入力した瞬間、緻密に設計された一連の処理が始まります。バイナリエントリはコンパイル済みの JavaScript を読み込み、cac をベースに構築された CLI パーサーが引数を解釈します。設定ファイルが探索され、Vitest のプラグインを注入した Vite dev server が作成されます。プラグインのフックが config()configResolved()configureServer() の順に発火し、最終的には Vitest クラスが解決済みの設定・プロジェクト・レポーターを持った完全な状態で初期化されます。

このパイプラインの仕組みを理解することは非常に重要です。設定値が反映されない原因を調査する場合でも、プログラマティック API を使って IDE 連携を構築する場合でも、カスタムプラグインで Vitest を拡張する場合でも、自分のコードがこのチェーンのどこに関わるかを正確に把握しておく必要があります。

バイナリエントリとCLIパース

処理の出発点は packages/vitest/vitest.mjs です。わずか2行のファイルです:

#!/usr/bin/env node
import './dist/cli.js'

コンパイルされた dist/cli.js の元は packages/vitest/src/node/cli.ts で、こちらも同様に最小限の内容です:

import { createCLI } from './cli/cac'
createCLI().parse()

CLI の実体は packages/vitest/src/node/cli/cac.ts にあります。createCLI() 関数は cac インスタンスを作成し、型付けされた設定オブジェクトからすべての CLI オプションを登録して、利用可能なコマンドを定義します:

packages/vitest/src/node/cli/cac.ts#L166-L206

flowchart TD
    BIN["vitest.mjs"] --> CLI["createCLI().parse()"]
    CLI --> CMD{Command?}
    CMD -- "run [...filters]" --> run["start('test', filters, {run: true})"]
    CMD -- "watch [...filters]" --> watch["start('test', filters, {watch: true})"]
    CMD -- "dev [...filters]" --> watch
    CMD -- "bench [...filters]" --> bench["start('benchmark', filters)"]
    CMD -- "list [...filters]" --> collect["collect('test', filters)"]
    CMD -- "init <project>" --> init["init project scaffolding"]
    CMD -- "[...filters] (default)" --> default_cmd["start('test', filters)"]
    run & watch & default_cmd --> startVitest

デフォルトコマンド(サブコマンドなし)は start('test', ...) にルーティングされ、環境に応じて watch モードを決定します。isCI が true の場合、または stdin が TTY でない場合は watch モードが無効になります。run コマンドは options.run = true を明示的に設定し、watch モードを強制的に無効化します。

ヒント: vitest/node からエクスポートされている parseCLI() 関数を使えば、Vitest の CLI 文字列をプログラムから解析できます。実際に実行せずに Vitest の呼び出しを理解するツールを構築する際に役立ちます。

startVitest() ― プログラマティックエントリポイント

すべての CLI コマンドは最終的に packages/vitest/src/node/cli/cli-api.ts#L56-L62startVitest() を呼び出します。この関数は二つの役割を担っています――CLI のバックエンドであると同時に、vitest/node からエクスポートされる公開プログラマティック API でもあります。

sequenceDiagram
    participant CLI as CLI / User Code
    participant SV as startVitest()
    participant CV as createVitest()
    participant Vite as Vite Dev Server
    participant Vitest as Vitest Instance

    CLI->>SV: startVitest(mode, filters, options)
    SV->>CV: createVitest(mode, options, overrides)
    CV->>Vite: createViteServer(config + VitestPlugin)
    Vite-->>Vitest: configureServer() → _setServer()
    CV-->>SV: return Vitest instance
    SV->>SV: Ensure coverage packages installed
    SV->>SV: Register console shortcuts (if TTY + watch)
    SV->>Vitest: ctx.start(cliFilters)

Vitest インスタンスの作成後、startVitest() はタグの一覧表示、キャッシュのクリア、レポートのマージ、スタンドアロンモードなど、いくつかの特殊モードを処理してから、通常の ctx.start(cliFilters) パスへと流れていきます。また、カバレッジプロバイダのパッケージがインストールされているかを確認し(必要に応じてユーザーに確認を求め)、watch モード用のキーボードショートカットも設定します。

さらに、Vite server が(設定変更などによって)再起動した際にテストの実行が自動的に再開されるよう、onAfterSetServer コールバックも登録します。

設定ファイルの探索とViteサーバーの作成

packages/vitest/src/node/create.tscreateVitest() 関数が、Vitest インスタンスと Vite server を結びつける場所です:

flowchart TD
    CV["createVitest()"] --> NewCtx["new Vitest(mode, options)"]
    CV --> FindConfig{"Config path?"}
    FindConfig -- "options.config = false" --> NoConfig["No config file"]
    FindConfig -- "options.config = path" --> Resolve["resolveModule(path)"]
    FindConfig -- "none specified" --> Discover["find.any(configFiles, {cwd: root})"]
    
    Discover --> Names["CONFIG_NAMES × CONFIG_EXTENSIONS"]
    Names --> |"vitest.config.ts<br>vitest.config.mts<br>vitest.config.cts<br>vitest.config.js<br>...<br>vite.config.ts<br>..."| FirstMatch["First match wins"]

    NoConfig & Resolve & FirstMatch --> CreateVite["createViteServer({<br>  configFile,<br>  mode,<br>  plugins: VitestPlugin<br>})"]
    CreateVite --> Server["Vite Dev Server ready"]

設定ファイルの探索には empathic/find を使用し、packages/vitest/src/constants.ts#L8-L14 に定義されたリストの中から最初にマッチするファイルを見つけます:

export const CONFIG_NAMES: string[] = ['vitest.config', 'vite.config']
export const CONFIG_EXTENSIONS: string[] = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
export const configFiles: string[] = CONFIG_NAMES.flatMap(name =>
  CONFIG_EXTENSIONS.map(ext => name + ext),
)

これにより12種類の候補が生成されます:vitest.config.tsvitest.config.mts、...、vite.config.cjs。Vitest 固有の設定ファイルは Vite の設定ファイルよりも優先されます。Vite server は VitestPlugin をプラグイン配列として注入し、設定ファイルのパスと 'test' または 'benchmark' のモードを指定して作成されます。

VitestPluginフックのライフサイクル

第1回で見たとおり、VitestPlugin は Vite プラグインの配列を返します。Vite server の作成時、コアプラグインのフックはこの順序で発火します:

packages/vitest/src/node/plugins/index.ts#L44-L59config() フックは事前マージを行います:

const testConfig = deepMerge(
  {} as UserConfig,
  configDefaults,
  removeUndefinedValues(viteConfig.test ?? {}),
  options,
)

マージの順序は重要です。まず configDefaults、次にあなたの Vite 設定の test プロパティ、そして CLI オプションの順です。CLI オプションが最優先されます。このフックはさらに、Vite の設定から define の値を取り除き(ワーカーへの注入のために別途保存)、HMR を無効化し、テスト用にサーバーを設定します。

212行目configResolved() フックは、他のすべての Vite プラグインが変更を適用した後に最終マージを行います。最終的な設定は、列挙不可能な _vitest プロパティとして Vite の設定オブジェクトに保持されます。

最後に、262行目configureServer() が Vitest の実際の初期化を開始します:

configureServer: {
  async handler(server) {
    await vitest._setServer(options, server)
    if (options.api && options.watch) {
      (await import('../../api/setup')).setup(vitest)
    }
    if (!options.watch) {
      await server.watcher.close()
    }
  },
},

設定の解決とデフォルト値

packages/vitest/src/node/config/resolveConfig.tsresolveConfig() 関数は、ユーザー設定と Vite の解決済み設定から ResolvedConfig を生成します。パスの正規化、シーケンサーの解決、オプションの組み合わせの検証、環境固有のデフォルト値の適用を行います。

packages/vitest/src/defaults.ts#L63-L139 のデフォルト値は、Vitest の設計思想を如実に表しています:

export const configDefaults = Object.freeze({
  allowOnly: !isCI,
  isolate: true,
  watch: !isCI && process.stdin.isTTY && !isAgent,
  globals: false,
  environment: 'node',
  include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
  exclude: ['**/node_modules/**', '**/.git/**'],
  teardownTimeout: 10000,
  maxConcurrency: 5,
  slowTestThreshold: 300,
  // ...
})

注目すべきデフォルト設定がいくつかあります。isolate: true は各テストファイルを独立したモジュールコンテキストで実行することを意味します。watch モードは CI 環境でなく、stdin が TTY であり、エージェント環境でない場合にのみ有効になります。globals: falsedescribe/test/expect がデフォルトではグローバルスコープに注入されないことを意味します――これらは明示的にインポートする必要があります。

デフォルトの include パターン **/*.{test,spec}.?(c|m)[jt]s?(x) は、foo.test.tsbar.spec.mjsbaz.test.cjsx のようなファイルにマッチします。デフォルトの exclude では常に node_modules.git が除外されます。

ヒント: テストファイルが Vitest に認識されない原因を調べる際は、まず include パターンを確認しましょう。デフォルトではファイル名に .test. または .spec. が含まれている必要があります。ディレクトリベースの探索を期待している初心者にとって、これはよくある混乱の原因です。

ワークスペースとマルチプロジェクトの解決

_setServer() がベース設定を解決すると、プロジェクトの解決が始まります。resolveProjects() 関数は、ワークスペース定義を3つのパスで処理します:

flowchart TD
    Def["Workspace definitions"] --> Type{Type?}
    Type -- "string (static path)" --> Static["resolve & stat"]
    Static --> IsFile{File?}
    IsFile -- "yes" --> ValidName{"Matches config pattern?"}
    ValidName -- "yes" --> ConfigFiles["Add to config files"]
    IsFile -- "no (directory)" --> ScanDir["Scan for config file"]
    ScanDir -- "found" --> ConfigFiles
    ScanDir -- "not found" --> NonConfig["Non-config directory project"]

    Type -- "string (glob)" --> Glob["glob() match"]
    Glob --> ConfigFiles & NonConfig

    Type -- "object / function" --> Inline["Inline project config"]

    ConfigFiles --> Init["initializeProject()"]
    NonConfig --> Init
    Inline --> Init
    Init --> Unique{"Names unique?"}
    Unique -- "yes" --> Browser["resolveBrowserProjects()"]
    Unique -- "no" --> Error["Throw duplicate name error"]

各プロジェクトは initializeProject() で初期化されます。新しい TestProject を作成し、WorkspaceVitestPlugin を持つ独自の Vite server をセットアップして、設定を独立して解決します。--pool--globals--bail などの CLI オーバーライドは、すべてのプロジェクトに伝播されます。

解決処理は、利用可能な CPU コア数に基づいて limitConcurrency() を使って並列実行されます。失敗したプロジェクトは最初のエラーで処理を止めずに収集され、AggregateError としてまとめて報告されます。

ワーカーへの設定シリアライズ

Node 側で設定が解決されたら、それをワーカースレッドに届けるためにプロセス境界を越える必要があります。packages/vitest/src/node/config/serializeConfig.tsserializeConfig() 関数は SerializedConfig を生成します。これは、structuredClone() や JSON シリアライズでは扱えない関数・クラスインスタンスなどを取り除いたプレーンオブジェクトです。

flowchart LR
    RC[ResolvedConfig] --> SC["serializeConfig()"]
    SC --> Stripped["SerializedConfig<br>(plain object)"]
    Stripped -- "MessagePort / process.send" --> Worker["Worker Process"]
    
    SC -.- Note["Strips:<br>- Functions<br>- Class instances<br>- Server references<br>- Plugin arrays<br><br>Keeps:<br>- Scalar values<br>- File paths<br>- Pattern strings<br>- Timeout values"]

この関数は「シリアライズ不可能なものを汎用的に除去する」アプローチではなく、フィールドを個別に選別します。これは意図的な設計です――新しい設定フィールドを追加する際に、ワーカーがそれを必要とするかどうかを明示的に判断することを強制するためです。カバレッジ設定は reportsDirectoryproviderenabledcustomProviderModule のみに絞り込まれます。deps 設定はオプティマイザーの完全な設定を失い、enabled フラグのみが保持されます。

packages/vitest/src/node/config/serializeConfig.ts#L6-L62

ワーカー内のテストランナーはこのシリアライズされた設定を使って動作します。ResolvedConfig のサブセットであり、テストの実行には十分ですが、Vite server の操作やレポーターの管理には必要な情報が含まれていません。

次回予告

起動シーケンスと設定パイプラインの仕組みが理解できたところで、次はテストが実際に実行される際の処理を掘り下げていきます。次回の記事では、Vitest の3層プールアーキテクチャ(PoolPoolRunnerPoolWorker)、birpc 通信ブリッジ、ワーカーの起動と環境セットアップ、そしてテスト結果が StateManagerTestRun を経由してレポーターに届くまでの流れを解説します。