面向人类的 React 组件测试

Avatar of Miroslav Nikolov
Miroslav Nikolov

DigitalOcean 为您的旅程每个阶段提供云产品。立即开始使用 $200 免费积分!

React 组件测试应该有趣、直观,并且易于人类构建和维护。

然而,当前的测试库生态系统不足以激励开发人员为 React 组件编写一致的 JavaScript 测试。测试 React 组件(以及 DOM 本身)通常需要在流行的测试运行器(如 Jest 或 Mocha)之上进行某种更高层的包装。

问题所在

使用当今可用的工具编写组件测试很无聊,即使你开始编写,它也需要很多麻烦。遵循类似 jQuery 风格(链式调用)的测试逻辑表达式很混乱。它与 React 组件的常见构建方式不一致。

下面的 Enzyme 代码可读性很好,但有点过于笨拙,因为它使用太多词语来表达最终只是简单标记的内容。

expect(screen.find(".view").hasClass("technologies")).to.equal(true);
expect(screen.find("h3").text()).toEqual("Technologies:");
expect(screen.find("ul").children()).to.have.lengthOf(4);
expect(screen.contains([
  <li>JavaScript</li>,
  <li>ReactJs</li>,
  <li>NodeJs</li>,
  <li>Webpack</li>
])).to.equal(true);
expect(screen.find("button").text()).toEqual("Back");
expect(screen.find("button").hasClass("small")).to.equal(true);

DOM 表示只是这样

<div className="view technologies">
  <h3>Technologies:</h3>
  <ul>
    <li>JavaScript</li>
    <li>ReactJs</li>
    <li>NodeJs</li>
    <li>Webpack</li>
  </ul>
  <button className="small">Back</button>
</div>

如果你需要测试更重的组件怎么办?虽然语法仍然可以忍受,但它并没有帮助你理解结构和逻辑。阅读和编写几个这样的测试肯定会让你精疲力尽——它确实让我精疲力尽。这是因为 React 组件遵循某些原则,最终生成 HTML 代码。另一方面,表达相同原则的测试并不直观。从长远来看,仅仅使用 JavaScript 链式调用无济于事。

在 React 中测试有两个主要问题

  • 如何才能以一种专门针对组件的方式编写测试
  • 如何避免所有不必要的噪音

让我们在深入实际示例之前进一步扩展这些问题。

React 组件测试方法

一个简单的 React 组件可能看起来像这样

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

这是一个接受 props 对象并使用 JSX 语法返回 DOM 节点的函数。

由于组件可以用函数表示,所以关键是测试函数。我们需要考虑参数以及它们如何影响返回的结果。将此逻辑应用于 React 组件测试的重点应该是设置 props 并测试 UI 中呈现的 DOM。由于用户操作(如 mouseoverclick、输入等)也可能导致 UI 变化,因此你需要找到一种方法以编程方式触发这些操作。

隐藏测试中的不必要噪音

测试需要一定程度的可读性,这可以通过简化措辞和遵循特定模式来描述每个场景来实现。

组件测试会经过三个阶段

  1. 准备: 准备组件 props。
  2. 执行: 组件需要将 DOM 渲染到 UI,并注册任何要以编程方式触发的用户操作(事件)。
  3. 断言: 设置预期结果,验证组件标记上的某些副作用。

这种单元测试模式被称为 准备-执行-断言

以下是一个例子

it("should click a large button", () => {
  // 1️⃣ Arrange
  // Prepare component props
  props.size = "large";

  // 2️⃣ Act
  // Render the Button's DOM and click on it
  const component = mount(<Button {...props}>Send</Button>);
  simulate(component, { type: "click" });

  // 3️⃣ Assert
  // Verify a .clicked class is added 
  expect(component, "to have class", "clicked");
});

对于更简单的测试,这些阶段可以合并

it("should render with a custom text", () => {
  // Mixing up all three phases into a single expect() call
  expect(
    // 1️⃣ Preparation
    <Button>Send</Button>, 
    // 2️⃣ Render
    "when mounted",
    // 3️⃣ Validation
    "to have text", 
    "Send"
  );
});

编写当今的组件测试

上面这两个例子看起来合乎逻辑,但实际上并非微不足道。大多数测试工具没有提供这种抽象级别,因此我们必须自己处理。也许下面的代码看起来更熟悉。

it("should display the technologies view", () => {
  const container = document.createElement("div");
  document.body.appendChild(container);
  
  act(() => {
    ReactDOM.render(<ProfileCard {...props} />, container);
  });
  
  const button = container.querySelector("button");
  
  act(() => {
    button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
  });
  
  const details = container.querySelector(".details");
  
  expect(details.classList.contains("technologies")).toBe(true);
});

将它与同一个测试进行比较,只是添加了一层抽象

it("should display the technologies view", () => {
  const component = mount(<ProfileCard {...props} />);

  simulate(component, {
    type: "click",
    target: "button",
  });

  expect(
    component,
    "queried for test id",
    "details",
    "to have class",
    "technologies"
  );
});

看起来确实更好。代码更少,流程更明显。这不是一个虚构的测试,而是你今天可以使用UnexpectedJS实现的测试。

以下部分将深入探讨测试 React 组件,但不会过于深入 UnexpectedJS。它的文档已经足够详细。相反,我们将专注于用法、示例和可能性

使用 UnexpectedJS 编写 React 测试

UnexpectedJS 是一个可扩展的断言工具包,与所有测试框架兼容。它可以扩展插件,以下测试项目中使用了一些插件。这个库最棒的地方可能在于它提供了一个方便的语法,用于描述 React 中的组件测试用例。

示例:个人资料卡片组件

测试的主题是一个个人资料卡片组件。

A card component where the persons name, photo, and number of posts are displayed to the left in a single column with a light red background, and the bio is displayed on the right in paragraph form with a title against a white background.

以下是 ProfileCard.js 的完整组件代码

// ProfileCard.js
export default function ProfileCard({
  data: {
    name,
    posts,
    isOnline = false,
    bio = "",
    location = "",
    technologies = [],
    creationDate,
    onViewChange,
  },
}) {
  const [isBioVisible, setIsBioVisible] = useState(true);

  const handleBioVisibility = () => {
    setIsBioVisible(!isBioVisible);
    if (typeof onViewChange === "function") {
      onViewChange(!isBioVisible);
    }
  };

  return (
    <div className="ProfileCard">
      <div className="avatar">
        <h2>{name}</h2>
        <i className="photo" />
        <span>{posts} posts</span>
        <i className={`status ${isOnline ? "online" : "offline"}`} />
      </div>
      <div className={`details ${isBioVisible ? "bio" : "technologies"}`}>
        {isBioVisible ? (
          <>
            <h3>Bio</h3>
            <p>{bio !== "" ? bio : "No bio provided yet"}</p>
            <div>
              <button onClick={handleBioVisibility}>View Skills</button>
              <p className="joined">Joined: {creationDate}</p>
            </div>
          </>
        ) : (
          <>
            <h3>Technologies</h3>
            {technologies.length > 0 && (
              <ul>
                {technologies.map((item, index) => (
                  <li key={index}>{item}</li>
                ))}
              </ul>
            )}
            <div>
              <button onClick={handleBioVisibility}>View Bio</button>
              {!!location && <p className="location">Location: {location}</p>}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

我们将使用组件的桌面版本。你可以阅读更多关于 设备驱动型代码拆分在 React 中的应用 的信息,但请注意,测试移动组件仍然非常直观。

设置示例项目

本文并未涵盖所有测试,但我们一定会查看最有趣的测试。如果你想跟着做,在浏览器中查看此组件,或检查其所有测试,请 克隆 GitHub 仓库

## 1. Clone the project:
git clone [email protected]:moubi/profile-card.git

## 2. Navigate to the project folder:
cd profile-card

## 3. Install the dependencies:
yarn

## 4. Start and view the component in the browser:
yarn start

## 5. Run the tests:
yarn test

以下是如何在项目启动后构建 <ProfileCard /> 组件和 UnexpectedJS 测试

/src
  └── /components
      ├── /ProfileCard
      |   ├── ProfileCard.js
      |   ├── ProfileCard.scss
      |   └── ProfileCard.test.js
      └── /test-utils
           └── unexpected-react.js

组件测试

让我们看看一些组件测试。这些测试位于 src/components/ProfileCard/ProfileCard.test.js 中。请注意每个测试是如何按照我们之前介绍的三个阶段进行组织的。

  1. 为每个测试设置所需的组件 props。
beforeEach(() => {
  props = {
    data: {
      name: "Justin Case",
      posts: 45,
      creationDate: "01.01.2021",
    },
  };
});

在每个测试之前,都会创建一个包含所需 <ProfileCard /> props 的 props 对象,其中 props.data 包含组件渲染所需的最小信息。

  1. 使用在线状态渲染。

现在我们检查个人资料是否使用“在线”状态图标进行渲染。

以及相应的测试用例

it("should display online icon", () => {
  // Set the isOnline prop
  props.data.isOnline = true;

  // The minimum to test for is the presence of the .online class
  expect(
    <ProfileCard {...props} />,
    "when mounted",
    "queried for test id",
    "status",
    "to have class",
    "online"
  );
});
  1. 使用生物文本渲染。

<ProfileCard /> 接受任何任意字符串作为其生物。

因此,让我们编写一个相应的测试用例

it("should display bio text", () => {
  // Set the bio prop
  props.data.bio = "This is a bio text";

  // Testing if the bio string is rendered in the DOM
  expect(
    <ProfileCard {...props} />,
    "when mounted",
    "queried for test id",
    "bio-text",
    "to have text",
    "This is a bio text"
  );
});
  1. 使用空列表渲染“技术”视图。

点击“查看技能”链接应切换到该用户技术的列表。如果未传递任何数据,则该列表应为空。

以下是该测试用例

it("should display the technologies view", () => {
  // Mount <ProfileCard /> and obtain a ref
  const component = mount(<ProfileCard {...props} />);

  // Simulate a click on the button element ("View Skills" link)
  simulate(component, {
    type: "click",
    target: "button",
  });

  // Check if the details element contains a .technologies className
  expect(
    component,
    "queried for test id",
    "details",
    "to have class",
    "technologies"
  );
});
  1. 渲染技术列表。

如果传递了技术列表,则在点击“查看技能”链接后,它将显示在 UI 中。

没错,另一个测试用例

it("should display list of technologies", () => {
  // Set the list of technologies
  props.data.technologies = ["JavaScript", "React", "NodeJs"];
 
  // Mount ProfileCard and obtain a ref
  const component = mount(<ProfileCard {...props} />);

  // Simulate a click on the button element ("View Skills" link)
  simulate(component, {
    type: "click",
    target: "button",
  });

  // Check if the list of technologies is present and matches the prop values
  expect(
    component,
    "queried for test id",
    "technologies-list",
    "to satisfy",
    {
      children: [
        { children: "JavaScript" },
        { children: "React" },
        { children: "NodeJs" },
      ]
    }
  );
});
  1. 渲染用户位置。

该信息应仅在作为 props 传递的情况下才在 DOM 中渲染。

相应的测试用例

it("should display location", () => {
  // Set the location 
  props.data.location = "Copenhagen, Denmark";

  // Mount <ProfileCard /> and obtain a ref
  const component = mount(<ProfileCard {...props} />);
  
  // Simulate a click on the button element ("View Skills" link)
  // Location render only as part of the Technologies view
  simulate(component, {
    type: "click",
    target: "button",
  });

  // Check if the location string matches the prop value
  expect(
    component,
    "queried for test id",
    "location",
    "to have text",
    "Location: Copenhagen, Denmark"
  );
});
  1. 在切换视图时调用回调函数。

此测试不会比较 DOM 节点,但会检查传递给 <ProfileCard /> 的函数 prop 是否在切换生物和技术视图时使用正确的参数执行。

it("should call onViewChange prop", () => {
  // Create a function stub (dummy)
  props.data.onViewChange = sinon.stub();
  
  // Mount ProfileCard and obtain a ref
  const component = mount(<ProfileCard {...props} />);

  // Simulate a click on the button element ("View Skills" link)
  simulate(component, {
    type: "click",
    target: "button",
  });

  // Check if the stub function prop is called with false value for isBioVisible
  // isBioVisible is part of the component's local state
  expect(
    props.data.onViewChange,
    "to have a call exhaustively satisfying",
    [false]
  );
});
  1. 使用一组默认 props 渲染。

关于 DOM 比较的一点说明
在大多数情况下,你应该避免在测试中使用 DOM 细节。改为使用测试 ID
如果出于某种原因你需要断言 DOM 结构,请参考以下示例。

此测试检查传递 namepostscreationDate 字段时组件生成的整个 DOM。

以下是结果在 UI 中的呈现方式

这是它的测试用例

it("should render default", () => {
  // "to exhaustively satisfy" ensures all classes/attributes are also matching
  expect(
    <ProfileCard {...props} />,
    "when mounted",
    "to exhaustively satisfy",
    <div className="ProfileCard">
      <div className="avatar">
        <h2>Justin Case</h2>
        <i className="photo" />
        <span>45{" posts"}</span>
        <i className="status offline" />
      </div>
      <div className="details bio">
        <h3>Bio</h3>
        <p>No bio provided yet</p>
        <div>
          <button>View Skills</button>
          <p className="joined">{"Joined: "}01.01.2021</p>
        </div>
      </div>
    </div>
  );
});

运行所有测试

现在,<ProfileCard /> 的所有测试都可以通过一个简单的命令执行

yarn test

请注意,测试是分组的。每个 <ProfileCard /> 视图(生物信息和技术)有两个独立的测试和两组测试。分组使测试套件更容易理解,并且是组织逻辑相关的 UI 单元的好方法。

一些最后的话

再说一次,这只是一个关于如何处理 React 组件测试的简单示例。关键是将组件视为接受道具并返回 DOM 的简单函数。从那时起选择测试库应该基于它提供的用于处理组件渲染和 DOM 比较的工具的有用性。 根据我的经验,UnexpectedJS 在这方面非常出色。

你的下一步是什么? 如果您还没有,请查看 GitHub 项目并试一试!检查 ProfileCard.test.js 中的所有测试,并尝试编写一些自己的测试。您还可以查看 src/test-utils/unexpected-react.js,这是一个从第三方测试库导出功能的简单辅助函数。

最后,这里还有一些我建议您查看的其他资源,以便更深入地了解 React 组件测试