お疲れ。今日もコードの海に潜ってリファクタリングの息継ぎをしているところか?
Reactを触り始めて半年、あるいは数年が経ち、「コンポーネントの分割も、カスタムフックの切り出しも大体できるようになったぜ」という中級エンジニアの君たち。そこでお上に「じゃあ、そのコンポーネントのテスト書いておいて」と言われた瞬間、急に手が止まっていないか?
「あれ、ボタンをクリックした後の状態変化をテストしたいだけなのに、なぜか `act(…)` の警告が出るぞ……?」
「非同期でAPI叩いてステート更新する部分、モックはどう書けばいいんだっけ……?」
よくある話だ。安心しろ、君が下手くそなんじゃない。Reactのレンダリングの仕組みと、テストライブラリの哲学の隙間に綺麗にハマっただけだ。
今日は、実務で絶対に避けて通れない React Testing Library(RTL)を用いた状態更新テストの極意 について、ブラウザの裏側の挙動まで含めて徹底的に叩き込んでやる。最後まで読めば、明日からのテストコードを書く手が驚くほど軽くなるはずだ。
—
なぜ、RTLでの状態テストでハマるのか?
まず大前提として、Reactの `useState` による状態更新は 「非同期(かつバッチ処理される)」 という性質を持っている。
現場でよくあるミスが、テストコード内でボタンをクリックした直後、「次の行ですぐにDOMが更新されているはずだ」 と思い込んでアサーション(検証)を書いてしまうケースだ。
// 良くあるダメなテストのイメージ
fireEvent.click(button);
expect(screen.getByText(‘カウント: 1’)).toBeInTheDocument(); // ここで落ちる!
なぜ落ちるのか?ブラウザの裏側で何が起きているかを知れば一目瞭然だ。
ブラウザの裏側:Reactの「バッチング」と「レンダリング・キュー」
React 18以降、複数の状態更新は自動的にバッチ化(まとめられ)し、パフォーマンスを最適化するために非同期で処理される。
ユーザーがボタンをクリックしたイベントハンドラが走った瞬間、Reactは「お、ステートが変わったな。よし、後でまとめて画面を再描画(Re-render)するか」と心にメモするだけで、その瞬間にDOMを書き換えているわけではない。
つまり、テストコードが上から下に流れるスピードと、Reactが非同期でDOMを更新するタイミングの間に「ズレ」が生じる。
RTLはこの現実世界の非同期性を正しく扱うために存在している。ここで登場するのが、RTLが誇る最強の非同期ユーティリティ、`findBy` クエリと `waitFor` だ。
—
実践:カウンターアプリから学ぶ「正しい非同期テストの書き方」
口で言うだけでは説得力がないな。実務でそのまま使える、少しリアリティのあるカウンターコンポーネントを例に見ていこう。
今回は、単に数字が増えるだけでなく、カウントが偶数か奇数かでメッセージが変わる仕様を盛り込んでみる。
1. テスト対象のコンポーネント (`Counter.tsx`)
import { useState } from ‘react’;
export const Counter = () => {
const [count, setCount] = useState
// ボタンをクリックした時のハンドラ(実務ではここに非同期APIが入ったりする)
const handleIncrement = () => {
setCount((prev) => prev + 1);
};
const isEven = count % 2 === 0;
return (
現在のカウント: {count}
{isEven ? ‘偶数です!イイね!’ : ‘奇数です!アグレッシブ!’}
);
};
2. シニアが書くべきテストコード (`Counter.test.tsx`)
さて、このコンポーネントに対するテストを書いてみよう。
ここで注目してほしいのは、ユーザー操作のシミュレートには `fireEvent` よりも、より人間に近い操作を再現できる `@testing-library/user-event` を使うのが今のスタンダード(モダン)であるという点だ。
import { render, screen } from ‘@testing-library/react’;
import userEvent from ‘@testing-library/user-event’;
import { Counter } from ‘./Counter’;
describe(‘Counterコンポーネントのテストスイート’, () => {
test(‘初期状態が正しくレンダリングされていること’, () => {
render(
// 初期値が0で、偶数のメッセージが出ていることを確認
expect(screen.getByTestId(‘count-display’)).toHaveTextContent(‘現在のカウント: 0’);
expect(screen.getByText(‘偶数です!イイね!’)).toBeInTheDocument();
});
test(‘ボタンをクリックするとカウントがインクリメントされ、表示が切り替わること’, async () => {
// 1. ユーザー操作のインスタンスを生成(最新のuserEventはsetup()が必要)
const user = userEvent.setup();
render(
// 取得しておく
const button = screen.getByRole(‘button’, { name: ‘増やす’ });
const countDisplay = screen.getByTestId(‘count-display’);
// 初期状態の確認
expect(countDisplay).toHaveTextContent(‘現在のカウント: 0’);
expect(screen.getByText(‘偶数です!イイね!’)).toBeInTheDocument();
// 2. ユーザーのクリック操作をシミュレート(内部でawaitが必要なことに注意!)
await user.click(button);
// 3. 状態更新後のDOM変化をアサーション
// 奇数に変わっているはず
expect(countDisplay).toHaveTextContent(‘現在のカウント: 1’);
// 非同期でDOMが更新されるのを待つ必要があれば findBy を使うが、
// userEventを使う場合は基本ロジックがPromiseベースなので、awaitの後に同期的に取得しても大体通る。
// しかし、さらに確実を期すなら以下のように書くのがプロの技。
const oddMessage = await screen.findByText(‘奇数です!アグレッシブ!’);
expect(oddMessage).toBeInTheDocument();
});
});
—
現場で役立つ!知っておくべき3つのベストプラクティス
ここまで読んだ君なら、基本のキはバッチリだ。最後に、現場でレビューを受けているとよく見落としがちな「3つの知見」を授けておこう。
1. `getBy`, `queryBy`, `findBy` の使い分けを迷うな
- `getBy…`: 要素が「そこにあるはず」の時に使う。見つからない即座にエラーを吐く。一番よく使う。
- `queryBy…`: 要素が「画面に存在しないこと」を確認したい時に使う(例: ローディングスピナーが消えたことの確認)。見つからなくてもエラーにならず `null` を返す。
- `findBy…`: 要素が「非同期で現れる(または消える)こと」を待つ時に使う。内部で `waitFor` の仕組みを持っているため、API通信後や重い状態更新のテストには必須。
2. 「実装」ではなく「ユーザーの目線」をテストしろ
初心者がやりがちな最悪のテストは、「コンポーネント内の `useState` の値がこう変わっているはずだ」という内部実装を直接テストしようとすることだ。React Testing Libraryは、あくまで 「ユーザーが画面を見てどう感じるか(DOMがどう変化するか)」 をテストするためのものだ。
stateの内部変数を直接覗き見ようとするな。画面に表示されているテキストやロール(Role)を信頼してテストを書け。
3. 例のあの警告 `act(…)`が出たらどうする?
テストを実行した時に、コンソールに以下のような赤い地獄のメッセージが出たことはないだろうか?
> `An update to Component inside a test was not wrapped in act(…)`
これは、「Reactが予期せぬタイミング(テスト終了後や非同期処理の解決後など)で状態を更新しようとしたけど、テストランナーがそれをキャッチできてないよ!」というReactからの悲鳴だ。
大体の原因は、「APIのモックが解決する前にテストが終了している」 か 「非同期処理の完了を `await` し忘れている」 ことのどちらか。
`user.click()` などの非同期アクションの前には必ず `await` をつけ、非同期のDOM変化を伴うなら `findBy…` や `waitFor` で画面の安定を待つことで、この警告の9割は消し去ることができる。
—
おわりに
テストコードは、単なる「バグを見つけるための防波堤」ではない。
「このコードはこういう仕様で動き、将来の自分がリファクタリングしても絶対に壊れない」という、チームへの強烈なラブレターであり、設計書の代わり なんだ。
最初は少し面倒くさいと感じるかもしれない。だが、テストを書く習慣が身につくと、コードの設計段階で「あ、このコンポーネント、責務が多すぎてテストしづらいな」と気づけるようになる。つまり、テスト容易性を意識するだけで、自然とコンポーネントの設計が美しく洗練されていくという最高の副次効果が手に入る。
さあ、エディタに戻って、昨日書いたあの複雑なコンポーネントにテストを一枚添えてやろうぜ。何か詰まったら、いつでも俺のところに聞きに来い。応援している。

コメント