测试React Query

介绍如何对包含React Query的组件进行测试

原文地址: Testing React Query

在React Query常见的问题中,我们经常可以看到一些关于测试这一主题的问题,所以我将在这里尝试回答其中的一些问题。我认为其中一个主要原因是测试“聪明”组件(通常被叫做容器组件)并非一件容易的事情。随着Hooks的广泛应用,这种拆分技巧已经被大量的废弃掉了。现在鼓励在需要的地方直接使用hooks,而不是进行大量任意拆分和向下传递props。

我认为这是对代码共享和代码可读性的一个很好的普适性的提升,但是我们现在有更多的组件需要“仅仅props属性”之外的依赖项。

它们可能会用 useContext。也可能会去使用 useSelector。它们还有可能使用 useQuery

这些组件在技术上讲不再是无副作用的了,因为在不同的环境中调用它们会导致不同的结果。 在测试它们时,我们需要仔细设置其所需要的环境以使其正常工作。

模拟网络请求

由于React Query是一个异步服务器状态管理库,我们的组件可能会向后端发出请求。 在测试时,此后端无法实际交付数据,即使后端是可以给出正确的数据,我们可能也不想让我们的测试依赖于它。

有大量关于如何用使用jest模拟数据的文章。 如果你已经在使用了,你完全可以模拟你的api客户端。你可以直接模拟fetch或axios。但是我只支持Kent C. Dodds 在他的文章停止模拟fetch吧中所写的内容:

请使用@ApiMocking开发的mock service worker

在模拟我们的api时,它可能是我们真正的单一数据源:

  1. 可以在node中进行测试
  2. 支持REST和GraphQL
  3. 具备stroybook插件,可以在stories中使用useQuery
  4. 在进行开发的过程中,我们可以在浏览器devtools中看到发出的请求
  5. 可以和fixtures类似的cypress协同工作

处理好我们的网络层后,我们可以开始讨论React Query需要关注的具体事项:

QueryClientProvider

无论何时我们需要使用React Query,我们都需要使用QueryClientProvider并为它提供一个queryClient,queryClient是一个 QueryCache 的容器。这个缓存将保存我们所有的查询数据。

我更喜欢为每个测试提供一个独占的QueryClientProvider并为每个测试创建一个新的QueryClient。这样,测试就完全相互隔离了。另一种方法可能是在每次测试后清除缓存,但我希望尽可能减少测试之间的共享状态。否则,如果我们并行运行测试,我们可能会得到意外和不稳定的结果。

自定义的hooks

如果我们正在测试自定义hooks,我很确定绝大部分人正在使用 react-hooks-testing-library。测试hooks是最简单的事情。使用该库,我们可以将我们的hooks包装在一个包装器中,这是一个 React组件,用于在渲染时包装测试组件。我认为这是创建QueryClient的理想场所,因为每次测试都会执行一次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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-hooks-testing-library简单渲染包装器似乎是一个不错的选择。让我们看看React Query如何进行内部进行测试

关掉请求重试

这是React Query测试中最常见的“陷阱”之一:React Query默认使用指数退避的三次重试,这意味着如果我们想测试查询出错的场景,我们的测试可能会超时。关闭重试的最简单方法是通过QueryClientProvider。 让我们扩展上面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 上设置重试的方案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const queryClient = new QueryClient()
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

function Example() {
  // 🚨 我们将无法在测试中覆盖该选项
  const queryInfo = useQuery('todos', fetchTodos, { retry: 5 })
}

我们应该使用下面的方案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
    },
  },
})

// ✅ 只有todos才进行5次重试

queryClient.setQueryDefaults('todos', { retry: 5 })

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

在这里,所有查询都会重试两次,只有 todos 会重试五次,我们仍然可以选择在我们的测试中为所有查询关闭它🙌。

ReactQueryConfigProvider

当然,这只适用于已知的查询键。 有时,我们真的想在组件树的子集上设置一些配置。 在React Query的v2版本中,有一个针对该确切用例的 ReactQueryConfigProvider(该功能在v2以后的版本中被统一到了QueryClientProvider 上)。 我们可以通过几行代码在React Query的v3版本中实现相同的目标:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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的例子中看它如何工作。

总是等待Query

由于React Query本质上是异步的,因此在运行hook时,我们不会立即得到结果。 它通常处于加载状态,没有要检查的数据。react-hooks-testing-library中的异步工具集提供了很多解决此问题的方法。 对于最简单的情况,我们可以等到查询转换为成功状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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中引入。它和原有的API设计稍有不同,它并不会准许返回布尔值,它期望的返回值是一个 Promise。因此我们需要对我们的代码稍作修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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允许通过设置日志选项来覆盖默认行为,所以这就是我们通常需要做的事情:

1
2
3
4
5
6
7
import { setLogger } from 'react-query'
setLogger({
  log: console.log,
  warn: console.warn,
  // ✅不要在控制台或者终端中输出错误
  error: () => {},
})

更新: setLogger 在v4中被移除了。相反,我们可以将自定义日志设置作为props传递给我们创建的 QueryClient

1
2
3
4
5
6
7
8
const queryClient = new QueryClient({
  logger: {
    log: console.log,
    warn: console.warn,
    // ✅ 不要在控制台或者终端中输出错误
    error: () => {},
  }
})

此外,不再在生产模式下记录错误日志可以避免对我们造成困扰。

把它们放在一起

我已经建立了一个github的项目仓库,所有这些都很好地结合在一起:mock-service-worker、react-testing-library和提到的包装器。同时它包含四个测试用例——测试非常基本的自定义hook和组件产生失败和成功的结果。可以访问此处:testing-react-query

译注

好的单元测试,可以充分避免我们在进行业务重构时,因为不“谨慎”而产生的新错误,从而有效的保证我们的业务的稳定性。