カスタムフックはロジックを再利用可能にする強力なツールですが、適切にテストしないとバグの温床になります。本記事では、@testing-library/react-hooks を使った実践的なテスト戦略を端的に解説します。
1. なぜカスタムフックをテストするか
1.1 テストの重要性
カスタムフックは複数のコンポーネントで使われるため、バグの影響範囲が広がります。単独でテストすることで:
- 早期にバグを発見できる
- リファクタリングが安全になる
- ドキュメントとして機能する
1.2 テストツール
npm install --save-dev @testing-library/react @testing-library/react-hooks
@testing-library/react-hooks は React 18 以降、@testing-library/react に統合されました。
2. 基本的なテスト方法
2.1 renderHook の使い方
import { renderHook } from '@testing-library/react';
import { useBoolean } from './useBoolean';
test('初期値が正しく設定される', () => {
const { result } = renderHook(() => useBoolean(true));
expect(result.current.value).toBe(true);
});
ポイント:
-
renderHookでカスタムフックを実行 -
result.currentで現在の値を取得 - コンポーネントを書かずにテストできる
2.2 act で状態更新をテスト
import { renderHook, act } from '@testing-library/react';
test('toggle で値が切り替わる', () => {
const { result } = renderHook(() => useBoolean(false));
expect(result.current.value).toBe(false);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
});
重要: 状態更新は act() で囲む必要があります。
3. 実践的なテストパターン
3.1 状態管理フックのテスト
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// テスト
test('useCounter の基本動作', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(11);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(10);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
3.2 副作用フックのテスト
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
handleResize(); // 初期値を設定
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// テスト
test('useWindowSize がウィンドウサイズを取得する', () => {
// window.innerWidth/innerHeight をモック
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1024,
});
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: 768,
});
const { result } = renderHook(() => useWindowSize());
expect(result.current.width).toBe(1024);
expect(result.current.height).toBe(768);
// リサイズイベントをシミュレート
act(() => {
window.innerWidth = 1920;
window.innerHeight = 1080;
window.dispatchEvent(new Event('resize'));
});
expect(result.current.width).toBe(1920);
expect(result.current.height).toBe(1080);
});
3.3 非同期処理フックのテスト
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let ignore = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => res.json())
.then(json => {
if (!ignore) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (!ignore) {
setError(err);
setLoading(false);
}
});
return () => { ignore = true; };
}, [url]);
return { data, loading, error };
}
// テスト
test('useFetch がデータを取得する', async () => {
const mockData = { id: 1, name: 'Test' };
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockData),
} as Response)
);
const { result, waitForNextUpdate } = renderHook(() =>
useFetch<User>('/api/user')
);
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
});
3.4 依存値の変更をテスト
test('依存値が変わると再実行される', () => {
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users/1' } }
);
// URL が変わったら再実行されることを確認
rerender({ url: '/api/users/2' });
expect(result.current.loading).toBe(true);
});
4. 高度なテストパターン
4.1 Context を使うフックのテスト
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// テスト
test('useAuth が Context から値を取得する', () => {
const mockContext = {
user: { id: 1, name: 'Test' },
login: jest.fn(),
logout: jest.fn(),
};
const wrapper = ({ children }) => (
<AuthContext.Provider value={mockContext}>
{children}
</AuthContext.Provider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual(mockContext.user);
});
4.2 複数のフックを組み合わせたテスト
test('複数のフックが連携する', () => {
const { result } = renderHook(() => {
const { value, toggle } = useBoolean(false);
const count = useCounter(0);
useEffect(() => {
if (value) {
count.increment();
}
}, [value]);
return { value, toggle, count: count.count };
});
expect(result.current.count).toBe(0);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
expect(result.current.count).toBe(1);
});
4.3 クリーンアップのテスト
test('クリーンアップが正しく実行される', () => {
const cleanup = jest.fn();
function useTestEffect() {
useEffect(() => {
return cleanup;
}, []);
}
const { unmount } = renderHook(() => useTestEffect());
unmount();
expect(cleanup).toHaveBeenCalledTimes(1);
});
5. よくある問題と解決方法
5.1 "act" 警告が出る
問題: 状態更新が act() で囲まれていない
// ❌ 警告が出る
test('状態更新', () => {
const { result } = renderHook(() => useCounter());
result.current.increment(); // act で囲んでいない
});
// ✅ 修正
test('状態更新', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
});
5.2 非同期処理のテスト
// ✅ async/await を使う
test('非同期処理', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetch('/api'));
await waitForNextUpdate();
expect(result.current.data).toBeDefined();
});
// ✅ waitFor を使う(推奨)
test('非同期処理', async () => {
const { result } = renderHook(() => useFetch('/api'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
5.3 タイマーのテスト
jest.useFakeTimers();
test('タイマーが正しく動作する', () => {
const { result } = renderHook(() => useInterval(() => {
// 何らかの処理
}, 1000));
act(() => {
jest.advanceTimersByTime(1000);
});
// アサーション
});
afterEach(() => {
jest.useRealTimers();
});
6. テストのベストプラクティス
6.1 テストの構造
describe('useCounter', () => {
// 各テストケース
test('初期値が正しく設定される', () => {});
test('increment で値が増える', () => {});
test('decrement で値が減る', () => {});
});
6.2 テストの命名
- 何をテストするかを明確に
- 期待される動作を記述
// ✅ 良い例
test('toggle で true から false に変わる', () => {});
test('依存配列が変わると再実行される', () => {});
// ❌ 悪い例
test('useBoolean works', () => {});
test('test 1', () => {});
6.3 モックの使い方
// ✅ 必要な部分だけモック
jest.spyOn(window, 'fetch').mockResolvedValue({
json: () => Promise.resolve({ data: 'test' }),
});
// ❌ 過度にモックしない
// 実際の動作に近いテストを心がける
7. まとめ
7.1 テストのポイント
| 項目 | 説明 |
|---|---|
| renderHook | カスタムフックを実行する |
| act | 状態更新を囲む |
| waitFor | 非同期処理を待つ |
| rerender | 依存値の変更をテスト |
| wrapper | Context や Provider を提供 |
7.2 テストすべき項目
- 初期値が正しく設定される
- 状態更新が正しく動作する
- 副作用が適切に実行される
- クリーンアップが正しく実行される
- 依存値の変更に正しく反応する
- エラーハンドリングが適切
7.3 参考リンク
カスタムフックのテストは、ロジックの品質を保証する重要な手段です。適切にテストすることで、安全にリファクタリングし、バグを早期に発見できます。