複数のテスト失敗を示すUI要素と、それらを指し示す開発者キャラクター。React Testing Libraryのactのネストによるエラー、fake timerとMSWの連携問題、renderHookによるプロバイダツリーの分裂といった、テストコードの罠が視覚的に表現されている。

はじめに

あるプロジェクトで、統合テスト52件のうち23件が失敗している状態が放置されていたことがあります。厄介だったのは、失敗自体ではなく「そのテストコマンドがどのCIからも呼ばれていなかった」ことでした。テストが壊れていることと、そもそも実行されていないことという二重の見落としが重なっていたわけです。

原因を洗い出してみると、本番実装側のコードには一切問題がありませんでした。すべてはテストコード側の書き方に起因しており、React Testing Libraryのact/waitFor、fake timer、MSWのようなモックサーバーを組み合わせる際に踏みやすい罠が、独立に4種類重なっていました。この記事では、それぞれの罠がなぜ起きるのか、どう直すのかを整理します。

こんな人におすすめ

  • React Testing Libraryで非同期処理を含むフックやコンポーネントをテストしている方
  • vi.useFakeTimers()とAPIモックを併用していて、テストが原因不明でtimeoutする方
  • “overlapping act() calls”のようなエラーに遭遇して対処法を探している方
  • CIに載っているはずのテストが、実は動いていなかった経験がある方

罠1: actのネストで “overlapping act() calls” が起きる

act(async () => { ... })の中でwaitForを呼ぶと、actがネストしてしまい“overlapping act() calls“というエラーになります。厄介なのは、この1件の書き方ミスが同じファイルの残り全テストのrenderまで巻き込んで壊してしまう点です。

解決策は、非同期処理の「開始」と「完了」を分離することです。

// Before: act(async () => { ... }) の中で waitFor すると
// act がネストして "overlapping act() calls" になり、
// 以降のテストの render まで壊れる

// After: 同期 act で処理を開始し、完了は最後に await する
let refreshPromise!: Promise<unknown>;
act(() => {
  refreshPromise = result.current.refreshSession();
});

await waitFor(() => {
  expect(result.current.isLoading).toBe(true);
});

await act(async () => {
  await refreshPromise;
});

同期のactで処理をキックし、途中経過はwaitForで確認、最後にもう一度actで完了を待つという3段構成にすることで、ネストを避けられます。

罠2: fake timerはshouldAdvanceTimeが無いとモックサーバーと噛み合わない

vi.advanceTimersByTimeAsync()を使うには、前提としてvi.useFakeTimers()の呼び出しが必要です。呼び忘れると“A function to advance timers was called but the timers APIs are not mocked“という分かりにくいエラーになります。

さらに厄介なのは、fake timerと非同期モックサーバー(MSWなど)を併用する場合です。vi.useFakeTimers({ shouldAdvanceTime: true })を指定しないと、waitForのポーリングとモックサーバーの応答が両方とも進まなくなり、全テストが一律で15秒timeoutします。

beforeEach(() => {
  // shouldAdvanceTime: true が無いと waitFor のポーリングと
  // モックサーバーの応答が進まず、全テストが timeout する。
  // デバウンス用の vi.advanceTimersByTime() はそのまま使える。
  vi.useFakeTimers({ shouldAdvanceTime: true });
  // ...セットアップ
});

オプション一つの有無でテストスイート全体が死ぬ、という典型例です。fake timerを導入する際は、このオプションをセットで覚えておくと事故を防げます。

罠3: renderHookを複数回呼ぶとプロバイダツリーが分裂する

renderHookを同じテスト内で複数回呼ぶと、それぞれ別々のプロバイダ(コンテキスト)ツリーが独立に立ち上がります。片方のツリーで状態を更新しても、もう片方のツリーには伝播しません。

// Before: renderHook を2回呼ぶとプロバイダが2つ別々に立ち、
// 片方の状態更新がもう片方のツリーに伝わらない

// After: 1つのツリーで両方のフックを読む
const { result } = renderHook(
  () => ({ auth: useAuth(), permission: usePermission() }),
  { wrapper }
);

複数のフックの相互作用をテストしたい場合は、それぞれ別にrenderHookするのではなく、1回の呼び出しでまとめて読む形に変えると解決します。

罠4: テスト用ディレクトリが型チェック対象外だと壊れたimportに気づけない

テスト用ディレクトリがtsconfig.jsonのinclude対象外だと、存在しないモジュールエクスポートをimportしてもコンパイル時に検出されません。今回のケースではserver.use(undefined)という形で黙って握りつぶされ、「初期状態は未認証」を前提にしたテストが実際には認証済み状態で走っていました。

型チェックの対象を広げれば防げる問題ですが、対象を広げると別の負債が表面化することもあります。実際、試作したテスト用tsconfigをincludeに含めてみたところ99件の型エラーが出ました。大半はJSXを使わずReact.createElementでラッパーコンポーネントを組み立てている箇所で、children propsを型が要求してくる機械的なパターンでしたが、件数が多いため別タスクとして切り出すという判断もありえます。

つまづきやすいポイント

  • テストが落ちているからといって、常に実装側が悪いとは限りません。今回は文字列の完全一致を期待するテスト側の記述が誤りで、実装はプレフィックス判定という仕様だったケースもありました
  • 上記の罠を直した結果、実行時間が約120秒(timeout待ち)から約3秒へ短縮されました。壊れたテストを「既知の失敗」としてCIから除外し続けるコストの大きさが、数字として表れた形です
  • 「テストが壊れている」ことと「テストがそもそも実行されていない」ことは別問題です。CI設定側でテストコマンド自体が呼ばれているかも合わせて確認する価値があります
  • 型チェックの対象範囲を広げる判断は、見つかったエラーの量と質を見てから、一括修正か段階的な切り出しかを決めるとスコープが暴走しにくくなります

まとめ

React Testing LibraryのactとwaitFor、fake timer、MSWのようなモックサーバーはそれぞれ単体では扱いやすい道具ですが、組み合わせた瞬間に前提条件が噛み合わなくなることがあります。今回紹介した4つの罠は独立に発生していたため、1つずつ切り分けて直すアプローチが有効でした。もし原因不明のtimeoutや“overlapping act() calls“に遭遇したら、まずはこの4パターンに当てはまらないか確認してみることをおすすめします。