M

React Queryのテスト

May 03, 2023

これは、Dominik Dorfmeister 氏のブログ記事であるTesting React Queryを日本語訳してみたものです。

誤訳などあればIssueや PR を頂けると幸いです。


テストのトピックに関する質問をよく React Query と合わせてもらうので、ここでその中のいくつかに答えましょう。 私はその理由の 1 つを、”スマート”なコンポーネント(コンテナーコンポーネントとも呼ばれます)のテストが簡単ではないからだと考えます。フックの登場により、この分割手法は概ねほぼ非推奨となりました。今では、ほぼ恣意的な分割とプロップドリルよりも、必要な場所で直接フックを使用することが推奨されるようになっています。

私は、これが全体的にコロケーションやコードの可読性に対するとても良い改善だと考えますが、”ただの props”以外の依存関係を用いるコンポーネントが増えることにもなります。

それはuseContextかもしれません。useSelectorかもしれません。はたまたuseQueryかもしれません。

このようなコンポーネントは、異なる状況で呼ばれると異なる結果が返されるので、技術的にはもはやピュアなものではありません。テストする際、適切に動作するよう注意深く周辺環境を設定しておく必要があります。

ネットワークリクエストのモック

React Query が非同期のサーバーステート管理ライブラリなので、コンポーネントはバックエンドへリクエストをするかもしれません。テストする際に、このバックエンドから実際にデータを提供できないし、例えできたとしても、それに依存したテストにしたくはないでしょう。

jest でデータをモックする方法についての記事は大量にあります。API クライアントがあればそれをモックできます。fetch や axios を直接モックもできます。私はただ Kent C. Dodds 氏による記事のStop mocking fetchに賛同をするだけです。

@ApiMockingによるmock service workerを利用しましょう。

API をモックすることで信頼できる唯一の情報源とすることができます。

  • テストにおいて Node.js で動作します
  • REST も GraphQL もサポートしています
  • Storybook のアドオンがあるので、useQueryを使っているコンポーネントをストーリーを書くことができます
  • 開発用にブラウザで動作し、ブラウザの開発者ツールでリクエストを確認できます
  • フィクスチャー同様に Cypress で動作します

ネットワークレイヤーのケアが済んだので、React Query 特有のことに目を向けていきましょう。

QueryClientProvider

React Query を使用するときは常に QueryClientProvider とそれに渡す queryClient - QueryCacheを保持する容器 - が必要です。キャッシュはクエリーのデータを順番に保持します。

私はテスト毎に固有の QueryClientProvider を持たせることとテスト毎にnew QueryClientを生成することを好みます。こうすることでテストはお互い完全に隔離されます。テスト毎にキャッシュをクリアする方法もあるかもしれませんが、私はテスト間で共有する状態を可能な限り小さくしたいと思っています。そうしなければ、並行してテストを実行したら予期しきれないフレーキーな結果が返されるかもしれません。

カスタムフックの場合

カスタムフックをテストする場合、react-hooks-testing-libraryを間違いなく利用するでしょう。これはフックをテストするための最も簡単なものです。このライブラリによって、レンダリング時にテストするコンポーネントを包み込む React コンポーネントであるラッパーにフックを包むことができます。私は、これがテスト毎に 1 度実行されるものであるため、QueryClient を生成するのに完璧な場所だと思います。

const createWrapper = () => {
  // ✅ テスト毎に新しいQueryClientを生成する
  const queryClient = new QueryClient();
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

test('my first test', async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper(),
  });
});

コンポーネントの場合

useQueryフックを使うコンポーネントをテストしたい場合においても、そのコンポーネントを QueryClientProvider でラップする必要があります。react-testing-libraryrenderを包む小さなラッパーは良い選択に思えます。React Query が自身の内部のテストをどのように行なっているか見ておきましょう。

リトライの無効化

これは、React Query とテストでのよくある”潜在的問題”の 1 つです。ライブラリの初期値は指数関数的なバックオフで 3 回リトライし、即ち、誤ったクエリーをテストしたい時にタイムアウトする可能性があることを意味します。リトライを無効化する最も簡単な方法もまたQueryClientProviderを通して行います。上述の例を拡張しましょう。

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        // ✅ リトライを無効化する
        retry: false,
      },
    },
  })

  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

test("my first test", async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })
}

これでコンポーネントツリーの全てのクエリーの初期設定を”リトライしない”ようにできます。これは明示的にリトライ指定せずuseQueryを使用するときに限って機能することを知っておくのが重要です。初期設定がフォールバックとして使われるだけなので、5 回リトライさせたいクエリーがあるときはそれが優先されます。

setQueryDefaults

この問題に対して私ができる最上の助言は、useQueryに直接このようなオプションを指定しないことです。可能な限り初期値を利用や上書きして、特定のクエリーに何か変更を加えたい時に限ってqueryClient.setQueryDefaultsを使ってください。

なので例えばuseQueryにリトライを指定する代わりに

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  );
}

function Example() {
  // 🚨 テストのためにこの設定を上書きすることはできません!
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    retry: 5,
  });
}

このように設定します。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
    },
  },
});

// ✅ todosに限り5回リトライするようにします
queryClient.setQueryDefaults(['todos'], { retry: 5 });

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  );
}

これで全てのクエリーが 2 回リトライするようになり、todosだけが 5 回リトライし、テストにおいて全てのクエリーのリトライを無効化する余地をまだ残しています 🙌。

ReactQueryConfigProvider

もちろん、これは既知のクエリーキーに限って機能します。時にはコンポーネントツリーのサブセットに対していくつか設定を加えたいこともあると思います。v2 で、React Query はそのような用途にあったReactQueryConfigProviderを提供していました。v3 では以下のような数行のコードで同じことを実現できます。

const ReactQueryConfigProvider = ({ children, defaultOptions }) => {
  const client = useQueryClient();
  const [newClient] = React.useState(
    () =>
      new QueryClient({
        queryCache: client.getQueryCache(),
        muationCache: client.getMutationCache(),
        defaultOptions,
      }),
  );

  return (
    <QueryClientProvider client={newClient}>{children}</QueryClientProvider>
  );
};

codesandbox の例でこれを実際に確認することができます。

常にクエリーを待つ

React Query はそれ自体非同期なので、フックを実行すると即座に結果が得られるわけではありません。大抵読み込み中で確認できるデータがない状態となります。react-hooks-testing-library による非同期のユーティリティーはこの問題を解決するさまざまな方法を提供しています。最もシンプルな例だと、クエリが成功した状態に遷移するまでただ待つことができます。

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  })
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

test("my first test", async () => {
  const { result, waitFor } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })

  // ✅ クエリが成功した状態に遷移するまで待つ
  await waitFor(() => result.current.isSuccess)

  expect(result.current.data).toBeDefined()
}

更新:
@testing-library/react v13.1.0から新しいrenderHookが利用できるようになりました。それ自体はwaitForユーティリティを返さないので、代わりに@testing-library/react から importしたものを使う必要があります。API が少し異なり、booleanを返すことができず、Promiseが返されることを期待します。つまりコードを少し修正する必要があります。

import { waitFor, renderHook } from '@testing-library/react'

test("my first test", async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })

  // ✅ waitForを期待するPromiseを返す
  await waitFor(() => expect(result.current.isSuccess).toBe(true))

  expect(result.current.data).toBeDefined()
}

エラーコンソールを沈黙させる

初期設定につき、React Query はコンソールにエラーを出力します。全てのテストが 🟢 なのにコンソールに 🔴 が見えことになるので、テストにおいてこの点をとても邪魔なものだと思います。React Query はそのデフォルトの挙動をロガーを設定することで上書きできるので、私は普段からこのようにしています。

import { setLogger } from 'react-query';

setLogger({
  log: console.log,
  warn: console.warn,
  // ✅ コンソールにエラーを出さない
  error: () => {},
});

更新:
v4 でsetLoggerは削除されました。その代わりQueryClientの prop としてカスタムのロガーを渡すことができます。

const queryClient = new QueryClient({
  logger: {
    log: console.log,
    warn: console.warn,
    // ✅ コンソールにエラーを出さない
    error: () => {},
  },
});

また、混乱を避けるため本番環境モードではエラーをログに記録しないようになりました。

まとめ

私はこれら全てがよくまとまっている即席のリポジトリーをセットアップしました。mock-service-worker や react-testing-library、そして前述のラッパーを含んでいます。これは 4 つのテストを含んでいます - カスタムフックとコンポーネントの基本的な失敗と成功のテストです。https://github.com/TkDodo/testing-react-query をご覧になってみてください。