T-CREATOR

【解決策】Vitest HMR 連携でテストが落ちる技術的原因と最短解決

【解決策】Vitest HMR 連携でテストが落ちる技術的原因と最短解決

Vitest と開発サーバーの HMR(Hot Module Replacement)を連携させた際に、突然テストが失敗してしまう現象に悩まされていませんか。

この問題は、多くの開発者が直面する複雑な技術課題で、単純な設定ミスではなく、HMR とテストランナーの根本的な動作メカニズムに起因しています。本記事では、この問題の技術的原因を詳しく解説し、確実に解決できる方法をご紹介します。

Vitest HMR で起こる謎のテスト失敗

典型的なエラーパターン

Vitest と HMR を組み合わせた開発環境では、以下のような予期しないエラーが発生することがあります。

bashError: Module not found: Cannot resolve 'src/components/Button.vue'
  at resolveModule (/node_modules/vite/dist/node/chunks/dep-xxx.js:123:45)
  at async loadAndTransform (/node_modules/vitest/dist/node.js:678:12)

このエラーは、開発サーバーでは正常に動作するコンポーネントが、テスト実行時のみ見つからないという現象です。また、以下のような状況も頻繁に発生します。

問題が起きやすい開発シーン

開発中のファイル更新時、以下の流れでエラーが発生しやすくなります。

mermaidflowchart TD
    dev[開発者がファイル編集] -->|保存| hmr[HMR 検知]
    hmr -->|モジュール更新| cache[キャッシュ更新]
    cache -->|並行実行| test[Vitest 実行]
    test -->|モジュール解決失敗| error[テストエラー]

    style error fill:#ffcccc
    style test fill:#ffffcc

図で理解できる要点:

  • ファイル保存と同時に HMR とテスト実行が競合状態になる
  • モジュールキャッシュの更新タイミングがずれることでエラーが発生
  • 開発サーバーとテストランナーが異なるモジュール解決を行う

根本原因の特定

HMR とテストランナーの干渉メカニズム

この問題の核心は、Vite の HMR システムと Vitest のテスト実行環境が、同じモジュールグラフを参照しながら異なるタイミングで動作することにあります。

typescript// HMR が動作する際の内部処理(簡略化)
interface HMRContext {
  moduleGraph: ModuleGraph;
  updateQueue: UpdatePayload[];
  invalidateModule: (id: string) => void;
}

HMR は以下の手順でモジュールを更新します。

typescript// 1. ファイル変更検知
const changedFile = '/src/components/Button.vue';

// 2. 依存関係の解析
const dependents =
  moduleGraph.getDirectDependents(changedFile);

// 3. モジュールの無効化
dependents.forEach((dep) => invalidateModule(dep.id));

モジュールキャッシュとテスト環境の競合

Vitest は独自のモジュール解決システムを持っており、以下のような処理フローで動作します。

typescript// Vitest のモジュール解決処理
class VitestModuleResolver {
  private cache = new Map<string, ModuleInfo>();

  async resolveModule(id: string): Promise<ModuleInfo> {
    // キャッシュチェック
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }

    // 新規解決
    const resolved = await this.resolve(id);
    this.cache.set(id, resolved);
    return resolved;
  }
}

問題は、HMR がモジュールを無効化するタイミングと、Vitest がキャッシュを参照するタイミングが一致しないことです。

ウォッチモードでの状態管理問題

ウォッチモードでは、さらに複雑な競合状態が発生します。以下の図で状況を整理できます。

mermaidsequenceDiagram
    participant ファイル
    participant HMR
    participant ViteCache
    participant Vitest
    participant TestCache

    ファイル->>HMR: ファイル変更通知
    HMR->>ViteCache: モジュール無効化
    HMR->>Vitest: テスト再実行トリガー

    Note over Vitest, TestCache: 競合発生ポイント

    Vitest->>TestCache: キャッシュ参照
    TestCache-->>Vitest: 古いモジュール情報
    Vitest->>ViteCache: モジュール要求
    ViteCache-->>Vitest: エラー(無効化済み)

要点:ファイル変更から HMR 処理、テスト実行まで時間差が生じ、キャッシュの状態が不整合になる

技術的解決策

設定ファイル最適化による根本解決

最も効果的な解決策は、Vitest の設定でモジュール解決とキャッシュの動作を適切に制御することです。

以下の設定により、HMR との競合を回避できます。

typescript// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  test: {
    environment: 'jsdom',
    // モジュール解決の安定化
    deps: {
      // 外部依存関係のキャッシュを無効化
      external: [/node_modules/],
      // インライン化対象を明示的に指定
      inline: [
        /^(?!.*node_modules).*\.vue$/,
        /^(?!.*node_modules).*\.ts$/,
      ],
    },
  },
});

さらに詳細な制御として、モジュール解決の設定を追加します。

typescript// 続き:vitest.config.ts
export default defineConfig({
  test: {
    // 前の設定に加えて
    server: {
      // テスト専用サーバー設定
      deps: {
        // HMR との分離
        hmr: false,
        // モジュールキャッシュの無効化間隔
        cacheDir: 'node_modules/.vitest',
      },
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
});

テスト隔離環境の構築

開発環境とテスト環境を完全に分離することで、HMR の影響を受けない安定したテスト実行が可能になります。

typescript// scripts/test-setup.ts
import { beforeEach, afterEach } from 'vitest';

// テスト間でのモジュールキャッシュクリア
beforeEach(() => {
  // 動的インポートキャッシュをクリア
  delete require.cache[require.resolve('../src/main.ts')];

  // Vitest 内部キャッシュのリセット
  if (global.__vitest_mocker__) {
    global.__vitest_mocker__.mockReset();
  }
});

テストファイルでの適切な処理も重要です。

typescript// tests/components/Button.test.ts
import { describe, it, expect, beforeEach } from 'vitest';

describe('Button コンポーネント', () => {
  let Button: any;

  beforeEach(async () => {
    // 毎回新しいモジュールをインポート
    Button = (
      await import('../../src/components/Button.vue')
    ).default;
  });

  it('正常にレンダリングされること', () => {
    // テストの実装
    expect(Button).toBeDefined();
  });
});

HMR 無効化とパフォーマンス両立

テスト実行時のみ HMR を無効化し、開発体験を損なわない設定方法をご紹介します。

typescript// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig(({ command, mode }) => {
  const isTest = mode === 'test';

  return {
    plugins: [vue()],
    server: {
      // テスト時は HMR を無効化
      hmr: isTest
        ? false
        : {
            port: 3001,
            overlay: true,
          },
    },
    optimizeDeps: {
      // テスト時の依存関係最適化を無効化
      disabled: isTest,
    },
  };
});

実装手順とコード例

vitest.config.ts の適切な設定

完全な設定ファイルの例を以下に示します。この設定により、HMR との競合を完全に回避できます。

typescript// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
  plugins: [vue()],
  test: {
    // テスト実行環境
    environment: 'jsdom',

    // グローバル設定
    globals: true,

    // セットアップファイル
    setupFiles: ['./tests/setup.ts'],

    // 依存関係管理
    deps: {
      // HMR 影響の排除
      external: [/node_modules/],
      inline: [
        // Vue SFC の処理
        /.*\.vue$/,
        // TypeScript ファイル
        /.*\.ts$/,
        // プロジェクト内のすべてのモジュール
        /^(?!.*node_modules).*/,
      ],
    },

    // ウォッチ設定
    watch: false, // CI/CD での安定性確保

    // 並行実行制御
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: true, // HMR 競合回避
      },
    },
  },

  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '~': resolve(__dirname, 'src'),
    },
  },
});

テストファイルでの対策実装

テストファイル側でも適切な対策を実装することで、より安定したテスト実行が可能になります。

typescript// tests/utils/module-loader.ts
/**
 * モジュールの動的読み込みユーティリティ
 * HMR の影響を受けない安全な読み込みを提供
 */
export async function safeImport<T = any>(
  modulePath: string
): Promise<T> {
  try {
    // キャッシュを迂回した読み込み
    const timestamp = Date.now();
    const module = await import(
      `${modulePath}?t=${timestamp}`
    );
    return module.default || module;
  } catch (error) {
    console.warn(
      `モジュール読み込みに失敗: ${modulePath}`,
      error
    );
    throw error;
  }
}

実際のテストファイルでの使用例:

typescript// tests/components/UserProfile.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { safeImport } from '../utils/module-loader';

describe('UserProfile コンポーネント', () => {
  let UserProfile: any;

  beforeEach(async () => {
    // 安全なモジュール読み込み
    UserProfile = await safeImport(
      '../../src/components/UserProfile.vue'
    );
  });

  it('ユーザー情報が正しく表示されること', async () => {
    const wrapper = mount(UserProfile, {
      props: {
        user: {
          name: '田中太郎',
          email: 'tanaka@example.com',
        },
      },
    });

    expect(
      wrapper.find('[data-testid="user-name"]').text()
    ).toBe('田中太郎');
    expect(
      wrapper.find('[data-testid="user-email"]').text()
    ).toBe('tanaka@example.com');
  });
});

開発環境とテスト環境の分離

package.json でのスクリプト設定により、環境を適切に分離できます。

json{
  "scripts": {
    "dev": "vite --mode development",
    "test": "vitest --mode test",
    "test:ui": "vitest --ui --mode test",
    "test:coverage": "vitest --coverage --mode test",
    "build:test": "vite build --mode test"
  },
  "devDependencies": {
    "@vitest/ui": "^1.0.0",
    "vitest": "^1.0.0"
  }
}

検証とトラブルシューティング

解決確認方法

設定変更後の動作確認は、以下の手順で行います。

bash# 1. 依存関係の再インストール
yarn install

# 2. キャッシュのクリア
yarn vitest --run --no-cache

# 3. ウォッチモードでのテスト
yarn vitest --watch

正常に動作している場合の出力例:

bash✓ tests/components/Button.test.ts (3)
✓ tests/utils/helpers.test.ts (5)
✓ tests/store/user.test.ts (4)

Test Files  3 passed (3)
Tests  12 passed (12)
Start at 10:30:45
Duration  1.2s

よくある落とし穴と回避策

問題 1:依然としてモジュール解決エラーが発生する

bashError: Failed to resolve import "~/components/Modal" from "src/views/Home.vue"

解決策: エイリアス設定の確認と統一

typescript// vitest.config.ts と vite.config.ts で設定を統一
const aliasConfig = {
  '@': resolve(__dirname, 'src'),
  '~': resolve(__dirname, 'src'),
};

// 両方の設定ファイルで同じエイリアスを使用
export default defineConfig({
  resolve: {
    alias: aliasConfig,
  },
});

問題 2:テスト実行時のパフォーマンス低下

解決策: 選択的な依存関係インライン化

typescript// vitest.config.ts
export default defineConfig({
  test: {
    deps: {
      // 必要最小限のインライン化
      inline: [
        // プロジェクト内のファイルのみ
        /^(?!.*node_modules).*\.(vue|ts|js)$/,
      ],
    },
  },
});

問題 3:CI/CD 環境での不安定な動作

解決策: 環境別設定の分離

typescript// vitest.config.ci.ts
import baseConfig from './vitest.config';

export default defineConfig({
  ...baseConfig,
  test: {
    ...baseConfig.test,
    // CI 環境での安定化設定
    watch: false,
    reporter: ['verbose', 'junit'],
    outputFile: 'test-results.xml',
  },
});

まとめ

Vitest と HMR の連携で発生するテスト失敗問題は、モジュール解決とキャッシュシステムの競合が根本原因でした。

本記事で紹介した解決策のポイントは以下の通りです。

対策項目効果実装難易度
vitest.config.ts の最適化
テスト環境の分離
モジュール読み込みの改善
CI/CD 環境の調整

特に重要なのは、deps.inline 設定によるモジュール解決の制御と、テスト実行時の HMR 無効化です。これらの設定により、開発体験を損なうことなく、安定したテスト環境を構築できます。

設定変更後は必ずキャッシュをクリアし、段階的に動作確認を行うことで、確実に問題を解決できるでしょう。

関連リンク