概要
@testing-library/react の renderHook と act を使い、カスタムフックの動作を単体テストする。同期フックのステート更新テストと、非同期フック(waitFor を使った API フェッチ)のテストパターンを示す。
インストール
npm install jest @types/jest ts-jest
npm install --save-dev @testing-library/react @testing-library/jest-dom jest-environment-jsdom
実装
jest.config.ts
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "jest-environment-jsdom",
setupFilesAfterFramework: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};
export default config;
jest.setup.ts
// jest.setup.ts
import "@testing-library/jest-dom";
テスト対象: useCounter フック
// hooks/useCounter.ts
import { useState } from "react";
export function useCounter(initialValue = 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 };
}
同期フックのテスト
// hooks/useCounter.test.ts
import { act, renderHook } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
it("初期値を返す", () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it("increment で count が増える", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrement で count が減る", () => {
const { result } = renderHook(() => useCounter(3));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(2);
});
it("reset で初期値に戻る", () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
非同期フックのテスト
// hooks/useFetch.ts
import { useEffect, useState } from "react";
export 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(() => {
fetch(url)
.then((res) => {
if (!res.ok) throw new Error("fetch failed");
return res.json() as Promise<T>;
})
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// hooks/useFetch.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { useFetch } from "./useFetch";
global.fetch = jest.fn();
describe("useFetch", () => {
it("データを取得して返す", async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, title: "テスト" }),
});
const { result } = renderHook(() => useFetch("/api/item"));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ id: 1, title: "テスト" });
expect(result.current.error).toBeNull();
});
it("エラー時に error をセットする", async () => {
(fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
const { result } = renderHook(() => useFetch("/api/item"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.data).toBeNull();
});
});
ポイント
renderHookでカスタムフックをコンポーネントなしにテストできる。result.currentから戻り値を参照する- ステートを更新する操作は
actでラップする。これにより React の更新処理が同期的に完了してから assertion できる - 非同期フックは
waitForを使い、条件が満たされるまで再チェックする fetchをモックする場合はglobal.fetch = jest.fn()で差し替え、mockResolvedValueOnceでレスポンスを定義するjest.config.tsでtestEnvironment: 'jest-environment-jsdom'を指定することでブラウザ API(DOM)が使える