CIでたまに落ちるFlaky Test。isMountedをやめてAbortControllerで直した

これまで、ローカルでは再現しないのでマシンスペックのせいかと思って再実行で済ませてましたが、調べてみたらコード側に原因があったのでメモします。

修正が必要そうなのは、Reactコンポーネントのデータ取得後のレンダリング待機部分です。まずはローカル環境で再現させたいので、vitest run --repeat 50 で何回か回してみたら、30回目あたりでようやく再現しました。

TestingLibraryElementError: Unable to find an element with text: /Welcome back, Jun/i.

以下は原因を追いかけた流れです。

まずはsetTimeoutで様子を見る

とりあえず描画が追いついてないだけだと思って、テスト側にsetTimeoutを挟んでみました。

await new Promise(r => setTimeout(r, 1000));

1秒待てば描画は終わると思って、テストにsetTimeoutを仕込んでPRを出したんですが、CIでは普通に落ちました。

setTimeoutを使うとどうなるか

1秒待ってみたけどCI上では結局落ちて、ログにはact(…)に関する警告が出ていました。

Error: Timing out after 5000ms
act(...) warning: An update to UserProfile inside a test was not wrapped in act(...)
Warning: You called act(async () => ...) without await.

setTimeoutを入れたことで、React Testing Libraryの非同期処理とタイミングがズレて、内部のactラッパーの動作と競合してしまっていました。

Fake Timersで別の問題に当たる

手動のsetTimeoutが悪さをしているなら、テスト用の偽物のタイマーに置き換えれば制御できるはず?と考え、VitestのFake Timersを入れてみました。

vi.useFakeTimers();
// ... ユーザー操作のシミュレート ...
vi.advanceTimersByTime(1000);

時間を進めようとしたら、今度は関係ないテストまで壊れ始めました。

TypeError: Cannot read properties of undefined (reading 'now')

アラートポップアップのタイマーや、APIモックに使っているMSWのネットワーク待機処理と競合したようです。"vitest fake timers msw async request conflict" で検索してみたものの、この組み合わせは回避が難しそうだったので、Fake Timersの導入は諦めて元に戻しました。

waitForを使ってもまだ落ちる

次は、React Testing Libraryの waitFor で要素を待つように書き換えます。

await waitFor(() => expect(screen.getByText(/Welcome back/)).toBeInTheDocument());

これでローカルの再現テストを回したら、30回に1回くらいまだ落ちるので、落ちたときのDOMを確認してみることに。

screen.debug() でDOMを確認する

ログを出すため、落ちる直前のDOMをscreen.debug()で確認してみます。

<div>Loading...</div>

データが返ってきてるはずなのに、コンポーネントが「Loading…」から「Welcome back, Jun」のユーザープロフィールに切り替わっていない。次はコンポーネント側の実装を確認してみます。

isMountedが引き起こしていた問題

UserProfile.tsx の非同期処理のコードです。

useEffect(() => {
  let isMounted = true;
  fetchUserData().then(data => {
    if (isMounted) setUser(data);
  });
  return () => { isMounted = false; };
}, []);

コードを追ってみると、React 18のStrict Modeによる二重マウントが関係していました。開発環境のStrict Modeでは、コンポーネントが「マウント→アンマウント→再マウント」という順で動きます。このとき、最初のマウントで走ったfetchUserDataのレスポンスが返ってくるより先にアンマウントが発生し、クリーンアップでisMountedfalseになります。その結果、せっかくデータが返ってきてもsetUserは実行されず、再マウント後に走る2回目のfetchUserDataだけが頼りになる状態でした。

そしてCI環境では、この2回目のリクエストの完了がたまたま遅れて、テストのタイムアウトまでに「Loading…」のまま画面が切り替わらなかった、というのが真相です。ローカルでは2回目のリクエストが速く完了するので問題にならず、CIの遅延でだけ表面化していたわけです。

解決策はAbortControllerとfindByText

原因が分かったので、コードを修正します。isMountedフラグを消し、AbortController でリクエスト自体をキャンセルするように変えました。

useEffect(() => {
  const controller = new AbortController();
  fetchUserData({ signal: controller.signal })
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') handleError(err);
    });
  return () => controller.abort();
}, []);

テスト側もwaitForfindByTextのような、本来の非同期な要素検索APIを使うように戻しました。手動のsetTimeoutFakeTimersは、結局不要でした。

// 修正後のテストコード
const welcomeText = await screen.findByText(/Welcome back, Jun/i);
expect(welcomeText).toBeInTheDocument();

isMountedを使う古い書き方とテストの相性が悪かったみたいなので、今後はAbortControllerに寄せたほうがよさそうです。