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

こんにちは。君もそろそろ「よし、コンポーネントの見た目のテストは書けるようになったぞ。でも、この複雑に絡み合ったカスタムフックやreducerのロジック、どうやって単体テスト書けばいいんだ……?」という壁にぶち当たっている頃じゃないかな。

大丈夫、その悩みは中級からシニアへとステップアップするフロントエンドエンジニアが必ず通る登竜門だ。

世の中には「コンポーネントと一緒にE2Eでテストすればいいじゃん」なんて乱暴な意見もある。だが、ちょっと待ってほしい。UIの変更のたびに重いインテグレーションテストが落ちる地獄を経験した君なら分かるはずだ。「ビジネスロジックは、純粋なロジックとして高速かつ堅牢にテストしたい」。そう、React Testing Library(RTL)を正しく使えば、UIを描画しなくてもカスタムフックやreducerの挙動を完璧に、しかもブラウザのレンダリングサイクルの裏側まで含めて検証できるんだ。

今回は、実務の現場で明日から使える「状態管理ロジックのテスト戦略」を、骨太に伝授しよう。

—

1. なぜ「カスタムフックやreducerのテスト」でハマるのか?

まず敵を知ろう。Reactのフックやreducerは、コンポーネントという「ライフサイクルの中」でしか本来動かない。よくある失敗が、カスタムフックを普通のJavaScriptの関数のように直接呼び出してしまうパターンだ。

// ❌ やっちゃいけない例:これは絶対に動かない
const { state, increment } = useCounter(); // Error: Invalid hook call!

「Invalid hook call(フックのエラー)」でお馴染みのこのエラーは、Reactの内部コンテキスト(Dispatcher)が存在しない場所でフックを呼んだときに発生する。Reactのフックは、Reactのレンダリングエンジンと密に結合しているからだ。

ここで登場するのが、RTLが提供する秘密兵器 `renderHook` だ。これを使えば、「ヘッドレス(画面なし)なテスト用コンポーネント」を裏側でサクッと立ち上げ、その中で安全にフックを動かして検証できる。ブラウザのDOMを構築しない分、テストの実行速度も圧倒的に爆速になる。

—

2. 実践:カスタムフックのテスト戦略

題材として、実務でよくある「非同期処理(API通信)の状態管理」を持つカスタムフックを用意した。ローディング中フラグ、データ、エラーを綺麗に管理するやつだ。

まずはそのカスタムフック(`useAsyncData`)のコードを見てほしい。

// useAsyncData.ts
import { useState, useCallback } from ‘react’;

type Status = ‘idle’ | ‘loading’ | ‘success’ | ‘error’;

export function useAsyncData(asyncFunction: () => Promise) {
const [status, setStatus] = useState(‘idle’);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

const execute = useCallback(async () => {
setStatus(‘loading’);
setError(null);
try {
const result = await asyncFunction();
setData(result);
setStatus(‘success’);
return result;
} catch (err) {
setError(err instanceof Error ? err : new Error(‘不明なエラーです’));
setStatus(‘error’);
throw err;
}
}, [asyncFunction]);

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

このフックのテストをどう書くか?
ポイントは、「非同期の状態更新が走ったあとのReactのバッチ処理と再描画をどう待ち受けるか」だ。`act`関数と`waitFor`の使い分けが勝負の分かれ目になる。

実際のテストコードを見てみよう。

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

describe(‘useAsyncData カスタムフックのテスト’, () => {
it(‘初期状態が正しく設定されていること’, () => {
const mockFn = jest.fn().mockResolvedValue(‘成功データ’);
const { result } = renderHook(() => useAsyncData(mockFn));

expect(result.current.status).toBe(‘idle’);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});

it(‘非同期処理が成功したときに、dataとstatusが正しく更新されること’, async () => {
const mockFn = jest.fn().mockResolvedValue(‘取得したユーザーデータ’);
const { result } = renderHook(() => useAsyncData(mockFn));

// 非同期のアクションを実行。Reactの状態更新を伴うため act() でラップする
let promise: Promise;
act(() => {
promise = result.current.execute();
});

// 実行直後はローディング中になっているべき
expect(result.current.status).toBe(‘loading’);

// Promiseの解決を待つ
await promise!;

// 状態が更新された後の値をアサート
expect(result.current.status).toBe(‘success’);
expect(result.current.data).toBe(‘取得したユーザーデータ’);
expect(result.current.error).toBeNull();
});

it(‘非同期処理が失敗したときに、errorとstatusが正しく更新されること’, async () => {
const mockError = new Error(‘通信エラーが発生しました’);
const mockFn = jest.fn().mockRejectedValue(mockError);
const { result } = renderHook(() => useAsyncData(mockFn));

// エラーがスローされるため try/catch もしくは reject をハンドリング
await act(async () => {
await expect(result.current.execute()).rejects.toThrow(‘通信エラーが発生しました’);
});

expect(result.current.status).toBe(‘error’);
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeNull();
});
});

💡 シニアからの現場アドバイス:`act`の正しい理解

「なぜここで `act()` が必要なのか?」と後輩によく聞かれる。
React 18以降、状態更新は自動バッチングされ、非同期処理の完了後にレンダリングがスケジュールされる。テスト環境(JSDOMなど)では、Reactのスケジューラーがブラウザのネイティブなイベントループと完全に同期しない。そのため、「今からReactの状態を大きく書き換える非同期処理を走らせるよ!Reactくん、その間の描画やキューをよしなに処理してくれ!」と明示的に伝えるために `act()`(あるいは `waitFor`)で囲む必要があるんだ。ここをサボると、「Not wrapped in act(…)」というお馴染みの赤文字エラーに悩まされることになる。

—

3. 複雑なreducerのテスト戦略

さて、次は `useReducer` を使った状態管理ロジックのテストだ。
reducerは「純粋関数(Pure Function)」なので、ぶっちゃけReactのコンテキストすら不要でテストできる。入力(現在の状態とアクション)に対して、出力(次の状態)が完全に一意に決まるため、Jestの通常のユニットテスト(`it` / `expect`)だけで極めてクリーンにテストを書ける。

ここに、ショッピングカートのreducerがあるとする。

// cartReducer.ts
export type CartItem = { id: string; name: string; price: number; quantity: number };
export type CartState = { items: CartItem[]; total: number };
export type CartAction =
| { type: ‘ADD_ITEM’; payload: CartItem }
| { type: ‘REMOVE_ITEM’; payload: { id: string } }
| { type: ‘CLEAR_CART’ };

export const initialCartState: CartState = { items: [], total: 0 };

export function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case ‘ADD_ITEM’: {
const existingIndex = state.items.findIndex(item => item.id === action.payload.id);
let updatedItems: CartItem[];

if (existingIndex > -1) {
updatedItems = state.items.map((item, index) =>
index === existingIndex
? { …item, quantity: item.quantity + action.payload.quantity }
: item
);
} else {
updatedItems = […state.items, action.payload];
}

const total = updatedItems.reduce((sum, item) => sum + item.price item.quantity, 0);
return { items: updatedItems, total };
}
case ‘REMOVE_ITEM’: {
const updatedItems = state.items.filter(item => item.id !== action.payload.id);
const total = updatedItems.reduce((sum, item) => sum + item.price item.quantity, 0);
return { items: updatedItems, total };
}
case ‘CLEAR_CART’:
return initialCartState;
default:
return state;
}
}

このreducerをテストする場合、React Testing Libraryの `renderHook` を使ってもいいし、もっとシンプルに純粋な関数としてテストしてもいい。個人的には、reducer単体であれば純粋関数として直接テストするのが最もシンプルでメンテナブルだと考えている。

// cartReducer.test.ts
import { cartReducer, initialCartState, CartState } from ‘./cartReducer’;

describe(‘cartReducer の単体テスト’, () => {
it(‘初期状態にアイテムが追加され、合計金額が正しく計算されること’, () => {
const action = {
type: ‘ADD_ITEM’ as const,
payload: { id: ‘1’, name: ‘TypeScript入門書’, price: 3000, quantity: 2 },
};

const nextState = cartReducer(initialCartState, action);

expect(nextState.items).toHaveLength(1);
expect(nextState.items[0].quantity).toBe(2);
expect(nextState.total).toBe(6000); // 3000 2
});

it(‘すでに存在するアイテムを追加した場合、数量が加算され合計金額が再計算されること’, () => {
const currentState: CartState = {
items: [{ id: ‘1’, name: ‘TypeScript入門書’, price: 3000, quantity: 1 }],
total: 3000,
};

const action = {
type: ‘ADD_ITEM’ as const,
payload: { id: ‘1’, name: ‘TypeScript入門書’, price: 3000, quantity: 2 },
};

const nextState = cartReducer(currentState, action);

expect(nextState.items).toHaveLength(1);
expect(nextState.items[0].quantity).toBe(3); // 1 + 2
expect(nextState.total).toBe(9000); // 3000 3
});

it(‘アイテムが削除され、合計金額が正しく更新されること’, () => {
const currentState: CartState = {
items: [
{ id: ‘1’, name: ‘本A’, price: 1000, quantity: 1 },
{ id: ‘2’, name: ‘本B’, price: 2000, quantity: 1 },
],
total: 3000,
};

const action = {
type: ‘REMOVE_ITEM’ as const,
payload: { id: ‘1’ },
};

const nextState = cartReducer(currentState, action);

expect(nextState.items).toHaveLength(1);
expect(nextState.items[0].id).toBe(‘2’);
expect(nextState.total).toBe(2000);
});
});

見てのとおり、reducerのテストには `renderHook` すら不要だ。引数に「現在の状態」と「アクション」を食らわせて、返ってきた「次の状態」をアサートするだけ。これぞ純粋関数の醍醐味であり、テストの書きやすさの極みと言える。

—

4. 現場で使えるテスト戦略のベストプラクティス

最後に、実務でフロントエンドのテストを回していく上での心構えをいくつか共有しておこう。

1. ビジネスロジックはフックやreducerに極力切り出す
コンポーネントの中に `useState` や複雑な計算ロジックを直書きすると、テストのハードルが一気に跳ね上がる。「画面を描画するコード」と「状態を管理するロジック」を分離する(いわゆるContainer/Presenterの思想やカスタムフックの活用)だけで、テストの書きやすさは10倍変わる。
2. UIのテストとロジックのテストを明確に分業する
ボタンの色が変わるか、モーダルが開くかといった「見た目の結合テスト」はRTLのコンポーネントテストで行う。一方で、データのフィルタリング、バリデーション、非同期のハンドリングといった「頭脳部分」は、今回紹介したカスタムフックやreducerの単体テストでバッチリ固める。この役割分担ができるチームは、仕様変更に対する耐性が圧倒的に高い。

テストは「面倒くさいお守り」ではなく、「将来の自分やチームメンバーをバグから守るための最高値の保険」だ。
ぜひ今回のコードをベースに、君のプロジェクトのカスタムフックやreducerにテストの網を張ってみてほしい。コードの変更に対する恐怖心が嘘のように消え去るはずだぞ!

コメント

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