T-CREATOR

Jest で Hooks(useState / useEffect)をテストする方法

Jest で Hooks(useState / useEffect)をテストする方法

React フックのテストは、モダンな React アプリケーション開発において非常に重要な要素です。特に useStateuseEffect は最も頻繁に使用されるフックであり、これらのテストを適切に行うことで、アプリケーションの品質を大きく向上させることができます。

この記事では、Jest と React Testing Library を使用して、フックのテストを効果的に行う方法について、実践的なコード例とともに詳しく解説していきます。

フックテストの基本

フックのテストには、React Testing Library の renderHook 関数を使用します。この関数を使うことで、コンポーネントの外でフックをテストすることができます。

まずは、基本的なセットアップから見ていきましょう。テストの実行前後でクリーンアップを行うことで、テスト間の影響を防ぎます:

typescript// setupTests.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

renderHook の使い方

renderHook を使用した基本的なテストパターンを見てみましょう。以下の例では、シンプルなカウンターフックをテストしています。このフックは初期値を受け取り、カウント値と増加関数を返します:

typescriptimport { renderHook } from '@testing-library/react';
import { useState } from 'react';

const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount((prev) => prev + 1);
  return { count, increment };
};

describe('useCounter', () => {
  test('初期値が正しく設定される', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
});

このテストでは、renderHook の戻り値から result オブジェクトを取得し、current プロパティを通じてフックの戻り値にアクセスしています。これにより、フックの初期状態を検証できます。

act 関数の重要性

React のステート更新をテストする際は、act 関数を使用することが重要です。これを使用しないと、以下のような警告が表示されることがあります:

cssWarning: An update to Component inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});

この警告を解消し、正しくテストを行うための例を見てみましょう:

typescriptimport { renderHook, act } from '@testing-library/react';

describe('useCounter with act', () => {
  test('increment関数でカウントが増加する', () => {
    const { result } = renderHook(() => useCounter(0));

    // act関数でラップすることで、React の更新サイクルを正しくシミュレート
    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

act 関数は React の更新サイクルをシミュレートし、すべての副作用が完了するまで待機します。これにより、テストが実際のユーザー操作により近い形で実行されます。

テストの基本パターン

フックテストでよく使用される基本パターンをいくつか紹介します。ここでは、再レンダリング時の値の保持とアンマウント時のクリーンアップをテストする例を示します:

typescriptimport { renderHook, act } from '@testing-library/react';

describe('フックテストの基本パターン', () => {
  // 再レンダリング時に内部状態が保持されることを確認
  test('再レンダリング時の値の保持', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useState(value),
      {
        initialProps: { value: 0 },
      }
    );

    expect(result.current[0]).toBe(0);

    // プロパティを変更して再レンダリング
    rerender({ value: 10 });
    // useState の値は変更されない(内部状態は維持される)
    expect(result.current[0]).toBe(0);
  });

  // クリーンアップ関数が呼び出されることを確認
  test('アンマウント時の検証', () => {
    const mockCleanup = jest.fn();
    const { unmount } = renderHook(() => {
      React.useEffect(() => {
        return () => mockCleanup();
      }, []);
    });

    unmount();
    expect(mockCleanup).toHaveBeenCalled();
  });
});

このパターンでは、rerender メソッドを使用してプロパティの変更をシミュレートし、unmount メソッドでコンポーネントのアンマウントをテストしています。これらは実際のアプリケーションでよく発生するシナリオです。

ステート管理のテスト

useState の初期値テスト

ステート管理のテストでは、初期値の設定が正しく行われているかを確認することが重要です。以下の例では、ユーザー情報を管理するフックをテストしています:

typescriptimport { renderHook } from '@testing-library/react';

const useUser = (initialUser = { name: '', age: 0 }) => {
  const [user, setUser] = useState(initialUser);
  return { user, setUser };
};

describe('useUser hook', () => {
  test('初期値が正しく設定される', () => {
    const initialUser = { name: 'Test User', age: 25 };
    const { result } = renderHook(() =>
      useUser(initialUser)
    );

    // 初期値が正しく設定されているか検証
    expect(result.current.user).toEqual(initialUser);
  });
});

このテストでは、オブジェクト型の初期値が正しく設定されることを確認しています。toEqual マッチャーを使用することで、オブジェクトの深い比較が可能です。

状態更新のテスト

複数の状態更新をテストする例を見てみましょう。ここでは、ユーザー情報の更新処理をテストします:

typescriptdescribe('状態更新のテスト', () => {
  test('ユーザー情報の更新', () => {
    const { result } = renderHook(() => useUser());

    // act関数で状態更新をラップ
    act(() => {
      result.current.setUser({
        name: 'Updated User',
        age: 30,
      });
    });

    // 更新後の状態を検証
    expect(result.current.user).toEqual({
      name: 'Updated User',
      age: 30,
    });
  });
});

このテストでは、act 関数を使用して状態更新をラップし、更新後の状態が期待通りになっていることを確認しています。

複数の状態管理

複数の状態を持つフックのテスト例を見てみましょう。ここでは、フォームの状態管理を行うフックをテストします:

typescriptconst useForm = (initialValues = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (name, value) => {
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    setErrors,
    setIsSubmitting,
  };
};

describe('useForm', () => {
  test('複数の状態が正しく更新される', () => {
    const { result } = renderHook(() =>
      useForm({ name: '' })
    );

    // 複数の状態を一度に更新
    act(() => {
      result.current.handleChange('name', 'Test');
      result.current.setErrors({ name: 'Required' });
      result.current.setIsSubmitting(true);
    });

    // すべての状態が期待通りに更新されているか検証
    expect(result.current.values).toEqual({ name: 'Test' });
    expect(result.current.errors).toEqual({
      name: 'Required',
    });
    expect(result.current.isSubmitting).toBe(true);
  });
});

このテストでは、複数の状態が独立して正しく更新されることを確認しています。各状態の更新が他の状態に影響を与えないことも重要なテストポイントです。

副作用のテスト

useEffect の依存配列テスト

依存配列の変更に応じて useEffect が正しく実行されることを確認します。以下の例では、ID が変更されたときにデータを再フェッチするフックをテストしています:

typescriptconst useDataFetcher = (id: string) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let isMounted = true;
    setLoading(true);

    fetchData(id)
      .then((result) => {
        // マウント状態をチェックしてから更新
        if (isMounted) {
          setData(result);
          setLoading(false);
        }
      })
      .catch((error) => {
        if (isMounted) {
          console.error(error);
          setLoading(false);
        }
      });

    // クリーンアップ関数でマウント状態を管理
    return () => {
      isMounted = false;
    };
  }, [id]);

  return { data, loading };
};

describe('useEffect dependencies', () => {
  test('IDが変更されたときに再フェッチが行われる', async () => {
    const mockFetch = jest.fn();
    global.fetch = mockFetch;

    const { rerender } = renderHook(
      ({ id }) => useDataFetcher(id),
      {
        initialProps: { id: '1' },
      }
    );

    // 初回フェッチの確認
    expect(mockFetch).toHaveBeenCalledWith('/api/data/1');

    // IDを変更して再レンダリング
    rerender({ id: '2' });
    // 新しいIDでフェッチが行われることを確認
    expect(mockFetch).toHaveBeenCalledWith('/api/data/2');
  });
});

このテストでは、依存配列に指定したプロパティ(この場合は id)が変更されたときに、useEffect が再実行されることを確認しています。また、isMounted フラグを使用して、アンマウント後の状態更新を防いでいます。

クリーンアップ関数の検証

useEffect のクリーンアップ関数が正しく動作することを確認します。以下の例では、イベントリスナーの追加と削除を適切に行うフックをテストしています:

typescriptconst useEventListener = (
  eventName: string,
  handler: Function
) => {
  useEffect(() => {
    // イベントリスナーを追加
    window.addEventListener(eventName, handler);

    // クリーンアップ関数でイベントリスナーを削除
    return () => {
      window.removeEventListener(eventName, handler);
    };
  }, [eventName, handler]);
};

describe('Cleanup function', () => {
  test('アンマウント時にイベントリスナーが削除される', () => {
    const handler = jest.fn();
    const addSpy = jest.spyOn(window, 'addEventListener');
    const removeSpy = jest.spyOn(
      window,
      'removeEventListener'
    );

    const { unmount } = renderHook(() =>
      useEventListener('click', handler)
    );

    // イベントリスナーが追加されたことを確認
    expect(addSpy).toHaveBeenCalledWith('click', handler);

    // アンマウント
    unmount();
    // クリーンアップ関数が呼ばれ、イベントリスナーが削除されたことを確認
    expect(removeSpy).toHaveBeenCalledWith(
      'click',
      handler
    );
  });
});

このテストでは、useEffect のクリーンアップ関数が確実に呼び出され、リソースが適切に解放されることを確認しています。これは、メモリリークを防ぐために重要なテストです。

非同期処理を含む effect のテスト

非同期処理を含む useEffect のテストでは、適切な待機処理が重要です。以下の例では、非同期処理の状態管理を行うフックをテストしています:

typescriptconst useAsyncEffect = (asyncFn: () => Promise<void>) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setIsLoading(true);
    asyncFn()
      .catch((err) => setError(err))
      .finally(() => setIsLoading(false));
  }, [asyncFn]);

  return { isLoading, error };
};

describe('Async effect', () => {
  test('非同期処理の状態遷移', async () => {
    // 100ms後に完了する非同期処理をモック
    const asyncFn = jest
      .fn()
      .mockImplementation(
        () =>
          new Promise((resolve) => setTimeout(resolve, 100))
      );

    const { result } = renderHook(() =>
      useAsyncEffect(asyncFn)
    );

    // 初期状態の確認
    expect(result.current.isLoading).toBe(true);
    expect(result.current.error).toBe(null);

    // 非同期処理の完了を待機
    await act(async () => {
      await new Promise((resolve) =>
        setTimeout(resolve, 100)
      );
    });

    // 完了後の状態を確認
    expect(result.current.isLoading).toBe(false);
    expect(result.current.error).toBe(null);
  });
});

このテストでは、非同期処理の進行に伴う状態の変化を確認しています。actasync​/​await を組み合わせることで、非同期処理の完了を適切に待機しています。

条件付き effect のテスト

条件に応じて useEffect の実行を制御するケースのテストです。このパターンは、特定の条件が満たされたときのみ副作用を実行する場合によく使用されます:

typescriptconst useConditionalEffect = (
  condition: boolean,
  callback: () => void
) => {
  useEffect(() => {
    if (condition) {
      callback();
    }
  }, [condition, callback]);
};

describe('Conditional effect', () => {
  test('条件がtrueの場合のみeffectが実行される', () => {
    const callback = jest.fn();

    const { rerender } = renderHook(
      ({ condition }) =>
        useConditionalEffect(condition, callback),
      { initialProps: { condition: false } }
    );

    // 条件がfalseの場合、コールバックは実行されない
    expect(callback).not.toHaveBeenCalled();

    // 条件をtrueに変更
    rerender({ condition: true });
    // コールバックが1回だけ実行されることを確認
    expect(callback).toHaveBeenCalledTimes(1);
  });
});

このテストでは、条件付きの副作用が適切なタイミングで実行されることを確認しています。特に、条件が false の場合にコールバックが実行されないことと、true に変更された時点で実行されることを検証しています。

カスタムフックのテスト

複数のフックを組み合わせたテスト

実際のアプリケーションでは、複数のフックを組み合わせて使用することが一般的です。以下の例では、フォームのバリデーション機能を持つカスタムフックをテストしています:

typescriptconst useFormWithValidation = (initialValues = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isValid, setIsValid] = useState(false);

  // 値が変更されるたびにバリデーションを実行
  useEffect(() => {
    const validate = () => {
      const newErrors = {};
      let valid = true;

      Object.entries(values).forEach(([key, value]) => {
        if (!value) {
          newErrors[key] = '必須項目です';
          valid = false;
        }
      });

      setErrors(newErrors);
      setIsValid(valid);
    };

    validate();
  }, [values]);

  const handleChange = (name: string, value: string) => {
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  return { values, errors, isValid, handleChange };
};

describe('useFormWithValidation', () => {
  test('値の更新とバリデーションの連携', () => {
    const { result } = renderHook(() =>
      useFormWithValidation({ name: '' })
    );

    // 初期状態では空値なのでバリデーションエラー
    expect(result.current.isValid).toBe(false);
    expect(result.current.errors).toHaveProperty('name');

    // 値を入力してバリデーションが通ることを確認
    act(() => {
      result.current.handleChange('name', 'Test User');
    });

    expect(result.current.isValid).toBe(true);
    expect(result.current.errors).toEqual({});
  });
});

このテストでは、useStateuseEffect を組み合わせたフォームバリデーションの動作を検証しています。値の更新から、バリデーション実行、エラー状態の更新までの一連のフローが正しく動作することを確認しています。

プロパティ変更時の挙動テスト

プロパティの変更に応じてフックの挙動が正しく変化することを確認するテストです。以下の例では、前回の値を記憶するカスタムフックをテストしています:

typescriptconst usePrevious = <T>(value: T) => {
  const ref = useRef<T>();

  useEffect(() => {
    // 現在の値を記憶
    ref.current = value;
  }, [value]);

  return ref.current;
};

describe('usePrevious', () => {
  test('値の更新履歴が正しく保持される', () => {
    const { result, rerender } = renderHook(
      ({ value }) => usePrevious(value),
      { initialProps: { value: 'initial' } }
    );

    // 初回レンダリング時は undefined
    expect(result.current).toBeUndefined();

    // 値を更新
    rerender({ value: 'updated' });
    // 前回の値が保持されている
    expect(result.current).toBe('initial');

    // さらに更新
    rerender({ value: 'final' });
    // 前回の値が保持されている
    expect(result.current).toBe('updated');
  });
});

このテストでは、useRefuseEffect を使用して前回の値を記憶する機能をテストしています。特に、値の更新タイミングと保持される値の整合性を確認しています。

エラーハンドリングのテスト

エラー発生時の挙動をテストする例です。API 呼び出しのエラーハンドリングを行うカスタムフックをテストしています:

typescriptconst useApiCall = <T>(apiFunc: () => Promise<T>) => {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  const execute = async () => {
    try {
      setError(null);
      const result = await apiFunc();
      setData(result);
    } catch (e) {
      setError(
        e instanceof Error ? e : new Error('Unknown error')
      );
    }
  };

  return { data, error, execute };
};

describe('useApiCall error handling', () => {
  test('APIエラーが適切にキャッチされる', async () => {
    const error = new Error('API Error');
    const mockApi = jest.fn().mockRejectedValue(error);

    const { result } = renderHook(() =>
      useApiCall(mockApi)
    );

    await act(async () => {
      await result.current.execute();
    });

    // エラーが正しく保持され、データはnullのまま
    expect(result.current.error).toBe(error);
    expect(result.current.data).toBeNull();
  });
});

このテストでは、API 呼び出しが失敗した場合のエラーハンドリングを検証しています。エラーオブジェクトが適切に状態として保持され、データ状態が正しく管理されることを確認しています。

テストの最適化とベストプラクティス

テストの信頼性向上

テストの信頼性を高めるためのベストプラクティスをいくつか紹介します:

typescript// テスト用のユーティリティ関数
const waitForNextTick = () =>
  act(async () => {
    await new Promise((resolve) => setTimeout(resolve, 0));
  });

const useDataFetcher = (id: string) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let isMounted = true;
    setLoading(true);

    fetchData(id)
      .then((result) => {
        // マウント状態をチェックしてから更新
        if (isMounted) {
          setData(result);
          setLoading(false);
        }
      })
      .catch((error) => {
        if (isMounted) {
          console.error(error);
          setLoading(false);
        }
      });

    // クリーンアップ関数でマウント状態を管理
    return () => {
      isMounted = false;
    };
  }, [id]);

  return { data, loading };
};

describe('Reliable testing', () => {
  test('非同期処理の信頼性の高いテスト', async () => {
    const { result, unmount } = renderHook(() =>
      useDataFetcher('test-id')
    );

    // 初期状態の確認
    expect(result.current.loading).toBe(true);

    // 非同期処理の完了を待機
    await waitForNextTick();

    // アンマウント後の更新がないことを確認
    unmount();
    await waitForNextTick();
    // エラーが発生しないことを確認
  });
});

このテストでは、以下のベストプラクティスを実践しています:

  1. マウント状態の管理による不要な更新の防止
  2. クリーンアップ関数による適切なリソース解放
  3. 非同期処理の待機を抽象化したユーティリティ関数の使用

パフォーマンス最適化

テストのパフォーマンスを最適化するためのテクニックを紹介します:

typescriptconst useOptimizedHook = (data: any[]) => {
  // メモ化による不要な再計算の防止
  const processedData = useMemo(() => {
    return data.map((item) => ({
      ...item,
      processed: true,
    }));
  }, [data]);

  // コールバック関数のメモ化
  const handleData = useCallback(() => {
    return processedData.length;
  }, [processedData]);

  return { processedData, handleData };
};

describe('Performance optimization', () => {
  test('メモ化による最適化の検証', () => {
    const initialData = [{ id: 1 }, { id: 2 }];
    const { result, rerender } = renderHook(
      ({ data }) => useOptimizedHook(data),
      { initialProps: { data: initialData } }
    );

    const firstResult = result.current.processedData;

    // 同じデータで再レンダリング
    rerender({ data: initialData });
    // メモ化により同じ参照が維持される
    expect(result.current.processedData).toBe(firstResult);

    // データを変更して再レンダリング
    rerender({ data: [{ id: 3 }] });
    // 新しいデータで再計算される
    expect(result.current.processedData).not.toBe(
      firstResult
    );
  });
});

このテストでは、以下の最適化テクニックを検証しています:

  1. useMemo による計算結果のキャッシュ
  2. useCallback によるコールバック関数の安定化
  3. 依存配列の適切な管理

テストのメンテナンス性向上

テストのメンテナンス性を高めるためのパターンを紹介します:

typescript// テスト用のカスタムレンダラー
const renderHookWithProps = <P, R>(
  hook: (props: P) => R,
  initialProps: P
) => {
  const wrapper = ({
    children,
  }: {
    children: React.ReactNode;
  }) => (
    <TestContext initialProps={initialProps}>
      {children}
    </TestContext>
  );

  return renderHook(hook, { wrapper });
};

// テスト用のモックデータ
const createMockApi = () => ({
  getData: jest.fn(),
  postData: jest.fn(),
  updateData: jest.fn(),
});

describe('Maintainable tests', () => {
  test('再利用可能なテストユーティリティ', () => {
    const mockApi = createMockApi();
    const { result } = renderHookWithProps(useCustomHook, {
      api: mockApi,
    });

    expect(result.current).toBeDefined();
  });
});

このアプローチでは、以下の点でメンテナンス性を向上させています:

  1. カスタムレンダラーによるテストセットアップの共通化
  2. モックデータ生成関数による一貫性の確保
  3. テストユーティリティの再利用

まとめ

React フックのテストでは、以下の点に注意して実装することが重要です:

  1. renderHookact を適切に使用して、フックの動作を正確にテストする
  2. 非同期処理やクリーンアップを確実に行い、テストの信頼性を確保する
  3. メモ化やパフォーマンス最適化の効果を検証する
  4. テストのメンテナンス性を考慮した設計を行う

これらの原則に従うことで、堅牢で信頼性の高いテストを実装することができます。

関連リンク