0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hooks を Hackしよう!【Part14: カスタムフックのテスト戦略】

Posted at

カスタムフックはロジックを再利用可能にする強力なツールですが、適切にテストしないとバグの温床になります。本記事では、@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 テストすべき項目

  1. 初期値が正しく設定される
  2. 状態更新が正しく動作する
  3. 副作用が適切に実行される
  4. クリーンアップが正しく実行される
  5. 依存値の変更に正しく反応する
  6. エラーハンドリングが適切

7.3 参考リンク


カスタムフックのテストは、ロジックの品質を保証する重要な手段です。適切にテストすることで、安全にリファクタリングし、バグを早期に発見できます。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?