🌊

React Hooksでテストをゴリゴリ書きたい - react-reduxやaxiosが使われているような場合もゴリゴリテストを書きたい

2020/12/24に公開

2023/12/25 続編が出ました🙆‍♂️

この記事は記述されてからある程度時間が経過してしまっており、自分の考え方も少し変化してきています。
その変化について新しく以下の記事を書いたので、ぜひ参照してみてください。

https://zenn.dev/zozotech/articles/9e9400e2c33e2b

追記

以下の記事は@testing-library/react-hooksのv3系を使っていました。

v5系に上げるとHookResultではなくRenderResultになったようなので、v5を使われる場合はRenderResultの方をお使いください🙏


🦍

テストコードを書くことがプロダクトコードを書くことと、同じくらい重要であるという認識が浸透しつつある昨今、多くの関数にはおそらくテストがあることと思います😊

最近はReactの開発がメインです。
僕は毎回フロントエンドでテストを書く場合は以下のような方針をとっています。

  • コンポーネントのテスト
    • storybook, Visual Regression Test
  • 関数や、hooks
    • jestで単体テスト <= 今回扱うのはこっち

コンポーネントの文言やDOMの構成、スタイルは変わりやすいですし、jest等でやるよりはstorybookやVisual Regression Testを行うほうが都合がいいと考えています。
なのでjestで単体テストを書くとなるとロジック関係の単体テストを書くわけですが、なかでもカスタムフックのテストの書き方がわからんみたいなことはあると思います。
react-reduxuseDispatchまたはuseSelectorが絡んでいる、axiosやらfetchが呼ばれている、、、、useEffect、、、 そもそもカスタムフックはコンポーネントの中でしか呼べないのにどうやってテストするわけ?みたいになって、「ま、後で調べて書くわ」となりがちな気がしています👀
ですが、昨今はカスタムフックの中でややこしい処理を行うことも少なくないと思います。そういう場合、テストを書くことで早期のバグの発見や、動作の担保をしたいはずです。
というわけでやっていきましょう。

準備

今回はcreate-react-appでプロジェクトを作ってみました。
そしてhooksのテストを書くために、@testing-library/react-hooksを入れます。

$ npm install --save-dev @testing-library/react-hooks

あとはこいつを動かすのにreact-test-rendererがいります👀

$ npm install --save-dev react-test-renderer

https://github.com/testing-library/react-hooks-testing-library
というかこいつを見れば大体書いていますね😀
APIリファレンスはここにあります🙆🏻♂️‍
https://react-hooks-testing-library.com/reference/api

実践🚑

基本編

これは上記にも書いてあるやつですね。だいたいこんな感じで使います。

// hooks/useCounter.ts
import { useCallback, useState } from 'react';

export type UseCounterReturnType = {
  count: number;
  increment: () => void;
};

export const useCounter = (): UseCounterReturnType => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((n) => n + 1), []);
  return {
    count,
    increment,
  };
};

簡単なカスタムフックですね🙆🏻♂️‍
というわけでテストです💪
コメントに説明を書きました🙆🏻♂️‍

// hooks/useCounter.spec.ts
import { HookResult, renderHook, act } from '@testing-library/react-hooks';
import { useCounter, UseCounterReturnType } from './useCounter';

describe('useCounter', () => {
  /**
    カスタムフックの結果を受け取る変数です
    カスタムフックの引数が変わる場合、各test()で作ることになりますが、そうでない場合は
    こんな感じでletで変数を定義してbeforeEachで受け取ればいいと思います🙆🏻♂️‍
  */
  let result: HookResult<UseCounterReturnType>;
  beforeEach(() => {
    /**
      カスタムフックを使う場合はrenderHookを使用します
      renderHookの中でhookをよび、返り値のresultを上記の変数に格納します
      返り値はresult.current.countのようにして入っているので、テストではresult.currentを使うことになります
      result以外ではrerenderやwaitForNextUpdateなどが入っているので、
      それらを使う場合はresultではなくrenderHookの返り値自体を格納するか別途受け取る変数を定義してやるといいでしょう
    */
    result = renderHook(() => useCounter()).result;
  });

  test('countの初期値は0になっている', () => {
    expect(result.current.count).toBe(0);
  });

  test('incrementを呼ぶと、countが期待通りの値に変更される', () => {
    expect(result.current.count).toBe(0);
    /**
      useStateの更新関数を呼ぶ場合はactの中で呼びます。
      そうしないとエラーになります
      Warning: An update to TestHook inside a test was not wrapped in act(...).
      When testing, code that causes React state updates should be wrapped into act(...):
     */
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
  });
});

主にカスタムフックのテストはこの流れになります🙆🏻♂️‍
いくつか補足しましょう。

■ renderHook

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

カスタムフックを呼ぶための関数です。当たり前ですがカスタムフックはReact.FCの中以外で呼ぶことができません。そのへんをいい感じにしてくれるのがこの関数になります。
以下のように、引数を渡せるようにして発火させることもできます。
後で紹介するrerender時に、異なる引数を渡したいような場合はこの形式で書くと、rerenderで新しいpropsを渡すことができます。

renderHook((props) => useCustom(props), {
  initialProps: {
    message: 'foo',
  }
});

renderHookが返す新しいオブジェクトで特に使用するものは以下の5つです。

result

renderHook().resultcurrentを持ちます。renderHookで呼んだカスタムフックの返り値の現在の値がここに入っています。
expectでstateの比較をしたり、返り値の関数を呼ぶときは主にここにぶら下がったものを使用することになると思います。

rerender

例えばuseEffectが含まれているとき、再度レンダリングされた時をシミュレートすることができます。引数に新しいpropsを渡すことができます(上で紹介したように、propsを受け取れる状態にしておくことが必要です)。

waitForNextUpdate

Promiseを返す関数です。単純に次の更新を待ちます。つまるところ、useStateの更新関数が呼ばれるから更新されるわけですが、値が更新されているかどうかはわかりません。
値の更新を確実に待つ場合は、次のwaitForを利用するほうが良さそうです。
https://github.com/testing-library/react-hooks-testing-library/issues/445#issuecomment-684851507
rerenderと組み合わせて使ったりすることになると思います。

注意としては例えば以下のような場合はいつまでも解決されず、テストがtimeoutして落ちてしまいます。

rerender(); // なんの更新もされない
await waitForNextUpdate(); // => 何も更新されないのに待ち続けてtimeoutしてしまう

waitFor

Promiseを返します。引数にbooleanを返す関数を渡し、それがtrueになるまで待つということになります。
実際には、ここでresult.current.fooが期待した結果とイコールかどうか、みたいな関数を渡すことになると思います。
永遠にならない場合はtimeoutしてテストが落ちます。

await waitFor(() => result.current.foo = expected);

unmount

わかりやすいですね。unmount時をシミュレートできます。useEffectのクリーンアップのテストをしたい時に使うことになると思います

■ act

https://ja.reactjs.org/docs/testing-recipes.html#act

actを呼ぶ必要があるときというのは、useStateの更新関数が呼ばれるときです。
なので、カスタムフックで返されるメソッドが更新関数を呼ぶのであればact内で行う必要があります。
あるいは、カスタムフックを呼んだ段階でuseEffectが呼ばれるなどで中で更新関数を呼ぶ場合はactの中でrenderHookを呼ぶ必要があります。

だいぶわかりが生じてきましたか?🙆🏻♂️‍

他にもメソッド等は用意されていると思いますので、必要があれば見てみてください🙆🏻♂️‍

axios、fetch等が絡んだhooksのテスト

axiosやらfetchを使ってGET、またはPOSTをすることは多いかと思います。だいたいそういったコードは直接componentに書かずに、カスタムフックに書いて、そのカスタムフックをコンポーネントで使うみたいなことになるかなと思います👀
そういうロジック関係はカスタムフックに押し込めたくなりますよね🙆🏻♂️‍
なんですが、axiosとかfetchとか呼ばれたらどうしたらいいわけ?となりがちです👀

そもそもこういうカスタムフックでのテストするポイントとしては、axiosとかfetchのテストではありません。
僕たちがテストしたいのはカスタムフックの処理です。axios等が成功してある値が返ってきたとき、またはエラーになったときにしっかりとハンドリングできているのかがテスト対象になります。

そういうわけで、axiosやらfetchやらを強気でmockしてしまいます。
以下の例はaxiosの場合です
ちょっと長いですが、まずはhookの具体例から

// hooks/useFetchMessage.ts
import axios from 'axios';
import { useState } from 'react';

export type FetcherResponse = {
  message: string;
};
const fetcher = async (): Promise<FetcherResponse> => {
  const response = await axios.get<FetcherResponse>('path/to/api');
  return response.data;
};

export const FETCH_MESSAGE_RESPONSE_TYPE = {
  success: 'success',
  error: 'error',
} as const;
export type FetchMessageResponseSuccess = FetcherResponse & {
  type: typeof FETCH_MESSAGE_RESPONSE_TYPE.success;
};
export type FetchMessageResponseError = {
  type: typeof FETCH_MESSAGE_RESPONSE_TYPE.error;
};
type FetchMessageResponse =
  | FetchMessageResponseSuccess
  | FetchMessageResponseError;

export type UseFetchMessageReturnType = {
  fetching: boolean;
  fetchMessage: () => Promise<FetchMessageResponse>;
};

export const useFetchMessage = (): UseFetchMessageReturnType => {
  const [fetching, setFetching] = useState(false);

  const fetchMessage = async () => {
    setFetching(true);
    try {
      const response = await fetcher();
      return {
        type: FETCH_MESSAGE_RESPONSE_TYPE.success,
        message: response.message,
      };
    } catch {
      return {
        type: FETCH_MESSAGE_RESPONSE_TYPE.error,
      };
    }
  };
  return {
    fetching,
    fetchMessage,
  };
};

型情報が多いので長いですが、やっていることは簡単ですね
useFetchMessageだけ見てもらえば、非常にシンプルなカスタムフックです。
次に、本題のテストです。

// hooks/useFetchMessage.spec.ts
import { HookResult, renderHook, act } from '@testing-library/react-hooks';
// こいつが肝です
import axios from 'axios';
import {
  useFetchMessage,
  UseFetchMessageReturnType,
  FetcherResponse,
  FetchMessageResponseSuccess,
  FETCH_MESSAGE_RESPONSE_TYPE,
} from './useFetchMessage';

// jest mockの第一引数にモジュールを入れることでモジュールをmockできます
jest.mock('axios');

describe('useFetchMessage', () => {
  let result: HookResult<UseFetchMessageReturnType>;
  beforeEach(() => {
    result = renderHook(() => useFetchMessage()).result;
  });

  test('fetchが成功したとき、期待した値を返却する', async () => {
    const mock: FetcherResponse = {
      message: 'hello world!!!',
    };
    // mockの返り値を返すようにします
    (axios.get as jest.Mock).mockResolvedValue({ data: mock })
    await act(async () => {
      const data = (await result.current.fetchMessage()) as FetchMessageResponseSuccess;
      expect(data.type).toBe(FETCH_MESSAGE_RESPONSE_TYPE.success);
      expect(data.message).toBe(mock.message);
    });
  });

  test('fetchが失敗したとき、期待した値を返却する', async () => {
    /**
      これだと簡単に失敗時のmockもできます🙆🏻♂️‍
      厳密にstatusを見るような場合は以下のようにシミュレーションしてやることもできます
      const response = {
        status: 404,
        data: {
          返り値
        },
      };
     */
   (axios.get as jest.Mock).mockRejectedValue({});
    await act(async () => {
      const data = await result.current.fetchMessage();
      expect(data.type).toBe(FETCH_MESSAGE_RESPONSE_TYPE.error);
    });
  });
});

jest.mock強いですね!
ちなみにjest.mockを使わない書き方もあります。
そういう場合はjest.mockは消して使うときに、getをmock関数に入れ替えます。

// 成功時
(axios as any).get = jest.fn(() => Promise.resolve({ data: mock }));

// 失敗時
(axios as any).get = jest.fn(() =>Promise.reject());

これもこれで手軽です。jest.mockはモジュールしかmockできないので、fetchをmockする場合はこの感じでやるといいと思います🙆🏻♂️‍

(fetch as any) = jest.fn(() =>
  Promise.resolve({
    json() {
      return mock;
    }
  })
);

react-reduxが絡んだhooksのテスト

react-reduxが絡むとProviderの関係もあり、どうやってテストするんだとなります。
こんなときも手軽にテストできる方法があります。さすがjest

まずは簡単なaction等を用意します。
react-redux@reduxjs/toolkitを別途インストールしています。

// store/message/index.ts
import { createSlice, Dispatch, PayloadAction } from '@reduxjs/toolkit';
import { useDispatch } from 'react-redux';

export type Message = {
  message: string;
};

const initialState: Message = {
  message: '',
};

const messageSlice = createSlice({
  name: 'message',
  initialState,
  reducers: {
    setMessage(_, action: PayloadAction<Message>) {
      return action.payload;
    },
  },
});

const { setMessage } = messageSlice.actions;
export type SetMessageAction = ReturnType<typeof setMessage>;
export const useSetMessageDispatch = (): Dispatch<SetMessageAction> =>
  useDispatch<Dispatch<SetMessageAction>>();

export { setMessage };
// store/index.ts
export * from './message';

なんの変哲もないコードですね。useDispachに型をつけてここでexportしています。
別にしてもいいですし、しなくてもいいことですが、この場合は一旦しています。
storeへの実際の接続は、今回の趣旨ではないのでしていません。

そういうわけで、さっきのhooks/useFetchMessage.tsでfetchが成功したときにdispatchを呼んでstoreの値を更新する処理を足したいと思います。
(useFetchMessageなのに、中でstore更新しちゃうのは気持ち悪い気もしますが、一旦そこは飲みこみます)

// hooks/useFetchMessage.ts
import axios from 'axios';
import { useState } from 'react';
import { setMessage, useSetMessageDispatch } from '../store';

// 中略

export const useFetchMessage = (): UseFetchMessageReturnType => {
  const [fetching, setFetching] = useState(false);
  const dispatch = useSetMessageDispatch(); // useDispatchを呼びます
  
  const fetchMessage = async () => {
    setFetching(true);
    try {
      const response = await fetcher();
      const { message } = response;
      
      // ここで成功したらdispatchを呼んでいます
      dispatch(setMessage({
        message,
      }));
      return {
        type: FETCH_MESSAGE_RESPONSE_TYPE.success,
        message,
      };
    } catch {
      return {
        type: FETCH_MESSAGE_RESPONSE_TYPE.error,
      };
    }
  };
  return {
    fetching,
    fetchMessage,
  };
};

はい。
なんかこの状態でもなんか行けそうな気も若干しますが、上記のtestを回すと以下のように怒られます。

could not find react-redux context value; please ensure the component is wrapped in a <Provider>

ですが、いちいち<Provider>で囲うのはだるいよね、という話です🤔

このとき考えるべきことは、この関数でテストしたいことは何かということです👀
useDispatchの挙動については、コミュニティがしっかりとしたテストをしているはずですし、reducersetMessageが渡ったときの挙動はstoreの方でするべきテストです。
今回の変更によってテストに追加したいことは、setMessage関数が期待通りの引数で発火されているか、ということになると思います。

というわけで、やります。
上記でやったように'../store'をmockしてしまいます🙆🏻♂️‍

// hooks/useFetchMessage.spec.ts
// 略 import

jest.mock('axios');

/**
  jest.mockの第一引数にmockしたいモジュールを記述します
  第二引数に、このモジュールが何を返すかを記述します
  今回の場合、useFetchMessageではsetMessageとuseSetMessageDispatchが使用されており、
  これらが問題でtestが動いていないので、両方ともmockしてしまいます
  なお、mock用関数は`mock`を接頭辞にしないと怒られます👀
 */
const mockSetMessage = jest.fn();
const mockSetMessageDispatch = jest.fn();
jest.mock('../store', () => ({
  setMessage: (...args: any[]) => mockSetMessage(...args),
  useSetMessageDispatch: () => (...args: any[]) => mockSetMessageDispatch(...args),
}))

describe('useFetchMessage', () => {
   let result: HookResult<UseFetchMessageReturnType>;
   
  beforeEach(() => {
    result = renderHook(() => useFetchMessage()).result;
    
    // 毎回mockはresetします
    mockSetMessage.mockReset();
    mockSetMessageDispatch.mockReset();
  });
  // 以下、追記
  test('fetchが成功したとき、期待した引数でsetMessageが呼ばれる', async () => {
    const mock: FetcherResponse = {
      message: 'hello world!!!',
    };
    (axios.get as jest.Mock).mockResolvedValue({ data: mock });
    await act(async () => {
      await result.current.fetchMessage();
      
      // ここでmockした関数がどのように呼ばれているかを確認します
      expect(mockSetMessage.mock.calls[0][0]).toStrictEqual(
        {
          message: mock.message
        }
      );
    });
  });

  test('fetchが失敗したとき、setMessageは呼ばれない', async () => {
    (axios.get as jest.Mock).mockRejectedValue({});
    await act(async () => {
      await result.current.fetchMessage();
      expect(mockSetMessage.mock.calls.length).toBe(0);
    });
  });
});

いいですね🙆🏻♂️‍
バッチリ動きました👀

useDispatchをhook内で直接読んでいる場合、例えば

jest.mock('react-redux', () => {
    useDispatch: () => () => mockUseDispatch()
});

のようにreact-redux自体をmockすることでも対応することできます🙆🏻♂️‍

useEffectを含んだhooksのテスト

コンポーネントが最初にレンダリングされたタイミングだけ情報を取得して格納したい、みたいなことはままありそうな気がします。
あるいは何らかの要件で、そのページが表示されたときにscriptなりを読み込んで、ページが遷移するときには先程挿入したscriptを消したい、みたいなことがあるかもしれません。

そういうときにはuseEffectの使用が検討されることでしょう。というわけでこのようなカスタムフックを用意しました。

// hooks/useFetchIfNeeded.ts
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';

export type FetchedData = {
  name: string;
};

export type UseFetchIfNeededPayload = {
  id: string;
};

export const useFetchIfNeeded = (
  payload: UseFetchIfNeededPayload
): FetchedData | null => {
  const [data, setData] = useState<FetchedData | null>(null);
  const fetch = useCallback(async () => {
    const fetchedData = await axios.get<FetchedData>('path/to/get', {
      params: payload,
    });
    setData(fetchedData.data);
  }, [payload]);

  useEffect(() => {
    fetch();
    return () => {
      setData(null);
    };
  }, [fetch]);

  return {
    data,
  };
};

さて、テストです。
useEffectのテストでこの様に非同期が絡む場合は、どっぷり@testing-library/react-hooksのお世話になることになります。

// hooks/useFetchIfNeeded.spec.ts
import { renderHook, act } from '@testing-library/react-hooks';
import axios from 'axios';
import {
  FetchedData,
  useFetchIfNeeded,
  UseFetchIfNeededPayload,
} from './useFetchIfNeeded';

describe('useFetchIfNeeded', () => {
  const mockPayload: UseFetchIfNeededPayload = {
    id: 'testId',
  };
  const mockFetchedData: FetchedData = {
    name: 'test boy',
  };
  let mockGet: jest.Mock;
  beforeEach(() => {
    mockGet = jest.fn(() =>
      Promise.resolve({
        data: mockFetchedData,
      })
    );
    (axios as any).get = mockGet;
  });

  test('payloadに変更がない場合、rerenderが行われても再度fetchされない', async () => {
    /**
     【ポイント⚡️】
      上述していますが、customHookので呼ばれるuseEffectの中でuseStateの更新関数を使っている場合、
      act関数の中でrenderHookしてやる必要があります
     */
    await act(async () => {
      // rerenderを呼びたいので、propsを渡す形式でやります
      const { waitForNextUpdate, rerender } = renderHook(
        (payload: UseFetchIfNeededPayload) => useFetchIfNeeded(payload),
        {
          initialProps: mockPayload,
        }
      );
      // 更新を待ちます
      await waitForNextUpdate();
      // このようにしてrerenderを起こすことができます
      // 今回のカスタムフックでは同じ参照のオブジェクト(参照という言葉は怖い)を渡しているので
      // 再度useEffectは呼ばれないはずです。
      rerender(mockPayload);
      await waitForNextUpdate();
    });
    expect(mockGet.mock.calls.length).toBe(1);
  });

  test('payloadに変更がある場合、rerenderが行われたときに再度fetchする', async () => {
    await act(async () => {
      const { waitForNextUpdate, rerender } = renderHook(
        (payload: UseFetchIfNeededPayload) => useFetchIfNeeded(payload),
        {
          initialProps: mockPayload,
        }
      );
      await waitForNextUpdate();
      // 新しいオブジェクトを渡した場合をシミュレートします
      rerender({ id: 'testId 2' });
      await waitForNextUpdate();
      rerender({ id: 'testId 3' });
      await waitForNextUpdate();
    });
    expect(mockGet.mock.calls.length).toBe(3);
  });

  test('unmount時にはクリーンアップ関数が呼ばれる', async () => {
    await act(async () => {
      const { result, waitFor, unmount } = renderHook(
        (payload: UseFetchIfNeededPayload) => useFetchIfNeeded(payload),
        {
          initialProps: mockPayload,
        }
      );
      await waitFor(() => result.current.data?.name === mockFetchedData.name);
      expect(result.current.data?.name).toBe(mockFetchedData.name);
      unmount();
      // このへんで何かしらのテストをする
      /**
        unmountされると、waitForNextUpload等で更新を待てなくなってしまうので
        stateのテストはできないっぽいですね、例として微妙でした😭
       */
    });
  });
});

クリーンアップ時の処理が思いつきませんでした😭

冷静に考えると、カスタムフックのテスト半分、jestの使い方半分という感じでしたね😊

多分、これで大体のカスタムフック(というか関数も)がテストできるはずなのでバリバリテストを書いていきましょう🙆🏻♂️‍

Discussion