测试React Hooks的完整指南
钩子是在2018年底的React 16.8中引入的。它们是钩住功能组件的函数,允许我们使用状态和组件功能,如componentDidUpdate ,componentDidMount ,等等。这在以前是不可能的。
此外,钩子允许我们在不同的组件中重用组件和状态逻辑。这在以前是很难做到的。因此,钩子已经改变了游戏规则。
在这篇文章中,我们将探讨如何测试React Hooks。我们将挑选一个足够复杂的钩子,并对其进行测试。
我们将在测试中使用的钩子
在这篇文章中,我们将使用我在之前的文章《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 值被设置为假。
在这一点上,我建议你花一些时间阅读上述钩子,以全面了解它的作用。
在这篇文章中,我们将看到如何测试这个钩子,首先使用无测试库(只有React Test Utilities和Jest),然后通过使用react-hooks-testing-library。
不使用测试库,即只使用测试运行器Jest ,其背后的动机是为了演示测试钩子是如何工作的。有了这些知识,你将能够调试在使用提供测试抽象的库时可能出现的任何问题。
定义测试案例
在我们开始测试这个钩子之前,让我们想出一个计划,我们要测试什么。既然我们知道这个钩子应该做什么,下面是我测试它的八步计划:
- 当钩子被装入URL
url1时,isLoading是true,数据是defaultValue。 - 在一个异步获取请求之后,钩子被更新为数据
data1,isLoading是false。 - 当URL被改变为
url2,isLoading再次变为真实,数据为defaultValue。 - 在一个异步获取请求之后,钩子被更新为新的数据
data2。 - 然后,我们将URL改回
url1。由于数据data1被缓存了,所以马上就能收到。isLoading是假的。 - 在一个异步获取请求之后,当收到一个新的响应时,数据被更新到
data3。 - 然后,我们将URL改回
url2。数据data2,因为它被缓存了,所以立即被接收。isLoading是假的。 - 在一个异步获取请求之后,当收到一个新的响应时,数据被更新到
data4。
上面提到的测试流程清楚地定义了钩子如何运作的轨迹。因此,如果我们能确保这个测试工作,我们就很好。
测试没有库的钩子
在本节中,我们将看到如何在不使用任何库的情况下测试钩子。这将使我们深入了解如何测试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 的值返回。它还在响应中添加了一个200ms到500ms的随机延迟。
如果我们想改变响应,我们只需将第二个参数suffix 设置为一个非空的字符串值。
在这一点上,你可能会问,为什么要有延迟?为什么我们不立即返回响应?这是因为我们想尽可能地复制真实世界。如果我们即时返回,我们就不能正确地测试钩子。当然,我们可以把延迟减少到50-100ms,以加快测试速度,但在这篇文章中我们不要担心这个问题。
在我们的fetch mock准备好后,我们可以把它设置为fetch 函数。我们使用beforeAll 和afterAll 来做,因为这个函数是无状态的,所以我们不需要在单个测试后重置它。
// runs before any tests start running
beforeAll(() => {
jest.spyOn(global, "fetch").mockImplementation(fetchMock);
});
// runs after all tests have finished
afterAll(() => {
global.fetch.mockClear();
});
然后,我们需要在一个组件中安装这个钩子。为什么呢?因为钩子只是自己的函数。只有在组件中使用时,它们才能对useState,useEffect, 等作出反应。
所以,我们需要创建一个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上安装它。我们使用beforeEach 和afterEach 来为每次测试安装和卸载我们的组件,因为我们想在每次测试前用一个新的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 必须是一个全局变量,因为我们希望在测试断言中能够访问它。
有了这个设置,让我们做第一个测试,我们渲染一个URLurl1 ,由于获取URL需要一些时间(见fetchMock ),它最初应该渲染 "加载 "文本。
it("useStaleRefresh hook runs correctly", () => {
act(() => {
render(<TestComponent url="url1" />, container);
});
expect(container.textContent).toBe("loading");
})
使用yarn test 来运行测试,结果与预期一致。
现在,让我们来测试一下,当这个loading 的文本变为获取的响应数据,url1 。
我们如何做到这一点呢?如果你看一下fetchMock ,你会发现我们等待了200-500毫秒。如果我们在测试中放一个sleep ,等待500毫秒,会怎么样?这将涵盖所有可能的等待时间。让我们试试吧。
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 hook中的状态更新发生在act()之外。为了确保DOM更新被及时处理,React建议你在每次可能发生的重新渲染或UI更新时使用act() 。因此,我们需要用act 来包裹我们的睡眠,因为这是状态更新发生的时间。这样做之后,错误就消失了。
import { act } from "react-dom/test-utils";
// ...
await act(() => sleep(500));
现在,再次运行它(代码在GitHub上)。正如预期的那样,它没有错误地通过了。
让我们来测试下一种情况,我们先把URL改成url2 ,然后检查loading 的屏幕,再等待获取响应,最后检查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__");
});
这整个测试给了我们信心,钩子确实如预期的那样工作(代码)。万岁!现在,让我们快速浏览一下使用辅助方法的优化。
通过使用辅助方法优化测试
到目前为止,我们已经看到了如何完全测试我们的钩子。这种方法并不完美,但它是有效的。然而,我们可以做得更好吗?
可以。请注意,我们正在等待一个固定的500ms的时间来完成每个获取,但每个请求需要200到500ms。所以,我们在这里显然是在浪费时间。我们可以通过等待每个请求的时间来更好地处理这个问题。
我们如何做到这一点呢?一个简单的技术是执行断言,直到它通过或达到超时。让我们创建一个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");
}
}
这个函数只是每隔10ms在try...catch 块内运行一个回调(cb),如果达到timeout ,它就抛出一个错误。这允许我们以安全的方式运行一个断言,直到它通过(即没有无限循环)。
我们可以在我们的测试中使用它,如下所示。我们使用我们的waitFor 函数,而不是睡眠500ms然后断言。
// 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的要点。我们还可以做一些改进,比如:
- 将
result变量移到本地范围 - 取消为每个我们要测试的钩子创建一个组件的需要
我们可以通过创建一个工厂函数来做到这一点,该函数里面有一个测试组件。它也应该在测试组件中渲染钩子,并让我们访问result 变量。让我们看看我们如何做到这一点。
首先,我们把TestComponent 和result 移到函数里面。我们还需要将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 };
}
现在,我们可以在我们的测试中使用它。我们不使用act 和render ,而是做以下事情。
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做了我们之前说过的所有事情,然后还有一些。例如,它处理容器的挂载和卸载,所以你不必在你的测试文件中这样做。这使我们能够专注于测试我们的钩子而不被分心。
它带有一个renderHook 函数,返回rerender 和result 。它还返回wait ,这与waitFor 相似,所以你不必自己去实现它。
下面是我们如何在React-hooks-testing-library中渲染一个钩子。注意,钩子是以回调的形式传递的。这个回调在每次测试组件重新渲染时都会运行。
const { result, wait, rerender } = renderHook(
({ url }) => useStaleRefresh(url, defaultValue),
{
initialProps: {
url: "url1",
},
}
);
然后,我们可以通过这样的方式测试第一次渲染的结果是否为isLoading ,返回值为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 ,用新的道具来更新它。注意我们不需要在这里传递defaultValue 。
rerender({ url: "url2" });
最后,测试的其余部分将以类似的方式进行(代码)。
总结
我的目的是通过一个异步钩子的例子来告诉你如何测试React钩子。我希望这能帮助你自信地处理任何种类的钩子的测试,因为同样的方法应该适用于大多数钩子。
我推荐你使用React-hooks-testing-library,因为它很完整,而且到目前为止我还没有遇到过重大问题。如果你确实遇到了问题,你现在知道如何使用本文描述的错综复杂的测试钩子来处理它。
了解基础知识
你如何测试React Hook?
人们可以使用 react-hooks-testing-library 等库来测试 React Hooks。测试钩子类似于测试React组件,这个库提供了一个方便的抽象来做到这一点。
你应该使用React Hooks吗?
React Hooks是完全稳定的,许多生产代码库只使用钩子,不使用类组件。所以人们可以随意使用钩子。
为什么React Hooks是好的?
React Hooks允许在多个组件之间共享组件的生命周期和状态逻辑。这使得开发者可以将UI和逻辑模块化。这在以前是不可能的,因此钩子取得了巨大的成功。
React Hooks稳定吗?
是的,React Hooks是稳定的,100%向后兼容。人们可以放心地在他们的代码库中使用它们。
什么是自定义钩子?
自定义钩子是使用内置的React钩子为特定需求创建的钩子函数。它们的命名和使用方法与内置钩子类似。