T-CREATOR

Vitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?

Vitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?

Vitest でテストを書いていると、API 呼び出しのモック化が必要になる場面に必ず出会います。しかし、モック手法には MSW、vi.mock、手動スタブなど複数の選択肢があり、「どれを使えば良いのか」と迷われた経験はないでしょうか。

本記事では、3 つの代表的なモック技術を実際のコード例とともに比較し、それぞれのメリット・デメリット、そして最適な使い分けの指針をご紹介します。テストコードの保守性や実装コストを考える上で、きっと役立つ内容となっているはずです。

背景

Vitest とモック技術の関係

Vitest は Vite ベースの高速テストフレームワーで、Jest 互換の API を持ちながらも、モダンな開発体験を提供してくれます。テストを書く際、外部 API や非同期処理を扱うコードでは、必然的にモック化が必要になるでしょう。

モック化することで、以下のようなメリットが得られます。

  • テストの実行速度向上(実際の API 呼び出しを回避)
  • テストの安定性確保(外部サービスの状態に依存しない)
  • エッジケースの再現(エラーレスポンスなど)

モック手法の選択肢

Vitest でのモック実装には、主に 3 つのアプローチが存在します。

#手法特徴
1MSW(Mock Service Worker)HTTP レベルでインターセプト
2vi.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 つの手法を表で比較してみましょう。

#項目MSWvi.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 つを選ぶ」のではなく、状況に応じて使い分けることです。プロジェクトの規模、チームの習熟度、テスト対象の性質を考慮し、最適な手法を選択してください。

本記事が、皆さんのテスト実装における意思決定の一助となれば幸いです。より信頼性の高い、保守しやすいテストコードを書いていきましょう。

関連リンク