【解決策】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 無効化です。これらの設定により、開発体験を損なうことなく、安定したテスト環境を構築できます。
設定変更後は必ずキャッシュをクリアし、段階的に動作確認を行うことで、確実に問題を解決できるでしょう。
関連リンク
- article
【解決策】Vitest HMR 連携でテストが落ちる技術的原因と最短解決
- article
Vitest カバレッジ技術の全貌:閾値設定・除外ルール・レポート可視化
- article
Vitest × Vue 3:SFC を簡単に効率的にテストする
- article
Vitest で React コンポーネントをテストする方法
- article
Vitest × TypeScript:型安全なテストの始め方
- article
Vitest と Vite で爆速フロントエンド開発ワークフロー
- article
gpt-oss の全体像と導入判断フレーム:適用領域・制約・成功条件を一挙解説
- article
【解決策】Vitest HMR 連携でテストが落ちる技術的原因と最短解決
- article
【解決策】GPT-5 構造化出力が崩れる問題を直す:JSON モード/スキーマ厳格化の実践手順
- article
【解決】Vite で「Failed to resolve import」が出る原因と対処フローチャート
- article
TypeScript ランタイム検証ライブラリ比較:Zod / Valibot / typia / io-ts の選び方
- article
Emotion 完全理解 2025:CSS-in-JS の強み・弱み・採用判断を徹底解説
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来