エラーの内容
テストを書いて、パスしたのに、コンソールにこんな警告が表示される:
Warning: An update to ForwardRef inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...).
at ForwardRef
at YourComponent
テスト自体は合格なのに、赤信号。Reactが伝えているのは、アサーションが実行された後に状態の処理が完了したということだ。これは一見わかりにくい競合状態であり、CI上で明確な理由なく失敗するフレーキーなテストを生み出す。
なぜ発生するのか
act()は、アサーションの前に状態の更新・エフェクト・再レンダリングをフラッシュするためのReactのテストユーティリティだ。排水溝のようなもので、キューを空にして安定したスナップショットに対してアサーションできるようにする。
この警告は、そのフラッシュサイクルの外側で状態の更新が発生したときに表示される。よくある原因:
- 非同期の状態更新 — データフェッチ、
setTimeout、setInterval - 状態を更新するイベントハンドラがテスト内でawaitされていない
- 内部で
forwardRefやコントロールされた入力を使用しているサードパーティコンポーネント - 非同期状態に対する同期アサーションが続く
fireEventの呼び出し - マウント時に非同期処理を開始する
useEffectフック
問題を再現する
最小限のケースを示す。マウント時にユーザー名をフェッチするコンポーネントだ:
// UserProfile.tsx
import { useState, useEffect } from 'react';
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<string | null>(null);
useEffect(() => {
fetchUser(userId).then((name) => setUser(name)); // 非同期の状態更新
}, [userId]);
return <div>{user ?? 'Loading...'}</div>;
}
async function fetchUser(id: string): Promise<string> {
return Promise.resolve('Alice');
}
// UserProfile.test.tsx — 壊れているバージョン
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
test('ユーザー名が表示される', () => {
render(<UserProfile userId="1" />);
// fetchUser はこの行の実行後に解決されるため、
// setUser が act() の外側で発火する
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
// → Warning: An update to ... inside a test was not wrapped in act(...)
修正1: waitFor または findBy* を使う(推奨)
React Testing Libraryの非同期ユーティリティは、自動的にact()でラップしてくれる。まずこれを試してほしい。
// UserProfile.test.tsx — 修正済みバージョン
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
test('フェッチ後にユーザー名が表示される', async () => {
render(<UserProfile userId="1" />);
// waitFor はデフォルトで50msごとにアサーションを再試行(最大1000ms)し、
// 内部でact()のラッピングを処理する
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
単一の要素を待つだけならfindBy*を優先しよう — waitForとgetBy*を1回の呼び出しにまとめたものだ:
test('フェッチ後にユーザー名が表示される', async () => {
render(<UserProfile userId="1" />);
const name = await screen.findByText('Alice'); // 見つかるまでポーリング、act()は自動処理
expect(name).toBeInTheDocument();
});
修正2: 非同期呼び出しをモックして手動でフラッシュする
中間のローディング状態をアサーションするなど、より細かい制御が必要な場合もある。関数をモックして、act()でマイクロタスクキューを排出しよう:
import { render, screen, act } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import * as api from './api';
jest.mock('./api');
test('ユーザー名が表示される', async () => {
jest.spyOn(api, 'fetchUser').mockResolvedValue('Alice');
render(<UserProfile userId="1" />);
await act(async () => {
await Promise.resolve(); // 1ティックで保留中のマイクロタスクをすべてフラッシュ
});
expect(screen.getByText('Alice')).toBeInTheDocument();
});
修正3: ユーザーイベントを正しくラップする
非同期状態を起こすボタンクリックやフォーム送信も、よくある原因だ。fireEventは同期的 — イベントをディスパッチするだけで、その後の状態更新が完了するのを待たない。
// NG — クリック後の状態更新がフラッシュされない場合がある
fireEvent.click(button);
expect(screen.getByText('Saved')).toBeInTheDocument(); // 警告が出る可能性がある
// OK — userEvent は act() のラッピングを処理し、実際のブラウザ動作をシミュレートする
import userEvent from '@testing-library/user-event';
test('フォームが保存される', async () => {
const user = userEvent.setup();
render(<MyForm />);
await user.click(screen.getByRole('button', { name: /save/i }));
expect(screen.getByText('Saved')).toBeInTheDocument();
});
実際のユーザー操作をシミュレートするものには、デフォルトでuserEventを使おう。fireEventは、状態更新を伴わないシンプルな同期DOMイベントに限定して使うべきだ。
修正4: setTimeout とタイマーを処理する
setTimeoutで状態の更新を遅延させるコンポーネントには、Jestのフェイクタイマーが必要だ。アサーションの前にReactが状態変更を処理できるよう、act()の中でタイマーを進めよう:
jest.useFakeTimers();
test('2秒後にメッセージが表示される', () => {
render(<DelayedMessage />);
act(() => {
jest.runAllTimers(); // 時計を進めて、保留中のコールバックを発火させる
});
expect(screen.getByText('Hello!')).toBeInTheDocument();
});
jest.useRealTimers();
修正5: サードパーティライブラリの警告を抑制する
日付ピッカー、モーダル、アニメーションライブラリなどは、自分では修正できないコード内で警告を発生させることがある。テストロジックが正しいことを確認した上で、そのファイル内だけに抑制を限定しよう:
const originalError = console.error;
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
originalError(...args);
});
});
afterAll(() => {
(console.error as jest.Mock).mockRestore();
});
これは最終手段だ。まずライブラリのドキュメントでテストセットアップガイドを確認しよう — 人気のあるパッケージのほとんどはこれをドキュメント化している。
テストスイートを強化する:警告をエラーに変える
残っている警告を見つけるために、verboseモードでテストを実行しよう:
npx jest --verbose 2>&1 | grep -i 'act\|warning'
# クリーンな出力はact()の警告がないことを意味する
警告をテストの失敗として扱い、見落としがないようにするには、Jestのセットアップファイルに以下を追加しよう:
// jest.setup.ts
const originalError = console.error.bind(console);
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('not wrapped in act')
) {
throw new Error(args[0]); // テストを即座に失敗させる
}
originalError(...args);
};
jest.config.tsに接続しよう:
// jest.config.ts
export default {
setupFilesAfterEnv: ['./jest.setup.ts'],
};
判断の早見表
- マウント時にデータをフェッチするコンポーネント →
await screen.findBy*()またはawait waitFor() - ユーザー操作が非同期状態を引き起こす →
await付きのuserEvent - コンポーネントが内部で
setTimeoutを使用している →act()内でJestのフェイクタイマー - 自分で制御できないライブラリからの警告 → ライブラリのテストドキュメントを確認し、修正方法がない場合のみ抑制する
本質的な考え方
Reactがこの警告を追加したのは、特定のカテゴリのテストバグ — 更新サイクルが完了する前に状態をアサーションすること — を検出するためだ。10回中9回は、テストをasyncにして、RTLの非同期クエリ(findBy*、waitFor、userEvent)を使うのが正しい対処だ。これらはすでに内部でact()を呼び出している。手動でのact()ラッピングはエッジケースのために存在するのであって、一般的なケースのためではない。

