React Queryのテスト

Published

これは、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 を生成するのに完璧な場所だと思います。

wrapper

_13
const createWrapper = () => {
_13
// ✅ テスト毎に新しいQueryClientを生成する
_13
const queryClient = new QueryClient()
_13
return ({ children }) => (
_13
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
_13
)
_13
}
_13
_13
test("my first test", async () => {
_13
const { result } = renderHook(() => useCustomHook(), {
_13
wrapper: createWrapper()
_13
})
_13
})

コンポーネントの場合

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

リトライの無効化

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

no-retries

_20
const createWrapper = () => {
_20
const queryClient = new QueryClient({
_20
defaultOptions: {
_20
queries: {
_20
// ✅ リトライを無効化する
_20
retry: false,
_20
},
_20
},
_20
})
_20
_20
return ({ children }) => (
_20
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
_20
)
_20
}
_20
_20
test("my first test", async () => {
_20
const { result } = renderHook(() => useCustomHook(), {
_20
wrapper: createWrapper()
_20
})
_20
}

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

setQueryDefaults

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

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

not-on-useQuery

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

このように設定します。

setQueryDefaults

_18
const queryClient = new QueryClient({
_18
defaultOptions: {
_18
queries: {
_18
retry: 2,
_18
},
_18
},
_18
})
_18
_18
// ✅ todosに限り5回リトライするようにします
_18
queryClient.setQueryDefaults(['todos'], { retry: 5 })
_18
_18
function App() {
_18
return (
_18
<QueryClientProvider client={queryClient}>
_18
<Example />
_18
</QueryClientProvider>
_18
)
_18
}

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

ReactQueryConfigProvider

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

ReactQueryConfigProvider

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

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

常にクエリーを待つ

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

waitFor

_23
const createWrapper = () => {
_23
const queryClient = new QueryClient({
_23
defaultOptions: {
_23
queries: {
_23
retry: false,
_23
},
_23
},
_23
})
_23
return ({ children }) => (
_23
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
_23
)
_23
}
_23
_23
test("my first test", async () => {
_23
const { result, waitFor } = renderHook(() => useCustomHook(), {
_23
wrapper: createWrapper()
_23
})
_23
_23
// ✅ クエリが成功した状態に遷移するまで待つ
_23
await waitFor(() => result.current.isSuccess)
_23
_23
expect(result.current.data).toBeDefined()
_23
}

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

new-render-hook

_12
import { waitFor, renderHook } from '@testing-library/react'
_12
_12
test("my first test", async () => {
_12
const { result } = renderHook(() => useCustomHook(), {
_12
wrapper: createWrapper()
_12
})
_12
_12
// ✅ waitForを期待するPromiseを返す
_12
await waitFor(() => expect(result.current.isSuccess).toBe(true))
_12
_12
expect(result.current.data).toBeDefined()
_12
}

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

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

silence-errors

_10
import { setLogger } from 'react-query'
_10
_10
setLogger({
_10
log: console.log,
_10
warn: console.warn,
_10
// ✅ コンソールにエラーを出さない
_10
error: () => {},
_10
})

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

logger-prop

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

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

まとめ

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