测试React Hooks的完整指南

305 阅读14分钟

测试React Hooks的完整指南

钩子是在2018年底的React 16.8中引入的。它们是钩住功能组件的函数,允许我们使用状态和组件功能,如componentDidUpdatecomponentDidMount ,等等。这在以前是不可能的。

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

在这篇文章中,我们将探讨如何测试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 ,其背后的动机是为了演示测试钩子是如何工作的。有了这些知识,你将能够调试在使用提供测试抽象的库时可能出现的任何问题。

定义测试案例

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

  1. 当钩子被装入URLurl1 时,isLoadingtrue ,数据是defaultValue
  2. 在一个异步获取请求之后,钩子被更新为数据data1isLoadingfalse
  3. 当URL被改变为url2isLoading 再次变为真实,数据为defaultValue
  4. 在一个异步获取请求之后,钩子被更新为新的数据data2
  5. 然后,我们将URL改回url1 。由于数据data1 被缓存了,所以马上就能收到。isLoading 是假的。
  6. 在一个异步获取请求之后,当收到一个新的响应时,数据被更新到data3
  7. 然后,我们将URL改回url2 。数据data2 ,因为它被缓存了,所以立即被接收。isLoading 是假的。
  8. 在一个异步获取请求之后,当收到一个新的响应时,数据被更新到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 函数。我们使用beforeAllafterAll 来做,因为这个函数是无状态的,所以我们不需要在单个测试后重置它。

// 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上安装它。我们使用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 必须是一个全局变量,因为我们希望在测试断言中能够访问它。

有了这个设置,让我们做第一个测试,我们渲染一个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的要点。我们还可以做一些改进,比如:

  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做了我们之前说过的所有事情,然后还有一些。例如,它处理容器的挂载和卸载,所以你不必在你的测试文件中这样做。这使我们能够专注于测试我们的钩子而不被分心。

它带有一个renderHook 函数,返回rerenderresult 。它还返回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钩子为特定需求创建的钩子函数。它们的命名和使用方法与内置钩子类似。