これから始めるReact Testing - ②カスタムフックのテスト

先日、同僚と京都にワーケーションに行きまして、京都の良さを改めて感じている大角です。

goodpatch-tech.hatenablog.com

前回はReact Componentのテストについて書きましたが、今回はカスタムフックのテストについて書きたいと思います。

npmパッケージの各種バージョンなどは前回の記事に記載しています。 また、カスタムフック自体の説明はReactの公式ページをご参照ください。

React Testing LibraryとReact Hooks Testing Library

前回のReact Componentのテストでは、React Testing Libraryを利用しましたが、カスタムフックを直接的にテストしようとすると以下のようなエラーが発生します。

import { useSomeHook } from "./hooks";

describe("useSomeHook", () => {
  test("some test", () => {
    const value = useSomeHook(); // Warning: Invariant Violation: Hooks can only be called inside the body of a function component.
    expect(value).toBe("initial value");
  })
});

カスタムフックは関数コンポーネント内で使われることを想定しているため、テストコードから直接呼び出すとエラーが発生してしまいます。このような問題を避けるために別のパッケージになっている React Hooks Testing Libraryを使うことが多いのではないかと思います。

github.com

しかし、React18(React Testing Libraryで言うとv13.1)からReact Testing LibraryにReact Hooks Testing Libraryと同等のカスタムフック用関数が追加されたため、React18以降はReact Testing Libraryのみでカスタムフックのテストができるようになりました。

github.com

基本的にはどちらのライブラリも同じ振る舞いかと思いますが、この記事ではReact Testing Libraryを使ったカスタムフックのテストについてご紹介します。

カスタムフックのテストに使う関数

renderHook

renderHook関数を利用すると、カスタムフックをテストコード内で直接実行することができます。

const { result } = renderHook(() => useSomeHook());
expect(result.current.value).toBe("initial value");

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

act

カスタムフック内でuseStateを使っており、stateを更新するような処理を行いたい場合は、act関数を利用します。

const { result } = renderHook(() => useSomeHook());
act(() => {
  result.current.updateState();
});
expect(result.current.value).toBe("updated value");

https://testing-library.com/docs/react-testing-library/api/#act

rerender

renderHookの戻り値として、rerender関数を受け取ることができます。 rerender関数はカスタムフックに新しいpropを渡して、再度レンダリングする場合に利用します。なお、renderHookの第二引数のinitialPropsで初期値となるpropを渡すこともできます。

const { result, rerender } = renderHook((value: string) => useSomeHook(value), {
  initialProps: "first value"
});
expect(result.current.value).toBe("first value");
rerender("second value");
expect(result.current.value).toBe("second value");

https://testing-library.com/docs/react-testing-library/api/#rerender-1

サンプルコード

最後にこれまでご紹介した関数などを利用して、四則演算を行うためのカスタムフックをテストしてみたいと思います。

以下は四則演算用の関数(add, sub, multi, div)と計算結果(currentValue)を返却するカスタムフックです。

// hooks/calculator/index.ts
import { useEffect, useState } from "react";

type Calculation = (newValue: number) => void;

type UseCalculator = {
  (value: number): {
    currentValue: number;
    add: Calculation;
    sub: Calculation;
    multi: Calculation;
    div: Calculation;
  };
};

export const useCalculator: UseCalculator = (value) => {
  const [currentValue, setCurrentValue] = useState<number>(value);

  const add: Calculation = (newValue) =>
    setCurrentValue(currentValue + newValue);
  const sub: Calculation = (newValue) =>
    setCurrentValue(currentValue - newValue);
  const multi: Calculation = (newValue) =>
    setCurrentValue(currentValue * newValue);
  const div: Calculation = (newValue) =>
    setCurrentValue(currentValue / newValue);

  useEffect(() => {
    setCurrentValue(value);
  }, [value]);

  return {
    currentValue,
    add,
    sub,
    multi,
    div,
  };
};

このカスタムフックに対して、以下のようなテストケースを書いてみます。

  • propを利用して初期値が設定されていること
  • 足し算ができること
  • 引き算ができること
  • 掛け算ができること
  • 割り算ができること
// hooks/calculator/index.test.ts
import { renderHook, act } from "@testing-library/react";
import { useCalculator } from "./index";

describe("useCalculator", () => {
  test("set default value", () => {
    const { result, rerender } = renderHook(
      (value: number) => useCalculator(value),
      {
        initialProps: 0,
      }
    );
    expect(result.current.currentValue).toBe(0);

    rerender(10);
    expect(result.current.currentValue).toBe(10);
  });

  test("add value", async () => {
    const { result } = renderHook(() => useCalculator(0));
    expect(result.current.currentValue).toBe(0);

    act(() => result.current.add(3));

    expect(result.current.currentValue).toBe(3);
  });

  test("subtract value", () => {
    const { result } = renderHook(() => useCalculator(0));
    expect(result.current.currentValue).toBe(0);

    act(() => result.current.sub(3));

    expect(result.current.currentValue).toBe(-3);
  });

  test("multiply value", () => {
    const { result } = renderHook(() => useCalculator(5));
    expect(result.current.currentValue).toBe(5);

    act(() => result.current.multi(3));

    expect(result.current.currentValue).toBe(15);
  });

  test("divide value", () => {
    const { result } = renderHook(() => useCalculator(10));
    expect(result.current.currentValue).toBe(10);

    act(() => result.current.div(2));

    expect(result.current.currentValue).toBe(5);
  });
});

おわりに

いかがでしたでしょうか。カスタムフックを直接実行してテストしたい場合は上記のような方法で行うことができますが、公式のrenderHookの説明を見るとrenderHookよりも、renderを使ったコンポーネントテストの方が読みやすく、堅牢なのでオススメとも書かれています。

This is a convenience wrapper around render  with a custom test component. The API emerged from a popular testing pattern and is mostly interesting for libraries publishing hooks. You should prefer render  since a custom test component results in more readable and robust tests since the thing you want to test is not hidden behind an abstraction.

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

僕自身は、基本的にはコンポーネントのテストを行うようにして、特別なカスタムフックのみテストを行うようにしています。

次回はReact Testingの連載最後として、状態管理ライブラリであるRecoilを使ったテストについて書きたいと思います!