module-mocking-architecture
リポジトリ: vitest-dev/vitest 分析日: 2026-03-05
概要
Vitest のモジュールモッキング基盤(@vitest/mocker パッケージ)を横断的に分析し、AST 変換によるホイスティング、環境非依存の自動モック生成、ブラウザ/Node 両対応のインターセプト戦略を抽出した。ESM の静的バインディング制約を Vite プラグインチェーンと MagicString による精密なコード書き換えで突破する手法は、テストツールに限らず「実行時にモジュールの振る舞いを差し替える」あらゆるシステムに応用できる。
背景にある原則
- 早期フィルタリングで AST 解析コストを回避すべき: 正規表現による事前チェック (
regexpHoistable) でvi.mock等の呼び出しが存在しないファイルを即座にスキップし、重い AST パースを避ける。根拠:hoistMocks.ts:84でregexpHoistable.test(code)を最初に実行し、マッチしなければ即returnする設計。 - コード変換は破壊的書き換えではなく差分パッチであるべき: MagicString を使い、元コードの位置情報を保持したまま
move/overwrite/appendLeftで変換する。ソースマップが自動生成され、デバッグ体験を損なわない。根拠:hoistMocks.ts全体でs.move()/s.update()を使用し、最後にs.generateMap({ hires: 'boundary' })でマップ生成。 - 環境差異はインターフェース境界で吸収すべき:
ModuleMockerInterceptorという 3 メソッドのインターフェースを定義し、ブラウザ(MSW / ネイティブ)と Node.js で別実装を差し込む。コア(MockerRegistry,mockObject)は環境非依存。根拠:interceptor.tsでregister/delete/invalidateのみ定義。 - 型によるモック分類で条件分岐を構造化すべき:
automock/autospy/manual/redirectの 4 種を判別共用体(discriminated union)で表現し、switch (mock.type)パターンで安全に分岐する。根拠:registry.ts:155-160のMockedModule型と各所のmock.typeによる分岐。
実例と分析
AST 変換によるモック宣言のホイスティング
vi.mock() は見た目上テストファイル内の任意の位置に書けるが、実際にはファイル先頭にホイストされる必要がある。hoistMocks.ts は以下の手順でこれを実現する:
- 正規表現で
vi.mock/vi.unmock/vi.hoistedの存在をチェック(高速フィルタ) esmWalkerで AST を走査し、CallExpressionから対象メソッド呼び出しを検出- 検出した呼び出しノードを
hoistedNodesセットに蓄積 - ノード同士のネスト関係を検証(相互ネストを禁止)
MagicString.move()でファイル先頭に物理的に移動import文をconst __vi_import_N__ = await import(...)に変換し、参照先を書き換え
// packages/mocker/src/node/hoistMocks.ts:84-88
const needHoisting = (options.regexpHoistable || regexpHoistable).test(code)
if (!needHoisting) {
return
}// packages/mocker/src/node/hoistMocks.ts:494-503
for (const node of arrayNodes) {
const end = getNodeTail(code, node)
if (hoistIndex === end || hoistIndex === node.start) {
hoistIndex = end
}
else {
s.move(node.start, end, hoistIndex)
}
}自動モック生成の再帰的オブジェクト走査
automocker.ts の mockObject 関数は、モジュールエクスポートを再帰的に走査し、関数をスパイ/スタブに置換する。循環参照を RefTracker で追跡し、無限再帰を防止する。
// packages/mocker/src/automocker.ts:183-201
class RefTracker {
private idMap = new Map<any, number>()
private mockedValueMap = new Map<number, any>()
public getId(value: any) {
return this.idMap.get(value)
}
public getMockedValue(id: number) {
return this.mockedValueMap.get(id)
}
public track(originalValue: any, mockedValue: any): number {
const newId = this.idMap.size
this.idMap.set(originalValue, newId)
this.mockedValueMap.set(newId, mockedValue)
return newId
}
}automock と autospy の差異は createMock 内で分岐する。autospy は元の実装を保持しつつ呼び出しを記録し、automock は空の関数に置換する:
// packages/mocker/src/automocker.ts:44-49
return createMockInstance({
name: currentValue.name,
prototypeMembers,
originalImplementation: options.type === 'autospy' ? currentValue : undefined,
keepMembersImplementation: options.type === 'autospy',
})ブラウザ環境でのモジュールインターセプト戦略
ブラウザでは 2 つのインターセプト戦略が用意されている:
- MSW 方式 (
interceptor-msw.ts): Service Worker でリクエストを傍受し、モックモジュールの URL にリダイレクトまたは動的生成スクリプトを返す - ネイティブ方式 (
interceptor-native.ts): WebSocket RPC でサーバー側レジストリに登録し、Vite のload/transformフックでコード変換
どちらも同一の ModuleMockerInterceptor インターフェースを実装する:
// packages/mocker/src/browser/interceptor.ts:1-7
export interface ModuleMockerInterceptor {
register: (module: MockedModule) => Promise<void>
delete: (url: string) => Promise<void>
invalidate: () => Promise<void>
}動的インポートのラッピング
dynamicImportPlugin.ts は、テストコード内の import() 式を globalThis["__vitest_mocker__"].wrapDynamicImport(() => import(...)) にラップする。モック登録が完了してからインポートが実行されることを保証する:
// packages/mocker/src/node/dynamicImportPlugin.ts:67-76
onDynamicImport(node) {
const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"'
const replaceString = `globalThis[${globalThisAccessor}].wrapDynamicImport(() => import(`
const importSubstring = code.substring(node.start, node.end)
const hasIgnore = importSubstring.includes('/* @vite-ignore */')
s.overwrite(
node.start,
(node.source as Positioned<Expression>).start,
replaceString + (hasIgnore ? '/* @vite-ignore */ ' : ''),
)
s.overwrite(node.end - 1, node.end, '))')
},レジストリのデュアルインデックスとシリアライズ設計
MockerRegistry は URL とモジュール ID の 2 つのインデックスで同一モックを管理する。これはブラウザ側(URL ベース)とサーバー側(ファイルパスベース)の参照方式の違いを吸収するためである。また各モッククラスは toJSON / fromJSON を持ち、WebSocket 越しにシリアライズ可能:
// packages/mocker/src/registry.ts:1-4
export class MockerRegistry {
private readonly registryByUrl: Map<string, MockedModule> = new Map()
private readonly registryById: Map<string, MockedModule> = new Map()パターンカタログ
Strategy パターン (分類: 振る舞い)
- 解決する問題: ブラウザ/Node.js でモジュールインターセプトの仕組みが根本的に異なる
- 適用条件: 同一インターフェースで異なる実行環境をサポートする必要がある場合
- コード例:
interceptor.ts:1-7,interceptor-msw.ts:34,interceptor-native.ts:5 - 注意点: インターフェースを最小化する(3 メソッド)ことで実装コストを低く保っている
Visitor パターン (分類: 振る舞い)
- 解決する問題: AST の各ノード型に対して異なる変換処理を適用する
- 適用条件: 木構造を走査しながら型ごとに異なるアクションを実行する場合
- コード例:
esmWalker.ts:64-66のVisitorsインターフェース - 注意点: DFS で収集し BFS で処理する二段階方式でホイスト宣言の順序を保証
Registry パターン (分類: 構造)
- 解決する問題: 複数の識別子(URL / ID)で同一オブジェクトを参照する
- 適用条件: エンティティに複数の自然キーがある場合
- コード例:
registry.ts:1-4のデュアル Map - 注意点: 削除時に両方のインデックスから除去しないと不整合が生じる
Good Patterns
- 正規表現による AST 解析ガード: 重い解析の前に正規表現で対象ファイルを絞り込む。
hoistMocks.ts:84でregexpHoistable.test(code)を行い、dynamicImportPlugin.ts:23でregexDynamicImport.test(source)を行う。解析不要なファイルの処理コストをゼロに近づける。
// packages/mocker/src/node/dynamicImportPlugin.ts:7,21-24
const regexDynamicImport = /import\s*\(/
// ...
transform(source, id) {
if (!regexDynamicImport.test(source)) {
return
}- RefTracker による循環参照の安全な処理: 再帰的なオブジェクト走査で循環参照を ID ベースで追跡し、遅延解決(finalizer パターン)で正しくリンクする。
// packages/mocker/src/automocker.ts:95-101
const refId = refs.getId(value)
if (refId !== undefined) {
finalizers.push(() =>
define(newContainer, property, refs.getMockedValue(refId)),
)
continue
}- Discriminated Union によるモック種別管理:
typeフィールドで 4 種のモックを区別し、switchや条件分岐で網羅的に処理。satisfies neverによる exhaustiveness check も適用されている。
Anti-Patterns / 注意点
- 環境固有ロジックの共有コードへの混入:
automock.ts:55-57でimport.meta.resolveとreadFileSyncを使うコードが Node.js 専用のnode/ディレクトリに正しく配置されている。仮にこれが共有モジュールにあった場合、ブラウザ環境で壊れる。
// Bad: 共有モジュールに環境固有コードを配置
// shared/automock.ts
import { readFileSync } from 'node:fs' // ブラウザで壊れる
// Better: 環境固有ディレクトリに分離
// node/automock.ts (Node.js 専用)
import { readFileSync } from 'node:fs' // OK- モック登録とインポートの競合状態:
vi.mock()はホイストされるが実行は非同期。動的インポートがモック登録完了前に走るとモックが適用されない。wrapDynamicImportによる待機が必須。
// Bad: モック登録前にインポートが実行される
vi.mock('./module')
const mod = await import('./module') // モックが未登録の可能性
// Better: wrapDynamicImport でキューの完了を待機
const mod = await mocker.wrapDynamicImport(() => import('./module'))導出ルール
[MUST]AST ベースのコード変換では元コードの位置情報を保持する差分パッチ(MagicString 等)を使い、ソースマップを生成する- 根拠:
hoistMocks.ts/automock.ts/dynamicImportPlugin.tsの全変換でMagicString+generateMap({ hires: 'boundary' })を使用し、デバッグ体験を維持している
- 根拠:
[MUST]再帰的オブジェクト走査では循環参照を検出・追跡する仕組みを設ける- 根拠:
automocker.ts:183-201のRefTrackerが ID ベースの参照追跡と遅延解決で無限再帰を防止している
- 根拠:
[SHOULD]重い AST 解析の前に正規表現で対象ファイルを事前フィルタリングする- 根拠:
hoistMocks.ts:84とdynamicImportPlugin.ts:23で正規表現テストによる早期リターンが実装され、不要な解析を回避している
- 根拠:
[SHOULD]環境差異を吸収するインターフェースはメソッド数を最小限(3-5個)に保つ- 根拠:
ModuleMockerInterceptorはregister/delete/invalidateの 3 メソッドのみで、MSW・ネイティブ・Node.js の 3 実装を統一している
- 根拠:
[SHOULD]非同期リソース(モック登録等)に依存する処理は、リソースの準備完了を保証するラッパーを提供する- 根拠:
wrapDynamicImportがprepare()の完了を待ってから動的インポートを実行し、登録とインポートの競合状態を防止している
- 根拠:
[AVOID]コード変換プラグインで AST 解析を条件なしに全ファイルに適用する- 根拠:
hoistMocksPlugin.ts:40-42でfilter(id)による除外と正規表現による事前チェックの二段構えで、無関係なファイルの解析を回避している
- 根拠:
適用チェックリスト
- [ ] コード変換を行うツールで MagicString 等の差分パッチライブラリを使い、ソースマップを生成しているか
- [ ] AST 変換プラグインに正規表現ベースの事前フィルタリングを設けているか
- [ ] 再帰的なオブジェクト/グラフ走査に循環参照の検出・追跡機構があるか
- [ ] 複数環境(ブラウザ/Node/エッジ等)をサポートする場合、環境差異を最小インターフェースで抽象化しているか
- [ ] 環境固有コード(
fs,process等)が共有モジュールに混入していないか - [ ] 非同期初期化に依存する処理で、初期化完了の待機メカニズムを提供しているか
- [ ] 判別共用体(discriminated union)で型の種別を管理し、網羅的な分岐処理を行っているか