[React]カスタムフックのテストを書く準備と基本的な書き方

## はじめに

カスタムフックをテストしたい！、、、となった時のライブラリの導入手順や簡単な例をまとめました。

#### 対象読者
Reactのカスタムフックをどのようにテストするか、導入部を知りたい人

## カスタムフックのテストを書く準備

以下のライブラリのインストールがまだの方は以下をインストールしてください。

https://github.com/testing-library/react-hooks-testing-library

https://www.npmjs.com/package/react-test-renderer

```terminal
npm install --save-dev @testing-library/react-hooks
npm install --save-dev react-test-renderer
```

ライブラリの公式ドキュメントはこちら

https://react-hooks-testing-library.com/


## ライブラリの主要なAPI

https://react-hooks-testing-library.com/reference/api#renderhook

ドキュメントはすぐに目を通せる分量ですが、hooksのテストを導入して、よく利用するであろうものは以下になるかなと思います。


## renderHook
[hooksは、関数コンポーネントの中でしか呼ぶことができない](https://ja.reactjs.org/docs/hooks-rules.html)のですが、この関数を使用するとテストファイルの中でも呼ぶことができるようになります。



#### result
これは、`result.current.**`でカスタムフックの現在の戻り値を取得することが可能です。
`expect`でstateの検証をしたり、カスタムフックの関数を呼ぶときに使用します。

```js
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0); // 初期値を検証
```

何らかのエラーが投げられた場合、`result.error`が返ってきます。

```js:11以上加算されるとエラーが投げられる場合
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を渡すことが可能です。

```js
const { result, rerender } = renderHook(
  ({ initialCount }) => useCounter(initialCount),
  {
    initialProps: { initialCount: 0 },
  }
);

rerender({ initialCount: 11 })
```

#### waitForNextUpdate
Promiseを返す関数で、stateの更新を待ちます。
設定されたtimeout値まで待機することができます。(デフォルトは1000ms)
例えば、以下のように非同期の関数を読んだ時に、2000ms待機した後に、値を評価することができます。

```js
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`を利用します。

```js
act(() => result.current.increment());
```

## 実際に書いてみる

よくあるカウンターコンポーネントでhooksをどのようにテストするか確認していきましょう。

```jsx:CustomCounter
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>
    </>
  );
};
```

```ts:useCustomCounter.ts
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,
  };
};

```

```js:CustomCounter.test.js
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が必要になりますが、そこら辺も整理してまとめたい。