T-CREATOR

Vitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める

Vitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める

フロントエンド開発において、テストコードの品質は製品の信頼性を大きく左右します。特に外部 API や複雑な依存関係を持つモジュールのテストでは、モックの適切な活用が不可欠です。Vitest は Jest ライクな API を持ちながら、Vite のエコシステムと統合された高速なテストフレームワークとして注目を集めています。

本記事では、Vitest のモジュールモック技術の中核である vi.mockvi.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 の主な特徴

#特徴説明メリット
1ESM ネイティブES Modules を標準でサポートモダンな JavaScript に対応
2Vite の設定を共有ビルド設定をそのまま利用設定の二重管理が不要
3HMR 対応テストのホットリロード開発体験の向上
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.mockvi.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() を呼び出すと、指定したモジュールのすべてのエクスポートが自動的にモック関数に置き換えられます。この時点で fetchUsercreateUser は実際の実装を持たない空のモック関数になります。

次に、モック関数の戻り値を設定してテストを書いていきます。

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', () => {
    // このテストも独立している
  });
});

以下の表は、主要なクリーンアップメソッドの使い分けを示しています。

モッククリーンアップメソッドの比較

#メソッド効果使用タイミング
1vi.clearAllMocks()呼び出し履歴をクリア各テストの前
2vi.resetAllMocks()履歴クリア + 実装リセットテストグループの前
3vi.restoreAllMocks()スパイを元の実装に戻す各テストの後
4mockClear()個別モックの履歴クリア特定モックのみリセット
5mockReset()個別モックの完全リセットモックの再設定前

グローバルモックの管理

グローバルなオブジェクト(fetchlocalStorage など)をモック化する場合の管理方法です。

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;
  }
}

このサービスは ApiClientcachelogger の 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);
  });
});

この例では、mockRejectedValueOncemockResolvedValueOnce を使用して、最初の呼び出しはエラー、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() ですべてのタイマーを完了させられます。

タイマーモックの主要メソッド

#メソッド説明使用例
1vi.useFakeTimers()タイマーをモック化テスト前の設定
2vi.useRealTimers()タイマーを元に戻すテスト後のクリーンアップ
3vi.advanceTimersByTime(ms)指定時間進める(同期)単純な時間経過
4vi.advanceTimersByTimeAsync(ms)指定時間進める(非同期)Promise を含む処理
5vi.runAllTimers()すべてのタイマー実行(同期)完了まで待たない
6vi.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, vi.mock, vi.spyOn, モジュールモック, TypeScript テスト, React テスト, タイマーモック, 非同期テスト, Jest 互換, テストダブル, スパイ, モック, スタブ