测试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时,它可能是我们真正的单一数据源:
- 可以在node中进行测试
- 支持REST和GraphQL
- 具备stroybook插件,可以在stories中使用useQuery
- 在进行开发的过程中,我们可以在浏览器devtools中看到发出的请求
- 可以和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
译注
好的单元测试,可以充分避免我们在进行业务重构时,因为不“谨慎”而产生的新错误,从而有效的保证我们的业务的稳定性。