mounty不可重新挂载因为先前没有完全卸载_【译】React Hooks测试完全指南

213d0b597ff5c96a7e3513aca923dca3.png
原文地址: https://www. toptal.com/react/testin g-react-hooks-tutorial

2018年底,React在16.8版本中引入了Hooks。它们(译注:指React Hooks)是连接函数组件并允许我们使用state和组件特性(如componentDidUpdatecomponentDidMount等)的函数。这在以前是不可能的。

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

在本文中,我们将探讨如何测试React Hooks,我们会选择开发一个复杂的hook并对其进行测试。

我们希望你是一个已经熟悉React Hooks的React开发人员。如果你想了解更多的知识,则应该阅读我们的教程,这是官方文档的链接(PS. 链接与本文无关,不贴了)。

用于测试的hook

本文我们将使用我在上一篇文章中编写的hook,即Stale-while-revalidate Data Fetching with React Hooks。这个hook称为useStaleRefresh。如果你还没有读过这篇文章,不用担心,我将在这里重述这一部分。

这就是我们将要测试的hook:

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是一个hook,它从URL获取数据,同时返回数据的缓存版本(如果存在的话),它使用一个简单的内存存储来保存缓存。

如果尚无可用的的数据或缓存,它还返回一个值为trueisLoading状态,客户端可以使用它来展示加载中标识。当缓存或新响应可用时,isLoading值设置为false

b9cdbb088b13e235b6bb01b6a1aa70f1.png
PS. 我建议花一些时间阅读上面的hook源码,以全面了解它的作用。

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

不使用测试库的目的是为了演示如何测试hook。 有了这些知识,你将能够debug在使用提供测试抽象的库(译注:例如上面提到的react-hooks-testing-library,就属于测试库)时可能出现的任何问题。

定义测试用例

在开始测试此hook之前,需要先准备测试计划,明确需要测试什么。既然我们知道hook应该做什么,这是我测试它的8步计划:

  1. 当hook使用URL url1挂载时,isLoadingtrue,数据为defaultValue
  2. 在异步获取请求之后,该hook将使用数据data1更新(组件),并且isLoadingfalse
  3. 当URL更改为url2时,isLoading再次变为true,数据为defaultValue
  4. 在异步获取请求之后,该hook将使用新数据data2更新(组件)。
  5. 然后,我们将URL改回url1。由于已缓存数据data1,因此会立即使用它。isLoading被设置为false
  6. 在异步获取请求之后,当接收到新的响应时,数据将更新为data3
  7. 然后,我们将URL改回url2。由于已缓存数据data2,因此会立即使用它。isLoading被设置为false
  8. 在异步获取请求之后,当接收到新的响应时,数据将更新为data4

上面提到的测试流程清楚地定义了hook是如何运行的。因此,如果我们可以确保此测试有效,那么说明hook是正确的。

d260018af955bd215db8a0eb727789a2.png

不依赖测试库测试

在本节中,我们将了解如何在不使用任何测试库的情况下测试hook,这将使我们深入了解如何测试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设置为非空字符串值。

此时,你可能会问,为什么要延迟?我们为什么不立即返回响应?这是因为我们想尽可能地模拟现实世界。如果我们立即返回hook,我们将无法正确测试hook。 当然,我们可以将延迟降低到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();
});

然后,我们需要将hook挂载到组件中。为什么呢?因为hooks只是它们(译注:指组件)的一种能力,只有在组件中使用时,hooks才能响应useStateuseEffect等。

因此,我们需要创建一个TestComponent以帮助我们挂载hook。

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

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

这是一个简单的组件,它要么render数据,要么render一个“loading”文本提示。

有了测试组件后,需要将其挂载到DOM中。对于每一次测试,我们使用beforeEachafterEach来挂载和卸载组件,因为我们希望在每次测试之前都是一个全新的DOM结构。

let container = null;

beforeEach(() => {
  // 指定一个DOM元素作为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),因此它应该首先渲染“loading”文本。

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

运行yarn test命令执行测试,其工作正常。完整的测试代码请参阅GitHub。

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

我们怎么做呢?从fetchMock得知,fetch首先会等待200-500毫秒。如果我们在测试中延迟500毫秒会怎么样?它将覆盖fetch所有可能的等待时间。让我们试试。

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包装sleep,因为这里是状态更新发生的时机。这样之后,错误就消失了:

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,这是用于修改响应数据的。因此,我们通过使用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__");
});

整个测试下来让我们确信hook确实按照预期工作(源码)。nice!现在,让我们快速了解一下使用辅助方法对测试过程进行优化。

使用辅助方法优化测试

到目前为止,我们已经了解了如何完整地测试我们的hook。这种方法是有效的,但其实并不完美。所以,我们能做得更好吗?

答案是肯定的!可以看到,每一次测试我们都需要等待固定的500ms以确保fetch完成,而每个请求会花费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),如果超时,则会抛出错误。这使得我们可以一直执行断言直到以安全的方式通过(即没有无限循环)为止。

可以在测试中使用它,如下所示:我们不用等待500ms然后再执行断言,而是使用了waitFor函数。

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

把所有的测试断言都按照这样修改之后,可以看到测试用例的运行速度会有显著的差异(源码)。

目前为止都很棒,但是有时我们不想通过UI来测试hook,而是想使用hook的返回值来测试,那么该怎么做呢?

这并不困难,因为我们已经可以访问hook的返回值了,只是它们在组件内部。如果能把这些变量放到全局可访问,它就可以做到只测试返回值了。

因为我们将通过hook的返回值而不是渲染的DOM来测试hook,所以可以从组件中删除HTML,并使其渲染null。此外还应该删除hook返回中的析构,使其更通用。以下是更新后的测试组件:

// global variable
let result;

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

现在,hook的返回值存储在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. 没必要每测试一个hook时都新建一个测试组件

可以通过创建一个自带测试组件的工厂函数来实现。它还应该在测试组件中执行要测试的hook,并允许外部访问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;
}

之所以将hook的返回值存储在result.current中,是因为我们希望在测试运行时(能够)更新返回值。 此处(指useStaleRefresh)hook的返回值是一个数组,如果直接返回,那么(renderHook)将返回hook的值拷贝(导致无法再次更新)。通过将其存储在对象中并返回对该对象的引用,可以通过更新result.current来更新(renderHook的)返回值。

现在,如何去更新hook呢?由于已经使用了一个闭包,因此再封装另一个函数来实现此功能。最终的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函数更新hook。 以下是一个简单的示例:

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

修改(所有的用例)之后,你将看到它们可以测试通过了,没有任何问题(源码)。

nice!现在我们有了更加清晰的抽象来测试hook了。但仍然可以有改进的地方,例如,即使保持不变,也需要每次rerender都传递defaultValue

但是,不要太过在意,因为我们已经有了一个可以大大改善这种体验的测试库,它就是react-hooks-testing-library。

使用React-hooks-testing-library

React-hooks-testing-library可以完成我们之前讨论的所有内容甚至更多。比如,它会处理container的挂载和卸载,因此你不必在测试文件中重复这些操作,这使得我们集中精力测试hook。

它带有一个renderHook函数,返回rerender方法和result对象。此外它还会返回与waitFor类似的wait方法,你不必自己去实现。

下面是我们在React-hooks-testing-library库中render hook的方法。注意,hook是以callback的形式传入的,每次测试组件re-render时callback都会重新执行。

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

现在,我们可以测试第一次render是否导致isLoadingtrue并返回值为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更新hook返回值了。请注意,此处无需再传defaultValue

rerender({ url: "url2" });

最后,剩下的测试用例也可以相对简单地进行(源码)。

结语

我的目的是通过一个简单的async hook来向你展示如何测试React Hooks。我希望这可以帮助你自信地解决任何类型的hooks的测试,因为文章介绍的方法应该可以适用于大部分hooks测试。

我建议使用React-hooks-testing-library,因为它已经足够完善了,到目前为止,我还没有遇到任何重大的问题。 如果确实遇到问题,你现在应该已经知道如何使用本文所述的方法来解决了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值