【テクニカル・上級編】 状態管理ロジックのテスト戦略 – React実践ガイド

こんにちは。フロントエンドの現場を渡り歩いていると、「なぜかテストが不安定」「状態の更新が非同期の波に飲まれてレースコンディションを起こしている」「モックの山でテストコードが本体より肥大化している」といった悲鳴を、優秀なエンジニアからさえも毎日のように耳にします。

特に、`useState` や `useReducer` を複雑に絡め合わせたカスタムフックのテスト。これを「コンポーネントに組み込んでからマウントして検証する」という前時代的なアプローチで乗り切ろうとしているなら、今すぐその手を止めてください。DOMのレンダリングライフサイクルを毎回巻き込むテストは、遅い、もろい、そして何よりエンジニアリングの美学に反します。

今回は、React Testing Library(RTL)の真髄である `@testing-library/react` とその相棒である `@testing-library/react-hooks`(現在はコアに統合されていますが)を駆使し、DOMの呪縛から解放された純度の高いカスタムフックおよびReducerのテスト戦略について、ブラウザのランタイムやReactの内部スケジューリングの挙動まで踏み込んで徹底的に解説します。

—

なぜカスタムフックのテストは「汚染」されやすいのか

私たちが書くカスタムフックの多くは、単なるUIのロジックではなく、非同期通信、キャッシュの管理、ブラウザのストレージ連携、そして複雑なステートマシーンを内包しています。

ここでよくあるアンチパターンが、テストのためにわざわざ「検証用の一時的なダミーコンポーネント」を作り、その中でフックを呼び出して、DOM要素の変化(例えば表示されたテキストなど)を通じて内部状態をアサートするという手法です。

// ──【反面教師】DOM経由のテスト(これはやめましょう)──
function TestComponent() {
const { count, increment } = useCounter();
return (

{count}

);
}

// テスト側でrender()してfireEventを叩く…

このアプローチの何が問題か?
第一に、Reactのレンダリングサイクルという余計なオーバーヘッドがアサーションに介在する点。第二に、DOMの構造変更という不要なリファクタリング耐性の低下を招く点です。そして何より、非同期の状態更新(`async/await` や `useEffect` 内でのdispatchなど)が走った際、React 18のConcurrent Rendererが裏でどのようにスケジューリングを行っているかが見えにくくなり、`act()` の警告という名のゾンビエラーに悩まされることになります。

私たちがテストすべきなのは「DOMが正しく描画されているか」ではなく、「与えられた入力(アクションや引数)に対して、フックが返す状態と副作用が、関数型プログラミングの原則に従って正しく遷移しているか」の一点に尽きます。

—

実践:`renderHook` を用いた堅牢なカスタムフックのテスト

では、DOMを完全にバイパスし、フックの純粋なロジックとライフサイクルを直撃するテストの実装を見ていきましょう。ここでは、非同期の競合(Race Condition)と多重リクエストを防ぐキャンセル機構を持った、実戦投入レベルの非同期データ取得フックを題材にします。

1. テスト対象のカスタムフック (`useAsyncResource.ts`)

まずは、よくある「通信中にアンマウントされたり、古いリクエストが後から返ってきたりするバグ」を防ぐための堅牢なフックを用意します。

import { useState, useCallback, useRef } from ‘react’;

interface Options {
fetcher: (signal: AbortSignal) => Promise;
}

export function useAsyncResource({ fetcher }: Options) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// 最後に発火したリクエストのAbortControllerを保持
const abortControllerRef = useRef(null);

const execute = useCallback(async () => {
// 既存の未完了リクエストがあれば強制キャンセル(競合の防止)
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

const controller = new AbortController();
abortControllerRef.current = controller;

setLoading(true);
setError(null);

try {
const result = await fetcher(controller.signal);
// キャンセルされていなければ状態を更新
if (!controller.signal.aborted) {
setData(result);
}
} catch (err: unknown) {
if (err instanceof Error && err.name !== ‘AbortError’) {
setError(err);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [fetcher]);

return { data, loading, error, execute };
}

2. React Testing Libraryによる単体テストの構築

このフックは、`AbortController` の制御、非同期の完了待ち、そしてReactのバッチ処理による状態更新が絡み合っています。RTLの `renderHook` と `act` を使って、これを完璧にテストします。

import { renderHook, act, waitFor } from ‘@testing-library/react’;
import { useAsyncResource } from ‘./useAsyncResource’;

describe(‘useAsyncResourceのアーキテクチャ検証’, () => {
// モックのフェッチャーを作成
const mockFetcher = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it(‘初期状態が正しく、execute呼び出しによってデータが正常に取得されること’, async () => {
const mockData = { id: 1, name: ‘Geek Architect’ };
mockFetcher.mockResolvedValueOnce(mockData);

// renderHookを用いてDOMなしでフックを直接インスタンス化
const { result } = renderHook(() => useAsyncResource({ fetcher: mockFetcher }));

// 初期状態のイミュータビリティを担保
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();

// 状態を変化させる非同期アクションは必ず act() でラップする
await act(async () => {
result.current.execute();
});

// ローディング中の瞬間を検証(必要であれば)
// ※今回は一瞬で解決するため、最終結果をwaitForで待つのが堅牢

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});

it(‘連続してリクエストが走った際、古いリクエストが適切にキャンセル(レースコンディション対策)されること’, async () => {
// 1つ目のリクエストはわざと遅延させる
let resolveFirstPromise!: (value: any) => void;
const firstPromise = new Promise((resolve) => {
resolveFirstPromise = resolve;
});

const secondData = { id: 2, name: ‘Latest Data’ };

mockFetcher
.mockReturnValueOnce(firstPromise)
.mockResolvedValueOnce(secondData);

const { result } = renderHook(() => useAsyncResource({ fetcher: mockFetcher }));

// 1回目の実行
act(() => {
result.current.execute();
});

expect(result.current.loading).toBe(true);

// 完了する前に2回目の実行を叩く(ここで1回目はAbortされるべき)
await act(async () => {
result.current.execute();
});

// 1つ目のPromiseを後から解決させる
await act(async () => {
resolveFirstPromise({ id: 1, name: ‘Old Data’ });
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

// 古いリクエストの結果で上書きされず、2つ目の結果が保持されていることを確認
expect(result.current.data).toEqual(secondData);
});
});

このテストコードの美しいところは、ブラウザのネットワーク層やDOMの描画を一切エミュレートせず、純粋にJavaScriptのメモリ上とReactのスケジューラ内で起きている状態の遷移だけを精緻に射抜いている点です。テストの実行速度は極めて高速であり、CI/CDパイプラインのボトルネックになりません。

—

`useReducer` のロジックテスト:純粋関数としての極限の追求

複雑な状態管理といえば `useReducer` です。reducer関数自体は、「現在の状態(State)とアクション(Action)を受け取り、新しい状態を返す純粋関数(Pure Function)」という、テストの神様が舞い降りたような特性を持っています。

コンポーネントに組み込む前の段階で、reducer単体を徹底的にイジメ抜くテストを書きましょう。Reactのランタイムすら不要です。

1. テスト対象のReducer (`counterReducer.ts`)

export type State = { count: number; history: number[] };
export type Action =
| { type: ‘INCREMENT’; payload: number }
| { type: ‘DECREMENT’; payload: number }
| { type: ‘RESET’ };

export const initialState: State = { count: 0, history: [0] };

export function counterReducer(state: State, action: Action): State {
switch (action.type) {
case ‘INCREMENT’: {
const nextCount = state.count + action.payload;
return {
…state,
count: nextCount,
history: […state.history, nextCount],
};
}
case ‘DECREMENT’: {
const nextCount = state.count – action.payload;
return {
…state,
count: nextCount,
history: […state.history, nextCount],
};
}
case ‘RESET’:
return initialState;
default:
// 万が一の型安全性の担保
const exhaustiveCheck: never = action;
return state;
}
}

2. Reducerの単体テスト(純粋関数テスト)

JestやVitestにおいて、ReducerのテストにRTLすら不要なケースが多々あります。TypeScriptの型システムとJestの組み合わせで、ステートマシーンの網羅性を完全に担保します。

import { counterReducer, initialState, State, Action } from ‘./counterReducer’;

describe(‘counterReducerのステートマシーン検証’, () => {
it(‘INCREMENTアクションが正しく状態を更新し、履歴をイミュータブルに追加すること’, () => {
const action: Action = { type: ‘INCREMENT’, payload: 5 };
const nextState = counterReducer(initialState, action);

// 状態がイミュータブル(新しいオブジェクトが返されているか)の確認
expect(nextState).not.toBe(initialState);
expect(nextState.count).toBe(5);
expect(nextState.history).toEqual([0, 5]);
});

it(‘DECREMENTアクションが続き、履歴の時系列が正しく維持されること’, () => {
const stateAfterInc: State = { count: 10, history: [0, 5, 10] };
const action: Action = { type: ‘DECREMENT’, payload: 3 };

const nextState = counterReducer(stateAfterInc, action);

expect(nextState.count).toBe(7);
expect(nextState.history).toEqual([0, 5, 10, 7]);
});

it(‘RESETアクションで初期状態に完全に戻ること’, () => {
const dirtyState: State = { count: 99, history: [0, 50, 99] };
const action: Action = { type: ‘RESET’ };

const nextState = counterReducer(dirtyState, action);

expect(nextState).toEqual(initialState);
});
});

このレベルの単体テストは、実行に数ミリ秒しかかかりません。UIの変更やスタイリングの変更に一切影響を受けず、ビジネスロジックの根幹たる「状態の数学的遷移」だけを半永久的に守り続けます。

—

チーフアーキテクトからの提言:テストカバレッジの幻想を捨てよ

現場でよくある悪習として、「カバレッジ100%を目指せ」という無慈悲なKPIがあります。その結果、どうでもいいボイラープレートコードに対してモックを重ね、意味のないアサーションを並べ立てた「メンテナンスコストの墓場」のようなテストスイートが完成します。

私たちが目指すべきなのは、無駄に高いカバレッジではなく、「アプリケーションの致命傷(非同期の競合、メモリリーク、予期せぬ状態の破壊)を未然に防ぐ、高精度なレーダー網」としてのテストです。

1. DOMに依存しないロジックは、純粋関数または `renderHook` で極限までドライにテストする。
2. 非同期の競合(Race Condition)やクリーンアップ(AbortControllerなど)の挙動こそ、テストの主戦場とする。
3. `act(…)` の警告に怯えるのではなく、React 18のスケジューリングの仕組みを理解し、非同期の完了を `waitFor` でエレガントに待つ。

この領域に踏み込んだとき、あなたの書くReactアプリケーションは、単なる「動くコード」から、予測可能性と堅牢性を極限まで高めた「エンジニアリングの芸術品」へと昇華するはずです。

さあ、エディタに戻り、あなたのカスタムフックのテストから無駄なDOMモックを剥ぎ取りましょう。そこにあるのは、研ぎ澄まされた美しいコードの世界です。

コメント

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