MSWを使った非同期コンポーネントのテスト: APIコールのモック

Shunku

APIコールを行うコンポーネントのテストは難しいことがあります。Mock Service Worker (MSW)はネットワークレベルでリクエストをインターセプトし、本番環境と全く同じようにコンポーネントをテストできます。

なぜMSWか?

従来のモックアプローチには制限があります:

flowchart TD
    subgraph Traditional["従来のモック"]
        A[fetch/axiosを直接モック]
        B[実装に依存]
        C[本番と異なる]
    end

    subgraph MSW["MSWアプローチ"]
        D[ネットワークレベルでインターセプト]
        E[実装に依存しない]
        F[本番と同じ動作]
    end

    style Traditional fill:#ef4444,color:#fff
    style MSW fill:#10b981,color:#fff
アプローチ 長所 短所
fetchをモック シンプルなセットアップ 実装に密結合
axiosをモック axios使用時は簡単 ライブラリを切り替えると壊れる
MSW どのライブラリでも動作 セットアップが少し多い

MSWはアプリからリクエストが出る前にインターセプトするため、コンポーネントのfetchコードは本番環境と全く同じように実行されます。

MSWのセットアップ

インストール

npm install -D msw

ハンドラーの作成

モックサーバーがリクエストにどう応答するかを定義します:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GETリクエスト
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
  }),

  // パスパラメータ付きGET
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id: Number(id), name: 'John Doe' });
  }),

  // POSTリクエスト
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body },
      { status: 201 }
    );
  }),
];

サーバーのセットアップ

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

テストセットアップ

テスト中にMSWを実行するよう設定:

// src/test/setup.ts
import '@testing-library/jest-dom';
import { server } from '../mocks/server';

// すべてのテスト前にサーバーを開始
beforeAll(() => server.listen());

// 各テスト後にハンドラーをリセット
afterEach(() => server.resetHandlers());

// すべてのテスト後にサーバーを閉じる
afterAll(() => server.close());

Vite/Jest設定を更新:

// vite.config.ts
export default defineConfig({
  test: {
    setupFiles: './src/test/setup.ts',
  },
});

データフェッチコンポーネントのテスト

基本的な非同期コンポーネント

// UserList.tsx
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div role="alert">{error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

ハッピーパスのテスト

import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';

test('ロード後にユーザーを表示', async () => {
  render(<UserList />);

  // ローディング状態を表示
  expect(screen.getByText('読み込み中...')).toBeInTheDocument();

  // ユーザーが表示されるのを待つ
  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();

  // ローディングは消える
  expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});

エラー状態のテスト

特定のテストでハンドラーをオーバーライド:

import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

test('失敗時にエラーメッセージを表示', async () => {
  // このテスト用にハンドラーをオーバーライド
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json(
        { message: 'Server error' },
        { status: 500 }
      );
    })
  );

  render(<UserList />);

  // エラーが表示されるのを待つ
  expect(await screen.findByRole('alert')).toBeInTheDocument();
});

ローディング状態のテスト

import { delay, http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

test('フェッチ中にローディング状態を表示', async () => {
  // レスポンスに遅延を追加
  server.use(
    http.get('/api/users', async () => {
      await delay(100);
      return HttpResponse.json([]);
    })
  );

  render(<UserList />);

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

  // ローディングが終わるのを待つ
  await waitForElementToBeRemoved(() => screen.queryByText('読み込み中...'));
});

データ送信フォームのテスト

フォームコンポーネント

// CreateUserForm.tsx
function CreateUserForm({ onSuccess }) {
  const [name, setName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

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

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

      const user = await res.json();
      onSuccess(user);
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">名前</label>
      <input
        id="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit" disabled={submitting}>
        {submitting ? '作成中...' : 'ユーザー作成'}
      </button>
      {error && <div role="alert">{error}</div>}
    </form>
  );
}

フォーム送信のテスト

import userEvent from '@testing-library/user-event';
import { CreateUserForm } from './CreateUserForm';

test('フォームを送信してonSuccessを呼び出す', async () => {
  const user = userEvent.setup();
  const handleSuccess = jest.fn();

  render(<CreateUserForm onSuccess={handleSuccess} />);

  await user.type(screen.getByLabelText('名前'), 'Jane Doe');
  await user.click(screen.getByRole('button', { name: 'ユーザー作成' }));

  // 送信完了を待つ
  await waitFor(() => {
    expect(handleSuccess).toHaveBeenCalledWith(
      expect.objectContaining({ name: 'Jane Doe' })
    );
  });
});

test('送信失敗時にエラーを表示', async () => {
  server.use(
    http.post('/api/users', () => {
      return HttpResponse.json(
        { message: 'Validation failed' },
        { status: 400 }
      );
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('名前'), 'Jane');
  await user.click(screen.getByRole('button'));

  expect(await screen.findByRole('alert')).toHaveTextContent(
    'ユーザー作成に失敗しました'
  );
});

test('送信中にボタンを無効化', async () => {
  server.use(
    http.post('/api/users', async () => {
      await delay(100);
      return HttpResponse.json({ id: 1, name: 'Jane' });
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('名前'), 'Jane');
  await user.click(screen.getByRole('button'));

  // ボタンがローディング状態を表示
  expect(screen.getByRole('button')).toHaveTextContent('作成中...');
  expect(screen.getByRole('button')).toBeDisabled();

  // 完了を待つ
  await waitFor(() => {
    expect(screen.getByRole('button')).toHaveTextContent('ユーザー作成');
  });
});

高度なMSWパターン

リクエストデータの検査

test('APIに正しいデータを送信', async () => {
  let capturedRequest;

  server.use(
    http.post('/api/users', async ({ request }) => {
      capturedRequest = await request.json();
      return HttpResponse.json({ id: 1, ...capturedRequest });
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('名前'), 'Jane Doe');
  await user.click(screen.getByRole('button'));

  await waitFor(() => {
    expect(capturedRequest).toEqual({ name: 'Jane Doe' });
  });
});

連続レスポンス

test('失敗後のリトライを処理', async () => {
  let callCount = 0;

  server.use(
    http.get('/api/users', () => {
      callCount++;
      if (callCount === 1) {
        return HttpResponse.json({ message: 'Error' }, { status: 500 });
      }
      return HttpResponse.json([{ id: 1, name: 'Alice' }]);
    })
  );

  const user = userEvent.setup();
  render(<UserListWithRetry />);

  // 最初のリクエストが失敗
  expect(await screen.findByRole('alert')).toBeInTheDocument();

  // リトライをクリック
  await user.click(screen.getByRole('button', { name: '再試行' }));

  // 2回目のリクエストが成功
  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

ネットワークエラー

test('ネットワーク障害を処理', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.error();
    })
  );

  render(<UserList />);

  expect(await screen.findByRole('alert')).toBeInTheDocument();
});

リクエストヘッダー

test('認証ヘッダーを送信', async () => {
  let authHeader;

  server.use(
    http.get('/api/users', ({ request }) => {
      authHeader = request.headers.get('Authorization');
      return HttpResponse.json([]);
    })
  );

  render(<AuthenticatedUserList token="bearer-token-123" />);

  await waitFor(() => {
    expect(authHeader).toBe('Bearer bearer-token-123');
  });
});

React Queryでのテスト

MSWはReact Queryと相性抜群です:

// UserProfile.tsx
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div role="alert">ユーザーの読み込みエラー</div>;

  return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function renderWithQueryClient(ui) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // テストでは失敗したリクエストをリトライしない
      },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

test('ユーザープロフィールを表示', async () => {
  server.use(
    http.get('/api/users/1', () => {
      return HttpResponse.json({ id: 1, name: 'John Doe' });
    })
  );

  renderWithQueryClient(<UserProfile userId={1} />);

  expect(await screen.findByText('John Doe')).toBeInTheDocument();
});

テストパターン

waitFor vs findBy

// findBy - 表示される要素を待つ
const element = await screen.findByText('Data');

// waitFor - アサーションが真になるのを待つ
await waitFor(() => {
  expect(mockFn).toHaveBeenCalled();
});

// waitForElementToBeRemoved - 消える要素を待つ
await waitForElementToBeRemoved(() => screen.queryByText('読み込み中...'));

テストの整理

describe('UserList', () => {
  describe('ロード中', () => {
    test('ローディングインジケーターを表示', () => {
      render(<UserList />);
      expect(screen.getByText('読み込み中...')).toBeInTheDocument();
    });
  });

  describe('正常にロード完了', () => {
    test('ユーザーを表示', async () => {
      render(<UserList />);
      expect(await screen.findByText('Alice')).toBeInTheDocument();
    });
  });

  describe('リクエスト失敗時', () => {
    beforeEach(() => {
      server.use(
        http.get('/api/users', () => HttpResponse.error())
      );
    });

    test('エラーメッセージを表示', async () => {
      render(<UserList />);
      expect(await screen.findByRole('alert')).toBeInTheDocument();
    });
  });
});

まとめ

概念 説明
http.get/post/put/delete モックハンドラーを定義
HttpResponse.json() JSONレスポンスを返す
server.use() 特定のテストでハンドラーをオーバーライド
delay() ネットワーク遅延をシミュレート
findBy* 要素が表示されるのを待つ
waitFor() アサーションが通るのを待つ

重要なポイント:

  • MSWはネットワークレベルでリクエストをインターセプトし、テストをより現実的に
  • デフォルトハンドラーはhandlersファイルに定義し、特定のテストでオーバーライド
  • server.use()でエラー状態やエッジケースをテスト
  • 非同期データの表示を待つにはfindByクエリを使用
  • テスト汚染を避けるため各テスト後にハンドラーをリセット
  • 完全なカバレッジのためにローディング、成功、エラー状態をテスト

MSWはAPIと連携するコンポーネントをテストするための強力で現実的な方法を提供します。テストは本番と同じコードを実行するため、コンポーネントが正しく動作するという信頼を得られます。

参考文献