テスト講座 #4 非同期とネットワークモッキング — MSW で横取りするパターン

#3 までのコンポーネントは props を受け取ってそのままレンダーするものでした。今回の記事は、コンポーネントが外部データを取得する場合です。この瞬間に二つの判断が入ります — 非同期の検証をどうするかネットワークをどう偽装するか

テスト講座におけるこの記事の位置づけ。

今回の記事は二つの要素がペアになります — MSW でネットワークを横取り + findBy* / waitFor で非同期検証

fetch を直接モックしないでください #

最もよく見かけるアンチパターンから。

アンチパターン — fetch の直接モック
import { vi } from 'vitest';

beforeEach(() => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Alice' }),
  } as Response);
});

これがなぜ良くないのか。

  • Response オブジェクトの一部分だけを偽装しています。コードが response.headers.get('...') を呼ぶと壊れる。
  • コードが axios を使っていれば、また別のモッキング方法が必要。tanstack-query / swr などの抽象化の上で動くコードもそれぞれ別。
  • 偽の Response の形が実物とずれる可能性がある — テストは通るのに本番で壊れる。
  • 一つのハンドラが別のエンドポイント(/posts/1 vs /posts/2)に対して別の応答をするシナリオを組むのが面倒。

#1 で指摘したライン — システム境界だけをモックする。その境界は fetch 関数ではなくネットワークです。fetch は本物として動かし、その呼び出しがどこへ行ってどんな応答を受け取るかだけを我々が定義するのがすっきりします。

MSW — ネットワークレイヤーの横取り #

MSW(Mock Service Worker)は fetch / XHR / axios のいずれであっても、ネットワーク呼び出しの直前に横取りして定義された応答を返してくれます。コードから見れば、本物のネットワーク往復との区別がつきません。

インストール。

MSW のインストール
pnpm add -D msw

ブラウザで動かすには service worker ファイルが public/ に必要ですが、テスト環境(node)ではそれは不要です。Node 用のセットアップだけ用意すれば OK。

ハンドラの作成 #

最もシンプルな形。

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

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: 'Alice',
    });
  }),

  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'First' },
      { id: 2, title: 'Second' },
    ]);
  }),

  http.post('/api/posts', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 99, ...body }, { status: 201 });
  }),
];

http.gethttp.post などが実際の HTTP メソッドと 1:1 に対応します。第二引数が実際の応答を作る関数です。

  • params — パスパラメータ(:id)。
  • request — 標準の Request オブジェクト。request.urlrequest.json()request.headers などそのまま。
  • HttpResponse.json(...) / HttpResponse.text(...) / new HttpResponse(...) — 応答の作成。

ここが fetch の偽装と違う点です。本物の Request/Response オブジェクトが行き来します。標準 Web API そのままなので迷うことがありません。

サーバーセットアップ(Node 環境) #

テスト開始時に server を起動し、終わったら片付ける。

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

export const server = setupServer(...handlers);
vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/test/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

各 hook の役割。

  • beforeAll(server.listen) — 全テスト開始前に一度だけ。onUnhandledRequest: 'error' が重要なオプション — 定義されていないエンドポイントへリクエストが飛ぶと即座にエラーで知らせてくれる。ハンドラ漏れを早く見つけられる。
  • afterEach(server.resetHandlers) — 各テスト後、追加されたハンドラを初期状態に戻す。あるテストの server.use(...) が別のテストに漏れないようにします。
  • afterAll(server.close) — 全テスト終了後の片付け。

最初の非同期テスト — UserCard #

データを fetch して表示するコンポーネント。

src/components/UserCard.tsx
import { useEffect, useState } from 'react';

type User = { id: number; name: string };

export function UserCard({ id }: { id: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then((res) => {
        if (!res.ok) throw new Error('Failed to load');
        return res.json();
      })
      .then(setUser)
      .catch((e) => setError(e.message));
  }, [id]);

  if (error) return <p role="alert">{error}</p>;
  if (!user) return <p>Loading...</p>;

  return (
    <article>
      <h2>{user.name}</h2>
      <p>id: {user.id}</p>
    </article>
  );
}

テストの最初の試み。

UserCard.test.tsx — happy path
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';

it('user データを受け取って画面に表示する', async () => {
  render(<UserCard id={1} />);

  // 最初は Loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // データが届いたら名前が見えるはず
  expect(await screen.findByRole('heading')).toHaveTextContent('Alice');
});

ポイントは findByRole#3 で「しばらく経って現れるもの」と指摘した部分です。デフォルトで 1 秒間ポーリングしながら要素が現れるのを待ちます。見つからなければそこでようやくエラー。

getByRole('heading') で試すと — データが届く前に即座に検査して失敗します。非同期の結果は常に findBy*waitFor

waitFor — より一般的な非同期検証 #

findBy* は「特定の要素が現れるのを待つ」役割です。より一般的な非同期条件は waitFor が引き受けます。

waitFor パターン
import { waitFor } from '@testing-library/react';

it('ある関数が N 回呼ばれたかを非同期で検証する', async () => {
  render(<MyComponent />);

  await waitFor(() => {
    expect(mockFn).toHaveBeenCalledTimes(2);
  });
});

waitFor のコールバックは検証コードです。失敗すれば再試行、1 秒間通らなければ最後のエラーを throw します。

findBy* vs waitFor の使い分け。

  • 画面に要素が現れるのを待つ → findBy* の方が簡潔。
  • DOM 以外の検証(コールバック呼び出し回数、外部 mock の状態)が非同期で起きる → waitFor

findBy* の中には実は waitFor が入っています。同じ道具で使い方が違うだけ。

Error path テスト — server.use でハンドラを上書き #

happy path だけテストするのは半分しか見ていないことになります。エラー応答時の動作も検証する必要があります。一つのテストだけ別の応答を返したいときは、server.use でそのテストの間だけハンドラを上書きします。

error path
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';

it('サーバーが 500 を返したらエラーメッセージを表示する', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserCard id={1} />);

  expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load');
});

セットアップに afterEach(server.resetHandlers) があるので、このハンドラはこのテストの中だけで効きます。次のテストはまた既定の handlers の応答を受け取ります。

さまざまなシナリオ #

よく書くバリエーション。

ネットワーク遅延のシミュレーション
import { delay } from 'msw';

server.use(
  http.get('/api/posts', async () => {
    await delay(500);  // 500ms 遅延
    return HttpResponse.json([]);
  })
);
リクエストボディの検証
http.post('/api/posts', async ({ request }) => {
  const body = await request.json();
  expect(body).toMatchObject({ title: 'Hello' });
  return HttpResponse.json({ id: 1, ...body });
})
ネットワーク自体の失敗
server.use(
  http.get('/api/users/:id', () => {
    return HttpResponse.error();  // network error
  })
);
レスポンスヘッダ / クッキー
http.get('/api/me', () => {
  return HttpResponse.json(
    { id: 1 },
    {
      headers: { 'X-Total-Count': '42' },
    }
  );
})

HttpResponse.error() は fetch から見ると「TypeError: Failed to fetch」と同じ結果になります。オフライン/CORS 失敗をシミュレーションするとき。

TanStack Query との組み合わせ #

実戦では fetch を直接呼ばず、@tanstack/react-query のようなものが入ってきます。MSW とよく合います — MSW はネットワークレイヤーを横取りするので、query ライブラリがどう呼ぼうと影響なし。

UserCard with TanStack Query
import { useQuery } from '@tanstack/react-query';

export function UserCard({ id }: { id: number }) {
  const { data, error, isPending } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
  });

  if (isPending) return <p>Loading...</p>;
  if (error) return <p role="alert">{error.message}</p>;

  return <article><h2>{data.name}</h2></article>;
}

テストするときは QueryClientProvider で包む必要があります。各テストがクリーンな cache で始まるよう、新しい client を作るのが安全。

custom render with QueryClient
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function render(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },  // テストでは retry をオフ
  });

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

export * from '@testing-library/react';

retry: false が重要なポイント。デフォルトで 3 回リトライするので — エラーテストで一度失敗して終わってほしいのに、3 回リトライしているうちに timeout が頻発します。

よくある非同期の落とし穴 #

ここがデバッグで最もやっかいです。よく出会うケース。

act 警告 — 「An update was not wrapped in act」 — 非同期の変化が await されていない痕跡。findBy* / waitFor で確実に待つか、各 user 操作に await を付けたか確認。

テストがたまに失敗する(flaky)setTimeout や非決定的な時間が入ったコード。フェイクタイマー(vi.useFakeTimers())で時間を制御。あるいは waitFortimeout オプションを伸ばす(基本的に時間を伸ばすのは答えではない)。

MSW のハンドラが拾われない — URL マッチング失敗。fetch が絶対パスで行ったのにハンドラが相対パスで定義されていた、あるいはその逆。onUnhandledRequest: 'error' をオンにしておけば即座に発見できる。

findByRole('alert') は引っかかったがテキストが空 — alert role の要素は出ているけれど、その中のテキストが非同期で埋まる流れ。waitFor(() => expect(el).toHaveTextContent('...')) でテキストまで待つ。

Loading... の検証とデータの検証が同じテストに混ざっている — render 直後に Loading を検証しようとしても、queryFn が同期的に終わると(特に cache hit)loading 段階を丸ごと見逃す。loading 検証は別テストに分離して、そこで意図的に応答を遅延させる。

テスト間で状態が漏れているように見えるserver.resetHandlers() の漏れ。あるいは QueryClient が一つのインスタンスで再利用されて cache が残っています。

デバッグ — screen.debug() + コンソール #

非同期テストが壊れたときによくあるパターン。

途中状態の確認
render(<UserCard id={1} />);

screen.debug();  // Loading 状態の DOM

await waitFor(() => {
  screen.debug();  // 試行ごとに DOM 出力 — 何が見えるか追跡
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

もう一つ — MSW のハンドラが本当に呼ばれているか確認。

MSW 呼び出しの追跡
server.events.on('request:start', ({ request }) => {
  console.log('MSW intercepted:', request.method, request.url);
});

まとめ #

  • fetch を直接モックしないこと。ネットワークレイヤーを横取りする MSW が定石。
  • MSW handler は標準の Request/Response そのまま。http.get/post(...)HttpResponse.json(...) の二つでほぼ事足りる。
  • beforeAll(server.listen) / afterEach(resetHandlers) / afterAll(server.close) は一度仕込めば終わり。
  • 非同期の結果は findBy*waitForgetBy* ではタイミングが合いません。
  • 一つのテストだけ別の応答 — server.use(...) で上書き、afterEach が自動で復元。
  • TanStack Query などライブラリの上でも MSW はそのまま動く。テストでは retry: false を推奨。
  • onUnhandledRequest: 'error' をオンにしておけばハンドラ漏れを早く発見できる。

次回(#5 ユーザーイベントとフォームテスト)では、入力と送信を扱います。userEvent の詳しいメソッド、React Hook Form のようなフォームライブラリの上のテスト、検証エラーのシナリオまで。

X