Vitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
フロントエンド開発において、テストコードの品質は製品の信頼性を大きく左右します。特に外部 API や複雑な依存関係を持つモジュールのテストでは、モックの適切な活用が不可欠です。Vitest は Jest ライクな API を持ちながら、Vite のエコシステムと統合された高速なテストフレームワークとして注目を集めています。
本記事では、Vitest のモジュールモック技術の中核である vi.mock と vi.spyOn に焦点を当て、基礎から実践的な応用パターンまでを段階的に解説します。これらの技術を習得することで、テストの独立性を保ちながら、実装の詳細に依存しない堅牢なテストコードを書けるようになるでしょう。
背景
テストにおけるモックの必要性
モダンな Web アプリケーションは、複数のモジュールやサービスが連携して動作します。例えば、フロントエンドのコンポーネントは API クライアント、状態管理ライブラリ、ユーティリティ関数など、様々な依存関係を持っています。
これらの依存関係をそのままテストすると、以下のような問題が発生します。
外部依存による問題点
| # | 問題 | 具体例 | 影響 |
|---|---|---|---|
| 1 | テストの不安定性 | API サーバーのダウンでテストが失敗 | CI/CD の信頼性低下 |
| 2 | 実行速度の低下 | ネットワーク通信の待ち時間 | 開発サイクルの遅延 |
| 3 | テストケースの制約 | エラーケースの再現困難 | テストカバレッジの低下 |
| 4 | 副作用のリスク | データベースへの書き込み | テスト環境の汚染 |
以下の図は、モックを使わない場合とモックを活用した場合のテスト構造の違いを示しています。
mermaidflowchart TB
subgraph without["モックなしのテスト"]
test1["テストコード"] -->|直接依存| comp1["コンポーネント"]
comp1 -->|API 呼び出し| api1["外部 API"]
comp1 -->|DB アクセス| db1[("データベース")]
api1 -.->|ネットワーク遅延| delay1["不安定要因"]
db1 -.->|副作用| side1["データ汚染"]
end
subgraph with["モック活用のテスト"]
test2["テストコード"] -->|制御された依存| comp2["コンポーネント"]
comp2 -->|モック化| mock_api["API モック"]
comp2 -->|モック化| mock_db["DB モック"]
mock_api -.->|即座に応答| fast["高速実行"]
mock_db -.->|隔離された環境| safe["安全性"]
end
モックを活用することで、テストコードは外部依存から解放され、高速で安定した実行が可能になります。
Vitest の特徴と位置づけ
Vitest は Vite エコシステムに最適化されたテストフレームワークです。Jest との互換性を保ちながら、以下の特徴を持っています。
Vitest の主な特徴
| # | 特徴 | 説明 | メリット |
|---|---|---|---|
| 1 | ESM ネイティブ | ES Modules を標準でサポート | モダンな JavaScript に対応 |
| 2 | Vite の設定を共有 | ビルド設定をそのまま利用 | 設定の二重管理が不要 |
| 3 | HMR 対応 | テストのホットリロード | 開発体験の向上 |
| 4 | 高速な実行 | esbuild による高速トランスパイル | テストサイクルの短縮 |
mermaidflowchart LR
vite["Vite 設定"] -->|共有| vitest["Vitest"]
vitest -->|実行| test_files["テストファイル"]
test_files -->|モック| vi_mock["vi.mock()"]
test_files -->|スパイ| vi_spyon["vi.spyOn()"]
vi_mock -.->|モジュール全体| mock_result["完全な制御"]
vi_spyon -.->|特定の関数| spy_result["部分的な監視"]
図で理解できる要点:
- Vitest は Vite の設定を直接利用するため、環境構築が簡潔
vi.mockとvi.spyOnが主要なモック機能を提供
課題
モック技術の複雑性
モック技術を効果的に活用するには、いくつかの課題を理解する必要があります。
1. モック手法の選択
Vitest には複数のモック手法が存在し、それぞれ適切な使い分けが求められます。
mermaidflowchart TD
start["モック対象の選定"] --> q1{"対象はモジュール<br/>全体か?"}
q1 -->|はい| vi_mock_choice["vi.mock() を使用"]
q1 -->|いいえ| q2{"既存の実装を<br/>活かすか?"}
q2 -->|はい| vi_spyon_choice["vi.spyOn() を使用"]
q2 -->|いいえ| manual_mock["手動モック作成"]
vi_mock_choice --> consideration1["・モジュール全体を置換<br/>・実装詳細から隔離"]
vi_spyon_choice --> consideration2["・特定メソッドのみ監視<br/>・他の機能は実際の実装"]
manual_mock --> consideration3["・カスタム実装<br/>・完全な制御"]
2. 型安全性の維持
TypeScript を使用している場合、モック化によって型情報が失われる問題があります。
typescript// 型情報が失われる例
import { fetchUser } from './api';
vi.mock('./api');
// この時点で fetchUser の型が any になる可能性がある
3. モックのライフサイクル管理
テスト間でモックの状態が漏れると、テストが相互に影響し合い、不安定になります。
モック管理の課題
| # | 課題 | 発生する問題 | 対策 |
|---|---|---|---|
| 1 | モックの残留 | 後続テストへの影響 | 適切なクリーンアップ |
| 2 | 呼び出し履歴の蓄積 | アサーションの誤判定 | mockClear() の活用 |
| 3 | グローバルな副作用 | テストの順序依存 | beforeEach での初期化 |
以下の図は、モックのライフサイクルが適切に管理されていない場合の問題を示しています。
mermaidsequenceDiagram
participant Test1 as テスト 1
participant Mock as モックオブジェクト
participant Test2 as テスト 2
Test1->>Mock: モックを設定
Test1->>Mock: 関数を呼び出し (1回目)
Note over Mock: 呼び出し履歴: 1回
Test1->>Test1: アサーション成功
Note over Test1,Test2: クリーンアップなし
Test2->>Mock: 関数を呼び出し (2回目)
Note over Mock: 呼び出し履歴: 2回<br/>(前回の履歴が残留)
Test2->>Mock: 呼び出し回数を確認
Note over Test2: 期待: 1回<br/>実際: 2回
Test2->>Test2: アサーション失敗
この図が示す問題点:
- テスト 1 のモック呼び出し履歴がテスト 2 に影響
- 適切なクリーンアップがないと、テストの独立性が損なわれる
解決策
vi.mock() によるモジュール全体のモック化
vi.mock() は、モジュール全体を置き換える強力な機能です。外部 API やデータベースアクセスなど、テスト時に実際の実装を使いたくない場合に最適です。
基本的な使用方法
最もシンプルな vi.mock() の使用例から始めましょう。
typescript// api.ts - モック対象のモジュール
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
export async function createUser(name: string) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name }),
});
return response.json();
}
上記のモジュールをモック化するテストコードは以下のようになります。
typescript// api.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { fetchUser, createUser } from './api';
// モジュール全体をモック化
vi.mock('./api');
vi.mock() を呼び出すと、指定したモジュールのすべてのエクスポートが自動的にモック関数に置き換えられます。この時点で fetchUser と createUser は実際の実装を持たない空のモック関数になります。
次に、モック関数の戻り値を設定してテストを書いていきます。
typescriptdescribe('ユーザー取得機能', () => {
beforeEach(() => {
// 各テスト前にモックをリセット
vi.clearAllMocks();
});
it('ユーザー情報を正常に取得できる', async () => {
// モックの戻り値を設定
const mockUser = { id: '123', name: '田中太郎' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
// テスト対象の関数を実行
const result = await fetchUser('123');
// アサーション
expect(result).toEqual(mockUser);
expect(fetchUser).toHaveBeenCalledWith('123');
expect(fetchUser).toHaveBeenCalledTimes(1);
});
});
この例では、vi.mocked() ヘルパー関数を使用して型安全にモック関数を操作しています。mockResolvedValue() は Promise を返す関数のモック値を設定するメソッドです。
部分的なモック化
モジュール内の一部の関数だけをモック化し、他の関数は実際の実装を使いたい場合があります。
typescript// utils.ts - 複数の関数を持つモジュール
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
export function getCurrentDate(): Date {
return new Date();
}
export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
この場合、vi.mock() の第二引数にファクトリ関数を渡すことで、部分的なモック化が実現できます。
typescript// utils.test.ts
import { describe, it, expect, vi } from 'vitest';
import {
formatDate,
getCurrentDate,
isWeekend,
} from './utils';
// getCurrentDate だけをモック化し、他は実際の実装を使う
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<
typeof import('./utils')
>();
return {
...actual,
getCurrentDate: vi.fn(),
};
});
上記のコードでは、importOriginal を使用して元のモジュールをインポートし、スプレッド構文で展開しています。その上で、getCurrentDate だけを vi.fn() で置き換えています。
実際のテストコードは以下のようになります。
typescriptdescribe('日付ユーティリティ', () => {
it('固定日付でテストできる', () => {
// getCurrentDate を固定日付に設定
const fixedDate = new Date('2024-03-15');
vi.mocked(getCurrentDate).mockReturnValue(fixedDate);
const current = getCurrentDate();
expect(formatDate(current)).toBe('2024-03-15');
// formatDate は実際の実装が動作
expect(isWeekend(current)).toBe(false); // 2024-03-15 は金曜日
});
});
この手法により、テストで制御したい部分だけをモック化し、他の機能は実際の実装のまま検証できます。
型安全なモック化
TypeScript を使用している場合、モック関数の型安全性を保つことが重要です。
typescript// userService.ts - 型定義を含むモジュール
export interface User {
id: string;
name: string;
email: string;
}
export interface UserService {
getUser(id: string): Promise<User>;
updateUser(
id: string,
data: Partial<User>
): Promise<User>;
deleteUser(id: string): Promise<void>;
}
export const userService: UserService = {
async getUser(id) {
// 実際の実装
const response = await fetch(`/api/users/${id}`);
return response.json();
},
async updateUser(id, data) {
// 実際の実装
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return response.json();
},
async deleteUser(id) {
// 実際の実装
await fetch(`/api/users/${id}`, { method: 'DELETE' });
},
};
型安全なモック化には、vi.mocked() ヘルパーと TypeScript のジェネリクスを活用します。
typescript// userService.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { userService, type User } from './userService';
vi.mock('./userService');
describe('ユーザーサービス', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ユーザー情報の更新が正しく動作する', async () => {
const mockUser: User = {
id: '123',
name: '更新後の名前',
email: 'updated@example.com',
};
// 型安全なモック設定
vi.mocked(userService.updateUser).mockResolvedValue(
mockUser
);
const result = await userService.updateUser('123', {
name: '更新後の名前',
});
expect(result).toEqual(mockUser);
expect(userService.updateUser).toHaveBeenCalledWith(
'123',
{
name: '更新後の名前',
}
);
});
});
vi.mocked() を使用することで、TypeScript の型チェックが有効になり、存在しないメソッドや誤った引数の型を指定した場合にコンパイルエラーが発生します。
vi.spyOn() による特定関数の監視
vi.spyOn() は、既存のオブジェクトのメソッドを監視しつつ、元の実装も活かせる柔軟な手法です。
基本的な使用方法
vi.spyOn() の基本的な使い方を見ていきましょう。
typescript// logger.ts - ログ機能を持つモジュール
export const logger = {
info(message: string): void {
console.log(`[INFO] ${message}`);
},
error(message: string, error?: Error): void {
console.error(`[ERROR] ${message}`, error);
},
warn(message: string): void {
console.warn(`[WARN] ${message}`);
},
};
このロガーを使用する関数をテストする場合、実際のコンソール出力は不要ですが、ログが正しく呼ばれているかは検証したいケースがあります。
typescript// processor.ts - ロガーを使用する処理
import { logger } from './logger';
export function processData(data: string[]): string[] {
logger.info(`処理開始: ${data.length} 件のデータ`);
const results: string[] = [];
for (const item of data) {
try {
// データ処理のロジック
const processed = item.toUpperCase();
results.push(processed);
} catch (error) {
logger.error(`処理エラー: ${item}`, error as Error);
}
}
logger.info(`処理完了: ${results.length} 件成功`);
return results;
}
このような関数のテストで vi.spyOn() を使用します。
typescript// processor.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from 'vitest';
import { processData } from './processor';
import { logger } from './logger';
describe('データ処理', () => {
let infoSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// logger のメソッドにスパイを設定
infoSpy = vi.spyOn(logger, 'info');
errorSpy = vi.spyOn(logger, 'error');
});
afterEach(() => {
// スパイをリストア
infoSpy.mockRestore();
errorSpy.mockRestore();
});
it('正常なデータ処理でログが出力される', () => {
const data = ['apple', 'banana', 'cherry'];
const result = processData(data);
expect(result).toEqual(['APPLE', 'BANANA', 'CHERRY']);
// info が 2 回呼ばれたことを確認
expect(infoSpy).toHaveBeenCalledTimes(2);
expect(infoSpy).toHaveBeenNthCalledWith(
1,
'処理開始: 3 件のデータ'
);
expect(infoSpy).toHaveBeenNthCalledWith(
2,
'処理完了: 3 件成功'
);
// error は呼ばれていないことを確認
expect(errorSpy).not.toHaveBeenCalled();
});
});
vi.spyOn() の第一引数にはオブジェクト、第二引数にはメソッド名を文字列で指定します。これにより、元のメソッドの動作を保ちながら、呼び出し回数や引数を検証できます。
実装を置き換えるスパイ
スパイを設定した後、mockImplementation() を使って実装を置き換えることもできます。
typescriptdescribe('データ処理(実装置き換え)', () => {
it('コンソール出力を抑制してテストできる', () => {
// 実装を空の関数に置き換え
const infoSpy = vi
.spyOn(logger, 'info')
.mockImplementation(() => {});
const errorSpy = vi
.spyOn(logger, 'error')
.mockImplementation(() => {});
const data = ['apple', 'banana'];
processData(data);
// コンソールには何も出力されないが、呼び出しは記録される
expect(infoSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
infoSpy.mockRestore();
errorSpy.mockRestore();
});
});
この手法は、副作用を持つ関数(ファイル書き込み、ネットワーク通信など)をテストする際に特に有効です。
戻り値を変更するスパイ
スパイで元のメソッドの戻り値を変更することで、特定のシナリオをシミュレートできます。
typescript// cache.ts - キャッシュ機能
export const cache = {
get(key: string): string | null {
// 実際の実装では localStorage や Redis を使用
return localStorage.getItem(key);
},
set(key: string, value: string): void {
localStorage.setItem(key, value);
},
has(key: string): boolean {
return localStorage.getItem(key) !== null;
},
};
// dataLoader.ts - キャッシュを使用するローダー
export async function loadData(
id: string
): Promise<string> {
// キャッシュにあれば返す
if (cache.has(id)) {
return cache.get(id)!;
}
// なければ API から取得してキャッシュに保存
const response = await fetch(`/api/data/${id}`);
const data = await response.text();
cache.set(id, data);
return data;
}
このキャッシュ機能をテストする際、vi.spyOn() で戻り値を制御します。
typescript// dataLoader.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { loadData } from './dataLoader';
import { cache } from './cache';
// fetch をモック化
global.fetch = vi.fn();
describe('データローダー', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('キャッシュがある場合は API を呼ばない', async () => {
// cache.has と cache.get の戻り値を制御
vi.spyOn(cache, 'has').mockReturnValue(true);
vi.spyOn(cache, 'get').mockReturnValue('cached-data');
const result = await loadData('123');
expect(result).toBe('cached-data');
expect(cache.has).toHaveBeenCalledWith('123');
expect(cache.get).toHaveBeenCalledWith('123');
expect(fetch).not.toHaveBeenCalled();
});
it('キャッシュがない場合は API を呼ぶ', async () => {
// キャッシュがないシナリオ
vi.spyOn(cache, 'has').mockReturnValue(false);
vi.spyOn(cache, 'set').mockImplementation(() => {});
// fetch のモック設定
vi.mocked(fetch).mockResolvedValue({
text: async () => 'api-data',
} as Response);
const result = await loadData('456');
expect(result).toBe('api-data');
expect(cache.has).toHaveBeenCalledWith('456');
expect(fetch).toHaveBeenCalledWith('/api/data/456');
expect(cache.set).toHaveBeenCalledWith(
'456',
'api-data'
);
});
});
スパイの戻り値を変更することで、キャッシュの有無による分岐を簡単にテストできます。
モックのライフサイクル管理
テストの信頼性を保つには、モックのライフサイクルを適切に管理することが不可欠です。
クリーンアップの基本パターン
各テストの前後で適切にモックをクリーンアップする標準的なパターンを示します。
typescriptimport {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from 'vitest';
describe('モック管理のベストプラクティス', () => {
// テスト前の初期化
beforeEach(() => {
// すべてのモックの呼び出し履歴をクリア
vi.clearAllMocks();
});
// テスト後のクリーンアップ
afterEach(() => {
// すべてのスパイを元に戻す
vi.restoreAllMocks();
});
it('テスト 1', () => {
// このテストは他のテストの影響を受けない
});
it('テスト 2', () => {
// このテストも独立している
});
});
以下の表は、主要なクリーンアップメソッドの使い分けを示しています。
モッククリーンアップメソッドの比較
| # | メソッド | 効果 | 使用タイミング |
|---|---|---|---|
| 1 | vi.clearAllMocks() | 呼び出し履歴をクリア | 各テストの前 |
| 2 | vi.resetAllMocks() | 履歴クリア + 実装リセット | テストグループの前 |
| 3 | vi.restoreAllMocks() | スパイを元の実装に戻す | 各テストの後 |
| 4 | mockClear() | 個別モックの履歴クリア | 特定モックのみリセット |
| 5 | mockReset() | 個別モックの完全リセット | モックの再設定前 |
グローバルモックの管理
グローバルなオブジェクト(fetch、localStorage など)をモック化する場合の管理方法です。
typescript// setup.ts - テストのセットアップファイル
import { beforeAll, afterAll, vi } from 'vitest';
// 元の実装を保存
const originalFetch = global.fetch;
const originalLocalStorage = global.localStorage;
beforeAll(() => {
// グローバルモックを設定
global.fetch = vi.fn();
global.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(),
length: 0,
} as Storage;
});
afterAll(() => {
// 元の実装に戻す
global.fetch = originalFetch;
global.localStorage = originalLocalStorage;
});
各テストファイルでは、このセットアップを前提として個別のモック設定を行います。
typescript// api.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
describe('API テスト', () => {
beforeEach(() => {
// グローバルモックの呼び出し履歴をクリア
vi.mocked(global.fetch).mockClear();
});
it('API を呼び出す', async () => {
vi.mocked(global.fetch).mockResolvedValue({
json: async () => ({ data: 'test' }),
} as Response);
// テストコード
});
});
グローバルモックは影響範囲が広いため、必ず afterAll で元に戻すことが重要です。
具体例
実践例 1:API クライアントのテスト
実際のアプリケーションで使用される API クライアントのテスト例を見ていきます。
API クライアントの実装
まず、テスト対象となる API クライアントを実装します。
typescript// apiClient.ts - API クライアントの実装
export interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
export interface ApiError {
status: number;
message: string;
details?: unknown;
}
export class ApiClient {
constructor(private baseUrl: string) {}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
const response = await fetch(
`${this.baseUrl}${endpoint}`
);
if (!response.ok) {
throw await this.handleError(response);
}
const data = await response.json();
return {
data,
status: response.status,
};
}
async post<T>(
endpoint: string,
body: unknown
): Promise<ApiResponse<T>> {
const response = await fetch(
`${this.baseUrl}${endpoint}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!response.ok) {
throw await this.handleError(response);
}
const data = await response.json();
return {
data,
status: response.status,
};
}
private async handleError(
response: Response
): Promise<ApiError> {
const text = await response.text();
let details: unknown;
try {
details = JSON.parse(text);
} catch {
details = text;
}
return {
status: response.status,
message: `API Error: ${response.statusText}`,
details,
};
}
}
この API クライアントは、GET と POST リクエストを処理し、エラーハンドリングも含んでいます。
テストの実装
vi.mock() を使用して fetch をモック化し、様々なシナリオをテストします。
typescript// apiClient.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { ApiClient } from './apiClient';
// fetch をモック化
global.fetch = vi.fn();
describe('ApiClient', () => {
let client: ApiClient;
beforeEach(() => {
vi.clearAllMocks();
client = new ApiClient('https://api.example.com');
});
describe('GET リクエスト', () => {
it('正常なレスポンスを処理できる', async () => {
const mockData = { id: 1, name: 'テストユーザー' };
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockData,
} as Response);
const result = await client.get('/users/1');
expect(result).toEqual({
data: mockData,
status: 200,
});
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users/1'
);
});
it('404 エラーを適切に処理する', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
text: async () =>
JSON.stringify({ error: 'User not found' }),
} as Response);
await expect(
client.get('/users/999')
).rejects.toMatchObject({
status: 404,
message: 'API Error: Not Found',
details: { error: 'User not found' },
});
});
});
});
このテストでは、fetch の戻り値を制御することで、様々な HTTP レスポンスをシミュレートしています。
次に、POST リクエストのテストを追加します。
typescriptdescribe('POST リクエスト', () => {
it('リクエストボディを正しく送信する', async () => {
const requestBody = {
name: '新規ユーザー',
email: 'new@example.com',
};
const mockResponse = { id: 2, ...requestBody };
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 201,
json: async () => mockResponse,
} as Response);
const result = await client.post('/users', requestBody);
expect(result).toEqual({
data: mockResponse,
status: 201,
});
// fetch が正しい引数で呼ばれたことを確認
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}
);
});
it('バリデーションエラーを処理する', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () =>
JSON.stringify({
errors: [
{
field: 'email',
message:
'メールアドレスの形式が正しくありません',
},
],
}),
} as Response);
await expect(
client.post('/users', {
name: 'テスト',
email: 'invalid',
})
).rejects.toMatchObject({
status: 400,
message: 'API Error: Bad Request',
});
});
});
このように、モックを活用することで実際のサーバーなしでも API クライアントの動作を網羅的にテストできます。
実践例 2:依存関係を持つサービスクラス
複数の依存関係を持つサービスクラスのテスト方法を解説します。
サービスクラスの実装
typescript// userService.ts - 複数の依存を持つサービス
import { ApiClient } from './apiClient';
import { cache } from './cache';
import { logger } from './logger';
export interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
export class UserService {
constructor(
private apiClient: ApiClient,
private cachePrefix: string = 'user:'
) {}
async getUser(id: string): Promise<User> {
const cacheKey = `${this.cachePrefix}${id}`;
// キャッシュを確認
const cached = cache.get(cacheKey);
if (cached) {
logger.info(
`ユーザー情報をキャッシュから取得: ${id}`
);
return JSON.parse(cached);
}
// API から取得
logger.info(`ユーザー情報を API から取得: ${id}`);
const response = await this.apiClient.get<User>(
`/users/${id}`
);
// キャッシュに保存
cache.set(
cacheKey,
JSON.stringify(response.data),
3600
);
return response.data;
}
async updateUser(
id: string,
updates: Partial<User>
): Promise<User> {
logger.info(`ユーザー情報を更新: ${id}`);
const response = await this.apiClient.post<User>(
`/users/${id}`,
updates
);
// キャッシュを削除
const cacheKey = `${this.cachePrefix}${id}`;
cache.remove(cacheKey);
logger.info(`キャッシュを削除: ${cacheKey}`);
return response.data;
}
}
このサービスは ApiClient、cache、logger の 3 つの依存関係を持っています。
依存関係をモック化したテスト
各依存関係を適切にモック化してテストを実装します。
typescript// userService.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { UserService } from './userService';
import { ApiClient } from './apiClient';
import { cache } from './cache';
import { logger } from './logger';
// 依存モジュールをモック化
vi.mock('./apiClient');
vi.mock('./cache');
vi.mock('./logger');
describe('UserService', () => {
let userService: UserService;
let mockApiClient: ApiClient;
beforeEach(() => {
vi.clearAllMocks();
// ApiClient のモックインスタンスを作成
mockApiClient = new ApiClient(
'https://api.example.com'
);
userService = new UserService(mockApiClient);
});
describe('getUser', () => {
const mockUser = {
id: '123',
name: '山田太郎',
email: 'yamada@example.com',
createdAt: '2024-01-01T00:00:00Z',
};
it('キャッシュがある場合はキャッシュから取得する', async () => {
// cache.get がキャッシュを返すように設定
vi.mocked(cache.get).mockReturnValue(
JSON.stringify(mockUser)
);
const result = await userService.getUser('123');
expect(result).toEqual(mockUser);
expect(cache.get).toHaveBeenCalledWith('user:123');
expect(logger.info).toHaveBeenCalledWith(
'ユーザー情報をキャッシュから取得: 123'
);
// API は呼ばれない
expect(mockApiClient.get).not.toHaveBeenCalled();
});
it('キャッシュがない場合は API から取得する', async () => {
// キャッシュなし
vi.mocked(cache.get).mockReturnValue(null);
// API のレスポンスを設定
vi.mocked(mockApiClient.get).mockResolvedValue({
data: mockUser,
status: 200,
});
const result = await userService.getUser('123');
expect(result).toEqual(mockUser);
expect(mockApiClient.get).toHaveBeenCalledWith(
'/users/123'
);
expect(cache.set).toHaveBeenCalledWith(
'user:123',
JSON.stringify(mockUser),
3600
);
expect(logger.info).toHaveBeenCalledWith(
'ユーザー情報を API から取得: 123'
);
});
});
describe('updateUser', () => {
it('ユーザー情報を更新し、キャッシュを削除する', async () => {
const updates = { name: '山田次郎' };
const updatedUser = {
id: '123',
name: '山田次郎',
email: 'yamada@example.com',
createdAt: '2024-01-01T00:00:00Z',
};
vi.mocked(mockApiClient.post).mockResolvedValue({
data: updatedUser,
status: 200,
});
const result = await userService.updateUser(
'123',
updates
);
expect(result).toEqual(updatedUser);
expect(mockApiClient.post).toHaveBeenCalledWith(
'/users/123',
updates
);
expect(cache.remove).toHaveBeenCalledWith('user:123');
expect(logger.info).toHaveBeenCalledTimes(2);
});
});
});
この例では、複数の依存関係を個別にモック化し、それぞれの呼び出しを検証しています。
以下の図は、テスト時の依存関係の流れを示しています。
mermaidflowchart TB
test[テストコード] -->|インスタンス化| service[UserService]
service --|依存|--> mockApi[ApiClient(モック)]
service --|依存|--> mockCache[cache(モック)]
service --|依存|--> mockLogger[logger(モック)]
test -.->|戻り値を制御| mockApi
test -.->|戻り値を制御| mockCache
test -.->|呼び出しを検証| mockLogger
service --|getUser() 呼び出し|--> flowNode[処理フロー]
flowNode --|1. キャッシュ確認|--> mockCache
flowNode --|2. API 取得|--> mockApi
flowNode --|3. ログ出力|--> mockLogger
test -.->|アサーション| result[期待する動作]
図で理解できる要点:
- テストコードがすべての依存関係をモック化して制御
- サービスクラスは実際の依存先ではなくモックと対話
- 各依存関係の呼び出しを個別に検証可能
実践例 3:React コンポーネントのテスト
React コンポーネント内で使用される API フックのモック化について解説します。
カスタムフックの実装
typescript// useUser.ts - ユーザー情報を取得するカスタムフック
import { useState, useEffect } from 'react';
import { userService } from './userService';
export interface UseUserResult {
user: User | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
export function useUser(userId: string): UseUserResult {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const data = await userService.getUser(userId);
setUser(data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUser();
}, [userId]);
return {
user,
loading,
error,
refetch: fetchUser,
};
}
このカスタムフックを使用するコンポーネントを実装します。
typescript// UserProfile.tsx - ユーザープロフィールコンポーネント
import React from 'react';
import { useUser } from './useUser';
interface UserProfileProps {
userId: string;
}
export function UserProfile({ userId }: UserProfileProps) {
const { user, loading, error, refetch } = useUser(userId);
if (loading) {
return <div data-testid='loading'>読み込み中...</div>;
}
if (error) {
return (
<div data-testid='error'>
<p>エラーが発生しました: {error.message}</p>
<button onClick={refetch}>再試行</button>
</div>
);
}
if (!user) {
return (
<div data-testid='not-found'>
ユーザーが見つかりません
</div>
);
}
return (
<div data-testid='user-profile'>
<h2>{user.name}</h2>
<p>メール: {user.email}</p>
<p>
登録日:{' '}
{new Date(user.createdAt).toLocaleDateString()}
</p>
<button onClick={refetch}>更新</button>
</div>
);
}
コンポーネントのテスト
@testing-library/react と Vitest を組み合わせてコンポーネントをテストします。
typescript// UserProfile.test.tsx
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { userService } from './userService';
// userService をモック化
vi.mock('./userService');
describe('UserProfile コンポーネント', () => {
const mockUser = {
id: '123',
name: '田中太郎',
email: 'tanaka@example.com',
createdAt: '2024-01-15T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('ローディング状態を表示する', () => {
// getUser を遅延させる
vi.mocked(userService.getUser).mockImplementation(
() => new Promise(() => {}) // 完了しない Promise
);
render(<UserProfile userId='123' />);
expect(
screen.getByTestId('loading')
).toBeInTheDocument();
expect(
screen.getByText('読み込み中...')
).toBeInTheDocument();
});
it('ユーザー情報を正常に表示する', async () => {
vi.mocked(userService.getUser).mockResolvedValue(
mockUser
);
render(<UserProfile userId='123' />);
// ローディングが表示される
expect(
screen.getByTestId('loading')
).toBeInTheDocument();
// データ取得後、ユーザー情報が表示される
await waitFor(() => {
expect(
screen.getByTestId('user-profile')
).toBeInTheDocument();
});
expect(
screen.getByText('田中太郎')
).toBeInTheDocument();
expect(
screen.getByText('メール: tanaka@example.com')
).toBeInTheDocument();
expect(
screen.getByText(/2024\/1\/15/)
).toBeInTheDocument();
});
it('エラー状態を表示する', async () => {
const error = new Error('ネットワークエラー');
vi.mocked(userService.getUser).mockRejectedValue(error);
render(<UserProfile userId='123' />);
await waitFor(() => {
expect(
screen.getByTestId('error')
).toBeInTheDocument();
});
expect(
screen.getByText(/エラーが発生しました/)
).toBeInTheDocument();
expect(
screen.getByText(/ネットワークエラー/)
).toBeInTheDocument();
});
it('再試行ボタンが機能する', async () => {
const error = new Error('一時的なエラー');
vi.mocked(userService.getUser)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(mockUser);
render(<UserProfile userId='123' />);
// 最初はエラー表示
await waitFor(() => {
expect(
screen.getByTestId('error')
).toBeInTheDocument();
});
// 再試行ボタンをクリック
const retryButton = screen.getByText('再試行');
await userEvent.click(retryButton);
// 成功してユーザー情報が表示される
await waitFor(() => {
expect(
screen.getByTestId('user-profile')
).toBeInTheDocument();
});
expect(
screen.getByText('田中太郎')
).toBeInTheDocument();
expect(userService.getUser).toHaveBeenCalledTimes(2);
});
});
この例では、mockRejectedValueOnce と mockResolvedValueOnce を使用して、最初の呼び出しはエラー、2 回目の呼び出しは成功というシナリオをシミュレートしています。
実践例 4:タイマーとモック
非同期処理やタイマーを含むコードのテストでは、Vitest のタイマーモック機能を活用します。
タイマーを使用する実装
typescript// retryService.ts - リトライ機能を持つサービス
export class RetryService {
async executeWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | null = null;
for (
let attempt = 0;
attempt <= maxRetries;
attempt++
) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
// 指数バックオフで待機
const waitTime = delayMs * Math.pow(2, attempt);
await this.delay(waitTime);
}
}
}
throw lastError;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(resolve, ms)
);
}
}
このサービスは、失敗時に指数バックオフで待機しながらリトライを行います。
タイマーモックを使用したテスト
typescript// retryService.test.ts
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from 'vitest';
import { RetryService } from './retryService';
describe('RetryService', () => {
let retryService: RetryService;
beforeEach(() => {
retryService = new RetryService();
// タイマーをモック化
vi.useFakeTimers();
});
afterEach(() => {
// タイマーを元に戻す
vi.useRealTimers();
});
it('成功するまでリトライする', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('1 回目失敗'))
.mockRejectedValueOnce(new Error('2 回目失敗'))
.mockResolvedValueOnce('成功');
// executeWithRetry を非同期で実行
const promise = retryService.executeWithRetry(
mockFn,
3,
1000
);
// 1 回目の失敗後、1000ms 待機
await vi.advanceTimersByTimeAsync(1000);
// 2 回目の失敗後、2000ms 待機(指数バックオフ)
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('成功');
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('最大リトライ回数を超えるとエラーをスローする', async () => {
const error = new Error('恒久的なエラー');
const mockFn = vi.fn().mockRejectedValue(error);
const promise = retryService.executeWithRetry(
mockFn,
2,
100
);
// すべてのタイマーを進める
await vi.runAllTimersAsync();
await expect(promise).rejects.toThrow('恒久的なエラー');
expect(mockFn).toHaveBeenCalledTimes(3); // 初回 + 2 回のリトライ
});
it('正しい待機時間で指数バックオフする', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('失敗 1'))
.mockRejectedValueOnce(new Error('失敗 2'))
.mockRejectedValueOnce(new Error('失敗 3'))
.mockResolvedValueOnce('成功');
const promise = retryService.executeWithRetry(
mockFn,
3,
100
);
// 1 回目の待機: 100ms
await vi.advanceTimersByTimeAsync(100);
expect(mockFn).toHaveBeenCalledTimes(2);
// 2 回目の待機: 200ms(指数バックオフ)
await vi.advanceTimersByTimeAsync(200);
expect(mockFn).toHaveBeenCalledTimes(3);
// 3 回目の待機: 400ms(指数バックオフ)
await vi.advanceTimersByTimeAsync(400);
expect(mockFn).toHaveBeenCalledTimes(4);
await expect(promise).resolves.toBe('成功');
});
});
vi.useFakeTimers() を使用することで、実際に時間を待たずにタイマーの動作をテストできます。vi.advanceTimersByTimeAsync() で時間を進め、vi.runAllTimersAsync() ですべてのタイマーを完了させられます。
タイマーモックの主要メソッド
| # | メソッド | 説明 | 使用例 |
|---|---|---|---|
| 1 | vi.useFakeTimers() | タイマーをモック化 | テスト前の設定 |
| 2 | vi.useRealTimers() | タイマーを元に戻す | テスト後のクリーンアップ |
| 3 | vi.advanceTimersByTime(ms) | 指定時間進める(同期) | 単純な時間経過 |
| 4 | vi.advanceTimersByTimeAsync(ms) | 指定時間進める(非同期) | Promise を含む処理 |
| 5 | vi.runAllTimers() | すべてのタイマー実行(同期) | 完了まで待たない |
| 6 | vi.runAllTimersAsync() | すべてのタイマー実行(非同期) | 完了まで待つ |
まとめ
本記事では、Vitest のモジュールモック技術について、基礎から実践的な応用まで詳しく解説しました。
重要なポイントの振り返り
vi.mock() と vi.spyOn() は、それぞれ異なる目的で使い分けることが重要です。vi.mock() はモジュール全体を置き換えるため、外部 API やデータベースなど完全に制御したい依存関係に適しています。一方、vi.spyOn() は既存の実装を活かしながら特定のメソッドだけを監視・制御したい場合に有効です。
モックのライフサイクル管理は、テストの信頼性を保つ上で欠かせません。beforeEach での vi.clearAllMocks()、afterEach での vi.restoreAllMocks() を習慣化することで、テスト間の独立性を維持できます。
型安全性の維持も重要な観点です。vi.mocked() ヘルパーを活用することで、TypeScript の型チェックを有効に保ちながらモックを操作できます。
実践で活かすために
具体例で示した API クライアント、サービスクラス、React コンポーネント、タイマー処理のテストパターンは、実際の開発現場でそのまま応用できる内容です。これらのパターンを自分のプロジェクトに合わせてカスタマイズし、テストコードの品質を高めていってください。
Vitest のモック機能を適切に活用することで、実行速度が速く、安定したテストスイートを構築できます。テストが高速であれば開発サイクルが短縮され、安定していれば CI/CD の信頼性が向上します。本記事で学んだ技術を活用して、より堅牢なアプリケーション開発を実現しましょう。
関連リンク
- Vitest 公式ドキュメント
- Vitest API リファレンス - Mocking
- Testing Library 公式ドキュメント
- TypeScript 公式ドキュメント
- Jest から Vitest への移行ガイド
検索キーワード Vitest, vi.mock, vi.spyOn, モジュールモック, TypeScript テスト, React テスト, タイマーモック, 非同期テスト, Jest 互換, テストダブル, スパイ, モック, スタブ
articleVitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
articleVitest フレーク検知技術の運用:`--retry` / シード固定 / ランダム順序で堅牢化
articleVitest テストアーキテクチャ技術:Unit / Integration / Contract の三層設計ガイド
articleVitest `vi` API 技術チートシート:`mock` / `fn` / `spyOn` / `advanceTimersByTime` 一覧
articleVitest × jsdom / happy-dom 技術セットアップ:最小構成と落とし穴
article5 分で導入!Vite × Vitest 型付きユニットテスト環境の最短手順
articleWebRTC が「connecting」のまま進まない:ICE 失敗を 5 分で切り分ける手順
articleWeb Components が “is not a constructor” で落ちる時:定義順序と複数登録の衝突を解決
articleVitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
articleVue.js リアクティビティ内部解剖:Proxy/ref/computed を図で読み解く
articleVite CSS HMR が反映されない時のチェックリスト:PostCSS/Modules/Cache 編
articleTailwind CSS 2025 年ロードマップ総ざらい:新機能・互換性・移行の見取り図
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来