【テクニカル・上級編】 React Testing Libraryによる状態更新のテスト – React実践ガイド

こんにちは。フロントエンドの迷宮を日夜さまよい、コンポーネントのライフサイクルと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(0);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState(null);

// 意図的な非同期遅延とエラーハンドリングを含むインクリメント処理
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からの極めて重要なアーキテクチャ上の警告シグナルだ。

さあ、エディタに戻り、あなたのコンポーネントのテストコードを見直そう。そこに妥協の跡が見つかったなら、今すぐ美しいコードへとリファクタリングするんだ。プロフェッショナルな仕事とは、そういう細部の積み重ねの先にあるのだから。

コメント

タイトルとURLをコピーしました