react-queryを使ったcustom hookをTestする

# はじめに
先日、react-queryを使ったカスタムフックを作ることになりました。カスタムフック本体については無事に動作したのですが、さあjestでテスト書くぞとなったところで詰まりました。

公式のドキュメントやらissueを巡り、なんとか正常に動作するようにできたので、その記録です。この流れ前もやったな。

# カスタムフックのテスト
今回のポイントは以下の通りです。

* テスト用のQueryClientProviderとQueryClientを用意したwrapperコンポーネントを利用する
* react-queryによる操作を行う場合はwaitForを使って操作が終わるまでact関数内で待機する

## テスト対象
react-queryを用いてlocalStorageからデータ読み書きをするカスタムフックです。
カスタムフックで実現することは、localStorageの特定のキーに以下の構造をしたjsonを文字列化したデータを入れ、その値に対して取得・更新・削除の動作を行います。

```:localStorage
{
  num:number,
  str:string
}
```

localStorageから値を取得する際、zodを使ってバリデーションをしていたりしますが、本筋からは離れるので詳しい説明は割愛します。
また、今回はあくまでlocalStorageを対象としていますが、jestでモックする部分をlocalStorageからfetchなどに変えれば、動くかとは思います。

```typescript:localStorage.ts
import { z } from "zod";

export const ObjItem = z.object({
  num: z.number(),
  str: z.string(),
});
export type ObjItem = z.infer<typeof ObjItem>;

export const objKey = "objStorageItem";

export const getObjStorage = (): Promise<ObjItem | undefined> =>
  new Promise((resolve) => {
    const item = localStorage.getItem(objKey);
    try {
      if (item) {
        const res = ObjItem.parse(JSON.parse(item));
        resolve(res);
      } else {
        resolve(undefined);
      }
    } catch {
      resolve(undefined);
    }
  });

export const setObjStorage = (newObjItem: ObjItem): Promise<void> =>
  new Promise((resolve) => {
    localStorage.setItem(objKey, JSON.stringify(newObjItem));
    resolve();
  });

export const removeObjStorage = (): Promise<void> =>
  new Promise((resolve) => {
    localStorage.removeItem(objKey);
    resolve();
  });

```

こちらがテスト対象のreact-queryを用いたカスタムフックです。

```typescript:useStorage.ts
import {
  useQueryClient,
  useQuery,
  useMutation,
  QueryStatus,
} from "react-query";
import {
  ObjItem,
  getObjStorage,
  setObjStorage,
  removeObjStorage,
} from "./localStorage";

export const objQueryKey = "ObjlocalStorageQueryKey";

export type useObjStorageType = {
  getObjItem: ObjItem | undefined;
  isLoadingGetObj: boolean;
  isFetching: boolean;
  statusGetObj: QueryStatus;
  setObjItem: (newStrItem: ObjItem) => void;
  isLoadingSetObj: boolean;
  statusSetObj: QueryStatus;
  removeObjItem: () => void;
  isLoadingRemoveObj: boolean;
  statusRemoveObj: QueryStatus;
};

export const useObjStorage = (): useObjStorageType => {
  const queryClient = useQueryClient();
  
  // localStorageの内容を取得する
  const {
    data: getObjItem,
    isLoading: isLoadingGetObj,
    isFetching,
    status: statusGetObj,
  } = useQuery(objQueryKey, getObjStorage);

  // localStorageの内容を更新する
  const {
    mutate: setObjItem,
    isLoading: isLoadingSetObj,
    status: statusSetObj,
  } = useMutation(objQueryKey, async (newObjItem: ObjItem) => {
    await setObjStorage(newObjItem);
    queryClient.invalidateQueries(objQueryKey);
  });

  // localStorageの内容を削除する
  const {
    mutate: removeObjItem,
    isLoading: isLoadingRemoveObj,
    status: statusRemoveObj,
  } = useMutation(objQueryKey, async () => {
    await removeObjStorage();
    queryClient.invalidateQueries(objQueryKey);
  });
  return {
    getObjItem,
    isLoadingGetObj,
    statusGetObj,
    setObjItem,
    isLoadingSetObj,
    statusSetObj,
    removeObjItem,
    isLoadingRemoveObj,
    statusRemoveObj,
    isFetching,
  };
};

```

## テスト

既にコードがだいぶ長くなっていますが、テストはこちらです。やっていることをまとめると以下の通りです。

* localStorage関連の関数(getItem,setItem,removeItem)のモックを作成
* react-queryをテストするため、QueryClientProviderとQueryClientを作成するwrapperコンポーネントを利用してlocalStorageを利用するためのカスタムフックをレンダリング
* waitForを利用しact内でreact-queryの更新が完了するまで待機

今回特に重要だったのが、waitForによる待機です。waitForでreact-queryの`isFetching`や`status`を監視し、確実に更新が終わるまで待機します。これを飛ばした場合、モックのlocalStorageの中身を確認しても情報が古いままだったり、カスタムフックを利用して取得したlocalStorageの値が更新されていないということになります。

また、beforeEach内でもwaitForを利用していますが、これはレンダリングした時点で値の取得が実行され、その後react-queryが内部で`isFetching`や`status`を書き換える影響でコンポーネントが更新され、act外での変更に対するエラーを回避するためです。
レンダンリングした時点でreact-queryが動き出すということを認識するまで、エラーが消えずに戸惑ったポイントでした。

```typescript:useStorage.test.tsx
import { ReactNode } from "react";
import {
  renderHook,
  act,
  RenderHookResult,
} from "@testing-library/react-hooks";
import { QueryClient, QueryClientProvider } from "react-query";

import { objKey, ObjItem } from "./localStorage";

import { useObjStorage, useObjStorageType } from "./useStorage";

type wrapperType = { children: ReactNode };

describe("useStrStorage", () => {
  // テスト用のQueryClientを用意したwrapperコンポーネント
  const queryClient = new QueryClient();
  const wrapper = ({ children }: wrapperType) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );

  // localStorageの初期値用の変数
  const initObjItem: ObjItem = {
    num: 100,
    str: "hoge",
  };

  // テスト対象のカスタムフック
  let objStorageHook: RenderHookResult<wrapperType, useObjStorageType>;

  // localStorageのモック群
  let mockedLocalStorage: { [key: string]: string };
  let mockedLocalStorageGet = jest.spyOn(
    window.localStorage.__proto__,
    "getItem"
  ) as jest.SpyInstance<string, [string]>;
  let mockedLocalStorageSet = jest.spyOn(
    window.localStorage.__proto__,
    "setItem"
  ) as jest.SpyInstance<void, [key: string, val: string]>;
  let mockedLocalStorageRemove = jest.spyOn(
    window.localStorage.__proto__,
    "removeItem"
  ) as jest.SpyInstance<void, [key: string]>;

  beforeEach(async () => {
    // テストごとにモックのlocalStorageを初期化
    mockedLocalStorage = { [objKey]: JSON.stringify(initObjItem) };
    mockedLocalStorageGet.mockImplementation(
      (key: string) => mockedLocalStorage[key]
    );
    mockedLocalStorageSet.mockImplementation((key: string, val: string) => {
      mockedLocalStorage[key] = val;
    });
    mockedLocalStorageRemove.mockImplementation((key: string) => {
      delete mockedLocalStorage[key];
    });

    // テスト用のwrapperコンポーネントをレンダリング
    objStorageHook = renderHook(() => useObjStorage(), { wrapper });
    await act(async () => {
      // カスタムフックを呼び出した時点でgetが走り、その後自動でisLoadingやstatusが更新され、カスタムフックを用いているコンポーネントに更新が走る
      // waitForでlocalStorageからの呼び出しが完了するまで待ち、act外でwrapperコンポーネントが更新されることを防ぐ
      await objStorageHook.waitFor(
        () =>
          !objStorageHook.result.current.isFetching &&
          objStorageHook.result.current.statusGetObj === "success"
      );
    });
    // 初回のget呼び出し回数をリセット
    mockedLocalStorageGet.mockClear();
  });

  afterEach(async () => {
    // テストごとにreact-queryのキャッシュをクリア
    queryClient.clear();

    // モックをリセット
    mockedLocalStorageGet.mockReset();
    mockedLocalStorageSet.mockReset();
    mockedLocalStorageRemove.mockReset();
  });

  test("データ取得", async () => {
    expect(mockedLocalStorage[objKey]).toBe(JSON.stringify(initObjItem));
    expect(objStorageHook.result.current.getObjItem).toStrictEqual(initObjItem);
  });

  test("データ更新", async () => {
    const testObj: ObjItem = {
      num: 200,
      str: "fuga",
    };
    expect(mockedLocalStorage[objKey]).toBe(JSON.stringify(initObjItem));
    expect(objStorageHook.result.current.getObjItem).toStrictEqual(initObjItem);

    await act(async () => {
      objStorageHook.result.current.setObjItem(testObj);
      // setとget処理が終わることを待つ
      await objStorageHook.waitFor(
        () =>
          objStorageHook.result.current.statusSetObj === "success" &&
          !objStorageHook.result.current.isLoadingSetObj &&
          objStorageHook.result.current.statusGetObj === "success" &&
          !objStorageHook.result.current.isFetching
      );
    });

    expect(mockedLocalStorageGet).toHaveBeenCalledTimes(1);
    expect(objStorageHook.result.current.getObjItem).toStrictEqual(testObj);
    expect(mockedLocalStorage[objKey]).toBe(JSON.stringify(testObj));
  });

  test("データ削除", async () => {
    expect(mockedLocalStorage[objKey]).toBe(JSON.stringify(initObjItem));
    expect(objStorageHook.result.current.getObjItem).toStrictEqual(initObjItem);

    await act(async () => {
      objStorageHook.result.current.removeObjItem();
      await objStorageHook.waitFor(
        () =>
          objStorageHook.result.current.statusRemoveObj === "success" &&
          !objStorageHook.result.current.isLoadingRemoveObj &&
          objStorageHook.result.current.statusGetObj === "success" &&
          !objStorageHook.result.current.isFetching
      );
    });

    expect(mockedLocalStorageGet).toHaveBeenCalledTimes(1);
    expect(mockedLocalStorage[objKey]).toBeUndefined();
    expect(objStorageHook.result.current.getObjItem).toBeUndefined();
  });
});
```

# おわりに
waitForを利用することで、react-queryの処理が完了したことを確認してテストすることができるようになりました。
振り返ると、最初にテストがうまくいかなかったことについてはreact-queryの理解が怪しかったことが原因だなと反省しております。statusやisLoading、isFetchingの違いなど、落ち着いて公式ドキュメントを読むことは大切ですね。
また、コードが長くなるので割愛しましたが、異常系のテストも忘れないようにしましょう。

# 参考
* [useQuery | React Query | TanStack](https://react-query.tanstack.com/reference/useQuery)
* [useMutation | React Query | TanStack](https://react-query.tanstack.com/reference/useMutation)
* [Testing | React Query | TanStack](https://react-query.tanstack.com/guides/testing)
  * react-queryを使ったjestのテスト方法に関する公式ドキュメント
  * wrapperコンポーネントをテスト用に用意し、テスト用のQueryClientProviderとQueryClientを用意する
* [Issues · tannerlinsley/react-query - Improve docs to avoid 'act' warning when using react-query in tests #432](https://github.com/tannerlinsley/react-query/issues/432)
  * 同様のエラーが出現しているreact-queryのリポジトリにあったissue
  * react-queryのバージョンが古い&原因が同じではないものの、waitForを使うことの参考になりました
* [API Reference | React Hooks Testing Library #waitfor](https://react-hooks-testing-library.com/reference/api#waitfor)
  * waitForの公式ドキュメントの説明