TL;DR
- Reactのコンポーネントテストにおいて、
act
を正しく処理しないとFlaky Test(不安定なテスト)になる - 非同期処理や状態更新の完了を待たずにアサーション(検証)すると、テスト結果が毎回変化する
-
act
を使わずに、waitFor
やfindBy
などのラッパー関数を使うのが安全
はじめに
React Testing Libraryをつかって、コンポーネントをテストしているとこんなWarningに遭遇しました。
An update to SimpleButton inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that ...(以下略)
要約すると、
「あんたのテスト、状態が正しく変更されてないままテスト終了してるで。ほんまに大丈夫?」
というWarningです。
このWarning自体はごもっともなメッセージなのですが、問題はact
で囲めということです。
act
とは何か
Reactの公式サイトによると、
act() というヘルパーは、あなたが何らかのアサーションを行う前に、これらの「ユニット」に関連する更新がすべて処理され、DOM に反映されていることを保証します。
言い換えると、Reactの状態更新や副作用(useEffectなど)をまとめて完了させてからアサーションを実行するためのヘルパー関数です。
act
によりFlaky Testになる3つの理由
1. 非同期処理の完了を正しく待てていない
なぜFlaky Testになる?
- タイミングの問題: 非同期処理の完了タイミングがネットワーク状況やCPU負荷によって毎回変わる
- 競合状態: 状態更新とアサーションが同時に実行され、どちらが先に完了するかが予測できない
悪い例
test('データ取得後にローディングが消える', async () => {
render(<DataComponent />);
// この時点でfetchDataが完了していない可能性がある
act(async () => {
await fetchData();
});
// fetchDataが完了する前にアサーションが実行される可能性
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
良い例
await
をつける。
test('データ取得後にローディングが消える', async () => {
render(<DataComponent />);
// fetchDataの完了を確実に待つ
await act(async () => {
await fetchData();
});
// この時点でfetchDataは確実に完了している
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
2. 複数のact
が重複して実行される
なぜFlaky Testになる?
- バッチ処理の混乱: Reactの状態更新バッチが正しく処理されない
- 実行順序の不確定性: 複数の非同期処理の完了順序が毎回変わる可能性
悪い例
test('複数のアクションを順次実行', async () => {
render(<Counter />);
// 前のactが完了していない状態で次のactが実行される
act(async () => {
fireEvent.click(screen.getByText('Increment'));
});
act(async () => {
fireEvent.click(screen.getByText('Increment'));
});
// カウンターの値が1なのか2なのか不確定
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
良い例
逐次実行な形に改善する。
test('複数のアクションを順次実行', async () => {
render(<Counter />);
// 最初のactの完了を待つ
await act(async () => {
fireEvent.click(screen.getByText('Increment'));
});
// 次のactを実行
await act(async () => {
fireEvent.click(screen.getByText('Increment'));
});
// この時点でカウンターは確実に2
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
3. UIの最新状態を見ていない
なぜFlaky Testになる?
- レンダリングの遅延: 状態更新からDOM反映までにタイムラグがある
- useEffectの実行タイミング: 副作用の実行タイミングが予測できない
- ブラウザの描画サイクル: ブラウザの描画処理が完了していない状態でテストが実行される
悪い例
test('ボタンクリック後にメッセージが表示される', () => {
render(<MessageComponent />);
// ボタンをクリック
fireEvent.click(screen.getByText('Show Message'));
// 状態更新がまだDOMに反映されていない可能性
expect(screen.getByText('Hello World!')).toBeInTheDocument();
});
良い例
waitFor
を利用する。
test('ボタンクリック後にメッセージが表示される', async () => {
render(<MessageComponent />);
fireEvent.click(screen.getByText('Show Message'));
// メッセージの表示を待つ
await waitFor(() => {
expect(screen.getByText('Hello World!')).toBeInTheDocument();
});
});
act
よりもwaitFor
やfindBy
を推奨する理由
-
可読性が高い
waitFor
やfindBy
はテストコードの意図が明確になり、何を待っているのかが一目で理解できる -
バグを防ぎやすい
非同期の状態更新や描画を自動的に待つため、act
のawait漏れや重複呼び出しによるFlaky Testを防ぎやすくなる -
テストの保守性が高まる
UIの変更や副作用のタイミングが変わっても、waitFor
やfindBy
を使っていればテストを壊しにくい
まとめ
-
act
のWarningは「テストがReactの状態更新を正しく待てていない」サイン -
act(async () => {...})
には必ずawait
を付けることが重要 - 可能な限りTesting Libraryの
waitFor
やfindBy
を使用する - Flaky Testの多くは非同期処理の待ち方のミスが原因
参考文献