测试 React Hooks 的完整指南

已发表: 2022-03-11

Hooks 于 2018 年底在 React 16.8 中引入。它们是挂钩到功能组件的函数,允许我们使用 state 和组件特性,如componentDidUpdatecomponentDidMount等。 这在以前是不可能的。

此外,钩子允许我们在不同组件之间重用组件和状态逻辑。 这在以前很难做到。 因此,钩子已经改变了游戏规则。

在本文中,我们将探讨如何测试 React Hooks。 我们将选择一个足够复杂的钩子并进行测试。

我们希望您是已经熟悉 React Hooks 的狂热 React 开发人员。 如果你想复习你的知识,你应该看看我们的教程,这里是官方文档的链接。

我们将用于测试的钩子

对于本文,我们将使用我在之前的文章 Stale-while-revalidate Data Fetching with React Hooks 中编写的钩子。 该钩子称为useStaleRefresh 。 如果你还没有读过这篇文章,别担心,我会在这里重述那部分。

这是我们将要测试的钩子:

 import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }

如您所见, useStaleRefresh是一个钩子,可帮助从 URL 获取数据,同时返回数据的缓存版本(如果存在)。 它使用一个简单的内存存储来保存缓存。

如果还没有数据或缓存可用,它还会返回一个isLoading值。 客户端可以使用它来显示加载指示器。 当缓存或新响应可用时, isLoading值设置为 false。

跟踪过时刷新逻辑的流程图

在这一点上,我建议你花一些时间阅读上面的钩子,以全面了解它的作用。

在本文中,我们将看到如何测试这个钩子,首先不使用测试库(仅使用 React Test Utilities 和 Jest),然后使用 react-hooks-testing-library。

不使用测试库(即仅使用测试运行器Jest )背后的动机是为了演示测试钩子的工作原理。 有了这些知识,您将能够调试在使用提供测试抽象的库时可能出现的任何问题。

定义测试用例

在我们开始测试这个钩子之前,让我们想出一个我们想要测试的计划。 既然我们知道钩子应该做什么,这是我测试它的八步计划:

  1. 当使用 URL url1挂载挂钩时, isLoadingtrue且 data 为defaultValue
  2. 在异步获取请求之后,钩子会使用数据data1更新,并且isLoadingfalse
  3. 当 URL 更改为url2时, isLoading再次变为 true 并且 data 为defaultValue
  4. 在异步获取请求之后,使用新数据data2更新挂钩。
  5. 然后,我们将 URL 改回url1 。 数据data1被缓存后立即被接收。 isLoading为假。
  6. 在异步获取请求之后,当收到新的响应时,数据将更新为data3
  7. 然后,我们将 URL 改回url2 。 数据data2被缓存后立即被接收。 isLoading为假。
  8. 在异步获取请求之后,当接收到新的响应时,数据将更新为data4

上面提到的测试流程清楚地定义了钩子如何运行的轨迹。 因此,如果我们能确保这个测试有效,我们就很好。

测试流程

在没有库的情况下测试 Hooks

在本节中,我们将了解如何在不使用任何库的情况下测试钩子。 这将使我们深入了解如何测试 React Hooks。

要开始这个测试,首先,我们要模拟fetch 。 这样我们就可以控制 API 返回的内容。 这是模拟的fetch

 function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }

此修改后的fetch假定响应类型始终为 JSON,默认情况下,它返回参数url作为data值。 它还为响应添加了 200 毫秒到 500 毫秒之间的随机延迟。

如果我们想更改响应,我们只需将第二个参数suffix设置为非空字符串值。

此时,您可能会问,为什么会延迟? 为什么我们不立即返回响应? 这是因为我们想尽可能地复制现实世界。 如果我们立即返回它,我们将无法正确测试钩子。 当然,我们可以将延迟减少到 50-100 毫秒以加快测试速度,但在本文中我们不必担心。

准备好 fetch 模拟后,我们可以将其设置为fetch函数。 我们使用beforeAllafterAll这样做是因为这个函数是无状态的,所以我们不需要在单独测试后重置它。

 // runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });

然后,我们需要将钩子挂载到一个组件中。 为什么? 因为钩子本身就是函数。 只有在组件中使用时才能响应useStateuseEffect等。

所以,我们需要创建一个TestComponent来帮助我们挂载我们的钩子。

 // defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }

这是一个简单的组件,它要么呈现数据,要么在数据正在加载(正在获取)时呈现“正在加载”文本提示。

一旦我们有了测试组件,我们就需要将它挂载到 DOM 上。 我们使用beforeEachafterEach来为每个测试安装和卸载我们的组件,因为我们希望在每次测试之前从一个新的 DOM 开始。

 let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });

请注意, container必须是全局变量,因为我们希望能够访问它以进行测试断言。

有了这个设置,让我们在渲染 URL url1的地方进行第一个测试,由于获取 URL 需要一些时间(请参阅fetchMock ),它应该首先呈现“加载”文本。

 it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })

使用yarn test运行测试,它按预期工作。 这是 GitHub 上的完整代码。

现在,让我们测试这个loading文本何时更改为获取的响应数据url1

我们如何做到这一点? 如果您查看fetchMock ,您会看到我们等待了 200-500 毫秒。 如果我们在等待 500 毫秒的测试中设置sleep会怎样? 它将涵盖所有可能的等待时间。 让我们试试看。

 function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });

测试通过了,但我们也看到了一个错误(代码)。

 PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).

这是因为useStaleRefresh钩子中的状态更新发生在 act() 之外。 为了确保及时处理 DOM 更新,React 建议您在每次可能发生重新渲染或 UI 更新时使用act() 。 因此,我们需要用act包裹我们的睡眠,因为这是状态更新发生的时间。 这样做之后,错误就消失了。

 import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));

现在,再次运行它(GitHub 上的代码)。 正如预期的那样,它没有错误地通过。

让我们测试下一种情况,我们首先将 URL 更改为url2 ,然后检查loading屏幕,然后等待 fetch 响应,最后检查url2文本。 由于我们现在知道如何正确等待异步更改,这应该很容易。

 act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");

运行这个测试,它也通过了。 现在,我们还可以测试响应数据发生变化和缓存发挥作用的情况。

你会注意到我们的fetchMock函数中有一个额外的参数suffix 。 这是为了更改响应数据。 所以我们更新我们的 fetch mock 以使用suffix .

 global.fetch.mockImplementation((url) => fetchMock(url, "__"));

现在,我们可以再次测试将 URL 设置为url1的情况。 它首先加载url1 ,然后加载url1__ 。 我们可以对url2做同样的事情,应该不会有什么意外。

 it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });

整个测试使我们确信钩子确实按预期工作(代码)。 欢呼! 现在,让我们快速浏览一下使用辅助方法进行的优化。

使用辅助方法优化测试

到目前为止,我们已经看到了如何完全测试我们的钩子。 这种方法并不完美,但它确实有效。 然而,我们能做得更好吗?

是的。 请注意,我们正在等待固定的 500 毫秒来完成每次提取,但每个请求需要 200 到 500 毫秒之间的任何时间。 所以,我们显然是在浪费时间。 我们可以通过等待每个请求花费的时间来更好地处理这个问题。

我们如何做到这一点? 一种简单的技术是执行断言,直到它通过或达到超时。 让我们创建一个执行此操作的waitFor函数。

 async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }

这个函数只是每 10 毫秒在try...catch块内运行一个回调(cb),如果达到timeout ,它会抛出一个错误。 这允许我们运行一个断言,直到它以安全的方式通过(即,没有无限循环)。

我们可以在测试中使用它,如下所示:我们使用waitFor函数,而不是休眠 500 毫秒然后断言。

 // INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );

在所有这样的断言中都这样做,我们可以看到我们的测试运行速度(代码)有很大的不同。

现在,这一切都很好,但也许我们不想通过 UI 测试钩子。 也许我们想使用它的返回值来测试一个钩子。 我们如何做到这一点?

这并不难,因为我们已经可以访问我们的钩子的返回值。 它们就在组件内部。 如果我们可以将这些变量放到全局范围内,它就会起作用。 所以让我们这样做。

由于我们将通过它的返回值而不是渲染 DOM 来测试我们的钩子,我们可以从我们的组件中删除 HTML 渲染并使其呈现null 。 我们还应该删除钩子返回中的解构以使其更通用。 因此,我们有了这个更新的测试组件。

 // global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }

现在钩子的返回值被存储在一个全局变量result中。 我们可以查询它以获取我们的断言。

 // INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");

在我们到处更改它之后,我们可以看到我们的测试正在通过(代码)。

至此,我们了解了测试 React Hooks 的要点。 我们仍然可以进行一些改进,例如:

  1. result变量移动到本地范围
  2. 无需为我们要测试的每个钩子创建组件

我们可以通过创建一个内部有一个测试组件的工厂函数来做到这一点。 它还应该在测试组件中渲染钩子并让我们访问result变量。 让我们看看如何做到这一点。

首先,我们在函数内部移动TestComponentresult 。 我们还需要将 Hook 和 Hook 参数作为函数的参数传递,以便它们可以在我们的测试组件中使用。 使用它,这就是我们所拥有的。 我们调用这个函数renderHook

 function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }

我们将result作为一个在result.current中存储数据的对象的原因是因为我们希望在测试运行时更新返回值。 我们的钩子的返回值是一个数组,所以如果我们直接返回它就会被按值复制。 通过将它存储在一个对象中,我们返回对该对象的引用,因此可以通过更新result.current来更新返回值。

现在,我们如何去更新钩子? 由于我们已经在使用闭包,让我们封装另一个可以执行此操作的函数rerender

最终的renderHook函数如下所示:

 function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }

现在,我们可以在测试中使用它。 我们不使用actrender ,而是执行以下操作:

 const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);

然后,我们可以使用result.current断言并使用rerender更新钩子。 这是一个简单的例子:

 rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true

一旦你在所有地方改变它,你会看到它没有任何问题(代码)。

杰出的! 现在我们有了一个更清晰的抽象来测试钩子。 我们仍然可以做得更好——例如,每次都需要传递defaultValue来重新rerender ,即使它没有改变。 我们可以解决这个问题。

但是我们不要绕弯子太多,因为我们已经有一个可以显着改善这种体验的库。

输入 react-hooks-testing-library。

使用 React-hooks-testing-library 进行测试

React-hooks-testing-library 完成了我们之前讨论过的所有事情,然后是一些。 例如,它处理容器安装和卸载,因此您不必在测试文件中执行此操作。 这使我们能够专注于测试我们的钩子而不会分心。

它带有一个返回rerenderresultrenderHook函数。 它还返回wait ,类似于waitFor ,因此您不必自己实现它。

下面是我们如何在 React-hooks-testing-library 中渲染一个钩子。 注意钩子是以回调的形式传递的。 每次测试组件重新渲染时都会运行此回调。

 const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );

然后,我们可以通过这样做来测试第一次渲染是否导致isLoading为 true 并返回值为defaultValue 。 与我们上面实现的完全相似。

 expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);

为了测试异步更新,我们可以使用renderHook返回的wait方法。 它带有act()包裹,因此我们不需要将act()包裹在它周围。

 await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);

然后,我们可以使用rerender用新的 props 更新它。 请注意,我们不需要在这里传递defaultValue

 rerender({ url: "url2" });

最后,其余的测试将以类似的方式进行(代码)。

包起来

我的目的是通过一个异步钩子的例子向你展示如何测试 React Hooks。 我希望这可以帮助您自信地处理任何类型的钩子的测试,因为同样的方法应该适用于大多数钩子。

我建议你使用 React-hooks-testing-library,因为它已经完成了,到目前为止我还没有遇到过重大问题。 如果您确实遇到了问题,您现在知道如何使用本文中描述的测试钩子来解决它。