Vitest + React + TypeScriptでテストを書こう(カスタムフック編)
こんにちは。
今回は、React + TypeScript + Vitest でカスタムフックをテストする方法をさまざまな例を交えて紹介していこうと思います。
vitest でのモックの使い方などは以前に記事にしているので、こちらも参考にしてみてください。
次回は React コンポーネントのテストの書き方についてお話しする予定です。
カスタムフックのテスト
React を使用していると、カスタムフックを作成することがあると思いますが、
状態管理の不具合や非同期処理のエラーは、実際にアプリを動かさないと気づきにくいので、カスタムフックのテストを書くことはバグを防ぐためにも大切です。
そこで、本記事では Vitest を用いて、カスタムフックのテストをどのように書くべきかを解説します。
カスタムフックのテストでは、@testing-library/react というライブラリを使用して、擬似的に関数コンポーネントの中でカスタムフックを実行したかのようにテストを行うことができます。
基本的な書き方
import { useState } from 'react';
export const useCounter = (initialValue: number = 0) => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((prev) => prev + 1);
const decrement = () => setCount((prev) => prev - 1);
const reset = () => setCount(initialValue);
return {
count,
increment,
decrement,
reset,
};
}
まずは、カウンターのカスタムフックのテストコードを例に、基本的な書き方を紹介します。
useCounter
というカスタムフックを作成し、count
という状態と、increment
、decrement
,reset
メソッドを返すようにしています。
import { act, renderHook } from '@testing-library/react';
import { describe, expect } from 'vitest';
import { useCounter } from './sample';
describe('useCounter', () => {
test('初期値が正しく設定されること', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increment でカウントが増加すること', () => {
const { result } = renderHook(() => useCounter(0));
// act()内で状態の更新を行うことで、Reactの状態の更新を待つことができる
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrement でカウントが減少すること', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('reset で初期値に戻ること', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
初期値が正しく設定されることを確認
まずは、初期値が正しく設定されることを確認します。
renderHook とは、カスタムフックをレンダリングするための関数です。
初期値を設定したカスタムフックを renderHook を使用してレンダリングし、期待通りの初期値が設定されているかを確認します。
result の current プロパティには、カスタムフックの戻り値が格納されていて、今回は count プロパティが初期値 10 であることを確認しています。
test("初期値が正しく設定されること", () => {
const { result } = renderHook(() => useCounter(10));
console.log(result);
expect(result.current.count).toBe(10);
});
result の中身
result の中身を確認してみると、以下のようになっています。
current プロパティには、カスタムフックの戻り値が格納されています。
上記の例では、result.current.count
で count
の値を取得しています。
{
current: {
count: 10,
decrement: [Function: decrement],
increment: [Function: increment],
reset: [Function: reset]
}
increment と decrement でカウントが増減することを確認
increment
と decrement
メソッドを呼び出して、カウントが増減することを確認します。
コード内で使用している act とは、React の状態や副作用が安定するのを待ってからテストを行うためのユーティリティです。
これによって、テスト中に発生する可能性のある非同期状態の変更が完了するのを待ってから検証を行うことが可能となります。
test("increment でカウントが増加すること", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test("decrement でカウントが減少すること", () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
様々なパターンのカスタムフックのテストの例
ここからはより実践的な例を交えて、いくつかのカスタムフックのテストパターンをみていきたいと思います。
localStorage を使ったカスタムフックのテスト
以下は localStorage に値を保存するカスタムフックの例です。
import { useState } from 'react';
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// localStorage から値を取得する
const item = localStorage.getItem(key);
// localStorage に値が存在する場合は JSON パースして返す
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
// localStorage に値を保存する
const setValue = (value: T) => {
setStoredValue(value);
localStorage.setItem(key, JSON.stringify(value));
};
return { setValue, storedValue };
};
各テストの影響を受けないように afterEach で localStorage をクリアしています。
import { act, renderHook } from '@testing-library/react';
import { vi } from 'vitest';
import { useLocalStorage } from './sample';
describe('useLocalStorage', () => {
afterEach(() => {
localStorage.clear();
});
test('値が localStorage に保存されること', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
act(() => result.current.setValue(10));
expect(localStorage.getItem('count')).toBe('10');
});
test('localStorage の読み取り時にエラーが発生した場合の処理', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('localStorage error');
});
const { result } = renderHook(() => useLocalStorage('count', 2));
expect(result.current.storedValue).toBe(2);
});
});
値が localStorage に保存されることを確認
setValue
メソッドを呼び出して、localStorage に値が保存されることを確認します。
localStorage に保存された値は、localStorage.getItem('count')
で取得できます。
test("値が localStorage に保存されること", () => {
const { result } = renderHook(() => useLocalStorage("count", 0));
act(() => result.current.setValue(10));
expect(localStorage.getItem("count")).toBe("10");
});
localStorage の読み取り時にエラーが発生した場合の処理
vi.spyOn
を使用して、Storage.prototype.getItem
メソッドをモック化し、エラーが発生するようにしています。
test("localStorage の読み取り時にエラーが発生した場合の処理", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("localStorage error");
});
const { result } = renderHook(() => useLocalStorage("count", 2));
// エラーが発生した場合は initialValue が返ることを確認
expect(result.current.storedValue).toBe(2);
});
Fetch API を使用するカスタムフック
以下は Fetch API を使用してデータを取得するカスタムフックの例です。
import { useEffect, useState } from 'react';
export const useFetch = (url: string) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!url) return;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, error, loading };
};
テストコードでは、fetch メソッドをモック化して、HTTP エラー、ネットワークエラー、正常なデータ取得の場合をそれぞれテストしています。
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, vi } from 'vitest';
import { useFetch } from './sample';
const mockFetchResponse = { data: 'mocked data' };
describe('useFetch', () => {
test('データが正常に取得されること', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
json: vi.fn().mockResolvedValue(mockFetchResponse),
ok: true,
status: 200,
} as unknown as Response);
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(mockFetchResponse);
expect(result.current.error).toBe(null);
});
test('HTTP エラー時にエラー状態を返すこと', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({ message: 'Server error' }),
ok: false,
status: 500,
} as unknown as Response);
const { result } = renderHook(() => useFetch('/api/error'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toBe(null);
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toContain('HTTP error! Status: 500');
});
test('ネットワークエラー時にエラー状態を返すこと', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(
new TypeError('Network error')
);
const { result } = renderHook(() => useFetch('/api/network-error'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toBe(null);
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe('Network error');
});
test('最初は loading が true であること', () => {
const { result } = renderHook(() => useFetch('/api/data'));
expect(result.current.loading).toBe(true);
});
test('データ取得後に loading が false になること', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
json: vi.fn().mockResolvedValue(mockFetchResponse),
ok: true,
status: 200,
} as unknown as Response);
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => expect(result.current.loading).toBe(false));
});
});
データが正常に取得されることを確認
spyOn を使用して、fetch メソッドをモック化し、mockResolvedValue でレスポンスで、{ data: 'mocked data' }
が返るようにしています。
test("データが正常に取得されること", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
json: vi.fn().mockResolvedValue(mockFetchResponse),
ok: true,
status: 200,
} as unknown as Response);
const { result } = renderHook(() => useFetch("/api/data"));
// ローディングが終了するまで待機
await waitFor(() => expect(result.current.loading).toBe(false));
// データが正常に取得されていることを確認
expect(result.current.data).toEqual(mockFetchResponse);
expect(result.current.error).toBe(null);
});
HTTP エラー時にエラー状態を返すことを確認
mockResolvedValueOnce で、HTTP ステータスコードが 500 の場合にエラーが発生するようにしています。
test("HTTP エラー時にエラー状態を返すこと", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({ message: "Server error" }),
ok: false,
status: 500,
} as unknown as Response);
const { result } = renderHook(() => useFetch("/api/error"));
// ローディングが終了するまで待機
await waitFor(() => expect(result.current.loading).toBe(false));
// エラー状態が返されることを確認
expect(result.current.data).toBe(null);
// エラーが発生していることを確認
expect(result.current.error).toBeInstanceOf(Error);
// エラーメッセージが正しいことを確認
expect(result.current.error?.message).toContain("HTTP error! Status: 500");
});
ネットワークエラー時にエラー状態を返すことを確認
mockRejectedValueOnce で、ネットワークエラーが発生するようにしています。
new TypeError('Network error') で、エラーメッセージを設定しています。
test("ネットワークエラー時にエラー状態を返すこと", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(
new TypeError("Network error")
);
const { result } = renderHook(() => useFetch("/api/network-error"));
// ローディングが終了するまで待機
await waitFor(() => expect(result.current.loading).toBe(false));
// エラー状態が返されることを確認
expect(result.current.data).toBe(null);
// エラーが発生していることを確認
expect(result.current.error).toBeInstanceOf(Error);
// エラーメッセージが正しいことを確認
expect(result.current.error?.message).toBe("Network error");
});
loading の状態を確認
最初は loading が true であることを確認します。
データが取得された後に loading が false になることを確認しています。
test("最初は loading が true であること", () => {
const { result } = renderHook(() => useFetch("/api/data"));
expect(result.current.loading).toBe(true);
});
test("データ取得後に loading が false になること", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
json: vi.fn().mockResolvedValue(mockFetchResponse),
ok: true,
status: 200,
} as unknown as Response);
const { result } = renderHook(() => useFetch("/api/data"));
await waitFor(() => expect(result.current.loading).toBe(false));
});
setInterval を使ったカスタムフック
指定した時間ごとにコールバックを実行する処理をテストしてみます。
export const useInterval = (callback: () => void, delay: number | null) => {
useEffect(() => {
if (delay === null) return;
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
};
まず、vi.useFakeTimers()
で、タイマーをモック化します。
vi.advanceTimersByTime(3000)
で、タイマーの時間を 3 秒進めます。
toHaveBeenCalledTimes(3)
で、コールバックが 3 回実行されたことを検証しています。
test("useInterval が正しくコールバックを実行すること", () => {
vi.useFakeTimers();
const callback = vi.fn();
renderHook(() => useInterval(callback, 1000));
// タイマーの時間を3秒進める
vi.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
まとめ
今回は React のカスタムフックを Vitest でテストする方法を紹介しました。
特に、状態管理、副作用、非同期処理などをテストする際のポイントを解説しました。
次回は React コンポーネントのテストについて紹介する予定です。
カスタムフックだけでなく、React コンポーネント全体をテストすることで、より安定したアプリケーションを構築することができます。
Discussion