测试技巧

React 组件的常见测试模式。

注意:

此章节假设你正在使用 Jest 作为测试运行器。如果你使用不同的测试运行器,你可能需要调整 API,但整体的解决方案是相同的。在测试环境章节阅读更多关于设置测试环境的细节。

在本章中,我们将主要使用函数组件。然而,这些测试策略并不依赖于实现细节,它对于 class 组件也同样有效。


创建/清理

对于每个测试,我们通常希望将 React 树渲染给附加到 document的 DOM 元素。这点很重要,以便它可以接收 DOM 事件。当测试结束时,我们需要“清理”并从 document 中卸载树。

常见的方法是使用一对 beforeEachafterEach 块,以便它们一直运行,并隔离测试本身造成的影响:

  1. import { unmountComponentAtNode } from "react-dom";
  2. let container = null;
  3. beforeEach(() => {
  4. // 创建一个 DOM 元素作为渲染目标
  5. container = document.createElement("div");
  6. document.body.appendChild(container);
  7. });
  8. afterEach(() => {
  9. // 退出时进行清理
  10. unmountComponentAtNode(container);
  11. container.remove();
  12. container = null;
  13. });

你可以使用不同的测试模式,但请注意,即使测试失败,也需要执行清理。否则,测试可能会导致“泄漏”,并且一个测试可能会影响另一个测试的行为。这使得其难以调试。


act()

在编写 UI 测试时,可以将渲染、用户事件或数据获取等任务视为与用户界面交互的“单元”。react-dom/test-utils 提供了一个名为 act() 的 helper,它确保在进行任何断言之前,与这些“单元”相关的所有更新都已处理并应用于 DOM:

  1. act(() => {
  2. // 渲染组件
  3. });
  4. // 进行断言

这有助于使测试运行更接近真实用户在使用应用程序时的体验。这些示例的其余部分使用 act() 来作出这些保证。

你可能会发现直接使用 act() 有点过于冗长。为了避免一些样板代码,你可以使用 React 测试库,这些 helper 是使用 act() 函数进行封装的。

注意:

act 名称来自 Arrange-Act-Assert 模式。


渲染

通常,你可能希望测试组件对于给定的 prop 渲染是否正确。此时应考虑实现基于 prop 渲染消息的简单组件:

  1. // hello.js
  2. import React from "react";
  3. export default function Hello(props) {
  4. if (props.name) {
  5. return <h1>你好,{props.name}!</h1>;
  6. } else {
  7. return <span>嘿,陌生人</span>;
  8. }
  9. }

我们可以为这个组件编写测试:

  1. // hello.test.js
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import Hello from "./hello";
  6. let container = null;
  7. beforeEach(() => {
  8. // 创建一个 DOM 元素作为渲染目标
  9. container = document.createElement("div");
  10. document.body.appendChild(container);
  11. });
  12. afterEach(() => {
  13. // 退出时进行清理
  14. unmountComponentAtNode(container);
  15. container.remove();
  16. container = null;
  17. });
  18. it("渲染有或无名称", () => {
  19. act(() => { render(<Hello />, container); }); expect(container.textContent).toBe("嘿,陌生人");
  20. act(() => {
  21. render(<Hello name="Jenny" />, container);
  22. });
  23. expect(container.textContent).toBe("你好,Jenny!");
  24. act(() => {
  25. render(<Hello name="Margaret" />, container);
  26. });
  27. expect(container.textContent).toBe("你好,Margaret!");
  28. });

数据获取

你可以使用假数据来 mock 请求,而不是在所有测试中调用真正的 API。使用“假”数据 mock 数据获取可以防止由于后端不可用而导致的测试不稳定,并使它们运行得更快。注意:你可能仍然希望使用一个“端到端”的框架来运行测试子集,该框架可显示整个应用程序是否一起工作。

  1. // user.js
  2. import React, { useState, useEffect } from "react";
  3. export default function User(props) {
  4. const [user, setUser] = useState(null);
  5. async function fetchUserData(id) {
  6. const response = await fetch("/" + id);
  7. setUser(await response.json());
  8. }
  9. useEffect(() => {
  10. fetchUserData(props.id);
  11. }, [props.id]);
  12. if (!user) {
  13. return "加载中...";
  14. }
  15. return (
  16. <details>
  17. <summary>{user.name}</summary>
  18. <strong>{user.age}</strong>
  19. <br />
  20. 住在 {user.address}
  21. </details>
  22. );
  23. }

我们可以为它编写测试:

  1. // user.test.js
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import User from "./user";
  6. let container = null;
  7. beforeEach(() => {
  8. // 创建一个 DOM 元素作为渲染目标
  9. container = document.createElement("div");
  10. document.body.appendChild(container);
  11. });
  12. afterEach(() => {
  13. // 退出时进行清理
  14. unmountComponentAtNode(container);
  15. container.remove();
  16. container = null;
  17. });
  18. it("渲染用户数据", async () => {
  19. const fakeUser = { name: "Joni Baez", age: "32", address: "123, Charming Avenue" }; jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ json: () => Promise.resolve(fakeUser) }) );
  20. // 使用异步的 act 应用执行成功的 promise
  21. await act(async () => {
  22. render(<User id="123" />, container);
  23. });
  24. expect(container.querySelector("summary").textContent).toBe(fakeUser.name);
  25. expect(container.querySelector("strong").textContent).toBe(fakeUser.age);
  26. expect(container.textContent).toContain(fakeUser.address);
  27. // 清理 mock 以确保测试完全隔离 global.fetch.mockRestore();});

mock 模块

有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要。使用虚拟数据来 mock 这些模块可以使你为代码编写测试变得更容易。

考虑一个嵌入第三方 GoogleMap 组件的 Contact 组件:

  1. // map.js
  2. import React from "react";
  3. import { LoadScript, GoogleMap } from "react-google-maps";
  4. export default function Map(props) {
  5. return (
  6. <LoadScript id="script-loader" googleMapsApiKey="YOUR_API_KEY">
  7. <GoogleMap id="example-map" center={props.center} />
  8. </LoadScript>
  9. );
  10. }
  11. // contact.js
  12. import React from "react";
  13. import Map from "./map";
  14. export default function Contact(props) {
  15. return (
  16. <div>
  17. <address>
  18. 联系 {props.name},通过{" "}
  19. <a data-testid="email" href={"mailto:" + props.email}>
  20. email
  21. </a>
  22. 或者他们的 <a data-testid="site" href={props.site}>
  23. 网站
  24. </a>。
  25. </address>
  26. <Map center={props.center} />
  27. </div>
  28. );
  29. }

如果不想在测试中加载这个组件,我们可以将依赖 mock 到一个虚拟组件,然后运行我们的测试:

  1. // contact.test.js
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import Contact from "./contact";
  6. import MockedMap from "./map";
  7. jest.mock("./map", () => { return function DummyMap(props) { return ( <div data-testid="map"> {props.center.lat}:{props.center.long} </div> ); };});
  8. let container = null;
  9. beforeEach(() => {
  10. // 创建一个 DOM 元素作为渲染目标
  11. container = document.createElement("div");
  12. document.body.appendChild(container);
  13. });
  14. afterEach(() => {
  15. // 退出时进行清理
  16. unmountComponentAtNode(container);
  17. container.remove();
  18. container = null;
  19. });
  20. it("应渲染联系信息", () => {
  21. const center = { lat: 0, long: 0 };
  22. act(() => {
  23. render(
  24. <Contact
  25. name="Joni Baez"
  26. email="test@example.com"
  27. site="http://test.com"
  28. center={center}
  29. />,
  30. container
  31. );
  32. });
  33. expect(
  34. container.querySelector("[data-testid='email']").getAttribute("href")
  35. ).toEqual("mailto:test@example.com");
  36. expect(
  37. container.querySelector('[data-testid="site"]').getAttribute("href")
  38. ).toEqual("http://test.com");
  39. expect(container.querySelector('[data-testid="map"]').textContent).toEqual(
  40. "0:0"
  41. );
  42. });

Events

我们建议在 DOM 元素上触发真正的 DOM 事件,然后对结果进行断言。考虑一个 Toggle 组件:

  1. // toggle.js
  2. import React, { useState } from "react";
  3. export default function Toggle(props) {
  4. const [state, setState] = useState(false);
  5. return (
  6. <button
  7. onClick={() => {
  8. setState(previousState => !previousState);
  9. props.onChange(!state);
  10. }}
  11. data-testid="toggle"
  12. >
  13. {state === true ? "Turn off" : "Turn on"}
  14. </button>
  15. );
  16. }

我们可以为它编写测试:

  1. // toggle.test.js
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import Toggle from "./toggle";
  6. let container = null;
  7. beforeEach(() => {
  8. // 创建一个 DOM 元素作为渲染目标
  9. container = document.createElement("div");
  10. document.body.appendChild(container);});
  11. afterEach(() => {
  12. // 退出时进行清理
  13. unmountComponentAtNode(container);
  14. container.remove();
  15. container = null;
  16. });
  17. it("点击时更新值", () => {
  18. const onChange = jest.fn();
  19. act(() => {
  20. render(<Toggle onChange={onChange} />, container);
  21. });
  22. // 获取按钮元素,并触发点击事件
  23. const button = document.querySelector("[data-testid=toggle]");
  24. expect(button.innerHTML).toBe("Turn on");
  25. act(() => {
  26. button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  27. });
  28. expect(onChange).toHaveBeenCalledTimes(1);
  29. expect(button.innerHTML).toBe("Turn off");
  30. act(() => {
  31. for (let i = 0; i < 5; i++) {
  32. button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  33. } });
  34. expect(onChange).toHaveBeenCalledTimes(6);
  35. expect(button.innerHTML).toBe("Turn on");
  36. });

MDN描述了不同的 DOM 事件及其属性。注意,你需要在创建的每个事件中传递 { bubbles: true } 才能到达 React 监听器,因为 React 会自动将事件委托给 root。

注意:

React 测试库为触发事件提供了一个更简洁 helper


计时器

你的代码可能会使用基于计时器的函数(如 setTimeout)来安排将来更多的工作。在这个例子中,多项选择面板等待选择并前进,如果在 5 秒内没有做出选择,则超时:

  1. // card.js
  2. import React, { useEffect } from "react";
  3. export default function Card(props) {
  4. useEffect(() => {
  5. const timeoutID = setTimeout(() => {
  6. props.onSelect(null);
  7. }, 5000);
  8. return () => {
  9. clearTimeout(timeoutID);
  10. };
  11. }, [props.onSelect]);
  12. return [1, 2, 3, 4].map(choice => (
  13. <button
  14. key={choice}
  15. data-testid={choice}
  16. onClick={() => props.onSelect(choice)}
  17. >
  18. {choice}
  19. </button>
  20. ));
  21. }

我们可以利用 Jest 的计时器 mock 为这个组件编写测试,并测试它可能处于的不同状态。

  1. // card.test.js
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import Card from "./card";
  6. let container = null;
  7. beforeEach(() => {
  8. // 创建一个 DOM 元素作为渲染目标
  9. container = document.createElement("div");
  10. document.body.appendChild(container);
  11. jest.useFakeTimers();
  12. });
  13. afterEach(() => {
  14. // 退出时进行清理
  15. unmountComponentAtNode(container);
  16. container.remove();
  17. container = null;
  18. jest.useRealTimers();
  19. });
  20. it("超时后应选择 null", () => {
  21. const onSelect = jest.fn();
  22. act(() => {
  23. render(<Card onSelect={onSelect} />, container);
  24. });
  25. // 提前 100 毫秒执行 act(() => {
  26. jest.advanceTimersByTime(100);
  27. });
  28. expect(onSelect).not.toHaveBeenCalled();
  29. // 然后提前 5 秒执行 act(() => {
  30. jest.advanceTimersByTime(5000);
  31. });
  32. expect(onSelect).toHaveBeenCalledWith(null);
  33. });
  34. it("移除时应进行清理", () => {
  35. const onSelect = jest.fn();
  36. act(() => {
  37. render(<Card onSelect={onSelect} />, container);
  38. });
  39. act(() => {
  40. jest.advanceTimersByTime(100);
  41. });
  42. expect(onSelect).not.toHaveBeenCalled();
  43. // 卸载应用程序
  44. act(() => {
  45. render(null, container);
  46. });
  47. act(() => {
  48. jest.advanceTimersByTime(5000);
  49. });
  50. expect(onSelect).not.toHaveBeenCalled();
  51. });
  52. it("应接受选择", () => {
  53. const onSelect = jest.fn();
  54. act(() => {
  55. render(<Card onSelect={onSelect} />, container);
  56. });
  57. act(() => {
  58. container
  59. .querySelector("[data-testid='2']")
  60. .dispatchEvent(new MouseEvent("click", { bubbles: true }));
  61. });
  62. expect(onSelect).toHaveBeenCalledWith(2);
  63. });

你只能在某些测试中使用假计时器。在上面,我们通过调用 jest.useFakeTimers() 来启用它们。它们提供的主要优势是,你的测试实际上不需要等待 5 秒来执行,而且你也不需要为了测试而使组件代码更加复杂。


快照测试

像 Jest 这样的框架还允许你使用 toMatchSnapshot / toMatchInlineSnapshot 保存数据的“快照”。有了这些,我们可以“保存”渲染的组件输出,并确保对它的更新作为对快照的更新显式提交。

在这个示例中,我们渲染一个组件并使用 pretty 包对渲染的 HTML 进行格式化,然后将其保存为内联快照:

  1. // hello.test.js, again
  2. import React from "react";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { act } from "react-dom/test-utils";
  5. import pretty from "pretty";
  6. import Hello from "./hello";
  7. let container = null;
  8. beforeEach(() => {
  9. // 创建一个 DOM 元素作为渲染目标
  10. container = document.createElement("div");
  11. document.body.appendChild(container);
  12. });
  13. afterEach(() => {
  14. // 退出时进行清理
  15. unmountComponentAtNode(container);
  16. container.remove();
  17. container = null;
  18. });
  19. it("应渲染问候语", () => {
  20. act(() => {
  21. render(<Hello />, container);
  22. });
  23. expect( pretty(container.innerHTML) ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */
  24. act(() => {
  25. render(<Hello name="Jenny" />, container);
  26. });
  27. expect(
  28. pretty(container.innerHTML)
  29. ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */
  30. act(() => {
  31. render(<Hello name="Margaret" />, container);
  32. });
  33. expect(
  34. pretty(container.innerHTML)
  35. ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */
  36. });

通常,进行具体的断言比使用快照更好。这类测试包括实现细节,因此很容易中断,并且团队可能对快照中断不敏感。选择性地 mock 一些子组件可以帮助减小快照的大小,并使它们在代码评审中保持可读性。


多渲染器

在极少数情况下,你可能正在使用多个渲染器的组件上运行测试。例如,你可能正在使用 react-test-renderer 组件上运行快照测试,该组件内部使用子组件内部的 ReactDOM.render 渲染一些内容。在这个场景中,你可以使用与它们的渲染器相对应的 act() 来包装更新。

  1. import { act as domAct } from "react-dom/test-utils";
  2. import { act as testAct, create } from "react-test-renderer";
  3. // ...
  4. let root;
  5. domAct(() => {
  6. testAct(() => {
  7. root = create(<App />);
  8. });
  9. });
  10. expect(root).toMatchSnapshot();

缺少什么?

如果有一些常见场景没有覆盖,请在文档网站的 issue 跟踪器上告诉我们。