Vitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
Vitest でテストを書いていると、API 呼び出しのモック化が必要になる場面に必ず出会います。しかし、モック手法には MSW、vi.mock、手動スタブなど複数の選択肢があり、「どれを使えば良いのか」と迷われた経験はないでしょうか。
本記事では、3 つの代表的なモック技術を実際のコード例とともに比較し、それぞれのメリット・デメリット、そして最適な使い分けの指針をご紹介します。テストコードの保守性や実装コストを考える上で、きっと役立つ内容となっているはずです。
背景
Vitest とモック技術の関係
Vitest は Vite ベースの高速テストフレームワーで、Jest 互換の API を持ちながらも、モダンな開発体験を提供してくれます。テストを書く際、外部 API や非同期処理を扱うコードでは、必然的にモック化が必要になるでしょう。
モック化することで、以下のようなメリットが得られます。
- テストの実行速度向上(実際の API 呼び出しを回避)
- テストの安定性確保(外部サービスの状態に依存しない)
- エッジケースの再現(エラーレスポンスなど)
モック手法の選択肢
Vitest でのモック実装には、主に 3 つのアプローチが存在します。
| # | 手法 | 特徴 |
|---|---|---|
| 1 | MSW(Mock Service Worker) | HTTP レベルでインターセプト |
| 2 | vi.mock | モジュールレベルでモック化 |
| 3 | 手動スタブ | 関数を直接差し替え |
それぞれに明確な設計思想があり、適用場面も異なります。次章では、具体的にどのような課題があるのか見ていきましょう。
以下の図は、3 つのモック手法がどの層でモック化を行うかを示したものです。
mermaidflowchart TB
subgraph app["アプリケーション層"]
component["React コンポーネント"]
logic["ビジネスロジック"]
end
subgraph module["モジュール層"]
apiModule["API モジュール"]
end
subgraph http["HTTP 層"]
fetch["fetch / axios"]
end
subgraph network["ネットワーク層"]
server["実際のサーバー"]
end
component --> logic
logic --> apiModule
apiModule --> fetch
fetch --> server
manual["手動スタブ<br/>(関数差し替え)"] -.->|モック| logic
viMock["vi.mock<br/>(モジュール置換)"] -.->|モック| apiModule
msw["MSW<br/>(HTTP インターセプト)"] -.->|モック| fetch
style manual fill:#e1f5ff
style viMock fill:#fff4e1
style msw fill:#e8f5e9
上図からわかるように、MSW は最も本番環境に近い HTTP 層でモック化を行い、vi.mock はモジュール層、手動スタブはビジネスロジック層で介入します。この違いが、テストの信頼性や実装コストに大きく影響してくるのです。
課題
モック手法選定の難しさ
テストコードを書く際、「どのモック手法を使うべきか」という判断は意外と難しいものです。それぞれの手法には異なる特性があり、間違った選択をすると以下のような問題が発生します。
保守性の低下
モック実装が複雑になると、テストコードの保守コストが上がってしまいます。特に以下のケースで顕著です。
- API のレスポンス構造が変更された際、多数のテストファイルを修正する必要がある
- モックの設定が散在し、どこで何をモックしているか把握しづらい
- テストごとにモック実装が異なり、一貫性がない
テストの信頼性の問題
モック化の粒度を誤ると、テストが本番環境の動作を正しく反映できなくなります。
- 過度にモックすると、実際のコードパスを通らない
- モック化が不足すると、外部依存によりテストが不安定になる
- 本番環境では発生しない挙動をテストしてしまう
学習コストと実装コスト
それぞれの手法には独自の記法や概念があり、チーム全体で習得する必要があります。
- MSW は Service Worker の概念理解が必要
vi.mockはホイスティングなど Vitest 特有の挙動を理解する必要がある- 手動スタブは DI(依存性注入)の設計が求められる
以下の図は、モック手法選定時に考慮すべき要素の関係性を示しています。
mermaidflowchart LR
choice["モック手法の選択"]
choice --> impl["実装コスト"]
choice --> maintain["保守性"]
choice --> trust["テスト信頼性"]
choice --> learn["学習コスト"]
impl -->|高い| problem1["開発速度低下"]
maintain -->|低い| problem2["修正時の影響範囲大"]
trust -->|低い| problem3["バグ見逃し"]
learn -->|高い| problem4["チーム浸透に時間"]
problem1 --> result["プロジェクト<br/>への影響"]
problem2 --> result
problem3 --> result
problem4 --> result
style choice fill:#fff4e1
style result fill:#ffebee
これらの課題を理解した上で、次章では各手法の具体的な実装方法と、それぞれが課題をどう解決するのか見ていきます。
解決策
1. MSW(Mock Service Worker)
MSW は HTTP レベルでリクエストをインターセプトし、モックレスポンスを返す仕組みです。Service Worker を利用するため、実際の HTTP 通信と同じコードパスを通る点が大きな特徴となります。
MSW の基本セットアップ
まず、必要なパッケージをインストールしましょう。
bashyarn add -D msw
次に、モックハンドラーを定義します。ハンドラーは API エンドポイントごとに作成し、レスポンスを定義していきます。
typescript// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
// ユーザー情報取得 API のモック
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
// モックレスポンスの定義
return HttpResponse.json({
id: Number(id),
name: 'テストユーザー',
email: 'test@example.com',
});
}),
];
上記のハンドラーでは、/api/users/:id へのリクエストに対して、固定のユーザー情報を返しています。:id の部分は URL パラメータとして取得できるため、動的なレスポンスも可能です。
次に、テスト用のサーバー設定を行います。
typescript// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// モックサーバーのセットアップ
export const server = setupServer(...handlers);
Vitest のセットアップファイルでサーバーを起動します。
typescript// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/mocks/server';
// テスト開始前にモックサーバーを起動
beforeAll(() => {
server.listen();
});
// 各テスト後にハンドラーをリセット
afterEach(() => {
server.resetHandlers();
});
// 全テスト終了後にサーバーを停止
afterAll(() => {
server.close();
});
この設定により、全てのテストで MSW が有効になります。afterEach でハンドラーをリセットすることで、テスト間の影響を防げるのです。
MSW を使ったテスト例
実際のテストコードを見てみましょう。
typescript// src/features/user/UserProfile.test.tsx
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserProfile } from './UserProfile';
describe('UserProfile コンポーネント', () => {
it('ユーザー情報を表示する', async () => {
// コンポーネントをレンダリング
render(<UserProfile userId={1} />);
// API 呼び出し完了を待つ
await waitFor(() => {
expect(
screen.getByText('テストユーザー')
).toBeInTheDocument();
});
// メールアドレスも表示されていることを確認
expect(
screen.getByText('test@example.com')
).toBeInTheDocument();
});
});
このテストでは、UserProfile コンポーネントが内部で API を呼び出していますが、MSW が自動的にリクエストをインターセプトし、モックレスポンスを返してくれます。そのため、テストコード内でモックを設定する必要がありません。
エラーケースのテスト
MSW では、特定のテストだけで異なるレスポンスを返すことも簡単です。
typescriptimport { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
it('API エラー時にエラーメッセージを表示する', async () => {
// このテストだけ 500 エラーを返す
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ message: 'サーバーエラー' },
{ status: 500 }
);
})
);
render(<UserProfile userId={1} />);
// エラーメッセージが表示されることを確認
await waitFor(() => {
expect(
screen.getByText('ユーザー情報の取得に失敗しました')
).toBeInTheDocument();
});
});
server.use() を使うことで、一時的にハンドラーを上書きできます。テスト終了後は自動的にリセットされるため、他のテストへの影響はありません。
MSW のメリット
| # | 項目 | 詳細 |
|---|---|---|
| 1 | 本番環境に近いテスト | 実際の HTTP 通信と同じコードパスを通る |
| 2 | モック定義の一元管理 | ハンドラーファイルで集中管理できる |
| 3 | ブラウザでも使える | 開発環境での API モック化にも利用可能 |
| 4 | テストコードがシンプル | 各テストでモック設定が不要 |
MSW のデメリット
一方で、以下のような制約もあります。
- 初期セットアップがやや複雑
- Service Worker の概念理解が必要
- HTTP 通信を行わないコードではオーバースペック
2. vi.mock による モジュールモック
vi.mock は Vitest が提供するモジュールモック機能で、特定のモジュール全体を置き換えることができます。Jest の jest.mock と同等の機能です。
基本的な使い方
まず、モック対象となる API モジュールを見てみましょう。
typescript// src/api/userApi.ts
export const fetchUser = async (userId: number) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('ユーザー情報の取得に失敗しました');
}
return response.json();
};
このモジュールをテストでモック化します。
typescript// src/features/user/UserProfile.test.tsx
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { UserProfile } from './UserProfile';
// モジュール全体をモック化
vi.mock('@/api/userApi');
// モック化した関数をインポート
import { fetchUser } from '@/api/userApi';
describe('UserProfile コンポーネント', () => {
beforeEach(() => {
// 各テスト前にモックをリセット
vi.resetAllMocks();
});
it('ユーザー情報を表示する', async () => {
// モック関数の戻り値を設定
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: 'テストユーザー',
email: 'test@example.com',
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(
screen.getByText('テストユーザー')
).toBeInTheDocument();
});
// fetchUser が正しい引数で呼ばれたか確認
expect(fetchUser).toHaveBeenCalledWith(1);
});
});
vi.mock はファイルの最上部で呼び出す必要があります。これは Vitest のホイスティング(巻き上げ)という仕組みによるものです。
部分的なモック実装
モジュールの一部だけをモック化したい場合は、以下のように記述します。
typescript// 実際のモジュールを取得しつつ、一部だけモック化
vi.mock('@/api/userApi', async () => {
const actual = await vi.importActual('@/api/userApi');
return {
...actual,
// fetchUser だけモック化
fetchUser: vi.fn(),
};
});
この方法を使えば、モジュール内の他の関数は実際の実装を使いながら、特定の関数だけをモック化できます。
動的なモック実装
テストケースごとに異なる挙動を設定することも可能です。
typescriptit('API エラー時にエラーメッセージを表示する', async () => {
// エラーを throw するようモック化
vi.mocked(fetchUser).mockRejectedValue(
new Error('ユーザー情報の取得に失敗しました')
);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(
screen.getByText('ユーザー情報の取得に失敗しました')
).toBeInTheDocument();
});
});
it('複数回呼び出される場合のテスト', async () => {
// 1 回目と 2 回目で異なる値を返す
vi.mocked(fetchUser)
.mockResolvedValueOnce({
id: 1,
name: 'ユーザー1',
email: 'user1@example.com',
})
.mockResolvedValueOnce({
id: 2,
name: 'ユーザー2',
email: 'user2@example.com',
});
// テストロジック...
});
vi.mock のメリット
| # | 項目 | 詳細 |
|---|---|---|
| 1 | シンプルな構文 | Jest 経験者には馴染みやすい |
| 2 | 呼び出し検証が容易 | 引数や呼び出し回数の確認が簡単 |
| 3 | セットアップ不要 | MSW のような初期設定が不要 |
| 4 | 細かい制御が可能 | テストごとに柔軟に挙動を変更できる |
vi.mock のデメリット
一方で、以下の点に注意が必要です。
- モジュール全体がモック化されるため、意図しない影響が出る可能性がある
- ホイスティングの仕組みを理解する必要がある
- モック定義がテストファイルに散在しやすい
- HTTP 層の動作は検証できない
3. 手動スタブによる依存性注入
手動スタブは、関数やクラスを直接差し替える最もシンプルなモック手法です。依存性注入(DI)のパターンと組み合わせることで、柔軟なテストが可能になります。
依存性注入の設計
まず、テスト可能な設計にリファクタリングします。
typescript// src/api/userApi.ts
export interface UserApiClient {
fetchUser: (userId: number) => Promise<User>;
}
// 本番用の実装
export const createUserApiClient = (): UserApiClient => ({
fetchUser: async (userId: number) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('ユーザー情報の取得に失敗しました');
}
return response.json();
},
});
インターフェースを定義することで、本番実装とテスト実装を切り替えられるようにします。
次に、このクライアントを受け取るビジネスロジックを実装します。
typescript// src/features/user/userService.ts
import type { UserApiClient } from '@/api/userApi';
export class UserService {
constructor(private apiClient: UserApiClient) {}
async getUserProfile(userId: number) {
try {
const user = await this.apiClient.fetchUser(userId);
return {
success: true,
data: user,
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: '不明なエラー',
};
}
}
}
UserService はコンストラクタで UserApiClient を受け取るため、テスト時にスタブを注入できます。
テストでの手動スタブ実装
テストコードでスタブを作成し、注入します。
typescript// src/features/user/userService.test.ts
import { describe, it, expect } from 'vitest';
import { UserService } from './userService';
import type { UserApiClient } from '@/api/userApi';
describe('UserService', () => {
it('ユーザー情報を正常に取得する', async () => {
// スタブの作成
const stubApiClient: UserApiClient = {
fetchUser: async (userId: number) => ({
id: userId,
name: 'テストユーザー',
email: 'test@example.com',
}),
};
// スタブを注入
const service = new UserService(stubApiClient);
// テスト実行
const result = await service.getUserProfile(1);
// 検証
expect(result.success).toBe(true);
expect(result.data?.name).toBe('テストユーザー');
});
});
スタブはシンプルなオブジェクトとして定義できるため、非常に読みやすいコードになります。
エラーケースのテスト
エラーケースも同様にスタブで表現できます。
typescriptit('API エラー時にエラーメッセージを返す', async () => {
// エラーを throw するスタブ
const stubApiClient: UserApiClient = {
fetchUser: async () => {
throw new Error('ユーザー情報の取得に失敗しました');
},
};
const service = new UserService(stubApiClient);
const result = await service.getUserProfile(1);
expect(result.success).toBe(false);
expect(result.error).toBe(
'ユーザー情報の取得に失敗しました'
);
});
より高度な検証
呼び出し履歴を記録するスタブを作れば、より詳細な検証も可能です。
typescriptit('正しい userId で API を呼び出す', async () => {
// 呼び出し履歴を記録するスタブ
const calls: number[] = [];
const stubApiClient: UserApiClient = {
fetchUser: async (userId: number) => {
calls.push(userId);
return {
id: userId,
name: 'ユーザー',
email: 'user@example.com',
};
},
};
const service = new UserService(stubApiClient);
await service.getUserProfile(123);
// 正しい引数で呼ばれたか検証
expect(calls).toEqual([123]);
});
手動スタブのメリット
| # | 項目 | 詳細 |
|---|---|---|
| 1 | 最もシンプル | 特別なライブラリや設定が不要 |
| 2 | 完全な制御 | 実装の細部まで自由に定義できる |
| 3 | 型安全性 | TypeScript の型チェックが効く |
| 4 | 学習コスト最小 | 通常の JavaScript/TypeScript の知識だけで実装可能 |
手動スタブのデメリット
手動スタブには以下のような課題もあります。
- 依存性注入の設計が必要(既存コードの修正が必要な場合がある)
- スタブの実装コードが増える
- 複雑なモックでは実装が煩雑になりやすい
- React コンポーネントのテストには向かない
比較まとめ
3 つの手法を表で比較してみましょう。
| # | 項目 | MSW | vi.mock | 手動スタブ |
|---|---|---|---|---|
| 1 | 学習コスト | ★★★ | ★★ | ★ |
| 2 | 初期セットアップ | 必要 | 不要 | 不要 |
| 3 | テスト信頼性 | ★★★ | ★★ | ★ |
| 4 | 保守性 | ★★★ | ★★ | ★★ |
| 5 | 実装の柔軟性 | ★★ | ★★★ | ★★★ |
| 6 | コード変更の必要性 | 不要 | 不要 | 必要(DI 設計) |
このように、それぞれの手法には明確な特徴があります。次章では、実際のプロジェクトでの使い分けを具体例とともに見ていきましょう。
具体例
ケース 1:E2E に近い統合テスト(MSW を採用)
API 連携を含む画面全体の動作を検証したい場合、MSW が最適です。実際の React コンポーネントとビジネスロジックを組み合わせたテストを見てみましょう。
テスト対象のコンポーネント
typescript// src/features/user/UserProfile.tsx
import { useState, useEffect } from 'react';
import { fetchUser } from '@/api/userApi';
import type { User } from '@/types/user';
export const UserProfile = ({
userId,
}: {
userId: number;
}) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadUser = async () => {
try {
setLoading(true);
const data = await fetchUser(userId);
setUser(data);
} catch (err) {
setError('ユーザー情報の取得に失敗しました');
} finally {
setLoading(false);
}
};
loadUser();
}, [userId]);
if (loading) return <div>読み込み中...</div>;
if (error) return <div role='alert'>{error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
このコンポーネントは、API 呼び出し、ローディング状態、エラーハンドリングなど、実際のアプリケーションで必要な処理を全て含んでいます。
MSW ハンドラーの定義
typescript// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// 正常系のハンドラー
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: '山田太郎',
email: 'yamada@example.com',
});
}),
];
統合テストの実装
typescript// src/features/user/UserProfile.test.tsx
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';
describe('UserProfile 統合テスト', () => {
it('ユーザー情報を取得して表示する', async () => {
render(<UserProfile userId={1} />);
// ローディング表示を確認
expect(
screen.getByText('読み込み中...')
).toBeInTheDocument();
// API 完了後、ユーザー情報が表示されることを確認
await waitFor(() => {
expect(
screen.getByText('山田太郎')
).toBeInTheDocument();
});
expect(
screen.getByText('yamada@example.com')
).toBeInTheDocument();
});
it('ネットワークエラー時にエラーメッセージを表示する', async () => {
// このテストだけネットワークエラーを返す
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error();
})
);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'ユーザー情報の取得に失敗しました'
);
});
});
});
このテストでは、コンポーネントの内部実装(fetchUser の呼び出し)を意識せず、ユーザーから見た挙動だけを検証しています。これが MSW の大きな利点です。
以下の図は、MSW を使った統合テストのデータフローを示しています。
mermaidsequenceDiagram
participant Test as テストコード
participant Component as UserProfile
participant API as fetchUser
participant MSW as MSW Handler
Test->>Component: render()
Component->>API: fetchUser(1)
API->>MSW: GET /api/users/1
MSW-->>API: モックレスポンス
API-->>Component: User データ
Component->>Component: 状態更新
Test->>Component: 表示確認
Note over Test,MSW: 実際の HTTP フローと同じコードパスを通る
ケース 2:ビジネスロジックの単体テスト(vi.mock を採用)
複雑なビジネスロジックを持つ関数をテストする場合、vi.mock が適しています。API 呼び出しの詳細な検証が必要なケースを見てみましょう。
テスト対象のビジネスロジック
typescript// src/features/user/userOperations.ts
import { fetchUser } from '@/api/userApi';
import { updateUserCache } from '@/cache/userCache';
import { logUserAccess } from '@/logging/userLogger';
export const loadUserWithCache = async (userId: number) => {
// ユーザー情報取得
const user = await fetchUser(userId);
// キャッシュに保存
await updateUserCache(userId, user);
// アクセスログ記録
await logUserAccess(userId, user.name);
return user;
};
この関数は複数の外部依存を持っており、それぞれをモック化する必要があります。
vi.mock によるテスト
typescript// src/features/user/userOperations.test.ts
import { describe, it, expect, vi } from 'vitest';
import { loadUserWithCache } from './userOperations';
// 3 つのモジュールをモック化
vi.mock('@/api/userApi');
vi.mock('@/cache/userCache');
vi.mock('@/logging/userLogger');
import { fetchUser } from '@/api/userApi';
import { updateUserCache } from '@/cache/userCache';
import { logUserAccess } from '@/logging/userLogger';
describe('loadUserWithCache', () => {
it('ユーザー取得、キャッシュ保存、ログ記録を順番に実行する', async () => {
// モックの戻り値を設定
const mockUser = {
id: 1,
name: '山田太郎',
email: 'yamada@example.com',
};
vi.mocked(fetchUser).mockResolvedValue(mockUser);
vi.mocked(updateUserCache).mockResolvedValue(undefined);
vi.mocked(logUserAccess).mockResolvedValue(undefined);
// 関数実行
const result = await loadUserWithCache(1);
// 戻り値の検証
expect(result).toEqual(mockUser);
// 各関数が正しい引数で呼ばれたか検証
expect(fetchUser).toHaveBeenCalledWith(1);
expect(updateUserCache).toHaveBeenCalledWith(
1,
mockUser
);
expect(logUserAccess).toHaveBeenCalledWith(
1,
'山田太郎'
);
// 呼び出し順序の検証
const fetchCall =
vi.mocked(fetchUser).mock.invocationCallOrder[0];
const cacheCall =
vi.mocked(updateUserCache).mock
.invocationCallOrder[0];
const logCall =
vi.mocked(logUserAccess).mock.invocationCallOrder[0];
expect(fetchCall).toBeLessThan(cacheCall);
expect(cacheCall).toBeLessThan(logCall);
});
it('API エラー時はキャッシュとログ処理をスキップする', async () => {
// fetchUser がエラーを throw
vi.mocked(fetchUser).mockRejectedValue(
new Error('API エラー')
);
// エラーが throw されることを確認
await expect(loadUserWithCache(1)).rejects.toThrow(
'API エラー'
);
// キャッシュとログ処理が呼ばれていないことを確認
expect(updateUserCache).not.toHaveBeenCalled();
expect(logUserAccess).not.toHaveBeenCalled();
});
});
vi.mock を使うことで、関数の呼び出し順序や引数まで詳細に検証できます。これはビジネスロジックの正確性を保証する上で非常に重要です。
ケース 3:純粋なロジックの単体テスト(手動スタブを採用)
外部依存が少なく、ロジックの正確性を検証したい場合は、手動スタブがシンプルで効果的です。
テスト対象のサービスクラス
typescript// src/features/user/userValidator.ts
import type { UserApiClient } from '@/api/userApi';
export class UserValidator {
constructor(private apiClient: UserApiClient) {}
async validateUserExists(
userId: number
): Promise<boolean> {
try {
await this.apiClient.fetchUser(userId);
return true;
} catch {
return false;
}
}
async validateUserEmail(
userId: number,
expectedEmail: string
): Promise<boolean> {
try {
const user = await this.apiClient.fetchUser(userId);
return user.email === expectedEmail;
} catch {
return false;
}
}
}
このクラスは UserApiClient に依存していますが、ロジック自体はシンプルです。
手動スタブによるテスト
typescript// src/features/user/userValidator.test.ts
import { describe, it, expect } from 'vitest';
import { UserValidator } from './userValidator';
import type { UserApiClient } from '@/api/userApi';
describe('UserValidator', () => {
it('ユーザーが存在する場合 true を返す', async () => {
// スタブの作成
const stubClient: UserApiClient = {
fetchUser: async () => ({
id: 1,
name: 'テストユーザー',
email: 'test@example.com',
}),
};
const validator = new UserValidator(stubClient);
const result = await validator.validateUserExists(1);
expect(result).toBe(true);
});
it('ユーザーが存在しない場合 false を返す', async () => {
// エラーを throw するスタブ
const stubClient: UserApiClient = {
fetchUser: async () => {
throw new Error('User not found');
},
};
const validator = new UserValidator(stubClient);
const result = await validator.validateUserExists(999);
expect(result).toBe(false);
});
it('メールアドレスが一致する場合 true を返す', async () => {
const stubClient: UserApiClient = {
fetchUser: async (userId) => ({
id: userId,
name: 'ユーザー',
email: 'correct@example.com',
}),
};
const validator = new UserValidator(stubClient);
const result = await validator.validateUserEmail(
1,
'correct@example.com'
);
expect(result).toBe(true);
});
it('メールアドレスが一致しない場合 false を返す', async () => {
const stubClient: UserApiClient = {
fetchUser: async (userId) => ({
id: userId,
name: 'ユーザー',
email: 'actual@example.com',
}),
};
const validator = new UserValidator(stubClient);
const result = await validator.validateUserEmail(
1,
'wrong@example.com'
);
expect(result).toBe(false);
});
});
手動スタブは非常に読みやすく、テストの意図が明確に伝わります。特別なライブラリの知識も不要なため、チームメンバー全員が理解しやすいコードになるでしょう。
使い分けのフローチャート
どの手法を選ぶべきか迷った時は、以下のフローチャートを参考にしてください。
mermaidflowchart TD
start["モック手法の選択"]
start --> q1{"テスト対象は<br/>React コンポーネント?"}
q1 -->|はい| msw["MSW を使用"]
q1 -->|いいえ| q2{"複数の外部依存を<br/>持つビジネスロジック?"}
q2 -->|はい| q3{"呼び出し順序や<br/>引数の検証が必要?"}
q3 -->|はい| viMock["vi.mock を使用"]
q3 -->|いいえ| manual["手動スタブを検討"]
q2 -->|いいえ| q4{"既存コードは<br/>DI 設計になっている?"}
q4 -->|はい| manual2["手動スタブを使用"]
q4 -->|いいえ| q5{"コード修正は可能?"}
q5 -->|はい| manual3["DI にリファクタリング後<br/>手動スタブを使用"]
q5 -->|いいえ| viMock2["vi.mock を使用"]
style msw fill:#e8f5e9
style viMock fill:#fff4e1
style viMock2 fill:#fff4e1
style manual fill:#e1f5ff
style manual2 fill:#e1f5ff
style manual3 fill:#e1f5ff
このフローチャートに従うことで、プロジェクトの状況に応じた最適なモック手法を選択できるようになります。
まとめ
Vitest における 3 つのモック技術を比較してきましたが、それぞれに明確な適用場面があることがお分かりいただけたでしょうか。
MSW は HTTP レベルでのモック化により、本番環境に最も近いテストを実現してくれます。React コンポーネントの統合テストや E2E に近いテストでは、この手法が最適です。初期セットアップは必要ですが、一度構築すれば全体の保守性が大きく向上するでしょう。
vi.mock は、複雑なビジネスロジックの単体テストで真価を発揮します。関数の呼び出し順序や引数の検証が必要な場面では、この手法が最も効率的ですね。Vitest 特有のホイスティングには注意が必要ですが、Jest からの移行であれば馴染みやすいはずです。
手動スタブ は、最もシンプルで学習コストが低い手法です。依存性注入の設計が必要になりますが、型安全性が保たれ、テストコードの可読性も高くなります。純粋なロジックのテストには、この方法が最適でしょう。
重要なのは、「どれか 1 つを選ぶ」のではなく、状況に応じて使い分けることです。プロジェクトの規模、チームの習熟度、テスト対象の性質を考慮し、最適な手法を選択してください。
本記事が、皆さんのテスト実装における意思決定の一助となれば幸いです。より信頼性の高い、保守しやすいテストコードを書いていきましょう。
関連リンク
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleVitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
articleVitest フレーク検知技術の運用:`--retry` / シード固定 / ランダム順序で堅牢化
articleVitest テストアーキテクチャ技術:Unit / Integration / Contract の三層設計ガイド
articleVitest `vi` API 技術チートシート:`mock` / `fn` / `spyOn` / `advanceTimersByTime` 一覧
articleReact クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離
articleWebLLM vs サーバー推論 徹底比較:レイテンシ・コスト・スケールの実測レポート
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articlePython ORMs 実力検証:SQLAlchemy vs Tortoise vs Beanie の選び方
articleVite で Web Worker / SharedWorker を TypeScript でバンドルする初期設定
articlePrisma Accelerate と PgBouncer を比較:サーバレス時代の接続戦略ベンチ
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来