T-CREATOR

Jest で外部 API をモックする:fetch/axios 対応例

Jest で外部 API をモックする:fetch/axios 対応例

外部 API との通信は現代の Web アプリケーション開発において欠かせない要素ですが、テストを書く際には大きな課題となります。実際の API に依存したテストは不安定で遅く、外部サービスの状態に左右されてしまいます。

そこで重要になるのが、外部 API をモック(模擬)する技術です。Jest を使用することで、fetch API や axios といった主要な HTTP クライアントを効果的にモックし、安定したテスト環境を構築できます。

この記事では、外部 API モックの基本概念から実践的な応用例まで、段階的に解説していきます。テスト駆動開発を実践する上で必須となる技術を、具体的なコード例とともにマスターしていきましょう。

外部 API モックの必要性とメリット

テスト環境での外部依存排除

外部 API に依存するテストは、多くの問題を抱えています。最も深刻なのは、テストの実行が外部サービスの状態に左右されることです。

typescript// 問題のあるテスト例:実際のAPIに依存
describe('UserService', () => {
  it('ユーザー情報を取得する', async () => {
    const userService = new UserService();
    // 実際のAPIを呼び出してしまう
    const user = await userService.fetchUser('123');
    expect(user.name).toBe('田中太郎');
  });
});

このようなテストは以下の問題を引き起こします:

#問題点影響
1外部サービス障害テストが予期せず失敗する
2ネットワーク依存オフライン環境で実行不可
3データの変更外部データ変更でテスト結果が変わる
4認証情報管理テスト環境での API キー管理が複雑

テスト実行速度の向上

外部 API の呼び出しは、テストの実行速度を大幅に低下させます。

typescript// 速度比較の例
describe('パフォーマンス比較', () => {
  // 実際のAPI呼び出し:平均500ms
  it('実際のAPI(遅い)', async () => {
    const start = Date.now();
    const response = await fetch(
      'https://api.example.com/users/1'
    );
    const user = await response.json();
    const duration = Date.now() - start;
    console.log(`実行時間: ${duration}ms`); // 約500ms
  });

  // モックを使用:平均1ms
  it('モックAPI(速い)', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ id: '1', name: '田中太郎' }),
    });

    const start = Date.now();
    const response = await fetch(
      'https://api.example.com/users/1'
    );
    const user = await response.json();
    const duration = Date.now() - start;
    console.log(`実行時間: ${duration}ms`); // 約1ms
  });
});

予測可能なテスト結果

モックを使用することで、テストの結果を完全に制御できます。

typescript// 予測可能なテスト例
describe('エラーハンドリングテスト', () => {
  it('APIエラー時の適切な処理', async () => {
    // 必ずエラーを返すモック
    global.fetch = jest
      .fn()
      .mockRejectedValue(new Error('Network Error'));

    const userService = new UserService();

    await expect(
      userService.fetchUser('123')
    ).rejects.toThrow('Network Error');
  });

  it('404エラー時の処理', async () => {
    // 404ステータスを返すモック
    global.fetch = jest.fn().mockResolvedValue({
      ok: false,
      status: 404,
      statusText: 'Not Found',
    });

    const userService = new UserService();
    const result = await userService.fetchUser(
      'nonexistent'
    );

    expect(result).toBeNull();
  });
});

fetch API のモック基礎

global.fetch の基本的なモック設定

fetch API は現代の JavaScript で HTTP リクエストを行う標準的な方法です。Jest で fetch をモックする基本的な方法を見ていきましょう。

typescript// 基本的なfetchモック設定
describe('fetch APIモック基礎', () => {
  // 各テスト前にfetchをモック
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  // 各テスト後にモックをリセット
  afterEach(() => {
    jest.resetAllMocks();
  });

  it('基本的なGETリクエスト', async () => {
    const mockResponse = {
      id: '1',
      name: '田中太郎',
      email: 'tanaka@example.com',
    };

    // fetchモックの設定
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      status: 200,
      json: async () => mockResponse,
    });

    // テスト対象の関数
    const response = await fetch('/api/users/1');
    const user = await response.json();

    expect(fetch).toHaveBeenCalledWith('/api/users/1');
    expect(user).toEqual(mockResponse);
  });
});

レスポンスオブジェクトの作成方法

実際の fetch API のレスポンスオブジェクトを正確に模擬することが重要です。

typescript// 詳細なレスポンスオブジェクトの作成
class MockResponse {
  private data: any;
  public ok: boolean;
  public status: number;
  public statusText: string;
  public headers: Headers;

  constructor(
    data: any,
    options: {
      status?: number;
      statusText?: string;
      headers?: Record<string, string>;
    } = {}
  ) {
    this.data = data;
    this.ok =
      (options.status || 200) >= 200 &&
      (options.status || 200) < 300;
    this.status = options.status || 200;
    this.statusText = options.statusText || 'OK';
    this.headers = new Headers(options.headers || {});
  }

  async json() {
    return this.data;
  }

  async text() {
    return JSON.stringify(this.data);
  }

  async blob() {
    return new Blob([JSON.stringify(this.data)]);
  }
}

describe('詳細なレスポンスモック', () => {
  it('完全なレスポンスオブジェクト', async () => {
    const userData = {
      id: '1',
      name: '田中太郎',
      profile: {
        age: 30,
        department: '開発部',
      },
    };

    global.fetch = jest.fn().mockResolvedValue(
      new MockResponse(userData, {
        status: 200,
        statusText: 'OK',
        headers: {
          'Content-Type': 'application/json',
          'Cache-Control': 'max-age=3600',
        },
      })
    );

    const response = await fetch('/api/users/1');

    expect(response.ok).toBe(true);
    expect(response.status).toBe(200);
    expect(response.statusText).toBe('OK');
    expect(response.headers.get('Content-Type')).toBe(
      'application/json'
    );

    const user = await response.json();
    expect(user).toEqual(userData);
  });
});

成功・失敗パターンの実装

様々なシナリオに対応したモックパターンを実装しましょう。

typescript// 成功・失敗パターンの包括的なテスト
describe('APIレスポンスパターン', () => {
  const userService = {
    async getUser(id: string) {
      const response = await fetch(`/api/users/${id}`);

      if (!response.ok) {
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }

      return response.json();
    },
  };

  it('成功パターン:正常なレスポンス', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      status: 200,
      statusText: 'OK',
      json: async () => ({
        id: '1',
        name: '田中太郎',
        email: 'tanaka@example.com',
      }),
    });

    const user = await userService.getUser('1');

    expect(user.name).toBe('田中太郎');
    expect(fetch).toHaveBeenCalledWith('/api/users/1');
  });

  it('失敗パターン:404 Not Found', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: false,
      status: 404,
      statusText: 'Not Found',
    });

    await expect(
      userService.getUser('999')
    ).rejects.toThrow('HTTP 404: Not Found');
  });

  it('失敗パターン:500 Internal Server Error', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    });

    await expect(userService.getUser('1')).rejects.toThrow(
      'HTTP 500: Internal Server Error'
    );
  });

  it('失敗パターン:ネットワークエラー', async () => {
    global.fetch = jest
      .fn()
      .mockRejectedValue(new Error('Failed to fetch'));

    await expect(userService.getUser('1')).rejects.toThrow(
      'Failed to fetch'
    );
  });
});

axios のモック基礎

axios.create とデフォルト axios のモック

axios は人気の高い HTTP クライアントライブラリです。デフォルトの axios インスタンスとカスタムインスタンスの両方をモックする方法を学びましょう。

typescriptimport axios from 'axios';

// axiosのモック設定
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('axiosモック基礎', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('デフォルトaxiosのモック', async () => {
    const userData = {
      id: 1,
      name: '田中太郎',
      email: 'tanaka@example.com',
    };

    // axiosのレスポンス形式に合わせる
    mockedAxios.get.mockResolvedValue({
      data: userData,
      status: 200,
      statusText: 'OK',
      headers: {},
      config: {},
    });

    const response = await axios.get('/api/users/1');

    expect(response.data).toEqual(userData);
    expect(response.status).toBe(200);
    expect(mockedAxios.get).toHaveBeenCalledWith(
      '/api/users/1'
    );
  });

  it('axios.createで作成されたインスタンスのモック', async () => {
    // カスタムaxiosインスタンス
    const apiClient = axios.create({
      baseURL: 'https://api.example.com',
      timeout: 5000,
      headers: {
        Authorization: 'Bearer token123',
      },
    });

    // インスタンスメソッドのモック
    const mockGet = jest.fn();
    apiClient.get = mockGet;

    const userData = { id: 1, name: '田中太郎' };
    mockGet.mockResolvedValue({
      data: userData,
      status: 200,
      statusText: 'OK',
      headers: {},
      config: {},
    });

    const response = await apiClient.get('/users/1');

    expect(response.data).toEqual(userData);
    expect(mockGet).toHaveBeenCalledWith('/users/1');
  });
});

## インターセプターを考慮したモック

axiosのインターセプター機能を使用している場合のモック方法を見ていきましょう。

```typescript
// インターセプターを含むAPIクライアント
class ApiClient {
  private axios;

  constructor() {
    this.axios = axios.create({
      baseURL: 'https://api.example.com'
    });

    // リクエストインターセプター
    this.axios.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('authToken');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // レスポンスインターセプター
    this.axios.interceptors.response.use(
      (response) => response,
      (error) => {
        if (error.response?.status === 401) {
          localStorage.removeItem('authToken');
          window.location.href = '/login';
        }
        return Promise.reject(error);
      }
    );
  }

  async getUser(id: string) {
    const response = await this.axios.get(`/users/${id}`);
    return response.data;
  }
}

describe('インターセプター付きaxiosのモック', () => {
  let apiClient: ApiClient;

  beforeEach(() => {
    // localStorageのモック
    Object.defineProperty(window, 'localStorage', {
      value: {
        getItem: jest.fn(),
        setItem: jest.fn(),
        removeItem: jest.fn()
      }
    });

    apiClient = new ApiClient();
  });

  it('認証トークン付きリクエスト', async () => {
    // localStorageにトークンを設定
    (localStorage.getItem as jest.Mock).mockReturnValue('token123');

    // axiosのモック設定
    mockedAxios.create.mockReturnValue({
      get: jest.fn().mockResolvedValue({
        data: { id: '1', name: '田中太郎' },
        status: 200
      }),
      interceptors: {
        request: { use: jest.fn() },
        response: { use: jest.fn() }
      }
    } as any);

    const user = await apiClient.getUser('1');

    expect(user.name).toBe('田中太郎');
    expect(localStorage.getItem).toHaveBeenCalledWith('authToken');
  });
});

axios 特有のレスポンス形式への対応

axios は独自のレスポンス形式を持っているため、正確にモックする必要があります。

typescript// axios レスポンス形式の完全なモック
interface AxiosResponseMock<T = any> {
  data: T;
  status: number;
  statusText: string;
  headers: Record<string, string>;
  config: Record<string, any>;
  request?: any;
}

function createAxiosResponse<T>(
  data: T,
  status: number = 200,
  statusText: string = 'OK'
): AxiosResponseMock<T> {
  return {
    data,
    status,
    statusText,
    headers: {
      'content-type': 'application/json'
    },
    config: {
      url: '/api/test',
      method: 'get'
    }
  };
}

describe('axios レスポンス形式のモック', () => {
  it('完全なレスポンスオブジェクト', async () => {
    const userData = {
      id: 1,
      name: '田中太郎',
      profile: {
        department: '開発部',
        position: 'シニアエンジニア'
      }
    };

    mockedAxios.get.mockResolvedValue(
      createAxiosResponse(userData, 200, 'OK')
    );

    const response = await axios.get('/api/users/1');

    expect(response.data).toEqual(userData);
    expect(response.status).toBe(200);
    expect(response.statusText).toBe('OK');
    expect(response.headers['content-type']).toBe('application/json');
  });

  it('エラーレスポンスのモック', async () => {
    const errorData = {
      message: 'ユーザーが見つかりません',
      code: 'USER_NOT_FOUND'
    };

    mockedAxios.get.mockRejectedValue({
      response: createAxiosResponse(errorData, 404, 'Not Found'),
      message: 'Request failed with status code 404'
    });

    await expect(axios.get('/api/users/999')).rejects.toMatchObject({
      response: {
        status: 404,
        data: errorData
      }
    });
  });
});

## DELETE リクエストの処理

DELETEリクエストは通常、レスポンスボディが空か最小限の情報のみを返します。

```typescript
// DELETEリクエストのモック
describe('DELETEリクエストのモック', () => {
  const userService = {
    // 単一ユーザー削除
    async deleteUser(id: string) {
      const response = await fetch(`/api/users/${id}`, {
        method: 'DELETE'
      });

      if (!response.ok) {
        throw new Error('ユーザーの削除に失敗しました');
      }

      // DELETEは通常、ステータスコードのみで判断
      return response.status === 204;
    },

    // 複数ユーザー削除
    async deleteUsers(ids: string[]) {
      const response = await fetch('/api/users/batch', {
        method: 'DELETE',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ ids })
      });

      return response.json();
    }
  };

  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('単一ユーザーの削除', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      status: 204,
      statusText: 'No Content'
    });

    const result = await userService.deleteUser('123');

    expect(result).toBe(true);
    expect(fetch).toHaveBeenCalledWith('/api/users/123', {
      method: 'DELETE'
    });
  });

  it('複数ユーザーの一括削除', async () => {
    const deleteIds = ['1', '2', '3'];
    const deleteResult = {
      deleted: 3,
      failed: 0,
      details: deleteIds.map(id => ({ id, success: true }))
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      status: 200,
      json: async () => deleteResult
    });

    const result = await userService.deleteUsers(deleteIds);

    expect(result.deleted).toBe(3);
    expect(result.failed).toBe(0);
    expect(fetch).toHaveBeenCalledWith('/api/users/batch', {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ ids: deleteIds })
    });
  });

  it('削除失敗時のエラーハンドリング', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 404,
      statusText: 'Not Found'
    });

    await expect(userService.deleteUser('nonexistent')).rejects.toThrow(
      'ユーザーの削除に失敗しました'
    );
  });
});

# 実践的なエラーハンドリングパターン

## ネットワークエラーのシミュレーション

実際の開発では、様々なネットワークエラーに対応する必要があります。

```typescript
// ネットワークエラーのシミュレーション
describe('ネットワークエラーのモック', () => {
  const apiService = {
    async fetchWithRetry(url: string, maxRetries: number = 3) {
      let lastError;

      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          const response = await fetch(url);
          if (response.ok) {
            return response.json();
          }
          throw new Error(`HTTP ${response.status}`);
        } catch (error) {
          lastError = error;

          if (attempt < maxRetries) {
            // 指数バックオフで待機
            await new Promise(resolve =>
              setTimeout(resolve, Math.pow(2, attempt) * 1000)
            );
          }
        }
      }

      throw lastError;
    }
  };

  beforeEach(() => {
    global.fetch = jest.fn();
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('一時的なネットワークエラー後の成功', async () => {
    let callCount = 0;

    (global.fetch as jest.Mock).mockImplementation(() => {
      callCount++;
      if (callCount < 3) {
        return Promise.reject(new Error('Network Error'));
      }
      return Promise.resolve({
        ok: true,
        json: async () => ({ success: true })
      });
    });

    const resultPromise = apiService.fetchWithRetry('/api/data');

    // タイマーを進めてリトライを実行
    jest.advanceTimersByTime(2000); // 1回目のリトライ
    jest.advanceTimersByTime(4000); // 2回目のリトライ

    const result = await resultPromise;

    expect(result).toEqual({ success: true });
    expect(fetch).toHaveBeenCalledTimes(3);
  });

  it('最大リトライ回数を超えた場合のエラー', async () => {
    (global.fetch as jest.Mock).mockRejectedValue(
      new Error('Persistent Network Error')
    );

    const resultPromise = apiService.fetchWithRetry('/api/data', 2);

    // すべてのリトライタイマーを進める
    jest.advanceTimersByTime(10000);

    await expect(resultPromise).rejects.toThrow(
      'Persistent Network Error'
    );
    expect(fetch).toHaveBeenCalledTimes(2);
  });
});

HTTP ステータスコード別の対応

各 HTTP ステータスコードに応じた適切な処理のテストを行います。

typescript// HTTPステータスコード別の処理
describe('HTTPステータスコード別の対応', () => {
  const apiService = {
    async handleResponse(url: string) {
      const response = await fetch(url);

      switch (response.status) {
        case 200:
          return {
            success: true,
            data: await response.json(),
          };
        case 201:
          return {
            success: true,
            created: true,
            data: await response.json(),
          };
        case 204:
          return { success: true, deleted: true };
        case 400:
          const badRequestData = await response.json();
          throw new Error(
            `Bad Request: ${badRequestData.message}`
          );
        case 401:
          throw new Error('認証が必要です');
        case 403:
          throw new Error('アクセス権限がありません');
        case 404:
          throw new Error('リソースが見つかりません');
        case 429:
          throw new Error('レート制限に達しました');
        case 500:
          throw new Error('サーバーエラーが発生しました');
        default:
          throw new Error(
            `Unexpected status: ${response.status}`
          );
      }
    },
  };

  const testCases = [
    {
      status: 200,
      description: '成功レスポンス',
      mockResponse: {
        ok: true,
        status: 200,
        json: async () => ({
          id: 1,
          name: 'テストユーザー',
        }),
      },
      expected: {
        success: true,
        data: { id: 1, name: 'テストユーザー' },
      },
    },
    {
      status: 201,
      description: 'リソース作成成功',
      mockResponse: {
        ok: true,
        status: 201,
        json: async () => ({ id: 2, name: '新規ユーザー' }),
      },
      expected: {
        success: true,
        created: true,
        data: { id: 2, name: '新規ユーザー' },
      },
    },
    {
      status: 204,
      description: 'リソース削除成功',
      mockResponse: {
        ok: true,
        status: 204,
      },
      expected: {
        success: true,
        deleted: true,
      },
    },
  ];

  testCases.forEach(
    ({ status, description, mockResponse, expected }) => {
      it(`${status}: ${description}`, async () => {
        global.fetch = jest
          .fn()
          .mockResolvedValue(mockResponse);

        const result = await apiService.handleResponse(
          '/api/test'
        );

        expect(result).toEqual(expected);
      });
    }
  );

  const errorCases = [
    {
      status: 400,
      description: 'Bad Request',
      mockResponse: {
        ok: false,
        status: 400,
        json: async () => ({
          message: '不正なリクエストです',
        }),
      },
      expectedError: 'Bad Request: 不正なリクエストです',
    },
    {
      status: 401,
      description: 'Unauthorized',
      mockResponse: {
        ok: false,
        status: 401,
      },
      expectedError: '認証が必要です',
    },
    {
      status: 403,
      description: 'Forbidden',
      mockResponse: {
        ok: false,
        status: 403,
      },
      expectedError: 'アクセス権限がありません',
    },
    {
      status: 404,
      description: 'Not Found',
      mockResponse: {
        ok: false,
        status: 404,
      },
      expectedError: 'リソースが見つかりません',
    },
    {
      status: 429,
      description: 'Too Many Requests',
      mockResponse: {
        ok: false,
        status: 429,
      },
      expectedError: 'レート制限に達しました',
    },
    {
      status: 500,
      description: 'Internal Server Error',
      mockResponse: {
        ok: false,
        status: 500,
      },
      expectedError: 'サーバーエラーが発生しました',
    },
  ];

  errorCases.forEach(
    ({
      status,
      description,
      mockResponse,
      expectedError,
    }) => {
      it(`${status}: ${description}`, async () => {
        global.fetch = jest
          .fn()
          .mockResolvedValue(mockResponse);

        await expect(
          apiService.handleResponse('/api/test')
        ).rejects.toThrow(expectedError);
      });
    }
  );
});

タイムアウト処理のテスト

タイムアウト処理は、ネットワークが不安定な環境での重要な機能です。

typescript// タイムアウト処理のテスト
describe('タイムアウト処理のモック', () => {
  const apiService = {
    async fetchWithTimeout(url: string, timeoutMs: number = 5000) {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

      try {
        const response = await fetch(url, {
          signal: controller.signal
        });
        clearTimeout(timeoutId);
        return response.json();
      } catch (error) {
        clearTimeout(timeoutId);
        if (error.name === 'AbortError') {
          throw new Error('Request timeout');
        }
        throw error;
      }
    }
  };

  beforeEach(() => {
    global.fetch = jest.fn();
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('正常なレスポンス(タイムアウト前)', async () => {
    const mockData = { id: 1, name: 'テストデータ' };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockData
    });

    const resultPromise = apiService.fetchWithTimeout('/api/data', 5000);

    // 短時間でレスポンスが返る
    jest.advanceTimersByTime(1000);

    const result = await resultPromise;

    expect(result).toEqual(mockData);
  });

  it('タイムアウトエラー', async () => {
    // 長時間かかるリクエストをシミュレート
    (global.fetch as jest.Mock).mockImplementation(() =>
      new Promise(resolve =>
        setTimeout(() => resolve({
          ok: true,
          json: async () => ({ data: 'late response' })
        }), 10000)
      )
    );

    const resultPromise = apiService.fetchWithTimeout('/api/data', 3000);

    // タイムアウト時間を経過
    jest.advanceTimersByTime(3000);

    await expect(resultPromise).rejects.toThrow('Request timeout');
  });

  it('AbortController の動作確認', async () => {
    let abortSignal: AbortSignal | undefined;

    (global.fetch as jest.Mock).mockImplementation((url, options) => {
      abortSignal = options?.signal;
      return new Promise((resolve, reject) => {
        if (abortSignal) {
          abortSignal.addEventListener('abort', () => {
            reject(new Error('AbortError'));
          });
        }
        // 長時間待機をシミュレート
        setTimeout(() => resolve({
          ok: true,
          json: async () => ({ data: 'response' })
        }), 10000);
      });
    });

    const resultPromise = apiService.fetchWithTimeout('/api/data', 2000);

    // タイムアウト発生
    jest.advanceTimersByTime(2000);

    await expect(resultPromise).rejects.toThrow('Request timeout');
    expect(abortSignal?.aborted).toBe(true);
  });
});

# Next.js/React での API 統合テスト

## コンポーネント内でのAPI呼び出しテスト

React コンポーネント内でのAPI呼び出しをテストする方法を学びましょう。

```typescript
import React, { useState, useEffect } from 'react';
import { render, screen, waitFor } from '@testing-library/react';

// テスト対象のコンポーネント
interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
}

const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
          throw new Error('ユーザーの取得に失敗しました');
        }

        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err instanceof Error ? err.message : '不明なエラー');
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchUser();
    }
  }, [userId]);

  if (loading) {
    return <div data-testid="loading">読み込み中...</div>;
  }

  if (error) {
    return (
      <div data-testid="error" role="alert">
        エラー: {error}
      </div>
    );
  }

  if (!user) {
    return <div data-testid="no-user">ユーザーが見つかりません</div>;
  }

  return (
    <div data-testid="user-profile">
      <img
        src={user.avatar || '/default-avatar.png'}
        alt={`${user.name}のアバター`}
        data-testid="user-avatar"
      />
      <h2 data-testid="user-name">{user.name}</h2>
      <p data-testid="user-email">メール: {user.email}</p>
    </div>
  );
};

describe('UserProfile コンポーネント', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('ローディング状態の表示', async () => {
    // 長時間かかるAPIをモック
    (global.fetch as jest.Mock).mockImplementation(() =>
      new Promise(resolve =>
        setTimeout(() => resolve({
          ok: true,
          json: async () => ({
            id: '1',
            name: '田中太郎',
            email: 'tanaka@example.com'
          })
        }), 1000)
      )
    );

    render(<UserProfile userId="1" />);

    expect(screen.getByTestId('loading')).toBeInTheDocument();
    expect(screen.getByText('読み込み中...')).toBeInTheDocument();
  });

  it('ユーザー情報の正常表示', async () => {
    const mockUser = {
      id: '1',
      name: '田中太郎',
      email: 'tanaka@example.com',
      avatar: 'https://example.com/avatar.jpg'
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockUser
    });

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument();
    });

    expect(screen.getByTestId('user-name')).toHaveTextContent('田中太郎');
    expect(screen.getByTestId('user-email')).toHaveTextContent(
      'メール: tanaka@example.com'
    );
    expect(screen.getByTestId('user-avatar')).toHaveAttribute(
      'src',
      'https://example.com/avatar.jpg'
    );
  });

  it('APIエラー時のエラー表示', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error'
    });

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument();
    });

    expect(screen.getByText('エラー: ユーザーの取得に失敗しました'))
      .toBeInTheDocument();
  });

  it('ネットワークエラー時の処理', async () => {
    (global.fetch as jest.Mock).mockRejectedValue(
      new Error('Network Error')
    );

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument();
    });

         expect(screen.getByText('エラー: Network Error'))
       .toBeInTheDocument();
   });
 });

useEffect と API モックの組み合わせ

useEffect フック内での API 呼び出しをテストする詳細な方法を見ていきましょう。

typescript// useEffect内でのAPI呼び出しテスト
import {
  renderHook,
  waitFor,
} from '@testing-library/react';

// カスタムフック
const useUserData = (userId: string) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!userId) return;

    const fetchUser = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `/api/users/${userId}`
        );

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  return { user, loading, error };
};

describe('useUserData フック', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('初期状態の確認', () => {
    const { result } = renderHook(() => useUserData(''));

    expect(result.current.user).toBeNull();
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('ユーザーデータの正常取得', async () => {
    const mockUser = {
      id: '1',
      name: '田中太郎',
      email: 'tanaka@example.com',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    });

    const { result } = renderHook(() => useUserData('1'));

    // 初期状態でローディングが開始される
    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBeNull();
  });

  it('API エラー時の処理', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 404,
    });

    const { result } = renderHook(() => useUserData('999'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toBeNull();
    expect(result.current.error).toBe('HTTP 404');
  });
});

カスタムフックのテスト手法

より複雑な API 統合を行うカスタムフックのテスト手法を学びましょう。

typescript// 高度なカスタムフックのテスト
import {
  renderHook,
  act,
  waitFor,
} from '@testing-library/react';

// CRUD操作を含むカスタムフック
const useUserManagement = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchUsers = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/users');
      const userData = await response.json();
      setUsers(userData);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const createUser = async (userData) => {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(userData),
      });

      if (!response.ok) {
        throw new Error('ユーザー作成に失敗しました');
      }

      const newUser = await response.json();
      setUsers((prev) => [...prev, newUser]);
      return newUser;
    } catch (err) {
      setError(err.message);
      throw err;
    }
  };

  const updateUser = async (id, updates) => {
    try {
      const response = await fetch(`/api/users/${id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(updates),
      });

      if (!response.ok) {
        throw new Error('ユーザー更新に失敗しました');
      }

      const updatedUser = await response.json();
      setUsers((prev) =>
        prev.map((user) =>
          user.id === id ? updatedUser : user
        )
      );
      return updatedUser;
    } catch (err) {
      setError(err.message);
      throw err;
    }
  };

  const deleteUser = async (id) => {
    try {
      const response = await fetch(`/api/users/${id}`, {
        method: 'DELETE',
      });

      if (!response.ok) {
        throw new Error('ユーザー削除に失敗しました');
      }

      setUsers((prev) =>
        prev.filter((user) => user.id !== id)
      );
    } catch (err) {
      setError(err.message);
      throw err;
    }
  };

  return {
    users,
    loading,
    error,
    fetchUsers,
    createUser,
    updateUser,
    deleteUser,
  };
};

describe('useUserManagement フック', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('ユーザー一覧の取得', async () => {
    const mockUsers = [
      {
        id: '1',
        name: '田中太郎',
        email: 'tanaka@example.com',
      },
      {
        id: '2',
        name: '佐藤花子',
        email: 'sato@example.com',
      },
    ];

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockUsers,
    });

    const { result } = renderHook(() =>
      useUserManagement()
    );

    await act(async () => {
      await result.current.fetchUsers();
    });

    expect(result.current.users).toEqual(mockUsers);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('ユーザーの作成', async () => {
    const newUserData = {
      name: '山田太郎',
      email: 'yamada@example.com',
    };

    const createdUser = {
      id: '3',
      ...newUserData,
      createdAt: '2024-01-15T10:00:00Z',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => createdUser,
    });

    const { result } = renderHook(() =>
      useUserManagement()
    );

    let createdResult;
    await act(async () => {
      createdResult = await result.current.createUser(
        newUserData
      );
    });

    expect(createdResult).toEqual(createdUser);
    expect(result.current.users).toContain(createdUser);
    expect(fetch).toHaveBeenCalledWith('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(newUserData),
    });
  });

  it('ユーザーの更新', async () => {
    const initialUsers = [
      {
        id: '1',
        name: '田中太郎',
        email: 'tanaka@example.com',
      },
    ];

    const updatedUser = {
      id: '1',
      name: '田中太郎(更新)',
      email: 'tanaka.new@example.com',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => updatedUser,
    });

    const { result } = renderHook(() =>
      useUserManagement()
    );

    // 初期状態を設定
    act(() => {
      result.current.users = initialUsers;
    });

    await act(async () => {
      await result.current.updateUser('1', {
        name: '田中太郎(更新)',
        email: 'tanaka.new@example.com',
      });
    });

    expect(result.current.users[0]).toEqual(updatedUser);
  });

  it('ユーザーの削除', async () => {
    const initialUsers = [
      {
        id: '1',
        name: '田中太郎',
        email: 'tanaka@example.com',
      },
      {
        id: '2',
        name: '佐藤花子',
        email: 'sato@example.com',
      },
    ];

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      status: 204,
    });

    const { result } = renderHook(() =>
      useUserManagement()
    );

    // 初期状態を設定
    act(() => {
      result.current.users = initialUsers;
    });

    await act(async () => {
      await result.current.deleteUser('1');
    });

    expect(result.current.users).toHaveLength(1);
    expect(result.current.users[0].id).toBe('2');
    expect(fetch).toHaveBeenCalledWith('/api/users/1', {
      method: 'DELETE',
    });
  });

  it('エラー時の適切なハンドリング', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 500,
    });

    const { result } = renderHook(() =>
      useUserManagement()
    );

    await act(async () => {
      try {
        await result.current.createUser({
          name: 'テストユーザー',
          email: 'test@example.com',
        });
      } catch (error) {
        // エラーが適切にスローされることを確認
        expect(error.message).toBe(
          'ユーザー作成に失敗しました'
        );
      }
    });

    expect(result.current.error).toBe(
      'ユーザー作成に失敗しました'
    );
  });
});

まとめ

この記事では、Jest を使用した外部 API モックの実装方法について、基礎から実践的な応用まで幅広く解説しました。

fetch API と axios の両方に対応したモック手法を学び、HTTP メソッド別の実装パターンや、エラーハンドリング、タイムアウト処理などの重要な要素を詳しく見てきました。また、React/Next.js での実際のコンポーネントテストやカスタムフックのテスト手法も紹介しました。

外部 API モックを適切に実装することで、以下のメリットを得られます:

#メリット効果
1テストの安定性向上外部サービスに依存しない一貫したテスト結果
2開発効率の向上高速なテスト実行とフィードバック
3エラーケースの網羅様々な失敗シナリオの確実なテスト
4開発コストの削減外部 API 呼び出しコストの削減

テスト駆動開発を実践する上で、外部 API モックは欠かせない技術です。この記事で学んだ技術を活用して、より堅牢で保守性の高い Web アプリケーションを開発していきましょう。

継続的な学習と実践を通じて、テストスキルをさらに向上させることで、チーム全体の開発品質向上に貢献できるはずです。

関連リンク

GET リクエストのモック

GET リクエストは最も基本的な HTTP メソッドです。様々なパターンでのモック実装を見ていきましょう。

typescript// GETリクエストの包括的なモック例
describe('GETリクエストのモック', () => {
  const userService = {
    // 単一ユーザー取得
    async getUser(id: string) {
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) {
        throw new Error('ユーザーの取得に失敗しました');
      }
      return response.json();
    },

    // ユーザー一覧取得(ページネーション付き)
    async getUsers(page: number = 1, limit: number = 10) {
      const response = await fetch(
        `/api/users?page=${page}&limit=${limit}`
      );
      return response.json();
    },

    // 検索機能付きユーザー取得
    async searchUsers(query: string) {
      const response = await fetch(
        `/api/users/search?q=${encodeURIComponent(query)}`
      );
      return response.json();
    },
  };

  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('単一ユーザーの取得', async () => {
    const mockUser = {
      id: '1',
      name: '田中太郎',
      email: 'tanaka@example.com',
      createdAt: '2024-01-15T10:00:00Z',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    });

    const user = await userService.getUser('1');

    expect(user).toEqual(mockUser);
    expect(fetch).toHaveBeenCalledWith('/api/users/1');
  });

  it('ユーザー一覧の取得(ページネーション)', async () => {
    const mockResponse = {
      users: [
        { id: '1', name: '田中太郎' },
        { id: '2', name: '佐藤花子' },
      ],
      pagination: {
        page: 1,
        limit: 10,
        total: 25,
        totalPages: 3,
      },
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockResponse,
    });

    const result = await userService.getUsers(1, 10);

    expect(result.users).toHaveLength(2);
    expect(result.pagination.total).toBe(25);
    expect(fetch).toHaveBeenCalledWith(
      '/api/users?page=1&limit=10'
    );
  });

  it('ユーザー検索', async () => {
    const searchQuery = '田中';
    const mockResults = {
      results: [
        {
          id: '1',
          name: '田中太郎',
          email: 'tanaka@example.com',
        },
        {
          id: '3',
          name: '田中次郎',
          email: 'tanaka2@example.com',
        },
      ],
      count: 2,
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockResults,
    });

    const results = await userService.searchUsers(
      searchQuery
    );

    expect(results.results).toHaveLength(2);
    expect(results.count).toBe(2);
    expect(fetch).toHaveBeenCalledWith(
      '/api/users/search?q=%E7%94%B0%E4%B8%AD'
    );
  });
});

POST/PUT/PATCH でのデータ送信モック

データを送信する HTTP メソッドでは、リクエストボディの検証も重要です。

typescript// データ送信系メソッドのモック
describe('データ送信メソッドのモック', () => {
  const userService = {
    // ユーザー作成
    async createUser(userData: {
      name: string;
      email: string;
      department: string;
    }) {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(userData),
      });

      if (!response.ok) {
        throw new Error('ユーザーの作成に失敗しました');
      }

      return response.json();
    },

    // ユーザー情報更新(完全更新)
    async updateUser(
      id: string,
      userData: {
        name: string;
        email: string;
        department: string;
      }
    ) {
      const response = await fetch(`/api/users/${id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(userData),
      });

      return response.json();
    },

    // ユーザー情報更新(部分更新)
    async patchUser(
      id: string,
      updates: Partial<{
        name: string;
        email: string;
        department: string;
      }>
    ) {
      const response = await fetch(`/api/users/${id}`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(updates),
      });

      return response.json();
    },
  };

  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('POSTリクエスト:ユーザー作成', async () => {
    const newUserData = {
      name: '山田太郎',
      email: 'yamada@example.com',
      department: '営業部',
    };

    const createdUser = {
      id: '123',
      ...newUserData,
      createdAt: '2024-01-15T10:00:00Z',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      status: 201,
      json: async () => createdUser,
    });

    const result = await userService.createUser(
      newUserData
    );

    expect(result).toEqual(createdUser);
    expect(fetch).toHaveBeenCalledWith('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(newUserData),
    });
  });

  it('PUTリクエスト:ユーザー完全更新', async () => {
    const userId = '123';
    const updateData = {
      name: '山田太郎(更新)',
      email: 'yamada.updated@example.com',
      department: 'マーケティング部',
    };

    const updatedUser = {
      id: userId,
      ...updateData,
      updatedAt: '2024-01-15T11:00:00Z',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => updatedUser,
    });

    const result = await userService.updateUser(
      userId,
      updateData
    );

    expect(result).toEqual(updatedUser);
    expect(fetch).toHaveBeenCalledWith(
      `/api/users/${userId}`,
      {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(updateData),
      }
    );
  });

  it('PATCHリクエスト:ユーザー部分更新', async () => {
    const userId = '123';
    const patchData = {
      department: 'エンジニアリング部',
    };

    const patchedUser = {
      id: userId,
      name: '山田太郎',
      email: 'yamada@example.com',
      department: 'エンジニアリング部',
      updatedAt: '2024-01-15T12:00:00Z',
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => patchedUser,
    });

    const result = await userService.patchUser(
      userId,
      patchData
    );

    expect(result).toEqual(patchedUser);
    expect(fetch).toHaveBeenCalledWith(
      `/api/users/${userId}`,
      {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(patchData),
      }
    );
  });
});