はじめに
カスタムフックをテストしたい!、、、となった時のライブラリの導入手順や簡単な例をまとめました。
対象読者
Reactのカスタムフックをどのようにテストするか、導入部を知りたい人
カスタムフックのテストを書く準備
以下のライブラリのインストールがまだの方は以下をインストールしてください。
npm install --save-dev @testing-library/react-hooks
npm install --save-dev react-test-renderer
ライブラリの公式ドキュメントはこちら
ライブラリの主要なAPI
ドキュメントはすぐに目を通せる分量ですが、hooksのテストを導入して、よく利用するであろうものは以下になるかなと思います。
renderHook
hooksは、関数コンポーネントの中でしか呼ぶことができないのですが、この関数を使用するとテストファイルの中でも呼ぶことができるようになります。
result
これは、result.current.**
でカスタムフックの現在の戻り値を取得することが可能です。
expect
でstateの検証をしたり、カスタムフックの関数を呼ぶときに使用します。
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0); // 初期値を検証
何らかのエラーが投げられた場合、result.error
が返ってきます。
test("countがMAX_COUNTを超えた場合、例外がスローされること", () => {
const { result } = renderHook(useCounter);
expect(result.error).toBeUndefined();
act(() => result.current.incrementByAmountValue(20));
expect(result.error.message).toBe("10までしか加算できません");
});
rerender
useEffect
等、コンポーネントが再レンダーされることをシュミレートしたい時に使われるケースが多いかなと思います。
以下のように引数に新たなpropsを渡すことが可能です。
const { result, rerender } = renderHook(
({ initialCount }) => useCounter(initialCount),
{
initialProps: { initialCount: 0 },
}
);
rerender({ initialCount: 11 })
waitForNextUpdate
Promiseを返す関数で、stateの更新を待ちます。
設定されたtimeout値まで待機することができます。(デフォルトは1000ms)
例えば、以下のように非同期の関数を読んだ時に、2000ms待機した後に、値を評価することができます。
act(() => result.current.asyncIncrement());
await waitForNextUpdate({ timeout: 2000 });
expect(result.current.count).toBe(1);
waitFor
この関数は引数に真偽値を返す関数を渡して、それがtrue
になるまで、待ちます。
何らかのアクションを起こし、値が期待した結果かどうかを判定するなどのケースで使えそうです。
interval
とtimeout
がそれぞれ設定でき、デフォルトはそれぞれ50ms(interval)
1000ms(timeout)
です。
他にも、useEffectのクリーンアップ関数の実行をテストしたい時に使用するunmount
等が用意されています。
act
stateの更新関数を呼ぶ際は、act
を利用します。
act(() => result.current.increment());
実際に書いてみる
よくあるカウンターコンポーネントでhooksをどのようにテストするか確認していきましょう。
import React from "react";
import { useCounter } from "./hooks/useCustomCounter";
export const CustomCounter: React.FC = () => {
const {
count,
amountVal,
increment,
asyncIncrement,
incrementByAmountValue,
onChangeAmountValue,
} = useCounter();
return (
<>
<h2>CustomHookTestSample</h2>
<p>{count}</p>
<button onClick={increment}>+1</button>
<button onClick={asyncIncrement}>+1(非同期)</button>
<div>
<label htmlFor="amountValue">AmountValue</label>
<input onChange={onChangeAmountValue} id="amountValue" type="text" />
<button onClick={() => incrementByAmountValue(amountVal)}>
incrementByAmountValue
</button>
</div>
</>
);
};
import { useCallback, useState } from "react";
export type UseCustomCounterReturnType = {
count: number;
amountVal: number;
increment: () => void;
asyncIncrement: () => void;
incrementByAmountValue: (num: number) => void;
onChangeAmountValue: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const MAX_COUNT = 10;
export const useCounter = (): UseCustomCounterReturnType => {
const [count, setCount] = useState(0);
const [amountVal, setAmountVal] = useState<number>(0);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec));
const asyncIncrement = useCallback(async () => {
await sleep(1000);
setCount(count + 1);
}, [count]);
const incrementByAmountValue = (amountVal: number) => {
setCount((prevCount) => prevCount + amountVal);
};
const onChangeAmountValue = (e: React.ChangeEvent<HTMLInputElement>) => {
setAmountVal(Number(e.target.value));
};
if (count > MAX_COUNT) {
throw new Error("10までしか加算できません");
}
return {
count,
amountVal,
increment,
asyncIncrement,
incrementByAmountValue,
onChangeAmountValue,
};
};
import { act, renderHook } from "@testing-library/react-hooks";
import { useCounter } from "pages/TestSample/components/CustomCounter/hooks/useCustomCounter";
describe("useCustomCounter", () => {
test("incrementボタン押下時、1加算されること", () => {
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0); // 初期値を検証
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
test("incrementByAmountボタン押下時に入力値が加算されること", () => {
const { result } = renderHook(useCounter);
act(() => result.current.incrementByAmountValue(10));
expect(result.current.count).toBe(10);
});
test("countがMAX_COUNTを超えた場合、例外がスローされること", () => {
const { result } = renderHook(useCounter);
expect(result.error).toBeUndefined();
act(() => result.current.incrementByAmountValue(20));
expect(result.error.message).toBe("10までしか加算できません");
});
test("非同期のincrementボタン押下時、1加算されること", async () => {
const { result, waitForNextUpdate } = renderHook(useCounter);
expect(result.current.count).toBe(0); // 初期値を検証
result.current.asyncIncrement();
await waitForNextUpdate({ timeout: 3000 });
expect(result.current.count).toBe(1);
});
});
describe("useCustomCounter FetchUser", () => {
test("incrementボタン押下時、1加算されること", () => {
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0); // 初期値を検証
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});
以上。
Reduxやfetch関数が絡むとmockが必要になりますが、そこら辺も整理してまとめたい。