作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Avi Aryan
Verified Expert in Engineering

Avi是一名精通Python、JavaScript和Go的全栈开发人员. 他也是谷歌代码之夏的多次导师.

Expertise

PREVIOUSLY AT

Google
Share

Hooks是在React 16中引入的.8 in late 2018. 它们是与功能组件挂钩的函数,允许我们使用状态和组件特性,比如 componentDidUpdate, componentDidMount, and more. 这在以前是不可能的.

此外,钩子允许我们跨不同的组件重用组件和状态逻辑. 这在以前是很棘手的. 因此,钩子改变了游戏规则.

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

We expect that you are an avid React developer 已经熟悉React Hooks了. 如果你想温习一下你的知识,你应该去看看 our tutorial,这是网站的链接 official documentation.

我们将用于测试的钩子

在本文中,我们将使用我在上一篇文章中编写的钩子, 用React Hooks重新验证数据获取. The hook is called useStaleRefresh. 如果你还没有读过这篇文章,不要担心,因为我将在这里重述这一部分.

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

从react中导入{useState, useEffect};
const CACHE = {};

导出默认功能useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID是如何根据唯一请求识别缓存
    const cacheID = url;
    //查看缓存并设置响应
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      //否则确保加载设置为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];
}

As you can see, useStaleRefresh 是一个钩子,帮助从URL获取数据,同时返回数据的缓存版本, if it exists. 它使用一个简单的内存存储来保存缓存.

It also returns an isLoading 值,如果没有可用的数据或缓存,则为true. 客户端可以使用它来显示加载指示器. The isLoading 当缓存或新鲜响应可用时,该值设置为false.

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

At this point, 我建议你花点时间阅读上面的钩子,以全面了解它的作用.

In this article, 我们将看到如何测试这个钩子, 首先不使用测试库(只有React test Utilities和Jest),然后使用 react-hooks-testing-library.

不使用测试库的动机是.e., only a test runner Jest,演示如何测试钩子. With that knowledge, 您将能够调试在使用提供测试抽象的库时可能出现的任何问题.

Defining the Test Cases

在开始测试这个钩子之前,让我们先制定一个要测试的内容的计划. 既然我们知道钩子应该做什么,下面是我测试它的8步计划:

  1. 挂载钩子时使用URL url1, isLoading is true and data is defaultValue.
  2. 在异步获取请求之后,用数据更新钩子 data1 and isLoading is false.
  3. When the URL is changed to url2, isLoading 再次成为现实,数据是 defaultValue.
  4. 在异步获取请求之后,钩子会用新数据更新 data2.
  5. 然后,我们将URL更改回 url1. The data data1 是立即收到的,因为它是缓存的. isLoading is false.
  6. 在异步获取请求之后,当接收到新的响应时,数据将更新为 data3.
  7. 然后,我们将URL更改回 url2. The data data2 是立即收到的,因为它是缓存的. isLoading is false.
  8. 在异步获取请求之后,当接收到新的响应时,数据将更新为 data4.

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

Test flow

测试没有库的钩子

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

要开始这个测试,首先,我们想要模拟 fetch. 这样我们就可以控制API返回的内容. Here is the mocked fetch.

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

This modified fetch 假设响应类型始终是JSON,并且在默认情况下返回参数 url as the data value. 它还为响应增加了200ms到500ms之间的随机延迟.

如果要更改响应,只需设置第二个参数 suffix to a non-empty string value.

在这一点上,你可能会问,为什么延迟? 为什么我们不立即返回响应呢? 这是因为我们想要尽可能地复制真实世界. 如果立即返回钩子,就不能正确地测试它. 当然,我们可以将延迟减少到50-100毫秒 faster 测试,但在本文中我们不用担心这个问题.

准备好fetch mock后,我们可以将其设置为 fetch function. We use beforeAll and afterAll 这样做是因为这个函数是无状态的,所以我们不需要在单个测试后重置它.

//在任何测试开始运行之前运行
beforeAll(() => {
  jest.spyOn(global, "fetch").mockImplementation (fetchMock);
});

//在所有测试完成后运行
afterAll(() => {
  global.fetch.mockClear();
});

然后,我们需要将钩子挂载到组件中. Why? 因为钩子本身就是函数. 只有在组件中使用时,它们才能响应 useState, useEffect, etc.

So, we need to create a TestComponent 这能帮我们装上鱼钩.

// defaultValue是一个全局变量,以避免在重新渲染时改变对象指针
//我们还可以在钩子的useEffect中深度比较' defaultValue '
const defaultValue = {data: ""};

TestComponent(url}) {
  const [data, isLoading] = useStaleRefresh(url, defaultValue);
  if (isLoading) {
    return 
loading
; } return
{data.data}
; }

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

有了测试组件之后,我们需要将其挂载到DOM上. We use beforeEach and afterEach 为每个测试安装和卸载我们的组件,因为我们希望在每次测试之前从一个新的DOM开始.

let container = null;

beforeEach(() => {
  //设置一个DOM元素作为渲染目标
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(容器);
  container.remove();
  container = null;
});

Notice that container 必须是一个全局变量,因为我们想要访问它的测试断言.

有了这个集合,让我们进行第一个测试,其中呈现一个URL url1,因为获取URL需要一些时间(参见 fetchMock),它应该首先呈现“loading”文本.

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

Run the test using yarn test, and it works as expected. Here’s the complete code on GitHub.

Now, let’s test when this loading 文本更改为获取的响应数据, url1.

How do we do that? If you look at fetchMock你看,我们等待200-500毫秒. What if we put a sleep 在等待500毫秒的测试中? 它将涵盖所有可能的等待时间. Let’s try that.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

it("useStaleRefresh hook runs correctly", async () => {
  act(() => {
    render(, container);
  });
  expect(container.textContent).toBe("loading");

  await sleep(500);
  expect(container.textContent).toBe("url1");
});

测试通过了,但我们也看到了一个错误(code).

 PASS  src/useStaleRefresh.test.js
  usestalerfresh钩子正常运行(519ms)

  console.错误node_modules / react-dom cj / react-dom.development.js:88
    警告:在测试中对TestComponent的更新没有封装在act(...).

这是因为状态更新在 useStaleRefresh hook happens outside act(). 为了确保及时处理DOM更新,React建议使用 act() 每次重新渲染或UI更新都可能发生. 所以,我们需要用 act 因为这是状态更新发生的时间. 这样做之后,错误就消失了.

从“react-dom/test-utils”中导入{act};
// ...
await act(() => sleep(500));

Now, run it again (code on GitHub). 正如预期的那样,它没有错误地通过.

让我们测试下一种情况,首先将URL更改为 url2, then check the loading 屏幕,然后等待取回响应,最后检查 url2 text. 既然我们现在知道如何正确地等待异步更改,这应该很容易.

act(() => {
  render(, container);
});
expect(container.textContent).toContain("loading");

await act(() => sleep(500));
expect(container.textContent).toBe("url2");

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

你会注意到我们有一个额外的参数 suffix in our fetchMock function. 这用于更改响应数据. 所以我们更新我们的fetch mock来使用 suffix.

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

现在,我们可以测试URL设置为的情况 url1 again. It first loads url1 and then url1__. We can do the same for url2应该不会有什么意外.

it("useStaleRefresh hook runs correctly", async () => {
  // ...
  // new response
  global.fetch.mockImplementation((url) => fetchMock(url, "__"));

  // set url to url1 again
  act(() => {
    render(, container);
  });
  expect(container.textContent).toBe("url1");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url1__");

  // set url to url2 again
  act(() => {
    render(, container);
  });
  expect(container.textContent).toBe("url2");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url2__");
});

整个测试让我们确信钩子确实按预期工作(code). Hurray! 现在,让我们快速查看一下使用助手方法的优化.

使用辅助方法优化测试

到目前为止,我们已经了解了如何完整地测试钩子. 这种方法并不完美,但很有效. And yet, can we do better?

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

How do we do that? 一种简单的技术是执行断言,直到它通过或达到超时. Let’s create a waitFor function that does that.

async函数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");
  }
}

这个函数只是在a中运行一个回调函数(cb) try...catch 每隔10ms阻塞一次,如果 timeout ,它会抛出一个错误. 这允许我们运行断言,直到它以一种安全的方式传递.e., no infinite loops).

我们可以在我们的测试中这样使用它:不是休眠500ms然后断言,我们使用我们的 waitFor function.

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

在所有这样的断言中都这样做,我们可以看到测试运行速度的显著差异(code).

这些都很好,但也许我们不想通过UI测试钩子. 也许我们想用钩子的返回值来测试它. How do we do that?

这并不困难,因为我们已经可以访问钩子的返回值. 它们只是在组件内部. 如果我们能把这些变量放到全局作用域中,它就会起作用. So let’s do that.

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

// global variable
let result;

TestComponent(url}) {
  result = useStaleRefresh(url, defaultValue);
  return null;
}

现在,钩子的返回值存储在 result, a global variable. 我们可以通过它查询我们的断言.

// 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");

在各处修改之后,我们可以看到我们的测试通过了。code).

至此,我们了解了测试React Hooks的要点. 我们还可以做一些改进,比如:

  1. Moving result variable to a local scope
  2. 不再需要为每个要测试的钩子创建组件

我们可以通过创建一个工厂函数来实现,其中包含一个测试组件. 它还应该在测试组件中呈现钩子,并让我们访问 result variable. 我们来看看怎么做.

First, we move TestComponent and result inside the function. 我们还需要将Hook和Hook参数作为函数的参数传递,以便在测试组件中使用它们. 利用这个,我们得到了. 我们调用这个函数 renderHook.

renderHook(钩子,参数){
  let result = {};

  函数TestComponent({hookArgs}) {
    result.current = hook(...hookArgs);
    return null;
  }

  act(() => {
    render(, container);
  });

  return result;
}

The reason we have result 作为存储数据的对象 result.current 是因为我们希望在测试运行时更新返回值吗. 钩子的返回值是一个数组, 如果我们直接返回它,它就会被按值复制. By storing it in an object, 返回对该对象的引用,因此可以通过更新来更新返回值 result.current.

现在,我们如何更新钩子? 由于我们已经使用了闭包,让我们将另一个函数封闭起来 rerender that can do that.

The final renderHook function looks like this:

renderHook(钩子,参数){
  let result = {};

  函数TestComponent({hookArgs}) {
    result.current = hook(...hookArgs);
    return null;
  }

  function rerender(args) {
    act(() => {
      render(, container);
    });
  }

  rerender(args);
  返回{result, renderer};
}

现在,我们可以在测试中使用它. Instead of using act and render, we do the following:

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

Then, we can assert using result.current and update the hook using rerender. Here’s a simple example:

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

一旦你在所有地方都做了改动,你就会发现它没有任何问题了。code).

Brilliant! 现在我们有了一个更清晰的抽象来测试钩子. 我们还可以做得更好,比如, defaultValue 需要每次都传递给 rerender 即使它没有改变. We can fix that.

但是我们不要拐弯抹角了,因为我们已经有了一个能显著改善这种体验的库.

Enter react-hooks-testing-library.

使用react -hooks-test -library进行测试

React-hooks-testing-library完成了我们之前讨论过的所有工作. For example, 它可以处理容器的安装和卸载,所以您不必在测试文件中这样做. 这让我们能够专注于测试钩子而不会分心.

It comes with a renderHook function that returns rerender and result. It also returns wait, which is similar to waitFor,所以你不必自己实现它.

下面是我们在React-hooks-testing-library中渲染钩子的方法. 注意,钩子是以回调的形式传递的. 每次测试组件重新呈现时,都会运行这个回调.

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

然后,我们可以测试第一次渲染是否导致 isLoading as true and return value as defaultValue by doing this. 与我们上面实现的完全相似.

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

要测试异步更新,可以使用 wait method that renderHook returned. It comes wrapped with act() so we don’t need to wrap act() around it.

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

Then, we can use rerender to update it with new props. 注意,我们不需要通过 defaultValue here.

rerender({ url: "url2" });

最后,测试的其余部分将以类似的方式进行(code).

Wrapping Up

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

我建议你使用React-hooks-testing-library,因为它已经完成了, 到目前为止,我还没有遇到什么大问题. 以防你遇到问题, 现在您知道了如何使用本文中描述的复杂的测试钩子来实现它.

Understanding the basics

  • How do you test a React Hook?

    我们可以使用React - Hooks -testing-library之类的库来测试React Hooks. 测试钩子类似于测试React组件, 这个库提供了方便的抽象.

  • Should you use React Hooks?

    React Hooks是完全稳定的, 而且许多产品代码库只使用钩子而不使用类组件. 所以人们可以随心所欲地使用钩子.

  • Why are React Hooks good?

    React Hooks允许共享组件生命周期 & 跨多个组件的状态逻辑. 这允许开发人员模块化UI和逻辑. 这在以前是不可能的,因此hooks获得了巨大的成功.

  • Are React Hooks stable?

    是的,React Hooks是稳定的,并且100%向后兼容. 您可以在自己的代码库中随意使用它们.

  • What are custom hooks?

    自定义钩子是使用内置的React钩子为特定需求创建的钩子函数. 它们被命名,并且可以像内置钩子一样使用.

聘请Toptal这方面的专家.
Hire Now
Avi Aryan

Avi Aryan

Verified Expert in Engineering

New Delhi, Delhi, India

Member since March 28, 2018

About the author

Avi是一名精通Python、JavaScript和Go的全栈开发人员. 他也是谷歌代码之夏的多次导师.

作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

PREVIOUSLY AT

Google

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 privacy policy.

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 privacy policy.

Toptal Developers

Join the Toptal® community.