こんにちは。フロントエンドの迷宮を日夜さまよい、コンポーネントのライフサイクルとDOMの鼓動に耳を澄ませているチーフアーキテクトだ。
今回は、React開発において避けて通れない「状態管理の基本」と、その牙城である「React Testing Library(RTL)」を用いた堅牢なテスト戦略について、少し深めの話をしよう。
世の中の入門書やチュートリアルは「`useState`で値を保持し、ボタンを押して画面が変わればテスト完了」というお花畑のようなコードばかりを量産しがちだ。しかし、実務で私たちが向き合うのは、非同期のバッチ処理、メモリリーク、そしてユーザーの神速の入力によるレースコンディション(競合状態)の荒波である。
なぜ、あなたのテストは「`act(…)` warning」に怯えなければならないのか? ブラウザのレンダリングパイプラインとReact 18の並行レンダリングの裏側を覗きながら、真にロバストなコンポーネントテストの極意を紐解いていこう。
—
1. なぜ `useState` のテストは罠だらけなのか?
`useState` はReactにおける最もプリミティブな状態管理のプリミティブだが、その裏側にある挙動は決して単純ではない。
React 18以降、状態更新はデフォルトで自動バッチング(Automatic Batching)される。これにより、複数の状態更新がひとまとめにされ、不要な再レンダリングが抑制されるという素晴らしいパフォーマンスの恩恵を受けている。しかし、テスト環境(JSDOMなど)において、この「非同期性」と「イベントループのスケジューリング」が牙を剥く。
ユーザーがボタンをクリックした瞬間、イベントハンドラが走り、`setState` が呼ばれ、Reactが差分計算(Reconciliation)を行い、コミットフェーズを経てDOMが更新される。この一連のライフサイクルは、テストコードの実行スレッドから見ると非同期の境界を跨ぐ。
ここに無頓着でいると、「状態が更新される前にアサーションが走る」という flakiness(不安定なテスト)の温床が完成する。これを力技の `setTimeout` でごまかすようなエンジニアは、私のチームにはいらない。RTLが提供する非同期ユーティリティを正しく使い、ブラウザのイベントループと調和したテストを書く必要があるのだ。
—
2. 実践:非同期とレースコンディションに立ち向かうテストコード
百聞は一見に如かず。ここでは、APIリクエストを模した非同期の状態更新と、連打(レースコンディション)対策が施されたカウンターコンポーネントを題材にする。
以下のコードは、実務の現場でそのまま通用するレベルの堅牢性を持たせた実装と、それに対するRTLのテストコードだ。
コンポーネント実装:`Counter.tsx`
import React, { useState, useTransition } from ‘react’;
export const Counter: React.FC = () => {
const [count, setCount] = useState
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState
// 意図的な非同期遅延とエラーハンドリングを含むインクリメント処理
const handleIncrement = async () => {
setErrorMessage(null);
try {
// ネットワーク遅延をシミュレート
await new Promise((resolve) => setTimeout(resolve, 100));
// トランジションを使って、緊急性の低い状態更新をマークし、UIのブロッキングを防ぐ
startTransition(() => {
setCount((prev) => prev + 1);
});
} catch (err) {
setErrorMessage(‘状態の更新に失敗しました’);
}
};
return (
現在のカウント: {count}
{isPending &&
処理中…
}
{errorMessage &&
{errorMessage}
}
);
};
テストコード実装:`Counter.test.tsx`
さて、このコンポーネントをどうテストすべきか。JSDOMの環境下で非同期処理とReactの内部スケジューリングを完全に調停させるためのコードを見てほしい。
import React from ‘react’;
import { render, screen, fireEvent, waitFor } from ‘@testing-library/react’;
import userEvent from ‘@testing-library/user-event’;
import { Counter } from ‘./Counter’;
describe(‘Counter コンポーネントの堅牢性テスト’, () => {
test(‘初期状態が正しくレンダリングされること’, () => {
render(
// 初期値の検証
expect(screen.getByTestId(‘count-display’)).toHaveTextContent(‘現在のカウント: 0’);
expect(screen.queryByTestId(‘loading-indicator’)).not.toBeInTheDocument();
});
test(‘ボタンクリックにより非同期でカウントがインクリメントされ、ローディングが適切に制御されること’, async () => {
// ユーザーインタラクションのリアルなシミュレーションには user-event を推奨
const user = userEvent.setup();
render(
const button = screen.getByRole(‘button’, { name: ‘インクリメント’ });
// ユーザーのクリック操作をシミュレート
await user.click(button);
// 非同期処理中のローディング表示をキャッチ
expect(screen.getByTestId(‘loading-indicator’)).toBeInTheDocument();
// ボタンが正しく無効化されているか(二重クリック防止のアーキテクチャ検証)
expect(button).toBeDisabled();
// waitFor を用いて、非同期処理の完了とDOMの更新を待ち受ける
await waitFor(() => {
expect(screen.getByTestId(‘count-display’)).toHaveTextContent(‘現在のカウント: 1’);
});
// 処理完了後にローディングが消え、ボタンが活性化することを確認
expect(screen.queryByTestId(‘loading-indicator’)).not.toBeInTheDocument();
expect(button).not.toBeDisabled();
});
});
—
3. アーキテクチャの視点:なぜ `userEvent` なのか? `fireEvent` との違い
ここでシニアエンジニアとしてのこだわりを語らせてほしい。テストを書く際、`fireEvent.click()` を安易に使っていないだろうか?
`fireEvent` は、単にDOMに対して低レベルなイベント(`click` イベントなど)を強制的にディスパッチするだけだ。それに対し、`@testing-library/user-event` は、ユーザーが実際に行う一連の動作(`hover` -> `mousedown` -> `mouseup` -> `click`)をモレートし、それに付随するフォーカス管理やキーボードイベント、さらにはブラウザのデフォルト挙動まで忠実に再現する。
メモリ効率やレンダリング負荷の観点から言っても、実世界のインタラクションに近い `userEvent` を使う方が、「コンポーネントが予期せぬイベントの嵐にどう耐えるか」という本番同等のストレステストに近い検証が可能になるのだ。
`act(…)` 警告の正体と正しい向き合い方
テストを実行した際に、コンソールに以下のような赤字の警告を見たことがないだろうか?
> “An update to Counter inside a test was not wrapped in act(…).”
これはReactからのSOSだ。「おい、レンダリングの外側で勝手に状態が更新されたぞ! 仮想DOMと実際のDOMの同期が崩れるかもしれないから俺に教えてくれ!」と叫んでいるのだ。
RTLの `waitFor` や `screen.findBy` などの非同期クエリは、内部で自動的に `act()` のスコープを貼ってくれる。したがって、開発者が自前で `act()` をインポートして囲む必要はほとんどない。もしこの警告が出たら、それは「非同期処理の完了を待たずにアサーションを抜けている」か「状態更新のタイミングがテストのライフサイクルとズレている」という動かぬ証拠である。直ちにコードの非同期境界を見直すべきだ。
—
4. チーフアーキテクトからの提言
テストコードは、単なる「動くことの証明書」ではない。それは、そのコンポーネントが抱える設計上の制約や、将来の変更に対する防壁(セーフティネット)そのものである。
`useState` を用いた状態管理において、非同期の競合や不要な再レンダリングを防ぐためには、以下の原則を胸に刻んでほしい。
1. イベントのディスパッチには `userEvent` を選べ。 妥協した `fireEvent` は偽りの安心感しか生まない。
2. 非同期の状態遷移は `waitFor` で包摂せよ。 タイムアウトに頼るな。DOMの変異を観測し続けろ。
3. `act` 警告を無視するな。 それはReactからの極めて重要なアーキテクチャ上の警告シグナルだ。
さあ、エディタに戻り、あなたのコンポーネントのテストコードを見直そう。そこに妥協の跡が見つかったなら、今すぐ美しいコードへとリファクタリングするんだ。プロフェッショナルな仕事とは、そういう細部の積み重ねの先にあるのだから。

コメント