React-测试库的测试简化指南-全-

React 测试库的测试简化指南(全)

原文:zh.annas-archive.org/md5/78e0742bd6864a92b3933cdf20ea2c00

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React 测试库RTL)是一个轻量级且易于使用的工具,用于测试组件的 文档对象模型DOM)输出。本书将向您展示如何利用这个现代、用户友好的工具来测试 React 组件,并解释其如何降低 React 应用程序中的风险。

本书展示了代码片段,让您能够轻松实现 RTL,帮助您了解 DOM 测试库的指导原则,从用户的角度编写测试。您将探索从实际使用您组件的个人角度测试组件的优势,并使用 测试驱动开发TDD)来驱动编写测试的过程。随着您的进步,您将发现如何将 RTL 添加到 React 项目中,使用 Context API 测试组件,以及如何使用流行的 Cypress 库编写 UI 端到端测试。在整个书中,您将通过实际示例和有用的解释来创建测试,确保在做出更改时测试不会中断。

在完成这本 React 书籍后,您将学会所有测试 React 组件所需的知识,并能轻松地进行测试。

这本书面向谁?

这本书是为那些想要了解使用最新测试工具 RTL 测试 React 组件的现代实践的软件工程师、质量工程师和 React 开发者而编写的。为了充分利用本书,需要具备基本的 React 开发知识。

这本书涵盖了哪些内容?

第一章探索 React 测试库,将帮助您了解 DOM 测试库的指导原则,从用户的角度编写测试。您将学习实施细节导向测试的缺点。最后,您将学习使用 jest-dom 通过 RTL 来增强我们的测试的优势。

第二章与 React 测试库一起工作,将教会您如何将 RTL 添加到 React 项目中。您将学习如何使用 API 正确构建测试结构。您将查看呈现组件并编写一些初始测试。最后,我们将学习如何使用调试方法来帮助我们编写测试。

第三章使用 React 测试库测试复杂组件,将帮助您了解如何测试更复杂的 React 组件。您将学习如何使用 Fire Event 和用户事件模块来模拟用户交互。您将学习如何使用 TDD 来驱动编写测试的过程。最后,您将单元测试与 API 交互的组件。

第四章在您的应用程序中集成测试和第三方库,教您如何在各种 React 应用程序中测试组件。您将学习如何测试集成组件以及如何使用 Context API 测试组件。最后,您将使用如 GraphQL 和 Redux 等流行的第三方库测试组件。

第五章使用 React Testing Library 重构遗留应用程序,将教您在重构遗留 React 应用程序时处理破坏性更改的策略。您将学习如何在生产包更新时使用 RTL 测试来引导您解决破坏性更改。您还将学习如何将使用 Enzyme 或 ReactTestUtils 编写的测试转换为 RTL。

第六章实现用于测试的额外工具和插件,将帮助您学习额外的工具,以增强使用 RTL 测试 React 应用程序的能力。

第七章使用 Cypress 进行端到端 UI 测试,将教您如何使用流行的 Cypress 库编写端到端 UI 测试。您将学习设计模式来构建您的测试。您将学习如何使用 Cypress 测试 API。最后,您将学习如何将 Cucumber 和 React Developer Tools 集成到您的测试套件中。

为了充分利用本书

您需要在计算机上安装 NodeJS 和 npm——如果可能的话,安装最新版本。所有代码示例都已使用 macOS Big Sur 版本 11.2.3 上的 RTL 版本 11.2.5 进行测试。然而,它们也应该适用于未来的版本发布。

您可以使用任何代码编辑器来编写章节示例。然而,为了在运行示例时增强您的体验,我们为 VSCode 编辑器提供了建议的扩展。

如果您正在使用本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制/粘贴相关的任何潜在错误。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 支持选项卡中选择。

  3. 点击代码下载

  4. 搜索框中输入书籍名称并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本解压缩或提取文件夹:

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供在 github.com/PacktPublishing/ 上找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800564459_ColorImages.pdf(_ColorImages.pdf)。

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将被设置为粗体:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都应如下编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从 管理 面板中选择 系统信息。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com。我们在此表示感谢。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问 www.packtpub.com/support/errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,无论形式如何,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书感兴趣,请访问 authors.packtpub.com

评论

请留下您的评价。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问packt.com

第一章: 探索 React 测试库

React 测试库 是一个用于测试 React 组件 UI 输出的现代工具。它抽象了很多样板代码,使你能够编写更易于阅读的代码,并允许你测试代码。该库鼓励你远离测试实现细节,以避免许多错误的否定和错误的肯定测试用例。相反,该库的工具 API 使你能够轻松编写模拟实际用户行为的测试用例,从而对应用程序按预期工作有信心。此外,由于该库敦促你在编写测试时关注用户,因此你不需要在重构代码的实现细节时不断更新失败的测试。React 测试库允许你编写在关键功能意外更改时失败的测试,从而提供更多价值。

到本章结束时,你将了解实现细节是什么以及它们在测试用例的维护和价值方面带来的缺点。多年来,团队通过关注代码的实现细节来测试他们的组件已经成为一种常见的做法。许多团队至今仍在使用这种方法。然而,在本章中,我们将介绍更好的测试组件的方法。你将学习如何通过理解如何从用户的角度转变思维来测试,从而增强你的测试用例规划信心。我们将介绍 文档对象模型 (DOM) 测试库背后的理念,以简化测试任务,在转向主要关注点,即库的 React 版本之前。

接下来,你将学习关于 Jest 测试框架,该框架负责执行和断言我们的测试输出。最后,你将学习如何安装和使用 jest-dom 工具来增强测试断言。

本章将涵盖以下主要内容:

  • 了解 DOM 测试库

  • 理解 Jest 在测试 React 应用程序中的作用

  • 了解使用 jest-dom 测试 Jest 的 React 应用程序的优点

  • 理解以实现细节为重点的测试的缺点

本章的教训将为理解你将如何在本书中使用 React 测试库来编写更好的、关注用户视角的测试奠定基础。本章获得的知识将帮助你在编写 React 应用程序的测试时,无论是新手还是经验丰富的测试人员寻找更好的验证代码按预期工作的方式。

技术要求

对于本章的示例,你需要在你的机器上安装 Node.js。你可以在这里找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter01

介绍 DOM 测试库

DOM 测试库是由 Kent C. Dodds 和众多贡献者创建的一系列工具集合,旨在在我们测试应用程序 UI 时从真实用户的视角出发时使我们的工作更加轻松。本节将为您概述该库如何帮助使关注用户视角的测试工作变得更加容易。

什么是 DOM 测试库?

DOM 测试库使得像真实用户一样测试应用程序的 UI 变得更加容易,从而增强我们对应用程序按预期为用户工作的信心。没有方法可以获取组件的状态值或直接调用组件方法。

该库鼓励您以所有用户都可以使用的方式选择 DOM 元素。库的 API 包括关注可访问性的查询方法,允许您以需要屏幕阅读器或其他辅助技术来导航应用程序的残疾用户一样与 DOM 交互。例如,假设您想在测试中选择以下元素:

<input
  type="text"
  id="firstname"
  placeholder="first name..."
>

在前述代码中,我们有一个类型为 "text" 的输入元素。您可以使用 DOM 测试库通过其占位符值 "First Name..." 来在屏幕上选择该输入元素:

screen.getByPlaceholderText(/first name/i) 

getByPlaceholderText 方法用于从屏幕对象中选择 DOM 元素,通过前述代码中的占位符值来选择。屏幕对象允许您选择附加到 DOM 的 body 元素上的元素。注意查询中使用的正则表达式。用户可能不会在意文本是大写还是小写,因此 DOM 测试库允许您通过文本值来搜索元素,而不考虑大小写。

能够根据元素文本值选择元素,而不考虑其大小写,这增加了测试在元素实现细节发生变化时仍能保持通过结果的能力。使用 getByPlaceholderText 方法是选择元素的好方法,但我们可以通过重构源代码来更加具体:

<label for="firstname">First name:</label>
<input
  type="text"
  id="firstname"
  name="firstname"
  placeholder="first name..."
>

在前述代码中,我们添加了一个 label 元素和一个可访问的 name 属性到输入元素。现在所有用户,包括使用屏幕阅读器的用户,都可以访问该元素。我们可以在测试中这样选择输入元素:

screen.getByRole('textbox', {
  name: /first name:/i
})

在前述代码中,我们使用 getByRole 方法通过具有 first name 名称的文本框角色来选择元素。当可用时,通过角色选择元素是 DOM 测试库中首选的选择元素方式。

该库非常灵活,您可以使用它与任何提供访问 DOM 的 API 的 JavaScript 测试运行器一起使用,例如 Mocha 或 Jest。本书中我们将使用 Jest 运行我们的测试,并在下一节中了解更多关于它的信息。DOM 测试库有许多针对流行 UI 框架和库的特定版本。

特定版本为 DOM Testing Library 的 API 添加了额外功能,以便更方便地使用。例如,要将待测试的 React 组件放入 DOM 中,我们可以使用 ReactDOMrender 方法将组件放入一个附加到 DOM 的元素中:

const div = document.createElement('div');
ReactDOM.render(<SomeComponent />, div);
document.body.appendChild(div);
screen.getByText('Login');

在前面的代码中,首先,我们创建一个 div 元素。接下来,我们将 SomeComponent 组件附加到 div 元素上。然后,我们将 div 元素附加到 DOM 的 body 元素上。最后,我们使用 DOM Testing Library 的 screen 对象的 getByText 方法来查找具有文本值 Login 的元素。如果我们使用 React Testing Library,我们可以将前面代码中的前三行替换为 render 方法:

render(<SomeComponent />);

在前面的代码中,我们使用 React Testing Library 的 render 方法自动将待测试的组件渲染到 DOM 中。我们将在本书中展示如何使用库的 React 版本。甚至支持许多流行的端到端测试框架,例如 Cypress (www.cypress.io/),我们将在 第七章使用 Cypress 进行端到端 UI 测试 中介绍。该库提供了一个用于增强 Jest 测试断言的实用工具,我们将在本章后面介绍。

接下来,我们将介绍 DOM Testing Library 背后的指导原则。

指导原则

DOM Testing Library 通过提供易于使用的 API 来驱动,以测试你在 DOM 中渲染的应用程序。这些工具有助于让你有信心,你的测试代表了实际用户可能采取的操作。例如,你可能构建一个允许客户订阅你的时事通讯的电子邮件表单,如下所示:

图 1.1 – 订阅表单组件

图 1.1 – 订阅表单组件

因为你想确保表单能正常工作,所以你会考虑潜在订阅者在与表单交互时可能采取的步骤。你心想,如果我是用户,首先,我可能会寻找一个名为“电子邮件地址”的标签或一个上面写着“输入电子邮件”的占位符。DOM Testing Library 有一个名为 getByLabelText 的方法,可以快速通过屏幕上显示的标签找到电子邮件输入框。DOM Testing Library 还有一个名为 getByPlaceholderText 的方法,允许你通过其占位符值定位输入框。

在输入电子邮件后,你心想,接下来,我会寻找一个上面写着“订阅”的按钮并点击它。DOM Testing Library 提供了一个名为 getByText 的方法,允许你通过文本值来查找按钮。此外,该库还提供了一个名为 getByRole 的方法,允许你通过 subscribe 文本值来查询具有 button 角色的 DOM 元素。

现在你已经了解了 DOM Testing Library 的一般概念,我们将在下一节讨论我们将用于执行测试用例的框架。

使用 Jest 执行测试用例

Jest 是由 Facebook 团队创建的 JavaScript 测试框架。在本节中,我们将简要介绍 Jest 以及如何使用它来验证 React 组件测试用例的输出。

使用 Jest 运行测试

大多数 React 项目都使用 Jest 进行测试,因此在学习 React 测试库的同时介绍这个工具是有意义的。当我们使用 React 测试库编写测试时,我们需要一个测试运行器来执行测试。

使用以下命令在您的项目中安装 Jest:

npm install --save-dev jest

我们将使用 Jest 运行我们的 React 测试。从高层次来看,Jest 提供了 describeittestexpect 函数来组织和执行测试。你可以把 describe 函数看作是一个测试套件。使用 describe 函数来组织特定组件的相关测试。ittest 函数是针对特定测试的。ittest 函数是可以互换的函数,用于存放和运行单个测试用例的代码。使用 expect 函数来断言预期的输出。Jest 还提供了模拟函数来处理测试范围之外的代码和覆盖率报告。使用 Jest 编写的测试可以像测试纯函数的输出一样简单。

在以下示例中,我们使用 testexpect 函数来断言提供的名称中的字符总数:

test('should return the total number of characters', () => {
  function totalCharsInName(name) {
    return name.length;
  }
  expect(totalCharsInName('Steve')).toEqual(5);
});

让我们看看一个示例测试,该测试将断言使用类式 React 组件创建的 Profile 组件的详细信息。Profile 组件接受员工信息,并在 DOM 中将其显示为卡片式元素。用户可以点击按钮来在屏幕上隐藏或显示员工的详细信息。以下是组件 DOM 输出的截图:

图 1.2 – 配置组件

图 1.2 – 配置组件

让我们看看 Profile 组件的代码。我们创建一个带有 showDetails 属性的 state 对象,初始值设置为 true。接下来,我们创建一个 setDetails 方法,该方法将更新 showDetailsstate 值:

import React, { Component } from 'react';
export default class Profile extends Component {
  state = { showDetails: true };
  setDetails = () => {
    this.setState((prevState) => ({ showDetails: !prevState.      showDetails }));
  };

render 方法内部,我们显示传递给组件的 nametitle 属性:

render() {
    return (
      <div className="card" style={{ width: "18rem" }}>
        <img
          className="card-img-top"
          src="img/286x180?font=lobster"
          alt="Card cap"
        />
        <div className="card-body">
          <h5 className="card-title">{this.props.name}</h5>
          <p className="card-subtitle mb-2 text-muted">{this.             props.title}</p>

注意,src 图像只是一个占位符图像,但理想情况下应该接受传递的值,例如 nametitle 属性值。

最后,我们有一个按钮,当点击时将调用 setDetails 方法。按钮的文本设置为 Hide DetailsDisplay Details,这取决于 state.showDetails 的值。此外,根据 state.showDetails 的值显示员工详细信息:

button onClick={this.setDetails} className="btn btn-primary">
            {this.state.showDetails ? "Hide Details" : "Display                  Details"}
          </button>
          {this.state.showDetails ? (
            <p className="card-text details">{this.props.                  details}</p>
          ) : null}
        </div>
      </div>
    );
  }
}

现在,让我们看看一个 Profile 组件测试的代码,该测试验证当点击带有文本 "hide details" 的按钮时,显示带有文本 "display details" 的按钮:

test('Profile, given click "hide details" button, shows "display details" button', () => {
  const div = document.createElement('div');
  ReactDOM.render(
    <Profile
      name='John Doe'
      title='Team Lead'
      details='This is my 5th year and I love helping others'
    />,
    div
  );
document.body.appendChild(div);

在前面的代码中,我们创建了一个 div 元素,稍后将与测试组件一起使用。然后,我们使用 ReactDOMrender 方法将带有传入属性的 Profile 组件渲染到 div 中。最后,我们将包含组件的 div 添加到 DOM 的 body 元素中。接下来,我们对生成的 DOM 输出执行操作:

  const hideDetailsBtn = screen.getByRole('button', { name: /     hide details/i });
  fireEvent.click(hideDetailsBtn);
  const displayDetailsBtn = screen.getByRole('button', {
    name: /display details/i,
  });

在前面的代码中,首先我们获取名为 hide details 的按钮。然后,我们在 hide details 按钮上触发一个点击事件。接下来,我们获取名为 display details 的按钮。然后,我们将进行断言:

  expect(displayDetailsBtn).toBeTruthy();
  // Test cleanup
    div.remove();
});

在前面的代码中,首先,我们期望 display details 按钮为 truthy,这意味着元素在 DOM 中被找到。然后,我们通过从 DOM 的 body 元素中移除 div 来进行一些测试清理,以便后续测试可以从一个干净的状态开始。

让我们再次查看之前的测试,重点关注代码中的 Jest 部分。

特定测试的所有代码都位于 ittest 方法内部:

test(Profile, given click "hide details" button, shows "display   details" button', () => {}
it(Profile, given click "hide details" button, shows "display   details" button', () => {}

当使用 npm test 运行 Jest 时,此方法和测试文件中的其他方法将执行。Jest 将找到您的测试文件,执行其中的测试,并在控制台显示测试结果:

![Figure 1.3 – 配置测试通过控制台输出]

![Figure 1.3 – B16887.jpg]

![Figure 1.3 – 配置测试通过控制台输出]

如果我们对一个组件有多个测试,我们可以在 describe 方法中将它们组织在一起:

describe(<Profile />, () => {
 test(given click "hide details" button, shows "display   details" button', () => {}
 test('another test for the profile component', () => {}
}

测试最重要的部分是断言。在 Jest 中,您可以使用 expect 方法断言测试的输出:

expect(displayDetailsBtn).toBeTruthy();

通过 expect 方法链式调用的 toBeTruthy 方法是 Jest 提供的许多匹配器方法之一,用于验证传递给 expect 的代码的预期结果。

现在,您已经理解了 Jest 在测试 React 应用程序中的作用。您可以为框架安装它,并使用其 API 来组织、编写和执行测试用例。接下来,您将了解 Testing Library 提供的一个实用工具,用于添加增强的 Jest 断言匹配器,以帮助测试 React 组件。

使用 jest-dom 增强 Jest 断言

jest-dom 是 DOM 测试库的一个实用工具,它为测试提供了额外的 Jest 断言。在本节中,您将学习如何安装 jest-dom 并了解使用 jest-dom 与 Jest 一起使用的优势。

将 jest-dom 添加到项目中

使用以下步骤将 jest-dom 添加到包含 Jest 的项目中:

  1. 使用 npm 安装此包:

    npm install –-save-dev @testing-library/jest-dom
    
  2. 将以下片段添加到您的测试文件顶部:

    import '@testing-library/jest-dom/extend-expect';
    

在将 jest-dom 安装并导入到您的测试文件后,您就可以开始使用额外的断言了,因为它们现在都是通过 expect 方法链式调用的。您将在下一节中看到详细的示例用法。

使用 jest-dom 与 Jest 一起使用的优势

您可以通过在使用 Jest 的 React 项目中包含 jest-dom 来增强以用户为中心的测试目标。jest-dom 为 Jest 断言提供了两个重要的增强。首先,jest-dom 提供了超过 20 个自定义 DOM 匹配器,可以创建更具描述性的测试代码。其次,jest-dom 还提供了更好的上下文特定错误消息。我们将在以下小节中展示这两个优势的示例。

jest-dom 描述性测试代码示例]

为了说明使用 jest-dom 的好处,首先,我们将回顾上一节中提到的 Profile 组件测试文件,即 介绍 DOM 测试库。请注意,我们只会关注测试中的断言代码。我们断言了一个元素的文本值:

const displayDetailsBtn = screen.getByRole('button', {
    name: /display details/i,
  });  
expect(displayDetailsBtn).toBeTruthy();

前面代码的语法可能对一些不熟悉代码库的开发者来说并不清晰。可能不明显的是,我们正在验证元素是否在 DOM 中找到。jest-dom 有一个 toBeInTheDocument() 方法可以提供更简洁的语法。

接下来,我们将使用 jest-domtoBeInTheDocument() 方法重构我们的代码,使代码更具描述性:

expect(displayDetailsBtn).toBeInTheDocument();

现在,从语法上更明显地看出,我们期望元素在 DOM 中。我们还可以断言当点击时,hide details 按钮将从 DOM 中移除:

    const removedHideDetailsBtn = screen.queryByRole('button',       {
    name: /hide details/i,
  });
  expect(removedHideDetailsBtn).toBeFalsy();

Jest 的 toBeFalsy() 方法被用来返回一个 null 值,这在 JavaScript 中评估为 false。对于测试来说,这意味着元素没有在 DOM 中找到。然而,语法并不明确表明使用 toBeFalsy() 表示元素没有按预期在 DOM 中找到,可能会让一些查看代码的开发者感到困惑。

我们可以再次使用 toBeInTheDocument() 方法来提供更简洁的语法:

  expect(removedHideDetailsBtn).not.toBeInTheDocument();

现在,从前面代码的语法中可以看出,我们期望元素不在 DOM 中。请注意,在 jest-dom 方法之前使用了 Jest 的 not 属性。not 属性用于返回相反的值。如果我们没有使用 not 属性,我们就会断言元素在 DOM 中。

作为另一个例子,假设我们有一个以下登录表单想要测试:

![图 1.4 – 登录组件图 1.4 – 登录组件

图 1.4 – 登录组件

在前面的屏幕截图中,我们有一个登录表单功能,允许用户输入用户名和密码,并检查 disabled 状态,直到用户为 usernamepassword 字段输入值。在这个例子中,登录表单目前仍在开发中,所以目前当用户点击 登录 按钮时没有任何操作。然而,我们可以编写一个测试来验证当用户输入凭据时,登录 按钮被启用:

test('Login, given credentials, returns enabled submit button', () => {
  const div = document.createElement('div');
  ReactDOM.render(<Login />, div);
  document.body.appendChild(div);
  const username = screen.getByRole('textbox', { name: /     username/i });
  const password = screen.getByLabelText(/password/i);
  const rememberMe = screen.getByRole('checkbox');
  const loginBtn = screen.getByRole('button', { name: /submit/i });

在前面的代码中,我们通过将 Login 组件渲染到 div 标签中并将其附加到 DOM 中的 body 元素来设置我们的测试。接下来,我们获取所有表单元素,包括 usernamepasswordrememberMelogin 按钮,并将它们放入变量中。

接下来,我们将对 DOM 执行用户操作:

  const fakeData = {
    username: 'test user',
    password: '123password',
  };
  fireEvent.change(username, { target: { value: fakeData.     username } });
  fireEvent.change(password, { target: { value: fakeData.     password } });
  fireEvent.click(rememberMe);

在前面的代码中,我们创建了一个fakeData对象,并赋予其值以用于测试。接下来,我们使用fireEventusernamepassword字段添加值,并最终点击rememberMe复选框。然后,我们将进行断言:

  expect(loginBtn.hasAttribute('disabled')).toBe(false);

在前面的代码中,我们断言loginBtn有一个设置为falsedisabled属性。然而,我们可以使用jest-dom断言方法来获得更简洁的语法:

  expect(loginBtn).not.toBeDisabled()

在前面的代码中,我们使用toBeDisabled()方法来验证jest-dom方法以验证输入值后表单的预期状态:

  expect(screen.getByTestId('form')).toHaveFormValues({
    username: fakeData.username,
    password: fakeData.password,
    rememberMe: true,
  });

在前面的代码中,我们使用toHaveFormValues()方法来验证表单输入具有输入的fakeData值,使用易于阅读的语法。我们之前的测试的伟大之处在于,只要功能保持不变,我们就可以继续构建登录表单或重构内部代码,而不用担心当前的测试会中断。

现在你已经了解了jest-dom方法如何让你编写更具描述性的测试代码。接下来,我们将使用相同的示例来说明jest-dom方法如何提供更好的上下文特定错误信息。

jest-dom 错误信息示例

在上一节中,我们断言了Login组件的usernamepassword凭证。我们还可以断言jest-dom assertion方法,我们可能在hasAttribute方法中错误地输入了disable而不是disabled

expect(loginBtn.hasAttribute('disable')).toBe(true);
fireEvent.change(username, { target: { value: fakeData.username } });
fireEvent.change(password, { target: { value: fakeData.password } });

前面代码中的错别字会导致以下测试结果输出:

图 1.5 – 登录组件假阴性测试

图 1.5 – 登录组件假阴性测试

在前面的屏幕截图中,结果显示测试在期望为true的点接收到了一个假值。结果是假阴性,因为源代码是正确的,但我们的测试代码是错误的。

错误信息并没有明确指出断言代码中存在错别字,导致我们的测试没有收到预期的结果。我们可能会通过错误地使用not属性与jest-domtoBeDisabled方法进行类似的测试代码错误:

expect(loginBtn).not.toBeDisabled();
fireEvent.change(username, { target: { value: fakeData.username } });
fireEvent.change(password, { target: { value: fakeData.password } });
fireEvent.click(rememberMe);

在前面的代码中,我们错误地断言在测试中应该被禁用的登录按钮没有被禁用。前面代码中的测试代码错误会导致以下测试结果输出:

图 1.6 – 第二个登录组件假阴性测试

图 1.6 – 第二个登录组件假阴性测试

在前面的屏幕截图中,结果显示测试失败,但我们还收到了有助于定位错误并在调试时提供反馈的有用信息。测试输出告诉我们接收到的元素已被禁用,并将元素记录到控制台以查看所有属性。disabled属性在element输出中显示,这有助于我们理解我们需要调试测试代码以了解为什么我们没有收到预期的结果。

现在你已经知道了jest-dom的方法如何提供更好的上下文特定错误消息以更快地解决问题。在下一节中,我们将了解在测试中包含实现细节的缺点。

测试实现细节

实现细节包括组件状态当前值或显式调用 DOM 中按钮附加的方法。实现细节是当用户使用组件时从用户抽象出来的组件的内部部分。作为一个类比,我们可以想到驾驶汽车的经历。为了移动,你必须使用钥匙启动汽车,将汽车置于驱动状态,并踩下油门。你不需要知道车辆引擎盖下的一切是如何连接的。你可能甚至不关心。你唯一关心的是,当你执行上述行为时,你可以驾驶汽车。

在本节中,你将探索在测试中关注实现细节的缺点。我们将向你展示包含实现细节的测试示例。最后,你将了解如何在测试时将注意力从考虑实现细节中转移开。

关注实现细节的测试的问题

当你编写关注代码内部细节的测试时,你会创建一个场景,增加你每次更改这些细节时重构测试的机会。例如,如果你有一个状态对象属性名为value,并且编写一个测试来断言state.value === 3,那么当将状态属性名称更改为currentValue时,该测试将失败。让你的测试代码依赖于状态对象属性名称是一个问题,因为它增加了大量的不必要的额外维护并减慢了你的工作流程。

另一个问题在于执行这个测试用例会产生一个错误的阴性结果,因为其功能并没有改变;只是状态名称发生了变化。你的测试应该让你对与应用程序中与用户行为相关的最有价值部分按预期工作充满信心,并迅速让你知道为什么这不是这种情况。

从最有价值的测试者角度——即实际用户的角度——测试实现细节并不能验证应用程序代码。例如,如果你构建了一个账户创建表单组件并将其部署到生产环境中,与表单通过 UI 交互的最终用户将关注填写表单并点击onChange方法或当用户输入新文本时对状态对象属性usernameVal的更新。

然而,如果你测试当用户填写表单并点击提交按钮时,预期的结果会发生,那么你可以降低用户的风险。用户不会直接与方法和状态对象交互;因此,我们的测试可以通过关注用户如何在 UI 中与表单交互而更有价值。

在另一个示例中,使用相同的组件,一个软件工程师是一个用户,他将在应用程序代码中添加账户创建表单以及所需的依赖项。工程师用户关心当他们尝试使用组件时,组件是否按预期渲染。同样,你可以测试第一个示例中提到的相同实现细节。

然而,如果你测试当工程师使用所需数据渲染表单时数据是存在的,那么你可以更有信心地认为组件将按预期为用户工作。记住,这并不意味着你应该永远不测试代码的实现细节。在大多数情况下,关注用户的测试比关注实现细节的测试更有信心。

接下来,我们将展示一个测试实现细节的示例,以进一步说明这一点。

集中于实现细节的测试示例

让我们通过一个示例测试来进一步说明测试组件实现细节的问题。这个测试将断言使用类式 React 组件创建的Profile组件的细节。Profile组件接受员工信息,并在 DOM 中以卡片式元素的形式显示。用户可以点击按钮来在屏幕上隐藏或显示员工的详细信息。以下是组件 DOM 输出的截图:

图 1.7 – Profile 组件

图 1.7 – Profile 组件

让我们来看看Profile组件的代码。我们创建了一个带有showDetails属性的 state 对象,初始值设置为true。接下来,我们创建了一个setDetails方法,它将更新showDetails的状态值:

import React, { Component } from 'react';
export default class Profile extends Component {
  state = { showDetails: true };
  setDetails = () => {
    this.setState((prevState) => ({ showDetails: !prevState.      showDetails }));
  };

render方法内部,我们显示传递给组件的nametitle属性:

render() {
    return (
      <div className="card" style={{ width: "18rem" }}>
        <img
          className="card-img-top"
          src="img/286x180?font=lobster"
          alt="Card cap"
        />
        <div className="card-body">
          <h5 className="card-title">{this.props.name}</h5>
          <p className="card-subtitle mb-2 text-muted">{this.             props.title}</p>

注意,src图像只是一个占位符图像,但理想情况下应该接受传递的值,例如nametitle属性值。

最后,我们有一个按钮,当点击时会调用setDetails方法。按钮的文本根据state.showDetails的值设置为Hide DetailsDisplay Details。此外,根据state.showDetails的值显示员工详细信息:

button onClick={this.setDetails} className="btn btn-primary">
            {this.state.showDetails ? "Hide Details" : "Display                 Details"}
          </button>
          {this.state.showDetails ? (
            <p className="card-text details">{this.props.                details}</p>
          ) : null}
        </div>
      </div>
    );
  }
}

现在,让我们看看Profile组件的测试代码。这个测试是用 Enzyme 创建的,Enzyme 是一个 React 测试工具,它使得避免测试实现细节变得困难,并且使用了 Jest。在测试文件中,我们对Profile组件有一个包含四个断言的测试。

我们将Profile组件挂载到 DOM 中,并设置了所需的依赖项的值。我们将name属性设置为"John Doe"title属性设置为"Team Lead"details属性设置为"这是我第 5 年,我喜欢帮助他人"

import React from 'react';
import { mount } from 'enzyme';
import Profile from './Profile';
test('setDetails sets the state value', () => {
 const wrapper = mount(
   <Profile
     name="John Doe"
     title="Team Lead"
     details="This is my 5th year and I love helping others"
   />
  );

接下来,我们对Profile组件的结果 DOM 输出进行断言。我们断言showDetails状态属性的值。我们在 DOM 中搜索具有.card-text.details类的元素,并断言其文本的值。然后,我们调用组件的setDetails方法:

 expect(wrapper.state('showDetails')).toEqual(true);
 expect(wrapper.find('.card-text.details').props().children).    toEqual(
  'This is my 5th year and I love helping others'
 );
wrapper.instance().setDetails();

最后,我们断言showDetails状态属性已更改,并且具有.card-text.details类的元素不再在 DOM 中:

 expect(wrapper.state('showDetails')).toEqual(false);
 expect(wrapper.update().find('.card-text.details').exists()).    toBeFalsy();
});

总结来说,测试对以下实现细节进行断言:

  • 状态对象

  • setDetails方法

  • 具有类.card-text.details的元素

如果状态对象的某个属性发生变化,我们的测试将失败。如果setDetails方法的名字被更改或替换为其他代码(不影响功能),我们的测试将失败。最后,如果测试使用的类名更改以选择元素,我们的测试将失败。

如您所见,如果实现细节与功能变化相比发生变化,测试的断言可能会产生假阴性结果。这增加了需要经常更新测试而不是基于实际用户行为的测试的可能性。

现在您已经了解了测试实现细节的缺点,让我们看看更好的测试方法。

如何摆脱实现细节测试

现在您已经了解了以实现细节为重点的测试的缺点,您如何编写测试以确保它们将按预期为用户工作?简单地说,就像正在测试的软件是一个黑盒一样创建您的测试。您的思考过程应该是,在使用这个软件时,软件工程师或最终用户可能会有哪些期望?让我们看看以下具有添加减去按钮的Counter组件,这些按钮显示当前值。根据点击的按钮,值会增加或减少 1:

图 1.8 – 计数器组件

图 1.8 – 计数器组件

尝试想出尽可能多的软件工程师或最终用户在使用表单时可能执行的黑盒场景。

这里有一些示例场景:

  • Counter组件被渲染时,计数器在 DOM 中显示。

  • 当用户点击添加按钮时,当前值增加 1。

  • 当用户点击减去按钮时,当前值减少 1。

这些场景确保与关注状态变化或方法调用等事物相比,我们的应用程序按预期为用户工作。

现在您知道如何摆脱以实现细节为重点的测试用例,而是专注于实际用户。我们已经在本章的前几节中看到了许多应用用户关注测试方法的例子。

摘要

在本章中,你了解了 DOM Testing Library 以及它是如何设计来帮助你编写以用户为中心的测试的。你现在理解了 DOM Testing Library 的设计如何帮助你获得信心,确保你的软件按预期为用户工作。你学习了如何安装 Jest,并理解它是一个测试运行器,我们将使用它来测试 React 代码。你了解了 jest-dom。你知道它可以为你的测试断言添加更好的错误消息和描述性的 DOM 匹配器。你现在可以在使用 Jest 的项目中安装和使用 jest-dom。最后,你对实现细节为中心的测试的缺点有了理解。

在下一章中,我们将学习如何使用 React Testing Library 安装和开始编写 React 组件的测试。

问题

  1. 安装本章中提到的所有工具,并编写一个简单的测试。

  2. 在线搜索关注实现细节的测试示例。识别所有实现细节,并使用 DOM Testing Library 创建测试的重构版本。

  3. 在 MDN Web Docs 中搜索关于 ARIA 角色的文章。接下来,练习使用 getByRole 查询来选择各种元素编写测试。

第二章: 使用 React 测试库

到本章结束时,你将了解如何将 React 测试库添加到 React 项目中。React 测试库是一个现代工具,用于从最终用户的角度测试 React 组件的 UI 输出。你将学习如何使用 API 中的方法正确构建测试。你将学习如何测试表现性组件。最后,你将学习如何使用 debug 方法来协助构建测试。

在本章中,我们将涵盖以下主题:

  • 将 React 测试库添加到现有项目中

  • 使用 React 测试库构建测试结构

  • 测试表现性组件

  • 在编写测试时使用 debug 方法

本章中你将学习的技能将为后续章节中更复杂的组件场景打下基础。

技术要求

对于本章的示例,你需要在你的机器上安装 Node.js。我们将使用 create-react-app CLI 工具来展示所有代码示例。如果需要,请在开始本章之前熟悉这个工具。你可以在这里找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter02

将 React 测试库添加到现有项目中

要开始使用 React 测试库,我们首先需要将工具安装到我们的 React 项目中。我们可以手动安装它,或者使用 create-react-app,这是一个特定的 React 工具,它自动为你安装了 React 测试库。

手动安装

使用以下命令将 React 测试库添加到你的项目中:

npm install --save-dev @testing-library/react

一旦工具安装到你的项目中,你就可以导入可用的 API 方法,在测试文件中使用。

接下来,我们将看到如何在 React 测试库已经为你安装的情况下,如何开始一个 React 项目的构建。

使用 create-react-app 自动安装

create-react-app 工具允许你快速创建一个单页 React 应用程序。create-react-app 工具提供了一个示例应用程序和相关测试,以帮助你入门。React 测试库已经变得如此流行,以至于从版本 3.3.0 开始,create-react-app 团队将 React 测试库作为默认测试工具添加。create-react-app 工具还包括 user-eventjest-dom 工具。我们之前在 第一章探索 React 测试库 中介绍了 jest-dom。我们将在 第三章使用 React 测试库测试复杂组件 中介绍 user-event 工具。

因此,如果你至少使用 create-react-app 的 3.3.0 版本,你将获得一个带有 React 测试库、user-eventjest-dom 自动安装和配置的 React 应用程序。

你可以通过两种方式运行 create-react-app 工具来创建一个新的 React 应用程序。默认情况下,运行 create-react-app 工具的两种方式都会自动安装 create-react-app 的最新版本。第一种方式是使用 npx,它允许你创建一个 React 项目,而无需在本地机器上全局安装 create-react-app 工具:

npx create-react-app your-project-title-here --use-npm

当使用前面的命令时,请确保将 your-project-title-here 替换为一个描述你独特项目的标题。同时,注意命令末尾的 --use-npm 标志。默认情况下,当你使用 create-react-app 创建项目时,它使用 Yarn 作为项目的包管理器。在这本书中,我们将使用 npm 作为包管理器。我们可以通过使用 --use-npm 标志来告诉 create-react-app 我们想要使用 npm 而不是 Yarn 作为包管理器。

使用 create-react-app 创建 React 应用程序的第二种方式是将工具全局安装到本地机器上运行。使用以下命令全局安装该工具:

npm install -g create-react-app

在之前的命令中,我们使用了 -g 命令在机器上全局安装该工具。一旦工具安装到你的机器上,运行以下命令来创建一个项目:

create-react-app your-project-title-here --use-npm

与我们之前使用 npx 创建项目时运行的命令一样,我们使用 npm 作为包管理器创建一个名为 your-project-title-here 的新项目。

现在你已经知道了如何手动安装 React Testing Library 或者使用 create-react-app 自动安装它。接下来,我们将学习一些常用的 React Testing Library API 方法,这些方法用于构建测试。

使用 React Testing Library 构建测试

为了构建和编写我们的测试代码,我们将使用在编写单元测试中典型的 安排-行动-断言 模式。有几种方法可以使用 React Testing Library API 来构建测试,但我们将使用 React Testing Library 团队推荐的方法来渲染 React 元素到 文档对象模型 (DOM) 中,选择生成的 DOM 元素,并对预期的行为进行断言。

渲染元素

要测试你的 React 组件的输出,你需要一种方法将它们渲染到 DOM 中。React Testing Library 的 render 方法接受一个传入的组件,将其放入一个 div 元素中,并将其附加到 DOM 上,正如我们在这里可以看到的:

import { render} from '@testing-library/react'
import Jumbotron from './Jumbotron'
it('displays the heading, () => {
  render(<Jumbotron />)
}

在之前的代码中,我们有一个测试文件。首先,我们从 React Testing Library 导入 render 方法。然后,我们导入我们想要测试的 Jumbotron 组件。最后,我们通过使用 render 方法来渲染要测试的组件,在 it 方法中安排我们的测试代码。

在许多测试框架中,编写额外的代码来清理我们的测试是必要的。例如,如果一个组件在一个测试中被渲染到 DOM 中,它需要在执行下一个测试之前被移除。从 DOM 中移除组件允许下一个测试从一个干净的状态开始,并且不受之前测试中的代码的影响。React Testing Library 的render方法通过自动处理从 DOM 中移除组件,使得测试清理变得更容易,因此不需要编写额外的代码来清理受之前测试影响的州。

现在你已经知道了如何通过将组件渲染到 DOM 中进行测试来安排测试,我们将在下一节学习如何与组件的 DOM 输出进行交互。

选择组件 DOM 输出中的元素

一旦我们将要测试的组件渲染到 DOM 中,下一步就是选择元素。我们将通过模拟用户查询输出来做这件事。DOM Testing Library API 包含在 React Testing Library 中的screen对象,允许你查询 DOM:

import { render, screen } from '@testing-library/react'

在之前的代码中,我们像导入render一样导入了screen从 React Testing Library。screen对象公开了许多方法,例如getByTextgetByRole,用于查询 DOM 中的元素,类似于我们可以在测试中使用的实际用户。例如,我们可能有一个渲染以下 DOM 输出的组件:

图 2.1 – Jumbotron 组件

图 2.1 – Jumbotron 组件

如果我们想要在 DOM 中搜索包含文本欢迎来到我们的网站的元素,我们可以有两种方式来做。

一种方式是使用getByText方法:

it('displays the heading', () => {
  render(<Jumbotron />)
  screen.getByText(/welcome to our site!/i)
})

getByText方法将查询 DOM,寻找与getByText方法匹配的文本的元素。一个寻找元素的普通用户不会关心文本是大写还是小写,所以getByText和所有其他screen对象方法都遵循相同的方法。

另一种查询 DOM 中包含文本getByRole方法:

it('displays the heading, () => {
  render(<Jumbotron />)
  screen.getByRole('heading', { name: /welcome to our 
    site!/i })
})

getByRole方法允许你以类似于任何人(包括使用屏幕阅读器的人)搜索的方式查询 DOM。屏幕阅读器会寻找具有heading角色和文本欢迎来到我们的网站的元素。screen对象上有许多其他方法,可以根据你决定如何找到它们来查询元素。DOM Testing Library 团队建议在文档中尽可能使用getByRole方法来选择元素。

此外,因为我们的测试代码本质上说的是,搜索一个包含文本'欢迎来到我们的网站'的标题元素,它比之前的例子更明确,在之前的例子中,我们使用了getByText搜索任何包含文本'欢迎来到我们的网站'的元素

第一章《增强 jest 断言的 jest-dom》部分,《探索 React Testing Library》中,我们了解到jest-dom的方法提供了上下文特定的错误信息。

screen对象上的方法提供了相同的好处。例如,如果你尝试使用getByRole来选择 DOM 中不存在的元素,该方法将停止测试执行并提供以下错误信息:

Unable to find an accessible element with the role 
  "heading" and name `/fake/i`

在前面的代码中,错误信息明确告诉你查询方法没有找到元素。此外,错误信息通过记录基于渲染 DOM 可选择的元素来帮助:

heading:
      Name "Logo":
      <h3
        class="navbar-brand mb-0"
        style="font-size: 1.5rem;"
      />
      Name "Welcome to our site!":
      <h1 />

在前面的代码中,日志记录的元素通过提供 DOM 的视觉表示来帮助理解为什么找不到你搜索的元素。现在你知道如何使用 React Testing Library 选择元素。

我们将在第三章中学习更多与组件交互的高级方法,例如点击或输入文本,《使用 React Testing Library 测试复杂组件》

接下来,我们将学习如何断言组件的预期输出。

断言预期行为

测试结构中的最后一步是对行为进行断言。在第一章《增强 jest 断言的 jest-dom》部分,《探索 React Testing Library》中,我们学习了如何安装和使用jest-dom工具进行断言。基于我们搜索文本为welcome to our site!的标题元素的测试,我们可以使用jest-dom中的toBeInTheDocument方法来验证元素是否在 DOM 中:

it('displays the heading', () => {
  render(<Jumbotron />)
  expect(
screen.getByRole('heading', { name: /welcome to our 
      site!/i })
  ).toBeInTheDocument()
})

如果找不到元素,我们将收到错误信息和视觉反馈,以帮助确定记录到控制台的问题来源,类似于我们在与组件 DOM 输出交互部分所看到的。如果我们得到预期的行为,那么我们将在控制台收到反馈,如下面的截图所示:

图 2.2 – Jumbotron 组件测试结果

图 2.2 – Jumbotron 组件测试结果

在上一张截图,结果显示显示标题测试通过。现在你知道如何使用 React Testing Library 对组件的输出进行断言。本节学到的技能为下一节奠定了基础,下一节我们将开始测试表现性组件。

测试表现性组件

在本节中,我们将利用我们对使用 React Testing Library 安装和构建测试的知识来测试表现性组件。表现性组件是不管理状态的组件。通常,你使用表现性组件来显示从父组件传递下来的作为 props 的数据,或者直接在组件本身中显示硬编码的数据。

创建快照测试

快照测试由 Jest 提供,当您只想确保组件的 HTML 输出不会意外更改时非常有用。假设开发者更改了组件的 HTML 结构,例如,通过添加另一个包含静态文本的段落元素。在这种情况下,快照测试将失败,并提供更改的视觉表示,以便您可以相应地做出反应。以下是一个将有关旅行服务的数据硬编码到 DOM 中的表现组件的示例:

const Travel = () => {
  return (
    <div className="card text-center m-1" style={{ width: 
      '18rem' }}>
      <i className="material-icons" style={{ fontSize: 
         '4rem' }}>
        airplanemode_active
      </i>
      <h4>Travel Anywhere</h4>

在前面的代码片段中,该组件在 <i> 元素中显示飞机图标,并在 <h4> 元素中显示标题:

<p className="p-1">
        Our premium package allows you to take exotic trips
          anywhere at the cheapest prices! 
      </p>
    </div>
  )
}
export default Travel

在组件的最后部分,前面的代码片段显示了段落元素内的文本。生成的 DOM 输出如下所示:

图 2.3 – 旅行组件

图 2.3 – 旅行组件

由于该组件仅显示几行静态硬编码的文本,因此它是一个很好的快照测试候选者。在以下示例中,我们使用快照测试来测试 Travel 组件:

import { render } from '@testing-library/react'
import Travel from './Travel'
it('displays the header and paragraph text', () => {
  const { container } = render(<Travel />)

首先,在我们的测试文件中,我们导入 React Testing Library 的 render 方法。接下来,我们导入 Travel 组件。然后,我们使用对象解构从渲染的组件中获取 containercontainer 代表组件的最终 HTML 输出。最后,我们使用 Jest 的 toMatchInlineSnapshot 方法来捕获生成的 HTML 输出。

以下是我们在本节开头看到的 Travel 组件输出的快照的一部分:

  expect(container).toMatchInlineSnapshot(`
    <div>
      <div
        class="card text-center m-1"
        style="width: 18rem;"
      >
        <i
          class="material-icons"
          style="font-size: 4rem;"
        >
          airplanemode_active
        </i>

现在,如果将来开发者更改了 Travel 组件的输出,测试将失败,并通知我们意外的更改。例如,开发者可能将标题从 "Travel Anywhere" 更改为 "Go Anywhere":

图 2.4 – 失败的旅行快照测试

img/B16887_02_04.jpg

图 2.4 – 失败的旅行快照测试

上一张截图显示测试失败,并显示了哪些行发生了变化。"Travel Anywhere" 是快照预期接收到的文本,与接收到的文本 "Go Anywhere" 不同。此外,还指出了发现差异的行号 8 和行中的位置 11。如果更改是故意的,我们可以用新的更改更新我们的快照。运行以下命令来更新快照:

npm test -- -u

如果您的测试目前正在监视模式下运行,只需按键盘上的 U 键即可更新快照。如果更改不是故意的,我们只需将文本改回组件文件中的原始值即可。

现在你已经知道了如何为表现组件创建快照测试,我们将学习如何验证传递给表现组件的属性。

测试预期属性

呈现式组件可以接收作为 props 传递的数据,而不是直接在组件中硬编码数据。以下是一个期望显示在表格中的员工对象数组的呈现式组件示例:

const Table = props => {
  return (
    <table className="table table-striped">
      <thead className="thead-dark">
        <tr>
          <th scope="col">Name</th>
          <th scope="col">Department</th>
          <th scope="col">Title</th>
        </tr>
      </thead>

在前面的代码片段中,该组件有一个包含每个员工 NameDepartmentTitle 标题的表格。以下是表格体:

      <tbody>
        {props.employees.map(employee => {
          return (
            <tr key={employee.id}>
              <td>{employee.name}</td>
              <td>{employee.department}</td>
              <td>{employee.title}</td>
            </tr>
          )
        })}
      </tbody>
    </table>
  )
}
export default Table

在前面的代码片段中,我们在表格体内部迭代 props 对象中的 employees 数组。我们为每个员工创建一个表格行,访问员工的名字、部门和头衔,并将数据渲染到表格单元格元素中。

以下是一个示例,展示了生成的 DOM 输出:

图 2.5 – 表格组件

图 2.5 – 表格组件

Table 组件显示与预期对象数组形状匹配的员工行,该数组具有 NameDepartmentTitle 属性。我们可以测试该组件是否正确接受并显示 DOM 中的员工数据行:

import { render, screen } from '@testing-library/react'
import fakeEmployees from './mocks/employees'
import Table from './Table'
it('renders with expected values', () => {
  render(<Table employees={fakeEmployees} />)

首先,我们从 React Testing Library 中导入 render 方法 和 screen 对象。接下来,我们传递一个名为 fakeEmployees 的虚构员工对象数组,该数组是为了测试目的而创建的,以及 Table 组件。fakeEmployees 数据看起来如下:

const fakeEmployees = [
  {
    id: 1,
    name: 'John Smith',
    department: 'Sales',
    title: 'Senior Sales Agent'
  },
  {
    id: 2,
    name: 'Sarah Jenkins',
    department: 'Engineering',
    title: 'Senior Full-Stack Engineer'
  },
  { id: 3, name: 'Tim Reynolds', department: 'Design', 
     title: 'Designer' }
]

最后,我们创建主要的测试代码来验证 fakeEmployee 数据是否存在于 DOM 中:

it('renders with expected values', () => {
  render(<Table employees={fakeEmployees} />)
  expect(screen.getByRole('cell', { name: /john smith/i 
    })).toBeInTheDocument()
  expect(screen.getByRole('cell', { name: /engineering/i 
    })).toBeInTheDocument()
  expect(screen.getByRole('cell', { name: /designer/i 
    })).toBeInTheDocument()
})

对于前面代码片段的断言,我们验证了每个对象中至少有一部分存在于 DOM 中。你也可以验证如果与你的测试目标一致,每条数据都存在于 DOM 中。务必验证你的代码测试了你期望测试的内容。例如,尝试通过使用 screen 对象查询不应存在的员工数据来使测试失败。如果测试失败,你可以更有信心地认为代码测试了你期望的内容。

尽管大多数时候我们想要避免实现细节并从用户的角度编写测试,但有时测试特定细节对于我们的测试目标可能是重要的。例如,如果验证表格组件渲染版本中是否存在条纹颜色主题对你很重要。在这种情况下,可以使用 Jest-domtoHaveAttribute 断言方法:

it('has the correct class', () => {
  render(<Table employees={fakeEmployees} />)
  expect(screen.getByRole('table')).toHaveAttribute(
    'class',
    'table table-striped'
  )
})

在前面的代码片段中,我们创建了一个测试来验证表格组件具有正确的类属性。首先,我们使用员工渲染 Table 组件。接下来,我们使用 screen 对象的 getByRole 方法选择 table 元素。最后,我们断言组件具有值为 table table-stripedclass 属性。通过使用 toHaveAttribute,我们可以在需要时断言组件属性的值。

现在你已经知道了如何测试接受 props 作为数据的呈现式组件。

在下一节中,我们将学习如何使用debug方法在构建测试时分析组件的当前状态。

使用debug方法

debug方法可以通过screen对象访问,是 React Testing Library API 中的一个有用工具,它允许你在构建测试时查看组件的当前 HTML 输出。在本节中,我们将学习如何显示整个组件或特定元素的 DOM 输出结果。

调试整个组件 DOM

当你运行测试时,你可以使用debug方法记录组件的整个 DOM 输出:

it('displays the header and paragraph text', () => {
   render(<Travel />)
   screen.debug()
})

在前面的代码中,我们首先将Travel组件渲染到 DOM 中。接下来,我们调用了debug方法。当我们运行测试时,以下内容将被记录到控制台:

![图 2.6 – Travel DOM 调试图片

图 2.6 – Travel DOM 调试

在前面的屏幕截图中,Travel组件的整个 DOM 输出被记录到屏幕上。记录整个输出可以帮助你构建测试,尤其是在与 DOM 中的一个元素交互影响当前 DOM 中其他元素时。现在你已经知道如何将整个组件 DOM 的输出记录到屏幕上。接下来,我们将学习如何记录 DOM 的特定元素到屏幕上。

调试特定组件元素

你可以使用debug方法将结果组件 DOM 的特定元素记录到屏幕上:

it('displays the header and paragraph text', () => {
  render(<Travel />)
  const header = screen.getByRole('heading', { name: 
    /travel anywhere/i })
  screen.debug(header)
})

在前面的代码中,首先,我们将Travel组件渲染到 DOM 中。接下来,我们使用getByRole方法查询 DOM 以获取名为travel anywhere的标题,并将其保存到名为header的变量中。然后,我们调用了debug方法并将header变量传递给该方法。当我们运行测试时,以下内容将被记录到控制台:

![图 2.7 – Travel 元素调试图片

图 2.7 – Travel 元素调试

当你传递一个通过可用查询方法找到的特定 DOM 节点时,debug方法只会记录该特定节点的 HTML。记录单个元素的输出可以帮助你只关注组件的特定部分。确保在提交之前从测试中移除任何debug方法代码,因为你在构建测试时才需要它。

现在,你已经知道了如何使用debug方法来渲染组件的 DOM 输出结果。debug方法是一个在编写新测试和调试失败的测试时非常有用的视觉工具。

概述

在本章中,你已经学会了如何将 React Testing Library 安装到你的 React 项目中。你现在理解了如何使用 API 方法来构建你的测试。你知道如何测试表现性组件,这为在下一章中构建知识奠定了基础。最后,你学会了如何在构建测试时调试组件的 HTML 输出。

在下一章中,我们将学习如何测试更复杂的代码。我们还将学习如何使用测试驱动开发TDD)方法来驱动测试的创建。

问题

  1. 用来将 React 组件放置到 DOM 中的方法是什么?

  2. 命名那个附加了查询 DOM 元素方法的对象。

  3. 哪些类型的组件适合进行快照测试?

  4. 用于记录组件 DOM 输出的方法是什么?

  5. 创建并测试一个接受对象数组作为 props 的展示组件。

第三章:使用 React Testing Library 测试复杂组件

第二章 使用 React Testing Library 工作中,我们学习了如何测试表现性组件。然而,大多数功能都是设计来允许用户操作,这些操作会导致状态和结果的改变。在将代码发送到生产环境供最终用户使用之前,测试尽可能多的用户操作场景对于降低风险至关重要。在本章结束时,你将学习如何使用fireEventuser-event模块来模拟用户操作。你将学习如何测试与 Web 服务 API 交互的组件。最后,你将学习如何将测试驱动开发作为构建特性的工作流程。

在本章中,我们将介绍以下主题:

  • 使用fireEvent模块在组件上执行操作

  • 模拟user-event模块

  • 测试与 API 交互的组件

  • 使用 React Testing Library 实现测试驱动开发

本章中你获得的技术将为你提供对测试用户行为结果的扎实理解。你还将获得一种从零开始构建组件的不同方法。

技术要求

对于本章的示例,你需要在你的机器上安装 Node.js。我们将使用create-react-app CLI 工具来展示所有代码示例。如果需要,请在开始本章之前熟悉这个工具。虽然这不是必需的,但在开始本章之前回顾前两章的内容可能会有所帮助。

您可以在此处找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter03

测试用户事件

在本节中,我们将学习如何模拟用户事件并测试生成的输出。为了测试组件交互,类似于用户的情况,我们需要在我们的测试中模拟 DOM 事件的方法。用户在 DOM 上可以引发多种事件。例如,用户可以通过在输入框中输入文本来执行按键事件,通过点击按钮来执行点击事件,或者通过鼠标悬停事件来查看下拉菜单项。DOM Testing Library 提供了两个库来模拟用户操作,即fireEventuser-event,我们将在接下来的章节中看到。

使用fireEvent模块模拟用户操作

我们可以使用fireEvent模块来模拟用户在组件生成的 DOM 输出上的操作。例如,我们可以构建一个可重用的Vote组件,渲染以下 DOM 输出:

图 3.1 – Vote 组件

图 3.1 – Vote 组件

在前面的截图上,数字10代表点赞评分。我们有两个按钮,用户可以点击它们来投票并更改点赞评分:一个点赞按钮和一个踩按钮。还有一个免责声明告知用户他们只能投票一次。当用户点击点赞按钮时,他们将看到以下输出:

图 3.2 – 点赞按钮投票

图 3.2 – 点赞按钮投票

在前面的截图上,点赞评分从10增加到11。当用户点击踩按钮时,他们将看到以下输出:

图 3.3 – 踩按钮投票

图 3.3 – 踩按钮投票

在前面的截图上,点赞评分已从fireEvent减少。在Vote组件的代码实现中,组件内部调用了一个事件处理器,其中包含更新屏幕上显示的点赞逻辑:

  const handleLikeVote = () => dispatch({ type: 'LIKE' })
  const handleDislikeVote = () => dispatch({ type: 
    'DISLIKE' })
  return (
    <div className="h1">
      <h5>Note: You are not allowed to change your vote 
         once selected!</h5>
      <button
        onClick={handleLikeVote}
        disabled={hasVoted}
        style={clickedLike ? { background: 'green' } : 
          null}
      >
        <img src={thumbsUp} alt="thumbs up" />
      </button>

在前面的代码块中,按钮有一个onClick事件处理器附加。当点赞按钮被点击时,事件处理器调用handleLikeVote方法,该方法调用另一个方法dispatch来更新点赞评分。

重要提示

请参阅第三章**Testing Complex Components with React Testing Library,在技术要求部分找到的代码示例,以查看组件的完整内容。

我们可以编写一个测试来断言投票的输出:

import { fireEvent, render, screen } from '@testing-library/react'
import Vote from './Vote'
test('increases total likes by one', () => {
  render(<Vote totalGlobalLikes={10} />)

在前面的代码块中,我们从 React Testing Library 中导入fireEventrenderscreen方法。接下来,我们导入Vote组件以进行测试。然后,我们在test方法中安排我们的测试代码,并使用render方法渲染Vote组件,将totalGlobalLikes属性的值10传递给组件。

totalGlobalLikes属性是我们组件渲染时在屏幕上最初看到的数字,它代表了应用的点赞状态。在一个完全完成的程序中,我们会通过父组件将totalGlobalLikes的值传递给Vote组件。接下来,我们将与渲染组件的输出进行交互并断言:

expect(screen.getByText(/10/i)).toBeInTheDocument()
  fireEvent.click(screen.getByRole('button', { name: 
    /thumbs up/i }))
  expect(screen.getByText(11).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /thumbs up/i 
    })).toHaveStyle(
    'background: green'
  )
})

在前面的代码块中,首先,我们断言Vote组件的本地totalGlobalLikes版本在文档中的值等于10。接下来,我们使用fireEventclick方法点击名为thumbs up的按钮。然后,我们断言文档中totalGlobalLikes的值更新为11。最后,我们断言点赞按钮的背景色已变为绿色。

在许多情况下,使用 fireEvent 是完全可行的。然而,它确实有一些限制。例如,当用户执行诸如在输入框中输入文本等操作时,会发生许多事件,例如 keydownkeyup。现在,fireEvent 有方法来实现这些单个动作,但它没有一种方法可以按顺序一起处理它们。

接下来,我们将学习如何使用 user-event 库来解决 fireEvent 模块的局限性。

使用 user-event 模拟用户操作

user-event 库是 fireEvent 的增强版本。在上一节中,我们了解到 fireEvent 有方法来模拟用户在输入框中输入文本时发生的各种事件。user-event 库有许多方法,例如 clicktype,可以自动模拟用户在 DOM 上执行操作时发生的所有事件。其优势是 user-event 方法比 fireEvent 方法提供了更多的价值。

create-react-app 已经预装了 user-event。对于不使用 create-react-app 的项目,请使用以下命令进行安装:

npm install --save-dev @testing-library/user-event

我们可以使用 user-event 更新上一节的 Vote 组件测试:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import Vote from './Vote'
test('increases total likes by one', () => {
  render(<Vote totalGlobalLikes={10} />)
  expect(screen.getByText(/10/i)).toBeInTheDocument()
  user.click(screen.getByRole('button', { name: /thumbs 
     up/i }))
  expect(screen.getByText(/11/i)).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /thumbs up/i 
    })).toHaveStyle(
    'background: green'
  )
})

在前面的代码中,我们将 user-event 库导入为 user。最后,我们使用 user-eventclick 方法点击了 点赞 按钮。我们的测试提供了更多的价值,因为我们更接近地模拟了用户的 DOM 操作。React 测试库团队建议尽可能多地使用 user-event,因此我们将在本书的其余部分不再使用 fireEvent

在上一节中介绍 Vote 组件时,我们提到用户只能投票一次。我们可以编写一个测试来处理这种情况:

test('A user can only vote once', () => {
  render(<Vote totalGlobalLikes={10} />)
  const thumbsUpBtn = screen.getByRole('button', { name: 
    /thumbs up/i })
  const thumbsDownBtn = screen.getByRole('button', { name: 
    /thumbs down/i })
  expect(screen.getByText(/10/i)).toBeInTheDocument()
  user.click(thumbsUpBtn)
  user.click(thumbsUpBtn)
  expect(screen.getByText(/11/i)).toBeInTheDocument()
  user.click(thumbsDownBtn)
  expect(screen.getByText(/11/i)).toBeInTheDocument()
})

在前面的代码中,首先,我们获取了 点赞 按钮。然后,我们验证当前的总点赞数是 10,并点击 点赞 按钮两次。接下来,我们验证总点赞数是 11。最后,我们点击 按钮,并断言总点赞数仍然是 11。作为另一个测试用例,我们还可以验证当用户点击 按钮时,totalGlobalLikes 的本地版本减少一个:

test('decreases total likes by one', () => {
  render(<Vote totalGlobalLikes={10} />)
  expect(screen.getByText(/10/i)).toBeInTheDocument()
  user.click(screen.getByRole('button', { name: /thumbs 
    down/i }))
  expect(screen.getByText(/9/i)).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /thumbs down/i 
    })).toHaveStyle(
    'background: red'
  )
})

在前面的代码中,我们点击了 按钮,并验证总点赞数从 10 减少到 9,背景颜色变为 红色

当我们运行 Vote 组件的所有测试时,我们得到以下结果,表明所有测试都通过了:

![图 3.4 – 投票组件测试结果图 3.4 – B16887.jpg

图 3.4 – 投票组件测试结果

上一张截图显示了 Vote.test.js 文件。

在另一个例子中,我们可能会为员工创建一个接受他们名字的输入组件:

![图 3.5 – 员工电子邮件输入图 3.5 – B16887.jpg

图 3.5 – 员工电子邮件输入

当员工输入他们的名字时,组件将其追加到公司的网站名称,并将结果显示在屏幕上:

图 3.6 – 完成的员工电子邮件输入

图 3.6 – 完成的员工电子邮件输入

如果员工输入由空格分隔的姓名和姓氏,则姓名会与一个 . 连接:

图 3.7 – 连接的员工电子邮件输入

图 3.7 – 连接的员工电子邮件输入

我们可以使用 user-eventtype 方法模拟在员工电子邮件组件中输入,并对结果进行断言,如下所示:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import EmployeeEmail from './EmployeeEmail'
test('it accepts a username and displays to the screen', ()
  => {
  render(<EmployeeEmail />)
  const input = screen.getByRole('textbox', { name: /enter 
    your name/i })
  user.type(input, 'jane doe')
  expect(screen.getByText(/jane.doe@software-
    plus.com/i)).toBeInTheDocument()
})

在前面的代码中,我们导入了 renderscreenuser-event 模块。然后,我们导入了 EmployeeEmail 组件。我们在屏幕上渲染该组件。然后,我们获取输入元素并将其存储在变量 input 中。接下来,我们使用 user-event 中的 type 方法将 jane doe 输入到输入框中。最后,我们断言文本 jane.doe@software-plus.com 在 DOM 中。

当我们运行测试时,我们会得到以下输出,表明场景按预期通过:

图 3.8 – 员工组件测试结果

图 3.8 – 员工组件测试结果

之前的屏幕截图显示了 EmployeeEmail.test.js 文件。现在你知道如何使用 user-event 模块模拟用户操作。在本节中学到的技能是必不可少的,因为我们的大多数测试通常都需要涉及某种类型用户操作。

接下来,我们将学习如何测试独立调用事件处理器的组件。

测试独立调用事件处理器的组件

创建从父组件传递下来的方法调用的子组件是非常常见的。在前面的一节中,我们有一个 Vote 组件,它在一个组件中包含了两个按钮,这可以在以下代码块中看到:

      <button
        onClick={voteLike}
        disabled={hasVoted}
        style={clickedLike ? { background: 'green' } : 
          null}
      >
        <img src={thumbsUp} alt="thumbs up" />
      </button>
      <div>{totalLikes}</div>
      <button
        onClick={voteDislike}
        disabled={hasVoted}
        style={clickedDislike ? { background: 'red' } : 
          null}
      >
        <img src={thumbsDown} alt="thumbs down" />
      </button>

我们可以决定将按钮代码提取到自己的文件中,成为一个可重用的组件:

const VoteBtn = props => {
  return (
    <button onClick={props.handleVote} 
      disabled={props.hasVoted}>
      <img src={props.imgSrc} alt={props.altText} />
    </button>
  )
}

在前面的代码块中,我们有一个 VoteBtn 组件,它接受 handleVotehasVotedimgSrcaltText 属性,这些属性通过 props 对象传递。父组件会向下传递这些属性。对于本节的目的,我们的主要关注点是 handleVote 属性。当点击按钮时,由于点击事件触发,会调用 handleVote 方法。当此方法在 Vote 组件内部运行时,结果是更新 totalGlobalLikes 的本地版本。按钮的最终屏幕输出如下:

图 3.9 – 投票按钮

图 3.9 – 投票按钮

在前面的屏幕截图中,我们看到一个带有 点赞 图标的 Vote 组件。为了独立测试 VoteBtn 组件,我们需要向组件提供属性,因为它不再被一个自动提供这些属性的组件所包裹。Jest 提供了作为测试替身的函数,用于替换我们测试中方法的真实版本。

测试替身是一个通用术语,用于表示在测试目的下替换真实对象的某个对象。用作 API 或数据库等依赖项占位符的测试替身被称为jest.fn函数,用于替换我们测试中的handleVote

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import thumbsUp from './images/thumbs-up.svg'
import VoteBtn from './VoteBtn'
test('invokes handleVote', () => {
  const mockHandleVote = jest.fn()
  render(
    <VoteBtn
      handleVote={mockHandleVote}
      hasVoted={false}
      imgSrc={thumbsUp}
      altText="vote like"
    />
  )

在前面的代码块中,首先,我们从 React Testing Library 中导入renderscreen方法。然后,我们导入user-event模块。接着,我们导入我们想要测试的thumbsUp图像和VoteBtn组件。然后,在test方法内部,我们创建一个jest函数作为模拟,并将其分配给mockHandleVote变量。

接下来,我们将VoteBtn组件渲染到 DOM 中,并将mockHandleVote和其他属性传递给组件。现在我们的测试代码已经安排好了,我们可以执行操作并做出断言:

  user.click(screen.getByRole('button', { name: /vote 
    like/i }))
  expect(mockHandleVote).toHaveBeenCalled()
  expect(mockHandleVote).toHaveBeenCalledTimes(1)
})

在之前的代码中,我们点击了名为mockHandleVote的按钮,当用户点击按钮时,会调用该方法。第二个断言确认mockHandleVote方法被调用了一次。当需要确保函数被正确使用时,mockHandleVote断言可能很重要。当我们运行测试时,我们得到以下输出,表明场景按预期通过:

图 3.10 – 投票按钮组件测试结果

图 3.10 – 投票按钮组件测试结果

上一张截图显示了VoteBtn.test.js文件。需要注意的是,尽管我们能够验证事件处理器被调用,但我们无法确认按钮在被点击后状态是否变为禁用。我们需要包含父组件并编写集成测试来验证该行为。我们将在第四章中学习如何处理这些场景,应用程序中的集成测试和第三方库

现在你已经知道了如何使用测试替身测试隔离组件中的事件处理器。在本节中,我们学习了如何模拟和测试用户交互。我们学习了如何使用fireEventuser-event来模拟动作。我们还学习了如何使用测试替身来测试事件处理器。在本节中学到的技能将有助于你在下一节中学习如何测试与 API 交互的组件。

测试与 API 交互的组件

本节将基于我们之前章节中学到的测试事件处理器的知识,通过查看如何测试发送和接收 API 数据的组件来构建。在我们的组件单元测试中,我们可以通过使用充当测试替身的工具来代替真实 API,从而减少应用风险。使用测试替身代替实际 API,我们可以避免缓慢的互联网连接或接收导致不可预测测试结果动态数据。

我们将学习如何安装和使用模拟服务工作者MSW)作为测试替身,在测试中捕获组件发起的 API 请求并返回模拟数据。我们将测试一个用于用户从 API 搜索饮料数据的组件。我们还将学习如何将 MSW 用作开发服务器。本节中的概念将帮助我们了解如何验证前端和 API 服务器之间的通信。

使用 fetch 请求 API 数据

我们可以创建一个组件,允许用户从 TheCockTailDB (www.thecocktaildb.com)搜索饮料,TheCockTailDB 是一个免费的开源服务,将扮演后端 API 的角色。我们的组件将访问该服务并请求数据。当组件首次渲染时,用户会看到一个输入字段和一个搜索按钮:

图 3.11 – 饮料搜索组件

图 3.11 – 饮料搜索组件

当用户搜索饮料时,API 返回类似以下的数据:

图 3.12 – 饮料搜索结果

图 3.12 – 饮料搜索结果

在前面的屏幕截图中,用户搜索了gin并从 API 收到了一系列结果。如果用户搜索的饮料没有返回结果,屏幕上会显示没有找到饮料的消息:

图 3.13 – 没有饮料搜索结果

图 3.13 – 没有饮料搜索结果

如果用户尝试搜索,但 API 服务器不可访问,则显示服务不可用的消息:

图 3.14 – 饮料搜索请求错误

图 3.14 – 饮料搜索请求错误

我们的组件将使用一个 HTTP request模块,该模块设计用于使用fetch方法从 API 请求饮料数据,fetch是浏览器中用于发送 HTTP 请求的工具:

const fetchDrinks = async drinkQuery => {
  const response = await fetch(
    `https://www.thecocktaildb.com/api/json/v1/1/search.php?s=$
  {drinkQuery}`
  )
  const data = await response.json()
  return data.drinks
}
export default fetchDrinks

在前面的代码块中,fetchDrinks接受一个drinkQuery参数,代表搜索数据,并通过 API 请求数据。

饮料搜索组件有一个表单,当提交时,将调用handleDrinkQuery方法,该方法最终调用request模块并带上要搜索的饮料:

<form onSubmit={handleDrinkQuery}>
  <input
    placeholder='search for a drink...'
    type='search'
    value={drinkQuery}
    onChange={(event) => setDrinkQuery(event.target.value)}
  />
  <button type='submit'>Search</button>
</form>

request模块发送包含饮料数组的响应时,饮料搜索组件将调用drinkResults方法,该方法在屏幕上渲染drinks

{drinks && <div>{drinkResults()}</div>}

如果响应没有返回任何饮料,则渲染没有找到饮料的代码:

{!drinks && <h5> No drinks found </h5>}

如果与服务器通信出现错误,则渲染服务不可用的代码:

{error && <h5> Service unavailable </h5>

现在我们已经了解了饮料搜索组件根据用户交互的行为。接下来,我们将学习如何创建模拟 API 数据来测试组件。

使用 MSW 创建模拟 API 数据

MSW 是一个我们可以用来捕获由我们的组件发起的 API 请求并返回模拟响应的工具。当我们的前端 React 应用程序向 API 服务器发出 HTTP 请求时,MSW 将在请求到达网络之前拦截该请求,并使用模拟数据做出响应。使用以下命令将 MSW 安装到您的项目中:

npm install msw --save-dev

要开始使用 MSW,首先,我们将创建一个模拟响应路由处理程序来覆盖我们的组件对特定 URL 的匹配调用:

import { rest } from 'msw'
export const handlers = [
  rest.get(
    'https://www.thecocktaildb.com/api/json/v1/1/search.php',
    (req, res, ctx) => {
      return res(
        ctx.status(200),
        ctx.json({
          drinks: [
            {
              idDrink: 1,
              strDrinkThumb: './images/thumbs-down.svg',
              strDrink: 'test drink',
              strInstructions: 'test instructions',
              strIngredient1: 'test ingredient'
            }
          ]

在前面的代码块中,我们导入了 restmswrest 对象允许我们指定 request 类型为 mock。在 get 方法中,我们指定当发出 GET 请求时要覆盖的路线。在 get 方法的 callback 参数中,接受三个参数 - req 参数提供了有关请求的信息,例如请求中发送的数据。res 参数是一个函数,用于创建模拟响应。ctx 参数为响应函数提供了一个上下文。

ctx 中,我们创建一个表示成功请求的 200 响应状态码,最后,我们创建要返回的 JSON 数据,这将是一个饮料数组。您可能会注意到,GET 请求路由并不匹配上一节中 HTTP 请求模块使用的整个 URL。MSW 将进行 URL 匹配,使得使用确切的 URL 字符串变得不必要。

接下来,我们将创建我们的模拟服务器,并传入模拟响应路由处理程序:

import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const mockServer = setupServer(...handlers)

在前面的代码中,首先,我们从 msw/node 导入 setupServer,它将用于拦截上一代码片段中创建的路由处理程序发出的请求。我们使用 msw/node 因为我们的测试代码将在 Node.js 环境中运行。接下来,我们导入路由处理程序。最后,我们将处理程序传递给 setupServer,并通过 mockServer 变量导出代码。现在我们已经设置了服务器,我们可以为 DrinkSearch 组件编写测试。

测试 DrinkSearch 组件

要开始测试组件,首先,我们将导入所需的代码并启动我们的模拟服务器:

import { render, screen} from '@testing-library/react'
import user from '@testing-library/user-event'
import DrinkSearch from './DrinkSearch'
import { mockServer } from './mocks/server.js'

在前面的代码块中,首先,我们从 React Testing Library 导入 renderscreen。然后,我们导入 user-event 模块。接着,我们导入我们想要测试的 DrinkSearch 组件。最后,我们导入 mockServer,我们的模拟服务器。接下来,我们需要启动我们的模拟服务器,并设置它在测试生命周期的不同点执行特定操作:

beforeAll(() => mockServer.listen())
afterEach(() => mockServer.resetHandlers())
afterAll(() => mockServer.close())

在前面的代码块中,首先,我们在运行任何测试之前设置我们的模拟服务器以监听 HTTP 请求。接下来,我们在每次测试后重置我们的模拟服务器,以确保测试之间不会相互影响。最后,在所有测试完成后关闭我们的模拟服务器。接下来,我们将创建主要的测试代码:

test('renders mock drink data, async () => {
  render(<DrinkSearch />)
  const searchInput = screen.getByRole('searchbox')
  user.type(searchInput, 'vodka, {enter}')

在前面的代码块中,我们渲染了DrinkSearch组件。接下来,我们获取搜索输入并输入vodka作为要搜索的饮料。在vodka后面的{enter}模拟了在键盘上按下Enter键。接下来,我们将对用户操作的结果进行断言:

  expect(
    await screen.findByRole('img', { name: /test drink/i })
  ).toBeInTheDocument()
  expect(
    screen.getByRole('heading', { name: /test drink/i })
  ).toBeInTheDocument()
  expect(screen.getByText(/test 
    ingredient/i)).toBeInTheDocument()
  expect(screen.getByText(/test 
    instructions/i)).toBeInTheDocument()
})

在前面的代码中,我们使用findByRole查询方法来获取图像元素。在先前的例子中,我们只使用了getBy*查询。getBy*查询可以在大多数情况下使用,当你期望元素在当前 DOM 状态中可用时。然而,在之前的代码中,我们使用了一个findBy*查询,因为与 API 通信的过程是异步的,所以我们需要给我们的应用程序一些时间来接收响应并更新 DOM,然后再尝试获取元素。

当使用getBy*查询来选择元素时,会抛出一个错误,如果元素在当前 DOM 中找不到,我们的测试将失败:

图 3.15 – 无饮料搜索失败的测试结果

图 3.15 – 无饮料搜索失败的测试结果

前面的截图显示了DrinkSearch.test.js文件。测试结果输出还提供了更多上下文,让我们知道它找不到名为test drink的图像元素。findBy*查询在元素找不到时也会抛出错误,但通常会在几秒钟后,这给了元素出现在屏幕上的时间。

我们还可以编写一个测试来验证当 API 服务器没有返回我们的饮料搜索结果时的输出。我们可以修改我们的 MSW 服务器的响应来设置测试场景:

test('renders no drink results', async () => {
  mockServer.use(
    rest.get(
      'https://www.thecocktaildb.com/api/json/v1/1/search.php',
      (req, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.json({
            drinks: null
          })
        )
      }
    )
  )

在前面的代码块中,我们使用use方法来覆盖我们的默认模拟值,以返回null。正如在使用 fetch 请求 API 数据部分中提到的,当服务器没有返回饮料数组时,我们的组件将返回No drinks found消息。现在我们已经设置了测试来发送正确的数据,我们可以编写主要的测试代码:

      render(<DrinkSearch />)
  const searchInput = screen.getByRole('searchbox')
  user.type(searchInput, 'vodka, {enter}')
  expect(
    await screen.findByRole('heading', { name: / no 
      drinks found /i })
  ).toBeInTheDocument()
})

我们像前面的测试中一样渲染了DrinkSearch组件并搜索了vodka。然而,这次我们期望看到No drinks found消息,而不是饮料数组。

对于我们的下一个测试,我们将验证当 API 服务器不可用时输出。就像我们在上一个测试中所做的那样,我们将修改我们的 MSW 服务器的响应来设置测试场景:

test('renders service unavailable', async () => {
  mockServer.use(
    rest.get(
      'https://www.thecocktaildb.com/api/json/v1/1/search.php',
      (req, res, ctx) => {
        return res(ctx.status(503))
      }
    )
  )

我们在之前的代码中覆盖了默认的模拟值,以响应503状态码,表示 API 不可用。正如在使用 fetch 请求 API 数据部分中提到的,当服务器离线时,我们的组件将返回Service unavailable消息。现在我们已经设置了测试来发送正确的响应,我们可以编写主要的测试代码:

render(<DrinkSearch />)
const searchInput = screen.getByRole('searchbox');
user.type(searchInput, 'vodka, {enter}');
expect(
  await screen.findByRole('heading', { name: /Service     unavailable/i })
).toBeInTheDocument()

与上一个测试中的代码一样,我们渲染了DrinkSearch组件并搜索了vodka。然而,现在我们期望文档中包含Service unavailable,因为服务器发送了503错误代码。

我们要写的最后一个测试将验证当用户尝试提交空白搜索查询时不会发出任何请求:

test('prevents GET request when search input empty', async 
  () => {
  render(<DrinkSearch />)
  const searchInput = screen.getByRole('searchbox')
  user.type(searchInput, '{enter}')
  expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})

在之前的代码中,我们按下 Enter 键而没有输入搜索字符串。当应用程序首次加载时,我们只能看到输入字段和用于搜索的按钮。应用程序设计为在向 API 提交搜索查询时显示包括标题元素在内的附加内容。我们期望使用 queryBy* 查询时屏幕上没有具有 heading 角色的元素。当您想验证特定元素不在屏幕上时,queryBy* 查询是首选的。

getBy*findBy* 查询不同,queryBy* 查询在找不到元素时不会抛出错误并使测试失败。当找不到元素时,queryBy* 查询返回 null,允许您在没有测试失败的情况下断言 DOM 中预期不存在元素。当我们运行测试时,我们应该收到以下输出,表明我们的测试套件已通过:

图 3.16 – 无饮料搜索通过测试结果

图 3.16 – 无饮料搜索通过测试结果

上述截图显示了 DrinkSearch.test.js 文件。现在您知道了如何使用 MSW 创建模拟服务器来测试请求 API 数据的组件。

接下来,我们将学习如何在使用 MSW 进行开发。

在开发中使用 MSW

除了在我们的测试中使用 MSW 模拟 HTTP 响应外,我们还可以在开发中创建模拟响应。拥有模拟开发服务器的优点是,即使后端 API 不完整,也可以构建和测试前端。我们需要了解前端和后端 API 之间的通信和数据交换将是什么样子,以便创建正确的模拟响应。

首先,我们需要将服务工作者文件添加到拦截前端发出的 HTTP 请求并使用模拟数据响应。MSW 文档指出我们应该将文件安装到项目的公共目录中。从项目根目录运行以下命令以安装:

npx msw init public/

之前的命令会自动将服务工作者文件下载到公共文件夹。如果您使用 create-react-app 构建项目,则 public 目录位于项目根目录。一旦下载,我们不需要在文件中做任何额外操作。接下来,我们需要在 src/mocks/ 目录中创建一个文件来设置和启动服务工作者,类似于我们在本章的 使用 MSW 创建模拟 API 数据 部分所做的那样。

然而,对于模拟开发服务器,我们将对设置服务器的方式做些轻微的调整:

import { rest, setupWorker } from 'msw'
const drinks = [
  {
    idDrink: '11457',
    strDrink: 'Gin Fizz',
    strInstructions:
      'Shake all ingredients with ice cubes, except soda 
       water. Pour into glass. Top with soda water.',
    strDrinkThumb:
         'https://www.thecocktaildb.com/images/media/drink/
           drtihp1606768397.jpg',
    strIngredient1: 'Gin',
    strIngredient2: 'Lemon',
    strIngredient3: 'Powdered sugar',
    strIngredient4: 'Carbonated water'
  },

]

在前面的代码中,我们从msw中导入了restsetupWorker。在本章的“使用 MSW 创建模拟 API 数据”部分,我们由于测试在 Node.js 环境中运行,因此从msw/node中导入了模块。模拟开发服务器将在浏览器中运行,因此我们不需要导入 Node.js 版本。接下来,我们创建了一个包含饮料数据的drinks数组。然后,我们为服务器设置了路由和响应:

export const worker = setupWorker(
  rest.get(
    'https://www.thecocktaildb.com/api/json/v1/1/search.php',
    (req, res, ctx) => {
      return res(
        ctx.status(200),
        ctx.json({
          drinks
        })
      )
    }
  )
)

在前面的代码中,我们创建了一个路由处理程序来处理对尝试访问鸡尾酒 API 的 URL 发出的GET请求。我们将饮料数组作为响应数据传入。在本章的“使用 MSW 创建模拟 API 数据”部分,我们将服务器设置代码和路由处理程序拆分到单独的文件中。我们将保持所有服务器设置代码在同一个文件中,以实现模拟开发服务器相同的成果。最后,我们需要做的是设置我们的应用程序,以便在开发环境中运行模拟服务器:

if (process.env.NODE_ENV === 'development') {
  const { worker } = require('./mocks/browser')
  worker.start()
}
ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

在前面的代码中,我们设置了服务器,在将NODE_ENV环境变量设置为development之前启动,然后将App组件渲染到 DOM 中。使用create-react-app构建的应用程序已经将NODE_ENV设置为development,所以我们只需要使用npmstart脚本来启动应用程序,这在构建create-react-app应用程序时很典型。

现在你已经知道如何使用 MSW 创建模拟服务器来测试请求 API 数据的组件。你还创建了一个 MSW 服务器,在开发环境中以假响应进行响应。此外,你现在知道何时在getBy*查询之外使用findBy*queryBy*查询。

在本节中,我们学习了如何安装和使用 MSW。我们测试了一个用于从 API 搜索饮料数据的组件。最后,我们学习了如何将 MSW 用作开发服务器。接下来,我们将学习如何使用测试驱动开发方法编写测试。

实施测试驱动开发

测试驱动开发TDD)意味着先编写单元测试,然后构建代码以通过测试。TDD 方法允许你思考代码是否适合你想要编写的测试。这个过程提供了一个关注最少代码以使测试通过的角度。TDD 也被称为红、绿、重构代表失败的测试,绿代表通过的测试,正如其名,重构意味着在保持通过测试的同时重构代码。典型的 TDD 工作流程如下:

  1. 编写一个测试。

  2. 运行测试,预期它会失败。

  3. 编写最少的代码以使测试通过。

  4. 重新运行测试以验证它是否通过。

  5. 根据需要重构代码。

  6. 如有必要,重复步骤 2 到 5。

我们可以使用 React Testing Library 通过 TDD 方法驱动 React 组件的开发。首先,我们将使用 TDD 来构建本章前一个部分中引入的Vote组件。然后,我们将使用 TDD 来创建一个Registration组件。

使用 TDD 构建投票组件

独立测试调用事件处理器的组件部分,我们首先构建了Vote Button组件,然后编写了测试。在本节中,我们将使用 TDD 来构建组件。首先,我们规划出组件在渲染到 DOM 时应有的外观以及用户应采取的操作。我们决定组件将是一个图像按钮。父组件应将图像源和图像 alt 文本作为props传递给组件。

组件还将接受一个用于hasVoted属性的布尔值,以设置按钮的状态为enableddisabled。如果hasVoted设置为true,用户可以点击按钮以调用一个方法来处理更新投票计数。接下来,我们根据我们的设计编写测试。第一个测试将验证组件是否以传入的props渲染到屏幕上:

  test('given image and vote status, renders button to 
    screen', () => {
    const stubHandleVote = jest.fn()
    const stubAltText = 'vote like'
    render(
      <VoteBtn
        handleVote={stubHandleVote}
        hasVoted={false}
        imgSrc={stubThumbsUp}
        altText={stubAltText}
      />
    )
    const image = screen.getByRole('img', { name: 
      stubAltText })
    const button = screen.getByRole('button', { name: 
      stubAltText })

在前面的代码中,首先,我们创建jest函数并将它们分配给stubHandleVotestubAltText变量。我们在变量名前加上stub,因为我们只是在测试中将它们用作依赖占位符。变量名也提供了更多关于它们在测试中用途的上下文。

接下来,我们使用传入的props值渲染组件。然后,我们获取imagebutton元素并将它们分配给相关变量。接下来,我们将进行断言:

    expect(image).toBeInTheDocument()
    expect(button).toBeInTheDocument()
    expect(button).toBeEnabled()
  })

在前面的代码中,我们断言imagebutton元素在 DOM 中。我们还断言按钮状态为enabled,这意味着用户可以点击它。我们创建了一个Vote Button组件的文件,如下所示:

const VoteBtn = props => {
  return null
}
export default VoteBtn

在前面的代码中,我们创建了一个VoteBtn组件,该组件目前不返回任何代码以在 DOM 中渲染。我们还导出组件以在其他文件中使用。当我们运行测试时,我们从测试结果中得到了以下输出:

图 3.17 – TDD 投票按钮测试步骤 1

图 3.17 – TDD 投票按钮测试步骤 1

在前面的屏幕截图中,DOM 中的image元素名为vote like。由于我们知道图像应该是button元素的子元素,因此接下来我们将通过在VoteBtn组件文件中创建带有子image元素的button元素并传递所需的属性来解决错误:

const VoteBtn = props => {
  return (
    <button disabled={props.hasVoted}>
      <img src={props.imgSrc} alt={props.altText} />
    </button>
  )
}
export default VoteBtn

在之前的代码中,我们创建了一个带有子image元素和必需的图像源、alt 文本和禁用属性的button元素。现在当我们运行测试时,我们得到了以下输出:

图 3.18 – TDD 投票按钮测试步骤 2

图 3.18 – TDD 投票按钮测试步骤 2

在前面的屏幕截图中,我们将编写允许用户点击按钮以调用一个方法来处理在hasVoted设置为true时更新投票计数的Vote Button代码。首先,我们将创建另一个测试来针对该功能:

  test('given clicked button, invokes handleVote', () => {
    const mockHandleVote = jest.fn()
    render(
      <VoteBtn
        handleVote={mockHandleVote}
        hasVoted={false}
        imgSrc={stubThumbsUp}
        altText="vote like"
      />
    )

在前面的代码中,首先,我们创建一个jest函数并将其分配给名为mockHandleVote的变量。我们在变量名前加上mock是因为我们将在测试中对该变量进行断言。接下来,我们将VoteBtn组件渲染到 DOM 中并传入所需的属性。请注意,我们将mockHandleVote传递给handleVote属性。接下来,我们将点击按钮并进行断言:

    user.click(screen.getByRole('button', { name: /vote
      like/i }))
    expect(mockHandleVote).toHaveBeenCalled()
    expect(mockHandleVote).toHaveBeenCalledTimes(1)
  })

在前面的代码中,首先,我们在组件内部点击按钮。然后,我们断言mockHandleVote被调用且恰好调用了一次。验证mockHandleVote是否以及如何被调用是至关重要的。如果mockHandleVote没有被调用或者每次点击调用次数超过一次,我们知道组件在与父组件集成时将无法正确通信。当我们运行测试时,我们得到以下输出:

![图 3.19 – TDD 投票按钮测试步骤 3图 3.19 – TDD 投票按钮测试步骤 3

图 3.19 – TDD 投票按钮测试步骤 3

在前面的代码中,传入组件的jest函数被要求至少调用一次,但它从未被调用。接下来,我们将通过向组件添加实现来解决这个问题:

    <button onClick={props.handleVote} disabled={props.        hasVoted}>

在前面的代码中,我们添加了一个onClick事件处理器,当按钮被点击时,它将调用作为属性传递给组件的handleVote方法。现在当我们运行测试时,我们得到以下输出:

![图 3.20 – TDD 投票按钮测试步骤 4图 3.20 – TDD 投票按钮测试步骤 4

图 3.20 – TDD 投票按钮测试步骤 4

在前面的屏幕截图中,投票按钮已被实现并测试,我们已使用 TDD 方法完成该功能的构建。

在下一节中,我们将使用 TDD 创建一个注册组件。

使用 TDD 构建注册表单

在上一节中,我们使用 TDD 构建了一个Vote组件。在本节中,我们将使用 TDD 构建用于创建网站用户账户的组件。然后,一旦我们构建了使测试通过的最小功能,我们还将重构组件的实现并验证测试是否继续通过。该组件将包含一个heading元素、emailpassword字段,并且应该调用handleSubmit方法。组件的最终版本应该如下所示:

图 3.21 – 注册表单

图 3.21 – 注册表单

在前面的屏幕截图中,我们有一个允许用户提交电子邮件和密码以注册网站账户的表单。现在我们了解了最终版本在屏幕上应该看起来是什么样子,我们将根据我们的设计编写一个测试。为了本节的目的,我们将验证当表单提交时是否调用handleRegister方法:

  test('given submitted form, invokes handleRegister', () 
    => {
    const mockHandleRegister = jest.fn()
    const mockValues = {
      email: 'john@mail.com',
      password: '123'
    }
    render(<Register handleRegister={mockHandleRegister} />)

在前面的代码中,我们创建 mockHandleRegistermockValues 变量。这些变量将在测试的后续部分被断言。然后,我们将测试组件渲染到 DOM 中,并传入 mockHandleRegister。现在,mockHandleRegister 将允许我们独立于 handleRegister 依赖项测试 Register 组件。接下来,我们将输入表单字段中的值:

user.type(screen.getByLabelText('Email Address'), 
  mockValues.email)
user.type(screen.getByLabelText('Create Password'), 
  mockValues.password)
user.click(screen.getByRole('button', { name: /submit/i }))

在前面的代码中,我们将 mockValues 对象中的值输入到 emailpassword 字段中。注意 getByLabelText 查询中使用的字符串值。当您不想使用正则表达式时,字符串值是查询的另一种选项。接下来,我们将进行断言:

    expect(mockHandleRegister).toHaveBeenCalledTimes(1)
    expect(mockHandleRegister).toHaveBeenCalledWith({
      email: mockValues.email,
      password: mockValues.password
    })
  })

在前面的代码中,我们期望 mockHandleRegister 被调用一次。最后,我们期望在调用 mockHandleRegister 时,mockValues 对象的值被作为参数包含在内。验证传递给 mockHandleRegister 的参数很重要,因为它有助于降低表单值未传递给 handleRegister 的风险。

接下来,我们将创建一个 Register 组件的文件,如下所示:

export default class Register extends React.Component {
  render() {
    return null
  }
}

在前面的代码中,我们创建并导出 Register 组件,该组件目前不返回任何要在 DOM 中渲染的代码。当我们运行测试时,我们从测试结果中得到以下输出:

图 3.22 – TDD 注册测试步骤 1

图 3.22 – TDD 注册测试步骤 1

在前面的屏幕截图中,DOM 中的 email 字段元素。接下来,我们将通过创建 email 字段来解决错误。我们还将创建 password 字段和 提交 按钮:

  state = {
    email: '',
    password: ''
  }
  handleChange = event => {
    const { id, value } = event.target
    this.setState(prevState => {
      return {
        ...prevState,
        [id]: value
      }
    })
  }

在前面的代码中,首先,我们创建一个 state 对象来存储 emailpassword 字段输入的值。接下来,我们创建一个 handleChange 方法,该方法将在用户在任何表单字段中输入值时被调用。handleChange 方法将根据更改的 form 字段更新 state 值。然后,我们创建 heading 元素和一个 email 字段:

      <main>
        <h1>Register here</h1>
        <form>
          <div>
            <label htmlFor='email'>Email Address</label>
            <input
              value={this.state.email}
              onChange={this.handleChange}
              type='email'
              id='email'
            />
          </div>

在前面的代码中,首先,我们创建一个 main 元素来包裹 headingform 元素。在 main 内部,我们创建 form 元素并为用户添加一个输入电子邮件地址的字段。当用户在字段中输入值时,将调用 onChange 事件处理器来调用 handleChange 以更新状态对象的相关值。字段的 value 属性始终显示状态对象相关键中存储的当前值。接下来,我们将为用户创建一个输入密码的字段和一个用于提交表单的 button 元素:

          <div>
            <label htmlFor='password'>Create Password
              </label>
            <input
              value={this.state.password}
              onChange={this.handleChange}
              type='password'
              id='password'
            />
          </div>
          <button type='submit'>Submit</button>
        </form>
      </main>

在前面的代码中,首先,我们创建一个 password 字段。该字段具有与 email 字段相同的处理程序方法。最后,我们创建一个 提交 按钮以允许用户提交表单中的输入值。现在当我们运行测试时,我们得到以下输出:

图 3.23 – TDD 注册测试步骤 2

图 3.23 – TDD 注册测试步骤 2

在前面的代码中,我们的测试仍然失败,但原因不同。现在测试可以输入值并提交表单,但 mockHandleRegister 没有被调用以提交的值。失败发生因为我们还没有实现一个 onSubmit 事件处理器来调用我们的 mockHandleRegister 方法以及表单提交时任何其他期望的行为。

接下来,我们将通过向表单添加 onSubmit 处理器并调用我们创建的 handleSubmit 方法来解决错误:

  handleSubmit = event => {
    event.preventDefault()
    this.props.handleRegister(this.state)
  }

在前面的代码中,我们创建了 handleSubmit 方法。当 handleSubmit 被调用时,触发该方法的浏览器 event 会被传递给它。接下来,我们使用 preventDefault 方法防止浏览器在提交表单后刷新页面。最后,我们调用作为 props 提供给组件的 handleRegister,并传递存储在 state 对象中的表单值。接下来,我们将 handleSubmit 附接到表单上:

        <form onSubmit={this.handleSubmit}>

在前面的代码中,我们添加了一个 onSubmit 事件处理器,并传递了 handleSubmit。当表单提交时,handleSubmit 会被调用,导致 handleRegister 被调用,表单值作为参数。现在当我们运行测试时,我们得到以下输出:

图 3.24 – TDD 注册测试步骤 3

图 3.24 – TDD 注册测试步骤 3

前面的截图显示,我们的测试最终通过了。技术上,我们可以在这里停止,因为我们的代码使测试通过。然而,我们可以通过将组件代码从类组件转换为函数组件来使代码更简洁。只要行为保持不变,我们的测试应该继续通过。我们可以像这样重构组件:

const Register = props => {
  const [values, setValues] = React.useState({
    email: '',
    password: ''
  })

在前面的代码中,首先,我们将类转换为函数。然后,我们使用 useState 钩子来管理表单值状态。接下来,我们将重构我们的 handleChangehandleSubmit 方法:

 const handleChange = event => {
   const { id, value } = event.target
   setValues({ ...values, [id]: value })
 }
 const handleSubmit = event => {
   event.preventDefault()
   props.handleRegister(values)
 }

在前面的代码中,handleChange 类和 handleSubmit 方法被转换为函数表达式。handleChange 方法调用 setValues 来更新每个输入表单值的州。handleSubmit 的实现几乎与类版本相同。接下来,我们将重构返回的代码,该代码在浏览器中以 HTML 的形式渲染:

<main className="m-3 d-flex flex-column">
      <h1>Register here</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="email">Email Address</label>
          <input
            value={values.email}
            onChange={handleChange}
// the rest of the component code ...   

在前面的代码中,首先,我们移除了 class 组件中所需的 render 方法。其余的代码与类版本非常相似。然而,value 属性使用 values 对象,并且传递给 onChange 事件处理器的 handleChange 方法不需要包含 this 关键字。当我们重新运行我们的测试时,我们得到以下结果:

图 3.25 – TDD 注册测试步骤 4

图 3.25 – TDD 注册测试步骤 4

在前面的截图中,经过重构后我们的测试仍然通过。refactor组件使我们的代码变得更加整洁。现在你了解了如何使用 React Testing Library 通过 TDD 来构建组件。在本节中,我们使用 TDD 来驱动投票和注册功能的创建。React Testing Library 提供的测试结果反馈使得开发过程更加愉快。

摘要

在本章中,你学习了如何安装和使用模块来模拟组件在最终 DOM 输出上的用户操作。你现在可以使用一个用户友好的工具安装和测试与 API 交互的功能。你理解了如何使用模拟函数将组件与事件处理程序依赖项隔离来测试组件。最后,你学习了如何结合 React Testing Library 实现构建功能的 TDD 方法。

在下一章中,我们将通过学习集成测试的好处来深入探讨。我们还将学习如何测试使用流行第三方库的 React 组件。

问题

  1. 为什么你应该在测试中优先选择user-event而不是fireEvent来模拟用户操作?

  2. 解释 MSW 如何允许你测试向 API 发起请求的组件。

  3. 模拟函数是什么?

  4. 解释使用模拟函数单独测试组件的应用风险。

  5. 用你自己的话描述 TDD 工作流程。

  6. 解释何时使用 getBy*findBy*queryBy* 查询来选择元素。

第四章:在您的应用程序中使用集成测试和第三方库

在前面的章节中,我们学习了如何测试与依赖项隔离的组件,以及如何测试管理状态的组件。在许多应用程序中,团队可以通过引入第三方工具来管理状态和构建组件来提高速度。到本章结束时,你将了解使用集成测试方法的好处。你将了解如何配置测试,以使用高级状态管理工具对组件进行断言。你将学习如何测试应用程序中渲染的错误。你将测试与 API 服务器交互的组件,这些服务器以与传统 表示状态传输REST)API 不同的方式结构化数据,允许你描述并接收前端应用程序所需的具体数据。最后,你将学习如何测试使用流行的 React 组件库的组件。

在本章中,我们将涵盖以下主要主题:

  • 通过集成测试获得价值

  • 测试使用 Context API 的组件

  • 测试使用 Redux 的组件

  • 测试使用 GraphQL 的组件

  • 测试使用 Material-UI 构建的组件

本章中获得的知识将加深我们对在各种场景下测试 React 组件的理解。

技术要求

对于本章的示例,您需要在您的机器上安装 Node.js。我们将使用 create-react-app CLI 工具来展示所有代码示例。如果需要,请在开始本章之前熟悉该工具。此外,您还需要对 Redux 和 React Context API 有基本的了解。本章将提供代码片段以帮助您理解要测试的代码,但目标是理解如何测试代码。您可以在以下位置找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter04

测试集成组件

在上一章中,我们学习了如何测试与依赖项(包括其他组件)隔离的组件。隔离测试有其优点,但也有缺点,因为真实依赖项被测试替身所取代。在本节中,我们将学习如何测试与其他组件集成的组件。在许多场景中,集成测试可以比隔离测试带来更多的价值,因为我们可以在更接近其生产使用的方式下测试代码。我们还可以更快地为组件添加测试覆盖率,因为一个测试可以同时覆盖多个组件。在本节中,我们将通过几个示例使用集成测试。

使用投票组件进行集成测试

在上一章中,我们测试了Vote组件,允许用户点击按钮来增加或减少总点赞数。在本节中,我们将将实现拆分为单独的组件,并编写集成测试。该组件包含两个button元素:

<button
        onClick={handleLikeVote}
        disabled={hasVoted}
        style={clickedLike ? { background: 'green' } : null}
      >
        <img src={thumbsUp} alt="thumbs up" />
      </button>
      <div>{totalLikes}</div>
      <button
        onClick={handleDislikeVote}
        disabled={hasVoted}
        style={clickedDislike ? { background: 'red' } : null}
      >
        <img src={thumbsDown} alt="thumbs down" />
      </button>

在之前的代码中,两个按钮元素的属性非常相似,可以提取到它们自己的组件文件中,以便在其他应用部分重复使用:

const VoteBtn = props => {
  return (
    <button onClick={props.handleVote} disabled={props.       hasVoted}>
      <img src={props.imgSrc} alt={props.altText} />
    </button>
  )
}

在之前的代码中,我们创建了一个VoteBtn组件,它可以在整个应用中任何地方重复使用。VoteBtn可以在Vote组件中使用,以替换硬编码的button元素:

return (
    <div className="h1">
      <h5>Note: You are not allowed to change your vote once          selected!</h5>
      <VoteBtn
        handleVote={handleVoteLike}
        hasVoted={hasVoted}
        imgSrc={thumbsUp}
        altText="thumbs up"
      />
      <div>{totalLikes}</div>
      <VoteBtn
        handleVote={handleVoteDislike}
        hasVoted={hasVoted}
        imgSrc={thumbsDown}
        altText="thumbs down"
      />
    </div>
  )

在之前的代码中,两个VoteBtn实例被集成到Vote组件中。我们可以从Vote组件中单独测试VoteBtn,但通过一起测试这两个组件的集成,我们可以获得更多的价值。对于第一个测试,我们可以验证一个"up"投票会增加总点赞数一个:

  test('given "up" vote, total likes increases by one', () => {
    render(<Vote totalGlobalLikes={10} />)
    user.click(screen.getByRole('button', { name: /thumbs up/i    }))
    expect(screen.getByText(/11/i)).toBeInTheDocument()
  })

在之前的代码中,首先,我们将Vote组件渲染到 DOM 中,并将10这个值传递给totalGlobalLikes属性。接下来,我们点击 DOM 中的11。对于下一个测试,我们将验证一个"down"投票会减少总点赞数一个:

  test('given "down" vote, total likes decreases by one', () => {
    render(<Vote totalGlobalLikes={10} />)
    user.click(screen.getByRole('button', { name: /thumbs        down/i }))
    expect(screen.getByText(/9/i)).toBeInTheDocument()
  })

在之前的例子中,代码与第一个例子相似。唯一的区别是,我们没有点击 DOM 中的9。最后一个我们将要编写的测试将验证用户只能投票一次:

  test('given vote, returns disabled vote buttons', () => {
    render(<Vote totalGlobalLikes={10} />)
    const thumbsUpBtn = screen.getByRole('button', { name: /       thumbs up/i })
    const thumbsDownBtn = screen.getByRole('button', { name: /       thumbs down/i })
    user.click(thumbsUpBtn)
    user.click(thumbsUpBtn)
    user.click(thumbsDownBtn)
    user.click(thumbsDownBtn)
    expect(screen.getByText(/11/i)).toBeInTheDocument()
  })

在之前的代码中,首先,我们将Vote组件渲染到 DOM 中,并将10这个值传递给totalGlobalLikes属性。接下来,我们在 DOM 中抓取了11。这个数字11是预期的,因为首先点击了点赞按钮,这会禁用其他按钮。通过使用集成测试方法,我们能够在同一个测试中验证屏幕上显示的总点赞数以及模拟点击事件后的按钮状态。

当我们运行所有的Vote组件测试时,我们得到以下输出:

![Figure 4.1 – Vote 组件测试结果Figure 4.1 – Vote component test results

图 4.1 – Vote 组件测试结果

上述截图显示了Vote.test.js测试文件。

现在你已经理解了与依赖项集成测试组件的优势。然而,在某些情况下,使用集成方法可能不是最佳策略。在下一节中,我们将探讨一个场景,其中单独测试组件比集成测试更有价值。

规划更适合单独测试的测试场景

在前面的章节中,我们学习了测试与依赖项集成组件的优势。然而,有些情况下使用隔离测试方法更为合适。在第三章实现测试驱动开发部分,[《使用 React 测试库测试复杂组件》]中,我们构建了一个注册表单。作为参考,组件的输出如下:

图 4.2 – 注册表单

图 4.2 – 注册表单

在前面的屏幕截图中,我们看到注册组件允许用户提交电子邮件地址和密码。测试使用了隔离方法,并验证了在表单提交时调用 handleRegister 方法的快乐路径。假设添加了一个新功能,其中成功消息从服务器发送到前端,并在注册成功时替换屏幕上的表单:

图 4.3 – 注册成功

图 4.3 – 注册成功

在前面的屏幕截图中,在成功提交表单后,显示消息注册成功!验证表单提交后屏幕上显示的消息可以使用集成方法进行测试,但可能是一个运行缓慢的测试。我们可以通过创建模拟服务器响应来创建一个运行速度更快的隔离测试。那么,当表单验证错误显示并阻止用户提交表单时会发生什么情况?例如,当用户输入无效的电子邮件或尝试提交空白字段时,会显示表单验证错误。密码字段也可能呈现几个测试场景,例如当密码长度不足或密码中不包含特殊字符时显示错误。

上述场景都是隔离测试方法的良好用例。屏幕上显示的错误不依赖于组件外的任何代码。我们可以在表单中测试各种组合和边缘情况,这些情况将运行得非常快,并迅速增加大量价值。一般来说,考虑在集成方法设置测试会显得繁琐或集成方法产生运行缓慢的测试时创建隔离测试。同时,记住在创建隔离测试时需要模拟多少依赖项。模拟多个依赖项提供的价值不如包含依赖项的测试。

现在你已经知道了如何测试与依赖项集成的组件。你理解了集成测试与单独测试相比的优势。你还知道在某些情况下,使用隔离方法测试组件可能比仅使用集成方法为你的测试计划带来更好的结果。是否单独测试组件或与依赖项集成将取决于你的测试计划。在下一节中,我们将探讨更多使用隔离和集成测试方法的示例,并学习如何测试使用 Context API 进行状态管理的组件。

测试使用 Context API 的组件

在本节中,我们将学习如何测试使用 React 库的Context Provider组件的组件:

import { LikesProvider } from './LikesContext'
import Vote from './Vote'
const App = () => (
  <LikesProvider initialLikes={10}>
    <Vote />
  </LikesProvider>
)

在上述代码中,负责向 Context 消费者提供 Context 状态的LikesProvider组件以Vote作为子组件进行渲染。LikesProvider为所有消费组件提供了一个initialLikes计数为10。由于VoteLikesProvider内部渲染,它可以查看和更新LikesProvider提供的状态。为了测试Context Provider组件的消费者,我们需要一种方法在测试中访问Context Provider。我们将使用零售应用程序来演示如何实现这些要求。

测试使用 Context 的Retail组件

在本节中,我们将测试一个Retail组件,该组件使用由RetailContext组件提供的状态。Retail组件的 UI 如下所示:

图 4.4 – 组件 UI

图 4.4 – Retail组件 UI

上述截图显示了Retail组件的初始屏幕输出。显示了一列服装产品和购物车。还有一个带有文本Retail Store的板块,点击后会显示产品详情:

图 4.5 – 产品详情

图 4.5 – 产品详情

上述截图显示了用户点击后的男士休闲高级修身 T 恤的详情。用户可以点击添加到收藏按钮来收藏该商品:

图 4.6 – 收藏的产品详情

图 4.6 – 收藏的产品详情

上述截图显示,一旦点击按钮,文本添加到收藏会变为已添加到收藏。最后,用户可以输入数量并点击添加到购物车按钮将产品添加到购物车中:

图 4.7 – 已添加到购物车的产品

图 4.7 – 已添加到购物车的产品

上述截图显示了购物车中添加了3男士休闲高级修身 T 恤。购物车显示了1 Items,代表购物车中的总商品数。购物车还显示了所有添加到购物车的商品的小计

在代码实现中,Retail组件作为App内部的RetailProvider的子组件进行渲染:

 import retailProducts from './api/retailProducts'
import Retail from './Retail'
import { RetailProvider } from './RetailContext'
const App = () => (
  <RetailProvider products={retailProducts}>
    <Retail />
  </RetailProvider>
)

在前面的代码中,RetailProvider 通过 retailProducts 接收产品数组。retailProducts 数据是来自 Fake Store API (fakestoreapi.com) API 的本地数据子集,这是一个免费的开放源代码 REST API,提供示例产品。Retail 组件包括三个独立的子组件 – ProductListProductDetailCart – 它们集成在一起以消费和管理 RetailContext 状态:

const Retail = () => {
  return (
    <div className="container-fluid">
      <div className="row mt-3">
        <ProductDetail />
        <Cart />
      </div>
      <ProductList />
    </div>
  )
}

Retail 组件在前面代码中的 div 元素内渲染 ProductListProductDetailCart 组件作为子组件。

我们将使用隔离单元测试和集成测试的组合来验证 Retail 代码按预期工作。

独立测试购物车组件

在本节中,我们将验证 Cart 组件的初始状态。我们将使用单元测试方法,因为初始状态依赖于 RetailContext 而不是其他 Retail 组件:

test('Cart, given initial render, returns empty cart', () => {
  render(
    <RetailProvider products={testProducts}>
      <Cart />
    </RetailProvider>
  )
  expect(screen.getByText(/0 items/i)).toBeInTheDocument()
  expect(screen.getByText(/\$0\.00/i)).toBeInTheDocument()
})

在前面的代码中,我们首先将 Cart 组件作为 RetailProvider 的子组件进行渲染。然后,我们进行两个断言。首先,我们断言文本 0 items 在 DOM 中。然后,我们断言文本 $0.00 在 DOM 中。当我们为 Cart 组件运行测试时,我们得到以下输出:

图 4.8 – 购物车组件测试结果

图 4.8 – 购物车组件测试结果

前面的截图显示了测试的 Cart 组件。

在下一节中,我们将测试 Product 组件。

独立测试产品组件

在本节中,我们将验证 Product 组件能否将传入的产品数据显示到 DOM 中。我们将使用流行的库 faker 生成测试数据。我们可以编写以下测试:

test('Product, given product properties, renders to screen', () => {
  const product = {
    title: faker.commerce.productName(),
    price: faker.commerce.price(),
    image: faker.image.fashion()
  }

在前面的代码片段中,我们使用 faker 为我们的测试生成随机的 productNamepricefashion image 数据。使用 faker 自动生成我们的测试数据,我们可以消除新团队成员查看我们的代码学习测试组件时的任何困惑。新团队成员可能会看到硬编码的数据,并认为组件必须具有特定的数据才能正常工作。由 faker 创建的随机数据可以使它更清晰,即组件不需要被特别硬编码才能按预期工作。接下来,我们为测试编写剩余的代码:

   render(
    <RetailProvider products={testProducts}>
      <Product
        title={product.title}
        price={product.price}
        image={product.image}
      />
    </RetailProvider>
  )
  expect(screen.getByText(product.title)).toBeInTheDocument()
  expect(screen.getByText(`$${product.price}`)).     toBeInTheDocument()
})

在前面的代码片段中,我们将 Product 组件包裹在 RetailProvider 中,将测试数据作为 props 传入,并渲染 DOM 组件。最后,断言产品 titleprice 在 DOM 中。现在我们已经验证了 Product 组件能够按预期接受并渲染 prop 数据到 DOM 中。当我们为 Product 组件运行测试时,我们得到以下输出:

图 4.9 – 产品组件测试结果

图 4.9 – 产品组件测试结果

前面的截图显示了测试的 Product 组件正确地将传入的数据显示到屏幕上。

接下来,我们将测试 ProductDetail 组件。

独立测试 ProductDetail 组件

本节将验证 ProductDetail 组件最初将文本 Retail Store 渲染到 DOM 中。Retail Store 文本作为占位符,直到用户点击其中一个产品。我们可以按照以下方式测试组件:

test('ProductDetail, given initial render, displays Placeholder   component', () => {
  render(
    <RetailProvider products={testProducts}>
      <ProductDetail />
    </RetailProvider>
  )
  expect(
    screen.getByRole('heading', { name: /retail store/i })
  ).toBeInTheDocument()
})

在前面的代码中,我们将 ProductDetail 包裹在 RetailProvider 中。然后,我们断言文本 Retail Store 在 DOM 中。运行测试会产生以下输出:

图 4.10 – ProductDetail 组件测试结果

图 4.10 – ProductDetail 组件测试结果

上一张截图显示了测试中 ProductDetail 在初始渲染时正确显示了文本。

接下来,我们将验证当消费者在 Context Provider 之外使用时显示的错误。

使用错误边界测试上下文错误

在本节中,我们将验证在使用 Retail 组件进行第一次测试之前,必须将其包裹在 RetailProvider 中。这个测试很重要,因为如果没有 RetailContext 提供的状态数据,Retail 组件无法按预期工作。在 RetailContext 代码内部,我们有一个检查来确保用于访问 RetailContext 的方法是在 Provider 内部使用的:

function useRetail() {
  const context = React.useContext(RetailContext)
  if (!context) {
    throw new Error('useRetail must be used within the        RetailProvider')
  }

在之前的代码片段中,如果用户尝试在 RetailProvider 之外使用 useRetail 方法访问 RetailContent 的状态数据,将会抛出一个 错误 并停止应用程序的运行。我们可以按照以下方式编写测试:

test('Retail must be rendered within Context Provider', () => {
  jest.spyOn(console, 'error').mockImplementation(() => {})
  const ErrorFallback = ({ error }) => error.message
  render(
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Retail />
    </ErrorBoundary>
  )

在前面的代码中,我们使用 jest.spyOn 方法在整个测试过程中监视 console.log 方法。我们还附加了一个空的回调方法作为 mockImplementation。我们使用 mockImplementation 来控制 console.error 被调用时会发生什么。我们不想在我们的测试结果中记录任何与 test.error 执行相关的特定内容,所以我们传递了一个空的回调函数。

接下来,我们创建 ErrorFallback 组件,这是我们用来接收 RetailContext 抛出的错误信息的组件。然后,我们将 Retail 组件包裹在 ErrorBoundary 中,这样我们可以控制组件抛出的错误。我们可以手动创建一个错误边界组件,但 react-error-boundary (github.com/bvaughn/react-error-boundary) 提供了一个易于使用的错误边界组件。我们将 ErrorFallback 作为 FallbackComponent 的值。当 Retail 组件渲染时,如果抛出错误,ErrorBoundary 组件会将错误传递给 ErrorFallback

接下来,我们执行断言:

const errorMessage = screen.getByText(/must be used within the   RetailProvider/i)
  expect(errorMessage).toBeInTheDocument()
  expect(console.error).toHaveBeenCalled()
  console.error.mockRestore()
})

在前面的代码中,首先,我们在 DOM 中查询错误消息 必须在使用 RetailProvider 时使用。然后,我们期望 console.error 被调用。最后,作为测试清理步骤,我们将 console.error 恢复到其原始状态,允许它在后续测试中必要时被调用。现在你知道如何验证消耗上下文的组件不能在 Context Provider 之外渲染。

使用集成测试来测试查看产品详情

对于我们的下一个测试,我们将验证用户能否点击产品并查看产品详情。查看产品详情的步骤是一个用户工作流程,使用集成方法进行测试会很好。我们可以这样编写测试:

  test('A user can view product details', () => {
    render(
      <RetailProvider products={testProducts}>
        <Retail />
      </RetailProvider>
    )
    const firstProduct = testProducts[0]
    user.click(
      screen.getByRole('heading', {
        name: firstProduct.title
      })
    )

在前面的代码中,我们在 DOM 中包装了 Retail 组件并在其中渲染。接下来,我们获取 testProducts 数组中的第一个项目并将其分配给变量 firstProduct。然后,我们点击屏幕上第一个产品的 title。最后,我们断言输出:

    expect(
      screen.getAllByRole('heading', { name: firstProduct.title          }).length
    ).toEqual(2)
    expect(screen.getByText(firstProduct.description)).       toBeInTheDocument()
    expect(
      screen.getByRole('heading', { name: `$${firstProduct.         price}` })
    ).toBeInTheDocument()
  })

在前面的代码中,我们断言第一个产品的标题在屏幕上显示两次。最后,我们断言产品的 descriptionprice 数据在屏幕上显示。

对于我们的下一个测试,我们将验证用户能否将产品添加到购物车。我们可以编写以下测试代码:

   function addFirstItemToCart() {
    const firstProduct = testProducts[1]
    const firstProductTitle = screen.getByRole('heading', {
      name: firstProduct.title
    })
    user.click(firstProductTitle)
    user.click(screen.getByRole('button', { name: /add to        cart/i }))
  }

在前面的代码片段中,我们创建了一个 addFirstItemToCart 函数来执行连续测试中的相同测试步骤并避免代码重复。接下来,我们编写主要的测试代码:

  test('A user can add a product to the cart', () => {
    render(
      <RetailProvider products={testProducts}>
        <Retail />
      </RetailProvider>
    )
    addFirstItemToCart()
    expect(screen.getByText(/1 items/i)).toBeInTheDocument()
  })

在前面的代码中,我们在 RetailProvider 内部渲染了 Retail 组件。接下来,我们执行 addFirstItemToCart 方法。最后,我们断言文本 1 items 出现在 DOM 中。现在我们确信用户可以使用 RetailProductProductDetailCart 组件集成来添加商品到购物车。

作为挑战,尝试编写以下测试场景的代码:用户可以更新购物车中商品的数量用户不能提交大于 10 的数量用户不能提交小于 1 的数量,以及用户可以将商品添加到收藏夹。这些测试场景的解决方案可以在第四章 代码示例中找到(github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter04/ch_04_context)。现在你知道如何编写使用 Context API 的组件的集成测试。你通过多个示例更好地理解了如何单独测试组件。你还知道如何使用错误边界测试抛出的错误。

本节的学习内容将在下一节中受益,那时我们将学习如何使用 Redux 测试管理状态组件。会有一些差异,但本节中的相似策略将普遍使用。

测试使用 Redux 的组件

本节将教你如何测试使用流行的 Context Provider 的组件。

要使用 Redux 测试组件,组件必须在 Redux 状态提供上下文中使用:

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

在前面的代码中,我们有一个作为 Redux Provider 组件子组件传递的顶层 App。将顶层 App 组件包裹起来是 Redux 中的一种常见模式,它允许应用程序中的任何子组件访问 Redux 提供的状态数据。状态数据和修改状态的方法通过传递给 Provider 的 store 属性。通常,你将在单独的文件中创建此代码,使用 Redux 的 API 方法将所有内容连接起来,并将组合结果传递给 store 属性。

在设计消耗 Redux 的组件测试时,我们需要传递测试数据,我们可以将其用作 Redux 状态。仅仅将测试数据传递给 Redux Provider 的 store 属性是不够的,因为我们还需要包括用于消耗和更新状态数据的 Redux 方法:

test('Cart, given initial render, displays empty cart', () => {
  render(
    <Provider store={'some test data'}>
      <Cart />
    </Provider>
  )

在前面的代码片段中,我们有一个 Cart 组件测试,它将字符串 一些测试数据 作为测试数据传递给 Redux 存储。当我们运行测试时,我们收到以下输出:

图 4.11 – 失败的购物车 Redux 组件测试

图 4.11 – 失败的购物车 Redux 组件测试

图 4.11 – 失败的购物车 Redux 组件测试

前面的截图显示了测试 TypeError: store.getState is not a function 被输出到控制台。测试失败是因为当被测试的组件渲染时,它试图访问 Redux 提供的用于访问 Redux 存储状态的方法,但该方法不可用。我们需要一种方法来传递所有相关的 Redux 状态方法和可控制测试数据到测试中。我们将在下一节中学习一种策略。

为测试消耗 Redux 组件创建自定义的 render 方法

在本节中,我们将学习如何为我们的测试创建一个自定义的 render 函数。在 第一章探索 React 测试库 中,我们了解到 React 测试库的 render 方法用于将组件放置在待测试的 DOM 中。自定义 render 方法使用 React 测试库 render 方法的 wrapper 选项,将组件放置在包含 Redux Provider 组件的 DOM 中,该组件提供对 Redux API 方法的访问。自定义 render 方法还将允许我们为每个测试传递独特的可控制测试数据。要开始,我们将为我们的方法创建一个文件并导入多个模块:

import { configureStore } from '@reduxjs/toolkit'
import { render as rtlRender } from '@testing-library/react'
import faker from 'faker'
import { Provider } from 'react-redux'
import retailReducer from '../retailSlice'

在前面的代码中,我们从 Redux Toolkit 库中导入 configureStore 方法。configureStore 方法是对标准 Redux createStore() 的抽象,用于设置 Redux 存储。接下来,我们导入 React Testing Library 的 render 方法,并将其命名为 rtlRenderrtlRender 名称是 React Testing Library Render 的简称。稍后在该文件中,我们将创建一个自定义的 render 方法,以消除使用相同方法名引起的问题。

接下来,我们导入 faker 模块。我们将使用 faker 自动生成初始状态的数据,并将其传递到我们的组件中。然后,我们从 React-redux 中导入 Provider 方法,以接受并将 store 传递给组件。最后,我们导入 retailReducer,它提供了组件可以用来访问和修改状态的方法。

接下来,我们将创建一个对象,作为 Redux 存储的 initialState 值:

const fakeStore = {
  retail: {
    products: [
      {
        id: faker.random.uuid(),
        title: faker.commerce.productName(),
        price: faker.commerce.price(),
        description: faker.commerce.productDescription(),
        category: faker.commerce.department(),
        image: faker.image.fashion()
      }, 
    ],
    cartItems: [],
    favorites: [],
    showProductDetails: null
  }
}

在前面的代码中,变量 fakeStore 包含了 productscartItemsfavoritesshowProductDetails 的所有初始状态值。产品数据是由 faker 创建的值的对象数组。接下来,我们将创建一个自定义的 render 方法,以替代 React Testing Library 的 render 方法:

function render(
  ui,
  {
    initialState,
    store = configureStore({
      reducer: { retail: retailReducer },
      preloadedState: initialState
    }),
    ...renderOptions
  } = {}
) {

在前面的代码片段中,该方法接受两个参数作为参数。首先,ui 参数接受要测试的组件作为子组件,并将其包裹在自定义方法中。下一个参数是一个具有许多属性的对象。首先,initialState 接受我们可以传递到测试文件中的组件内的自定义测试数据。接下来,store 使用 configureStore 方法设置 Redux 存储,包括 reducerpreloadedStatereducer 属性是一个对象,它接受我们创建的 reducers 来管理应用程序状态。preloadedState 属性接受传递到测试文件组件中的 initialState。最后,任何其他传入的参数都由 renderOptions 处理。

接下来,我们将创建 Wrapper 方法:

  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>
  }
  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}

在前面的代码中,Wrapper 接受 children,它将是被测试的组件。接下来,该方法返回带有 storechildren 传入的 Provider。最后,render 方法返回对 rtlRender 的调用,其中包含 ui 和一个包含 Wrapper 方法和其他 renderOptions 传入的对象。

最后一步是将自定义代码导出,以便在测试文件中导入和使用:

export * from '@testing-library/react'
export { render, fakeStore }

在前面的代码中,首先,我们导出 React Testing Library 的所有内容。最后,我们导出一个包含自定义 render 方法(该方法覆盖 React Testing Library 的 render 方法)和 fakeStore(作为自定义测试数据,用于任何测试)的对象。现在你已了解如何创建一个用于测试 Redux 消费组件的自定义 render 方法。

接下来,我们将在测试中使用自定义方法。

在测试中使用测试 Redux Provider

在本节中,我们将使用自定义的 render 方法来测试一个组件。在本章的“测试一个上下文消费的零售组件”部分,我们测试了一个 Retail 组件及其子组件。开发团队可能决定使用 Redux 来构建组件状态。Retail 组件包括我们可以用自定义 render 方法测试的 Cart

import Cart from './Cart'
import { render, screen, fakeStore } from './utils/test-utils'

在前面的代码中,首先,我们在测试文件中导入要测试的 Cart 组件。接下来,我们从自定义方法文件中导入 renderscreenfakeStore 方法。render 方法是在文件中创建的自定义方法。screen 方法是来自 React Testing Library 的真实 screen 方法。fakeStore 方法是我们自定义方法文件中创建的自定义测试数据。接下来,我们将编写主要的测试代码:

test('Cart, given initial render, displays empty cart', () => {
  render(<Cart />, { initialState: fakeStore })
  expect(screen.getByText(/0 items/i)).toBeInTheDocument()
  expect(screen.getByText(/\$0\.00/i)).toBeInTheDocument()
})

在前面的代码中,首先,我们使用自定义的 render 方法在 DOM 中渲染 Cart 组件。作为 render 方法的第二个参数,我们将一个对象和 fakeStore 作为 initialState 的值传递。fakeStore 是我们可以使用的默认测试数据,但我们可以创建并传递特定于测试的不同数据。自定义 render 方法使我们的代码更简洁,因为我们看不到测试代码的 Provider 方法。最后,我们断言文本 0 件$0.00 在 DOM 中。当我们运行测试时,我们得到以下输出:

图 4.12 – 通过 Redux 组件测试购物车 Redux 组件

图 4.12 – 通过 Redux 组件测试购物车 Redux 组件

上述截图显示了预期的测试 render 方法。请参阅 第四章代码示例 (github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter04/ch_04_redux) 以获取更多测试 Redux 消费组件的示例。现在您知道了如何创建一个自定义的 render 方法来测试消费 Redux 状态的组件。这个自定义方法可以用来测试几乎任何消费 Redux 的 React 组件。

在下一节中,我们将学习如何测试通过 GraphQL 消费 API 数据的组件。

测试使用 GraphQL 的组件

在本节中,您将学习如何测试使用我们在本章“测试集成组件”部分测试过的 Table 组件的组件,但现在该组件将被重构以通过 Apollo Clientwww.apollographql.com/docs/react/) 从 GraphQL 服务器接收数据。

我们可以查看 Table 组件的实现细节,以了解它如何与 GraphQL 交互:

export const EMPLOYEES = gql`
  query GetEmployees {
    employees {
      id
      name
      department
      title
    }
  }
`

在前面的代码中,我们创建了一个employees GraphQL 查询,用于访问组件中渲染在table行元素内的employees数据。当组件渲染时,查询将自动与 GraphQL 服务器通信,并返回组件可用的employee数据。我们将在稍后的测试文件中使用此查询。

App组件在与 GraphQL 服务器通信中扮演着重要的角色:

client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache()
})
const App = () => {
  return (
    <ApolloProvider client={client}>
      <Table />
    </ApolloProvider>
  )
}

在前面的代码中,创建了一个名为client的变量,并将其设置为从ApolloClient导入的新实例。ApolloClient有一个uri属性,我们可以将其设置为运行中的 GraphQL 服务器的 URL,在代码片段中是http://localhost:4000ApolloClient还将cache属性设置为InMemoryCache方法。

InMemoryCache方法是一个出色的性能增强特性,因为它将存储从 GraphQL 接收到的数据本地存储,并且只有在数据需要更新时才会向 GraphQL 服务器发出额外的调用。App组件还使用来自 Apollo 库的ApolloProvider来渲染Table作为子组件。现在Table组件可以查询 GraphQL 服务器。ApolloProvider的行为与我们在本章的测试使用 Redux 的组件部分中学习的 Redux Provider组件类似。现在我们理解了 GraphQL 与消费Table组件之间的联系,我们可以开始编写测试。

我们将要编写的第一个测试将验证当Table组件首次渲染时,屏幕上会显示一个加载消息:

import { MockedProvider } from '@apollo/client/testing'
import { act, render, screen } from '@testing-library/react'
import faker from 'faker'
import Table, { EMPLOYEES } from './Table'

在前面的代码中,我们导入了MockedProviderMockedProvider是 Apollo 的一个特定方法,我们可以用于测试。使用MockedProvider,我们不需要创建任何自定义的Provider组件,就像我们在本章的测试使用 Redux 的组件部分中所做的那样。

接下来,我们从 React Testing Library 中导入actrenderscreenact方法将让 React 知道我们正在显式执行它不期望的操作。我们将在相关的测试中详细说明这一点。然后,我们导入faker以帮助生成测试数据。最后,我们导入要测试的Table组件和EMPLOYEES GraphQL 查询。

接下来,我们可以创建一个模拟的 GraphQL 响应:

const mocks = [
  {
    request: {
      query: EMPLOYEES
    },
    result: {

在前面的代码中,我们创建了一个mocks变量,并将其设置为包含一个数组的实例,其中request属性设置为我们的EMPLOYEES查询。mocks变量将用我们用于测试的版本替换实际的 GraphQL 查询。接下来,我们可以创建测试将响应的测试数据:

      data: {
        employees: [
          {
            id: faker.random.uuid(),
            name: faker.fake('{{name.firstName}} {{name.               lastName}}'),
            department: faker.commerce.department(),
            title: faker.name.jobTitle()
          },
          {
            id: faker.random.uuid(),
            name: faker.fake('{{name.firstName}} {{name.               lastName}}'),
            department: faker.commerce.department(),
            title: faker.name.jobTitle()
          }
        ]

在前面的代码中,我们为Table组件创建了一个data属性,将其设置为employees数组的实例,以便组件可以消费并在屏幕上显示。使用faker模块可以消除创建硬编码值的需要。现在我们可以编写主要的测试代码:

  test('given initial render, returns loading message', () => {
    render(
      <MockedProvider mocks={mocks}>
        <Table />
      </MockedProvider>
    )
    expect(screen.getByText(/Loading.../)).toBeInTheDocument()
  })

在之前的代码中,我们在MockedProvider内部渲染了Table组件,并传递了mocks数据。然后,我们断言文本加载中…在 DOM 中。加载文本是组件渲染时首先显示的内容,直到前端 React 应用程序从 GraphQL 服务器接收数据。

对于下一次测试,我们将验证是否达到了完成状态,这意味着组件已接收并渲染了员工数据:

  test('given completed state, renders employee data', async ()      => {
    render(
      <MockedProvider mocks={mocks}>
        <Table />
      </MockedProvider>
    )
        await act(() => new Promise(resolve =>            setTimeout(resolve, 0)))
    screen.debug()
    expect(screen.getAllByTestId('row').length).toEqual(2)
  })

在之前的代码中,我们在MockedProvider内部渲染了Table组件,并传递了mocks数据。然后,我们将Promise设置为resolved状态,并用act方法包裹。尽管我们没有访问真实的 GraphQL 服务器,但我们使用的 Apollo 方法都是异步的,需要一些时间来完成。我们在0秒后显式完成异步过程,以继续测试步骤。

0秒的值可能看起来很奇怪,但强制resolved状态是必要的,因为异步 JavaScript 操作会在完成之前等待一段时间,然后继续执行下一个操作。如果我们不使用act方法,我们的测试将通过,但也会在屏幕上渲染一个错误消息:

图 4.13 – 未包裹在 act 中的错误

图 4.13 – 未包裹在 act 中的错误

上一张截图显示了当我们不使用act方法处理 React 不知道的显式组件更新时,在控制台显示的错误消息。最后,我们断言在 DOM 中找到了两个row元素。

对于最后一个测试,我们将验证错误状态会导致错误消息在屏幕上显示:

  test('given error state, renders error message', async () =>     {
    const mocks = [{ request: { query: EMPLOYEES }, error: new        Error() }]
    render(
      <MockedProvider mocks={mocks}>
        <Table />
      </MockedProvider>
    )
    await act(() => new Promise(resolve => setTimeout(resolve,        0)))
    expect(screen.getByText(/Error/i)).toBeInTheDocument()
  })

在之前的代码中,我们在MockedProvider内部渲染了Table组件,并传递了mocks数据。然而,与之前的测试不同,我们将error属性设置为Error对象的新实例。当设置error属性时,意味着发生了某些事情,阻止了从 GraphQL 服务器到前端的数据发送和接收过程。

接下来,我们将Promise设置为resolved状态,并用act方法包裹,就像之前的测试一样。最后,我们断言文本Error在文档中。当我们运行测试时,我们得到以下输出:

图 4.14 – 表组件测试结果

图 4.14 – 表组件测试结果

现在,你已经知道了如何使用 Apollo Client 测试消耗 GraphQL 服务器数据的组件。随着 GraphQL 继续获得人气,将我们讨论的测试策略放入你的工具箱中,以快速验证预期行为将是有帮助的。

在下一节中,我们将学习如何测试使用流行的 Material-UI 组件库进行前端开发的组件。

测试使用 Material-UI 的组件

在本节中,我们将学习如何测试使用 Material-UI 组件库的组件。在大多数情况下,您可以使用 React Testing Library 直接选择 Material-UI 组件渲染的 DOM 元素。然而,有时添加作为结果 DOM 元素属性渲染的组件属性是有帮助的。我们将学习如何添加属性以测试 VoteCustomer Table 组件。

添加 ARIA 标签以测试投票组件

在本章的 测试一个使用上下文的投票组件 部分,我们测试了一个 Vote 组件。

我们可以使用 Material-UI 的组件来重建该组件:

    <div>
      <Box display="flex" flexDirection="column" css={{ width:         100 }}>
        <Button
          onClick={() => voteLike()}
          disabled={hasVotedLike}
          variant="contained"
          color="primary"
        >
          <ThumbUpIcon />
        </Button>

在前面的代码中,我们使用了 Material-UI 的 BoxButtonThumbUpIcon 组件来快速构建一个带有 CSS层叠样式表)样式的 点赞 按钮,使其看起来很漂亮。

接下来,我们将构建组件的其余部分:

        <Typography variant="h3" align="center">
          {totalLikes}
        </Typography>
        <Button
          onClick={() => voteDislike()}
          disabled={hasVotedDislike}
          variant="contained"
          color="primary"
        >
          <ThumbDownAltIcon />
        </Button>
      </Box>
    </div>

在前面的代码中,我们使用了 Material-UI 的 TypographyButtonThumbDownAltIcon 组件来构建点赞按钮,并在屏幕上显示总点赞数。当我们将在浏览器中渲染组件时,我们得到以下输出:

![图 4.15 – Material-UI 投票组件图 4.15 – Material-UI 投票组件

图 4.15 – Material-UI 投票组件

上述截图显示了我们在本章前面的部分测试的 Vote 组件,用户可以通过点赞或点踩来改变总点赞数。然而,使用 React Testing Library 抓取按钮会有困难,因为当前的组件实现没有可访问的方式,例如标签。

为了解决这个问题,我们可以在 Button 组件中添加 aria-label 属性。aria-label 将为元素添加一个可见的标签,使用屏幕阅读器的用户可以理解元素的目的。我们可以这样给组件添加 aria-label

<Button
  aria-label="thumbs up"

我们在之前的代码片段中为第一个 Button 组件添加了 aria-label。接下来,我们将为另一个 Button 组件添加 aria-label

<Button
  aria-label="thumbs down"

在前面的代码中,我们为第二个 Button 组件添加了 aria-label。Material UI 将将 Button aria-label 属性转发到将在 DOM 中渲染的按钮元素。由于 aria-label 属性对所有用户都是可访问的,包括使用辅助设备导航屏幕的用户,React Testing Library 可以通过这些属性抓取元素。现在我们可以选择元素,我们可以编写测试并断言结果行为。

对于第一个测试,我们将验证用户只能将总点赞数减少一个:

  test('given multiple "down" votes, total likes only decrease      by one', () => {
    render(<Vote totalGlobalLikes={10} />)
    const thumbsUpBtn = screen.getByRole('button', { name: /       thumbs up/i })
    user.click(thumbsUpBtn)
    user.click(thumbsUpBtn)
    user.click(thumbsUpBtn)
    expect(screen.getByText(/11/i)).toBeInTheDocument()
  })

在前面的代码中,我们使用 totalGlobalLikes 的值为 10 渲染 Vote 组件。接下来,我们抓取我们添加的 aria-label 属性并将其分配给 thumbsUpBtn 变量。然后,我们点击 DOM 中的 11

对于下一个测试,我们将验证用户可以移除他们的 "up" 投票:

  test('given retracted "up" vote, returns original total       likes', () => {
    render(<Vote totalGlobalLikes={10} />)
    const thumbsUpBtn = screen.getByRole('button', { name: /       thumbs up/i })
    const thumbsDownBtn = screen.getByRole('button', { name: /       thumbs down/i })
    user.click(thumbsUpBtn)
    user.click(thumbsDownBtn)
    expect(screen.getByText(/10/i)).toBeInTheDocument()
  })

在之前的代码中,我们使用 totalGlobalLikes 的值为 10 渲染了 Vote 组件。接下来,我们抓取添加到两个 Buttons 上的 aria-label 属性并将它们分配给变量。

接下来,我们点击屏幕上的 10。当我们运行测试时,我们得到以下结果:

图 4.16 – Material-UI 投票测试结果

图 4.16 – Material-UI 投票测试结果

上一张截图显示,测试 给定多个 "down" 投票,总喜欢数只减少一个,以及 给定撤回的 "up" 投票,返回原始总喜欢数 如预期通过。

作为挑战,尝试为以下场景编写测试:给定 "up" 投票,总喜欢数增加一个给定多个 "up" 投票,总喜欢数只增加一个;以及给定撤回的 "down" 投票,返回原始总喜欢数。这些测试场景的解决方案可以在 第四章代码示例 中找到。现在你知道了如何通过添加 aria-labels 来使 Material-UI 组件可测试。

在下一节中,我们将学习如何添加一个特定于 React Testing Library 的属性,以便使组件可测试。

为 CustomerTable 组件添加测试 ID 进行测试

在上一节中,我们学习了如何通过添加 aria-labels 来使 Material-UI 组件可测试。在本节中,我们将学习如何添加 data-testid 来使组件可测试。data-testid 是 React Testing Library 查询 DOM 元素时的另一种选项。data-testid 查询是在无法使用其他首选方法(如 *byText*byRole)且我们希望避免使用 classID 选择器时的一种最后手段。我们可以通过将 data-testid 作为属性附加到 DOM 元素上来使用它:

<h5 data-testid="product-type">Electronics</h5>

我们在之前的代码片段中添加了一个 "product-type" data-testid,以唯一选择标题元素。在本节中,我们将测试一个接受客户数据并渲染以下内容的 CustomerTable 组件:

图 4.17 – Material-UI 表格组件

图 4.17 – Material-UI 表格组件

上一张截图显示了一个包含多行客户数据的表格。用户可以使用 da,以下结果将被显示:

图 4.18 – Material-UI 表格过滤结果

图 4.18 – Material-UI 表格过滤结果

上一张截图显示了两个结果行。这两行被显示为匹配结果,因为文本 da 在行相关列中可见。我们将为该组件编写三个测试。

对于第一个测试,我们将验证组件是否可以接收并渲染传入的客户数据:

  const fakeCustomers = [
    {
      id: 1,
      name: 'John Doe',
      email: 'john@mail.com',
      address: '123 John Street',
      phone: '(111) 1111111',
      avatar: 'http://dummyimage.com/235x233.jpg/ff4444/ffffff'
    },
    // two additional objects
  ]

在前面的代码中,我们创建了一个测试对象数组,将其传递到组件中。需要注意的是,代码片段只显示了单个客户对象。CustomerTable 测试文件的代码示例将包含三个。

接下来,我们可以编写主要的测试代码:

 test('given data, renders table rows', () => {
    render(<CustomerTable data={fakeCustomers} />)
    expect(screen.getAllByTestId('row').length).toEqual(3)
  })

在之前的代码中,首先,我们通过将fakeCustomers传递给data属性来渲染CustomerTable。最后,我们断言行数等于3。我们使用getAllByTestId查询来访问所有行。*allBy查询允许我们获取多个相似 DOM 元素。在CustomerTable的代码实现中,data-testid被添加为每个为每个客户数据对象创建的TableRow组件的属性:

<TableRow data-testid="row" key={customer.id}>

在之前的代码中,向TableRow组件添加了data-testid属性。使用data-testid是因为在此场景中不能使用首选的查询方法来选择所有行。对于第二个测试,我们验证返回单个匹配的查询返回一个结果:

  test('given single-matching query, single result returned', () => {
    render(<CustomerTable data={testData} />)
    const searchBox = screen.getByRole('textbox')
    user.type(searchBox, 'john')
    expect(screen.queryAllByTestId('row').length).toEqual(1)
  })

在之前的代码中,首先,我们通过将testData传递给data属性来渲染CustomerTable。然后,我们获取文本框并将其存储在searchBox变量中。最后,我们断言 DOM 中的行数为 1。

对于最终测试,我们将验证非匹配查询不会向屏幕返回任何row元素:

  test('given non-matching query, no results returned', () => {
    render(<CustomerTable data={testData} />)
    const searchBox = screen.getByRole('textbox')
    user.type(searchBox, 'zzz')
    expect(screen.queryAllByTestId('row').length).toEqual(0)
  })

之前的代码与之前的测试类似,有两个不同之处。首先,我们在searchBox中输入zzz。然后,我们断言在 DOM 中找到0 row元素。当我们运行测试时,我们得到以下输出:

图 4.19 – Material-UI 表格测试结果

图 4.19 – Material-UI 表格测试结果

之前的截图显示,测试给定数据,渲染表格行给定单个匹配查询,返回单个结果给定非匹配查询,没有返回结果都按预期通过。作为挑战,尝试编写一个针对场景给定多匹配查询,返回多个结果的测试。之前的测试场景解决方案可以在第四章代码示例中找到(github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter04/ch_04_mui)。

本节内容已为您提供技能,通过添加aria-labeldata-testid属性,在需要时使用 React Testing Library 获取特定 Material-UI 组件。

摘要

在本章中,您已经学习了如何使用集成测试方法测试组件,而不是使用带有模拟依赖项的单元测试方法。您知道如何测试使用 Context API 管理应用程序状态的组件。您还学习了如何在使用第三方 Redux 库的项目中创建自定义方法来测试组件。最后,您学习了如何向使用流行的 Material-UI 库构建的组件添加属性。

在下一章中,我们将学习如何重构遗留项目的测试。

问题

  1. 解释测试集成组件与独立测试的优点。

  2. 你应该在何时使用 data-testid 属性来获取组件?

  3. 你应该在何时使用 React Testing Library 中的 act 方法?

第五章:使用 React Testing Library 重构遗留应用程序

在上一章中,我们学习了如何测试与依赖项分离的独立组件。我们学习了测试组件的好处如何与其他组件集成。我们还学习了如何测试使用流行的第三方 用户界面UI) 和状态管理工具的组件。在本章结束时,您将学习处理重构遗留 React 应用程序时断开更改的策略。您将学习如何在更新生产包的同时使用 React Testing Library 测试来引导您解决断开更改。您还将学习如何将使用 Enzyme 或 ReactTestUtils 编写的测试转换为 React Testing Library。

在本章中,我们将涵盖以下主要主题:

  • 使用测试来捕捉更新依赖项时的回归

  • 重构使用 Enzyme 编写的测试

  • 重构使用 ReactTestUtils 编写的测试

  • 重构测试以符合常见的测试最佳实践

本章获得的技术将使您能够减轻重构遗留应用程序的负担。

技术要求

对于本章的示例,您需要在您的机器上安装 Node.js。我们将使用 create-react-app CLI 工具来处理所有代码示例。如果需要,请在开始本章之前熟悉该工具。此外,对 Material UI 库的基本了解将有所帮助。

本章将提供代码片段,以帮助您理解待测试的代码,但目标是理解如何测试代码。您可以在以下位置找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter05

使用测试来捕捉更新依赖项时的回归

在本节中,我们将学习如何使用测试来驱动应用程序依赖项的更新。这些测试将帮助验证应用程序代码是否继续按预期工作,并在更新依赖项后快速捕捉到回归。当应用程序运行时,预算应用程序将渲染以下内容:

图 5.1 – 预算应用

图 5.1 – 预算应用

上一张截图显示了一个包含基于用户输入的 收入支出剩余 金额的摘要部分。用户可以点击 设置收入 按钮来更新 收入 的值:

图 5.2 – 设置预算收入

图 5.2 – 设置预算收入

上一张截图显示了一个允许用户输入并提交数字以更新 收入 值的模型。用户还可以为各种类别创建预算:

图 5.3 – 添加预算类别

图 5.3 – 添加预算类别

前面的截图显示了一个允许用户选择类别金额并添加新预算的模型。该模型还显示了一条消息,告知用户预算的可接受值。一旦用户创建了一个新的预算,该预算就会被添加到屏幕上:

图 5.4 – 预算类别详情

图 5.4 – 预算类别详情

前面的截图显示了一个屏幕上添加了购物预算$200。预算中添加了相关的条形图和支出进度条。用户可以点击三角形按钮向预算中添加金额,或者点击垃圾桶图标删除预算。

预算应用程序有以下生产依赖项:

  "dependencies": {
    "@material-ui/core": "¹.4.2",
    "@material-ui/icons": "².0.1",
    "react": "¹⁶.4.2",
    "react-dom": "¹⁶.4.2",
    "recharts": "¹.1.0",
    "uuid": "³.3.2"
  },

前面的代码显示了所有项目依赖项的当前版本。我们将更新"@material-ui/core"到版本"4.11.3""@material-ui/icons"到版本"4.11.2",以及"recharts"到版本"2.0.4",以便应用程序具有最新的依赖项代码。我们将使用的更新依赖项的方法将涉及运行一系列自动化测试,以帮助在更新每个依赖项后捕捉任何回归。预算应用程序没有现有的测试。

在没有现有测试的遗留应用程序的情况下,一个很好的开始方式是在其他测试级别添加测试之前,为关键工作流程编写自动化的 UI 端到端测试。请参阅第七章使用 Cypress 进行端到端 UI 测试,了解相关内容。

本章将教会你如何在开始依赖项重构任务之前,使用 React Testing Library 编写自动化的组件测试。请参阅第二章使用 React Testing Library 工作,了解安装说明。现在你已经了解了应用程序和更新依赖项的方法,我们将在下一节中着手编写回归测试。

创建回归测试套件

在本节中,我们将使用 React Testing Library 编写几个集成测试,以增加我们在更新应用程序依赖项时能够捕捉回归的信心。请参阅第四章在您的应用程序中编写集成测试和第三方库,了解相关内容。我们将为以下主要功能编写测试:设置收入、删除预算、创建预算和预算详情。对于第一个测试,我们将针对设置收入功能,通过验证用户是否可以输入income的金额来进行测试:

  function setOneDollarIncome() {
    user.click(screen.getByText(/set income/i));
    user.type(screen.getByRole('spinbutton'), '1');
    user.click(screen.getByText(/submit/i));
  }

在前面的代码中,我们创建了一个名为setOneDollarIncome的函数,用于设置income金额为$1setOneDollarIncome函数将减少后续测试中的重复代码。接下来,我们将编写主要的测试代码:

  test('SetIncome, given income amount, sets income', () => {
    render(<App />);
    setOneDollarIncome();
    const leftOverBudget = screen.getByText(/left over:/i);
    const leftOverBudgetAmount = within(leftOverBudget).       getByText(/\$1/i);
    expect(leftOverBudgetAmount).toBeInTheDocument();
    expect(
      screen.getByRole('heading', { name: /income: \$1/i })
    ).toBeInTheDocument();
  });

在前面的代码中,首先,我们在 DOM 中渲染App组件。接下来,我们通过setOneDollarIncome函数设置income金额为$1。然后,我们获取left over文本并使用 React Testing Library 的within方法访问金额文本。within方法可用于我们想要访问父元素子元素的情况。当我们运行应用程序时,屏幕上Left Over部分的最终 HTML 元素输出如下:

<p class="MuiTypography-root BudgetSummary-leftoverText-4   MuiTypography-body1">
  Left over: <span class="BudgetSummary-profit-6">$1</span>
</p>

在前面的代码中,一个p元素作为子内容拥有文本Left over。在测试代码中,我们通过Left Over文本获取p元素并将其存储在leftOverBudget变量中。然后,我们使用within获取文本为$1span元素并将其存储在leftOverBudgetAmount变量中。

最后,我们断言leftOverBudgetAmount在 DOM 中。对于下一个测试,我们将针对创建预算功能,通过验证用户设置预算后预算摘要部分的结果金额:

  function createCarBudget(amount = '5') {
    user.click(screen.getByText(/create new budget/i));
    user.selectOptions(screen.getByRole('combobox', { name: /       category/i }), [
      screen.getByText('Auto'),
    ]);
    user.type(screen.getByRole('spinbutton'), amount);
    user.click(screen.getByText(/add budget/i));
  }

在前面的代码中,我们创建了一个函数createCarBudget,以减少在多个测试用例中使用的创建预算的重复步骤。如果没有为函数的amount参数传递值,将使用默认值5。接下来,我们将编写主要测试代码:

    test.each`
      budgetAmount | spending           | leftOver
      ${'4'}       | ${'Spending: $5'}  | ${'$-4'}
      ${'5'}       | ${'Spending: $5'}  | ${'$-4'}
      ${'6'}       | ${'Spending: $10'} | ${'$-9'}
    `(
      'given budget, updates budget summary',
      ({ budgetAmount, spending, leftOver }) => {

在前面的代码中,我们使用 Jest 的each方法允许相同的测试在多个不同的值上多次运行。budgetAmountspendingleftOver变量代表每个测试迭代的测试值。我们在变量下有三行数据,用于传递给每个测试运行中的变量。接下来,我们在测试中安排和执行操作:

        render(<App />);
        setOneDollarIncome();
        createCarBudget(budgetAmount);
        const leftOverBudget = screen.getByText(/left over:/i);
        const leftOverBudgetAmount = within(leftOverBudget).           getByText(leftOver);

在前面的代码中,首先我们在 DOM 中渲染应用并调用setOneDollarIncome函数。接下来,我们调用createCarBudget函数并传入当前测试迭代的budgetAmount值。然后,我们获取与leftOverBudget变量关联的元素,类似于之前的测试。最后,我们进行以下断言:

        expect(leftOverBudgetAmount).toBeInTheDocument();
        expect(
          screen.getByRole('heading', { name: spending })
        ).toBeInTheDocument();
      }
    );

在前面的代码中,首先,我们断言leftOverBudgetAmount在 DOM 中。最后,我们断言当前name值的标题元素在 DOM 中。作为一个挑战,编写一个测试来验证创建的预算是否显示预算图表。

之前场景的解决方案可以在第五章的代码示例中找到,使用 React Testing Library 重构遗留应用程序

对于下一个测试,我们将针对删除预算功能,通过验证已删除的预算是否已从屏幕上移除:

  test('DeleteBudget, given deleted budget, budget removed from      DOM', () => {
    render(<App />);
    setOneDollarIncome();
    createCarBudget();
    user.click(screen.getByLabelText(/trash can/i));
    expect(screen.queryByRole('listitem')).not.       toBeInTheDocument();
  });

在前面的代码中,首先,我们通过在 DOM 中渲染 App 组件并调用 setOneDollarIncomecreateCarBudget 函数来安排我们的测试。然后,我们点击垃圾桶图标。最后,我们断言 DOM 中没有 listitem 元素。

由于 listitem 元素在屏幕上渲染预算,我们可以确信如果没有在 DOM 中找到任何元素,功能将按预期工作。对于最后一个测试,我们将通过验证添加支出是否更新预算进度来针对 预算详细信息 功能:

    test('given budget expense, updates budget progress', async        () => {
      render(<App />);
      setOneDollarIncome();
      createCarBudget();
      user.click(screen.getByRole('button', { name: /         arrowright/i }));
      expect(
        screen.getByRole('heading', { name: /\$5 of \$5/i })
      ).toBeInTheDocument();
    });

在前面的代码中,首先,我们通过在 DOM 中渲染 App 组件并调用与上一个测试类似的 setOneDollarIncomecreateCarBudget 函数来安排我们的测试。然后,我们点击右箭头图标。最后,我们断言屏幕上存在文本 $5 of $5

作为一项挑战,尝试编写以下测试场景的代码:Budget,给定预算,显示详细信息。此测试场景的解决方案可以在 第五章使用 React 测试库重构遗留应用程序 的代码示例中找到。

当我们运行我们的测试时,我们收到以下输出:

图 5.5 – 预算应用测试结果

图 5.5 – 预算应用测试结果

前面的屏幕截图显示所有测试都通过。现在我们有一个通过回归测试套件,我们将在下一节中升级应用程序的生产依赖项。需要注意的是,我们的测试自动设置为在 观察模式 下运行,这是一个 Jest 功能,在关联的组件文件更改时自动重新运行测试。

观察模式功能为您提供信心,我们可以在代码更改实施时快速发现回归。对于未自动设置在观察模式下运行 Jest 的项目,只需在命令行执行 Jest 时传递 --watch 标志。

升级 Material UI 依赖项

在上一节中,我们创建了一个回归测试套件。在本节中,我们将删除所有突出显示的文本,并通过升级 @material-ui/icons@material-ui/core 依赖项来获取最新的依赖项代码。@material-ui/icons 包依赖于 @material-ui/core,因此我们将同时更新这两个依赖项。

package.json 文件中,将 @material-ui/icons 的当前版本 2.0.1 替换为 4.11.2,将 @material-ui/core 替换为 4.11.3,并重新安装所有依赖项。现在,当我们运行我们的测试时,我们收到以下输出:

图 5.6 – 预算应用失败的测试结果

图 5.6 – 预算应用失败的测试结果

在前面的屏幕截图中,测试结果表明依赖项更新破坏了我们的测试。结果提供了有关为什么每个测试失败的相关详细信息。以下是在控制台显示的测试结果信息的概述:

Integration: Budget App › SetIncome, given income amount, sets income
    TestingLibraryElementError: Unable to find an accessible       element with the role "heading" and name `/income: \$1/i`
Integration: Budget App › Budget › given budget, displays details
    TestingLibraryElementError: Unable to find an accessible       element with the role "heading" and name `/\$0 of \$5/i`
Integration: Budget App › Budget › given budget expense, updates budget progress
    TestingLibraryElementError: Unable to find an accessible       element with the role "heading" and name `/\$5 of \$5/i`
Integration: Budget App › CreateNewBudget › given budget, updates budget summary
    TestingLibraryElementError: Unable to find an accessible       element with the role "heading" and name "Spending: $10"
    TestingLibraryElementError: Unable to find an accessible       element with the role "heading" and name "Spending: $5"

在先前的控制台输出中,测试结果告诉我们由于无法在 DOM 中找到目标 heading 元素而失败的特定测试。当我们更新依赖项时,我们的源代码中发生了回归。控制台中也显示了错误信息,提供了定位源代码中问题的信息。以下是在控制台中显示的错误信息的概要版本:

Warning: Failed prop type: Invalid prop `spacing` of value `24` supplied to `ForwardRef(Grid)`, expected one of [0,1,2,3,4,5,6,7,8,9,10].
Warning: Failed prop type: Invalid prop `variant` of value `title` supplied to `ForwardRef(Typography)`, expected one of ["h1","h2","h3","h4","h5","h6","subtitle1","subtitle2","body1","body2","caption","button","overline","srOnly","inherit"].
Warning: Failed prop type: Invalid prop `variant` of value `subheading` supplied to `ForwardRef(Typography)`, expected one of ["h1","h2","h3","h4","h5","h6","subtitle1","subtitle2","body1","body2","caption","button","overline","srOnly","inherit"].
Material-UI: theme.spacing.unit usage has been deprecated.
It will be removed in v5.
You can replace `theme.spacing.unit * y` with `theme.spacing(y)`.

错误信息告诉我们,我们的源代码现在正在使用来自先前控制台输出的材料 UI 依赖项中的过时属性值,这导致我们的测试无法找到特定的 heading 元素并失败。错误信息输出还告诉我们每个错误信息下错误发生的确切组件文件。例如,错误来源 Material-UI: theme.spacing.unit 使用已被弃用。 可以在这里找到:

(src/components/SetIncome.js:27:26)

上述控制台输出告诉我们错误来源位于 SetIncome 组件文件的第 27 行的第 26 个空格。现在我们知道了每个测试失败的具体原因,我们可以相应地更新源代码。

在更新源代码以添加信心,确保我们会捕捉到由于更新代码可能发生的任何新回归时,我们将保持测试在监视模式下运行。一旦我们根据错误信息在我们的组件文件中更新了代码,当我们运行测试时,我们会收到以下输出:

图 5.7 – 预算应用更新依赖测试结果

图 5.7 – 预算应用更新依赖测试结果

上述截图显示,在根据错误信息更新源代码后,所有测试现在都已通过。现在你知道了如何更新生产依赖项并向旧应用程序添加测试。在使用 React Testing Library 完成此任务时的好处是,我们知道在更新源代码时我们永远不需要更新我们的测试代码。

我们的测试代码不依赖于组件的实现细节,只要最终的 DOM 输出和行为不改变,我们就可以自由地更改源代码。在下一节中,我们将学习如何重构使用 Enzyme 的旧代码的测试。

重构使用 Enzyme 编写的测试

在上一节中,我们学习了如何更新生产依赖项并向旧应用程序添加组件测试。在本节中,我们将学习如何用 React Testing Library 替换现有用 Enzyme 编写的旧测试。在 React Testing Library 创建之前,Enzyme 是一个流行的库,用于测试 React 组件的 UI。Enzyme 是一个伟大的工具,但 API 的设计允许测试组件的实现细节,导致开发者在更新源代码时必须频繁更新测试代码。我们将用 React Testing Library 替换旧 Enzyme 测试,以解决需要不断更新关注实现细节的测试的问题。

我们将使用这种方法重构遗留的 Enzyme 测试,以保留当前测试,并在安装和逐步重构它们的同时使用 React Testing Library。请参阅第二章使用 React Testing Library,以获取安装说明。一旦我们完成遗留代码的重构并且所有测试都通过,我们将从应用程序中移除 Enzyme。测试将被重构为本章创建回归测试套件部分中创建的测试。我们将重构的第一个测试是验证用户可以设置收入金额:

  test('SetIncome, given income amount, sets income', () => {
    const wrapper = mount(<App />);
    wrapper.find('SetIncome').props().setIncome(1);
    expect(wrapper.find('h3#income').text()).toEqual('Income:       $1');
  });

在前面的代码中,使用了 Enzyme 的mount方法来在 DOM 中渲染App组件。接下来,使用find方法定位到SetIncome组件,并使用值1调用setIncome方法。最后,进行断言以验证具有idincomeh3元素的文本值等于Income: $1

存在许多实现细节,如果更改这些细节将破坏测试。例如,如果SetIncome组件或setIncome方法的名称更改,测试将失败。对income id的更改也会破坏测试。我们可以重构测试,使其从用户的角度出发,如下所示:

test('SetIncome, given income amount, sets income', () => {
    render(<App />);
    setOneDollarIncome();
    const leftOverBudget = screen.getByText(/left over:/i);
    const leftOverBudgetAmount = within(leftOverBudget).      getByText(/\$1/i);
    expect(leftOverBudgetAmount).toBeInTheDocument();

在前面的代码中,我们重构了使用 React Testing Library 验证用户可以使用 React Testing Library 设置收入金额的 Enzyme 测试代码。接下来,我们将重构的测试验证当用户创建预算时,预算摘要部分是否更新:

      test('given budget, updates budget summary', () => {
        const wrapper = mount(<App />);
        const budgetAmount = Math.ceil(parseInt(5, 10) / 5) * 5;
        wrapper.find('CreateNewBudget').props().addNewBudget({
          id: '1',
          category: 'Auto',
          amount: budgetAmount,
          amtSpent: 0,
        });
  wrapper.find('CreateNewBudget').props().     setTotalSpending(budgetAmount);

在前面的代码中,首先我们在 DOM 中渲染了App组件。接下来,使用Math对象中的ceil方法和parseInt方法将传入的预算金额5四舍五入到最接近的5的倍数。然后,使用find方法调用CreateNewBudget组件内部的addNewBudget方法,并传入一个表示预算的对象。

然后,我们在同一组件中调用setTotalSpending方法,并传入budgetAmount变量的结果。接下来,我们将进行断言:

expect(wrapper.find('h3#spending').text()).toEqual('Spending:   $5');
expect(wrapper.find('span#leftover').text()).toEqual("$-5");

在之前的代码中,我们断言具有idspendingh3元素的文本值等于Spending: $5。最后,我们断言具有idleftoverspan元素的文本值等于$-5。我们可以使用 React Testing Library 重构之前的代码,如下所示:

test('given budget, updates budget summary', () => {
  render(<App />);
  setOneDollarIncome();
  createCarBudget(5);
  const leftOverBudget = screen.getByText(/left over:/i);
  const leftOverBudgetAmount = within(leftOverBudget).     getByText('df');
  expect(leftOverBudgetAmount).toBeInTheDocument();
  expect(screen.getByRole('heading', { name: 'Spending: $5'
})).toBeInTheDocument();
});

在前面的代码中,我们重构了使用 React Testing Library 验证当用户使用 React Testing Library 创建预算时,预算摘要部分是否更新的 Enzyme 测试代码。接下来,我们将重构的测试验证当用户创建预算时是否显示图表:

    test('given budget, displays budget chart', () => {
      const wrapper = mount(<App />);
      const budgetAmount = Math.ceil(parseInt(5, 10) / 5) * 5;
      wrapper.find('CreateNewBudget').props().addNewBudget({
        id: '1',
        category: 'Auto',
        amount: budgetAmount,
        amtSpent: 0,
      });
      wrapper.find('CreateNewBudget').props().         setTotalSpending(budgetAmount);
      wrapper.update();

在前面的代码中,我们使用mount方法在 DOM 中渲染App组件,并创建一个budgetAmount变量将预算转换为与之前测试中类似的五的倍数。接下来,我们使用find方法在CreateNewBudget组件内部调用addNewBudget方法,并传入一个budget对象。

然后,我们在 CreateNewBudget 中调用 setTotalSpending 方法并传入预算金额。接下来,我们调用 update 方法以同步我们的测试与 Chart 组件创建的代码。接下来,我们可以进行断言:

      expect(wrapper.find('div#chart')).toBeTruthy();
    });

在前面的代码中,我们断言具有 idchartdiv 元素是 truthy,这意味着它在 DOM 中被找到。正如我们在本章的 创建回归测试套件 部分中看到的,使用 React 测试库编写的预算应用程序的所有测试用例在运行时都将按预期通过。

现在所有酶测试都已重构为 React 测试库,我们可以从 package.json 文件中移除 enzymeenzyme-adapter-react-16 依赖。我们还可以从 setupTests.js 文件中移除以下代码:

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });

前面的代码用于配置 Enzyme 在测试文件中工作。在从应用程序中移除 Enzyme 之后,这段代码就不再需要了。现在你知道如何将使用 Enzyme 创建的遗留测试重构为 React 测试库。React 测试库的测试提供了更大的信心,并减少了在重构源代码时测试中断的风险。

在下一节中,我们将学习如何重构使用 ReactTestUtils 创建的测试。

重构使用 ReactTestUtils 编写的测试

在上一节中,我们学习了如何将使用 Enzyme 编写的测试转换为 React 测试库。这个过程涉及重构现有测试然后卸载 Enzyme 库。在本节中,我们将使用类似的过程,只是我们不需要卸载现有的测试库。ReactTestUtils 模块包含在 React 中,因此当我们不想使用它时,我们只需在测试文件中不导入该模块即可。由于重构过程与上一节类似,我们本节将只查看一个示例。我们将重构的测试用于验证用户可以设置收入金额:

import React from 'react';
import ReactDOM from 'react-dom';
import { act } from 'react-dom/test-utils';
import App from './App';

在前面的代码中,我们导入了 ReactReactDOMact 方法。从 test-utils 模块导入的 act 方法用于同步组件更新并确保我们的测试行为与 React 在浏览器中的行为相似。接下来,我们将安排测试所需的代码:

  it('SetIncome, given initial render, displays budget summary       values', () => {
    let container = document.createElement('div');
    document.body.appendChild(container);
    act(() => {
      ReactDOM.render(<App />, container);
    });

在前面的代码中,我们创建一个 div 元素以在 DOM 中渲染并将其分配给 container 变量。接下来,我们将 container 变量附加到 DOM 的 body 元素上。然后,我们在 container 中渲染 App 组件,并用 act 方法包裹。接下来,我们将获取 DOM 元素并对其文本值进行断言:

    const income = container.querySelector('h3#income');
    const spending = container.querySelector('#spending');
    const leftover = container.querySelector('#leftover');
    expect(income.textContent).toBe('Income: $0');
    expect(spending.textContent).toBe('Spending: $0');
    expect(leftover.textContent).toBe('$0');

在前面的代码中,我们使用 querySelector 方法访问 DOM 中的 incomespendingleftover 元素。然后,我们使用 textContent 属性断言前三个元素的值。最后,我们将添加代码来清理测试:

    document.body.removeChild(container);

在前面的代码中,我们从 DOM 中移除了容器元素。移除容器将确保我们可以从一张干净的纸开始连续的测试。我们可以使用 React Testing Library 对之前的测试进行重构:

it('SetIncome, given initial render, displays budget summary   values', () => {
  render(<App />);
  const income = screen.getByRole('heading', { name: /income:     \$0/i });
  const spending = screen.getByRole('heading', { name: /    spending: \$0/i });
  const leftover = screen.getByText(/left over:/i);
  expect(income).toHaveTextContent('Income: $0');
  expect(spending).toHaveTextContent('Spending: $0');
  expect(leftover).toHaveTextContent('$0');
});

在前面的代码中,我们使用 React Testing Library 对 SetIncome, given initial render, displays budget summary values 测试进行了重构。React Testing Library 版本的测试更简洁,并且对源代码更改更具弹性,因为它不使用实现细节选择 DOM 元素。当我们运行测试时,会得到以下输出:

![图 5.8 – 通过设置收入测试图片 5.8

图 5.8 – 通过设置收入测试

结果显示,SetIncome, given initial render, displays budget summary values 测试在之前的代码中按预期通过。现在你已了解如何使用 ReactTestUtils 模块重构测试。本节学到的技能将帮助你将遗留的测试代码重构为使用现代测试工具。

重构测试以符合常见的测试最佳实践

在上一节中,我们学习了如何使用 ReactTestUtils 重构测试。在本节中,我们将介绍一些场景,其中我们可以重构现有的测试代码,使其更健壮和易于维护。我们将使用以下反馈表单应用程序来展示示例:

图 5.9 – 反馈表单

图 5.9 – 反馈表单

在前面的屏幕截图中,我们有一个表单,用户可以填写 姓名电子邮件 字段,以及选择评分、输入评论,最后提交他们的信息。如果用户尝试提交包含必填字段空白值的表单,则会显示错误消息:

![图 5.10 – 反馈表单错误验证图片 5.10

图 5.10 – 反馈表单错误验证

在前面的屏幕截图中,每个带有空值的输入下都显示了表单验证错误。最后,当用户提交包含有效输入数据的表单时,会显示一条感谢信息:

图 5.11 – 提交反馈表单

图 5.11 – 提交反馈表单

在前面的屏幕截图中,显示了消息 我们感谢您的回复,John Doe! 消息中的 John Doe 部分是表单中 姓名 输入元素的输入值。我们将重构的第一个测试是验证当输入无效电子邮件时,会显示错误消息:

test.each`
  value
  ${'a'}
  ${'a@b'}
  ${'a@b.c'}
`('displays error message', async ({ value }) => {

在前面的代码中,首先,使用了 Jest 的each方法用不同的值运行相同的测试:aa@ba@b.c。接下来,我们看到测试名称,displays error message。测试名称比较模糊,因为它没有提供足够的细节来描述测试的上下文。使用测试命名约定来消除模糊测试名称的问题是很常见的。有许多流行的命名约定,例如when_stateUnderTest_expect_expectedBehaviorgiven_preconditions_when_stateUnderTest_then_expectedBehavior,它们描述了被测试的代码、对代码执行的操作以及最终的预期结果。重要的是要记住使用与您的项目团队一致的命名约定。

在我们的重构工作中,我们将使用以下约定,组件,给定先决条件,预期结果。我们可以像这样重构当前的测试名称:

  'Form, given invalid email value "$value", displays error       message',

在前面的代码中,我们将当前的测试名称重构为Form, given invalid email value "$value", displays error message。当阅读测试名称时,现在很清楚我们正在测试一个Form组件,给定的先决条件是无效的值,预期的结果是屏幕上显示错误消息。注意测试名称中的$value变量。该变量将在每个测试迭代中替换为当前值的名称,从而进一步提供上下文以理解特定的测试代码。

接下来,我们将分析和重构主要测试代码:

  async ({ value }) => {
    const { getByRole, getByText } = render(<App />)
    const emailInput = getByRole('textbox', { name: /email/i })
    user.click(emailInput)
    user.tab()
    user.type(emailInput, value)
    await waitFor(() => {
      const errorMessage = getByText(/invalid email address/i)
      expect(errorMessage).toBeInTheDocument()
    })
  }
)

在前面的代码中,使用了对象解构方法来访问getByRolegetByText查询方法。然而,解构方法需要您手动跟踪在构建测试代码时添加或删除哪些查询。如第一章中提到的,探索 React 测试库,从 React 测试库的9版本开始,我们可以使用screen对象来访问查询方法。

使用screen对象访问查询方法比解构方法更容易维护,并产生更简洁的代码。waitFor方法也用于异步抓取和验证错误消息是否在 DOM 中显示。然而,screen对象的findBy*查询也是异步的,并且当您需要查询在屏幕上需要时间才能出现元素时,它们是比waitFor更容易使用的选项。我们可以像这样重构当前的测试代码:

  async ({ value }) => {
    render(<App />)
    const emailInput = screen.getByRole('textbox', { name: /      email/i })
    user.click(emailInput)
    user.tab()
    user.type(emailInput, value)
    const errorMessage = await screen.findByText(/invalid email       address/i)
    expect(errorMessage).toBeInTheDocument()
  }
)

在前面的代码中,我们将解构查询替换为通过屏幕对象访问。我们还用异步的findByText查询替换了waitFor方法。现在测试代码更简洁,更容易维护。

下一个我们将重构的测试验证了当用户未为任何必填表单输入输入值时,会显示错误消息:

test('Form, given blank input value, displays error message',   async () => {
  render(<App />)
  const nameInput = screen.getByRole('textbox', { name: /name/i })
  const emailInput = screen.getByRole('textbox', { name: /    email/i })
  const ratingSelect = screen.getByRole('combobox', { name: /    rating/i })
  const commentsInput = screen.getByRole('textbox', { name: /    comments/i })

在前面的代码中,首先,应用程序被渲染到 DOM 中。接下来,通过获取所有表单 input 元素并将它们存储在相应的值中,对测试进行了安排。接下来,对表单元素执行以下操作:

  user.click(nameInput)
  user.click(emailInput)
  user.click(ratingSelect)
  user.click(commentsInput)
  user.tab()

在前面的代码中,每个 input 元素都被点击。然后,按键盘上的 Tab 键模拟将焦点从活动选择的 ratingSelect 元素移开。最后,进行了四个断言:

  expect(await screen.findByText(/name required/i)).    toBeInTheDocument()
  expect(await screen.findByText(/email required/i)).    toBeInTheDocument()
  expect(await screen.findByText(/rating required/i)).    toBeInTheDocument()
  expect(await screen.findByText(/comments required/i)).    toBeInTheDocument()

在前面的代码中,有一个断言用于验证在表单值为空时显示特定的错误消息。然而,例如,如果对于姓名必填错误消息的第一个断言失败,测试将失败,并且不会执行其他任何断言。在同一个测试中进行多个断言的方法不是一种好的实践,因为我们无法知道剩余断言的代码是否按预期工作。

测试中的每个断言都是独立的,因此应该位于它们自己的单独测试中。我们可以这样重构测试:

  test.each`
  inputLabel
  ${'Name'}
  ${'Email'}
  ${'Rating'}
  ${'Comments'}
`(
  'Form, given blank $inputLabel input value, displays error message',

在前面的代码中,首先,我们使用 each 方法为 input 元素名称创建单独的测试。输入名称将传递给每个测试运行的 inputLabel 变量。接下来,我们将编写主要的测试代码:

  async ({ inputLabel }) => {
    render(<App />)
    user.click(screen.getByText(`${inputLabel} *`))
    user.tab()
    const errorMessage = await screen.findByText(`${inputLabel}         Required`)
    expect(errorMessage).toBeInTheDocument()
  }
)

在前面的代码中,首先,我们在 DOM 中渲染 App 组件。接下来,我们使用 getByText 点击输入标签。然后,我们模拟按下 Tab 键将焦点从 input 元素移开。最后,我们获取带有错误消息的元素,将其存储在 errorMessage 变量中,并验证它是否在 DOM 中。当我们运行重构后的测试时,我们得到以下输出:

图 5.12 – 反馈表单测试结果

图 5.12 – 反馈表单测试结果

在前面的屏幕截图中,所有重构的测试用例都按预期通过。现在你知道如何重构测试以使用命名约定和将多个断言从单个测试中拆分到单独测试中的测试最佳实践。你还学习了如何将用 React 测试库编写的遗留测试重构为现代方法。

摘要

在本章中,你学习了如何减轻遗留应用程序更新生产依赖项的负担。你学习了如何使用现代 React 测试库工具重构遗留测试。你还学习了一些测试最佳实践。本章获得的知识应该让你有信心,你可以成功重构过时的代码到当前版本而不会出现重大问题。你也应该能够重构测试代码以使其更易于维护。

在下一章中,我们将学习关于测试的额外工具和插件。

问题

  1. 解释与 Enzyme 或 ReactTestUtils 等工具相比使用 React 测试库的好处。

  2. 解释在 Jest 的监视模式下运行测试的好处。

  3. 当你在编写测试时应该何时使用 Jest 的 each 方法?

第六章:实现测试的额外工具和插件

在前面的章节中,我们学习了 React Testing Library 的基础知识以及如何使用该工具测试从简单到复杂的组件。在本章中,我们将学习如何通过使用额外的工具来提高我们的生产力。我们将安装并使用一些插件,以帮助我们避免常见错误并遵循 React Testing Library 的最佳实践。

我们将添加一个库来审计并提高应用程序的可访问性。我们将确保我们使用 Testing Playground 选择最佳的 React Testing Library 查询方法。最后,我们将通过使用 Wallaby.js 从代码编辑器快速获取测试状态反馈来提高我们的生产力。

在本章中,我们将涵盖以下主要主题:

  • 使用eslint-plugin-testing-library来遵循最佳实践,避免在使用 React Testing Library 时犯常见错误

  • 使用eslint-plugin-jest-dom来遵循最佳实践,避免在使用jest-dom时犯常见错误

  • 使用jest-axe来提高应用程序的可访问性

  • 使用 Testing Playground 选择 Testing Library 推荐的查询

  • 使用 Wallaby.js 提高我们的测试生产力

本章中的技能将提高你的生产力,并增强你使用 Testing Library 最佳实践测试 React 应用程序的能力。

技术要求

对于本章的示例,你需要在你的机器上安装 Node.js。我们将使用create-react-app CLI 工具来展示所有代码示例。如果需要,请在开始本章之前熟悉这个工具。本章将提供代码片段来理解要测试的代码,但目标是理解如何测试代码。

你可以在github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter06找到本章的代码示例。

使用 Testing Library ESLint 插件实现最佳实践

在本节中,你将学习如何安装和使用eslint-plugin-testing-libraryeslint-plugin-jest-dom。这些插件的目的是对你的测试代码进行审计,并帮助你编写遵循jest-dom最佳实践的测试。插件通过突出可以改进的区域并提供重构代码的建议来工作。

在安装插件之前,我们需要在我们的项目中安装ESLint。ESLint 是一个统计分析并通知你代码中问题的工具。你可以把 ESLint 想象成有人在你的肩膀上查看,指出你可能需要更长的时间自己调试的问题。例如,你可以创建以下函数:

const reverseWord = str => str.split('').reverse().join('')

在前面的代码中,我们有一个reverseWord函数,该函数反转传入的字符串。如果我们用单词packt调用该函数,我们得到以下结果:

reverseWord('packt') // tkcap

在前面的代码中,当我们将 packt 作为参数传递给函数时,我们得到的结果是 tkcap。然而,如果我们错误地拼写函数名称并运行代码,我们会得到以下结果:

图 6.1 – reverseWord 函数名称拼写错误

图 6.1 – reverseWord 函数名称拼写错误

在之前的代码中,控制台输出指示 ReferenceError。错误指的是解释器没有在文件中找到定义的函数,名为 reverseeWord。问题在于用户错误地多加了一个 e 在函数名称中。我们可以通过在我们的项目中安装和配置 ESLint 来创建一个更好的工作流程,以帮助调试问题。

如果你正在使用 create-react-app 为你的项目,那么 ESLint 应该会自动为你安装。对于尚未安装 ESLint 的项目,请使用以下命令:

npm install eslint --save-dev

之前的命令将 ESLint 作为开发依赖项安装到你的项目中。

接下来,我们可以创建一个配置文件来告诉 ESLint 我们希望它如何检查我们的文件:

{
  "extends": "eslint:recommended",
  "parserOptions": {
    "ecmaVersion": 2021,
    "sourceType": "module"
  }
}

在之前的代码中创建的以 json 格式的配置文件包含一些设置,告诉 ESLint 如何检查我们的文件。"extends" 键设置为 "eslint:recommended"。这意味着我们希望使用 ESLint 推荐的检查规则。"parserOptions" 键设置为包含两个键的对象。"ecmaVersion" 键设置为 "sourceType""module" 键设置为 "module",这意味着我们的代码将支持 ES 模块。ESLint 可以以多种方式配置来检查你的项目文件。

注意

请参阅 配置 ESLint (eslint.org/docs/user-guide/configuring/) 获取更多详细信息。

使用以下命令来运行 ESLint 检查你的项目文件:

npx eslint .

在之前的命令中,我们使用 npx 命令来运行 ESLint 检查所有项目文件。请注意,npx 允许你快速执行 npm 包,无论该包是否已在本地的机器上本地或全局安装,或者根本未安装。在运行命令后,我们在控制台中收到以下输出:

图 6.2 – ESLint 输出

图 6.2 – ESLint 输出

图 6.2 – ESLint 输出

在之前的命令中,ESLint 通知我们代码中有两个错误。第一个错误说 reverseWord 函数从未在行 1 上使用,引用了 ESLint 的 no-unused-vars 规则。第二个错误说行 3 上的 reverseeWord 在文件中任何地方都没有定义,引用了 ESLint 的 no-undef 规则。我们还可以通过在代码编辑器中直接显示输出来增强我们的 ESLint 工作流程,以在学习代码运行之前了解任何潜在的问题。例如,VSCode 和 Atom 代码编辑器都有第三方工具,我们可以安装这些工具以在编辑器中直接显示问题。

注意

请参阅ESLint(marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)以获取 VSCode 编辑器扩展的详细信息。或者,你可以查阅linter-eslint(atom.io/packages/linter-eslint)以获取 Atom 编辑器插件的安装和配置细节。

将 lint 输出直接显示在代码编辑器中比手动通过命令行运行 ESLint 提供更快的反馈。现在你已经了解了如何启动 ESLint,我们将在下一节中安装和配置eslint-plugin-testing-library

安装和配置 eslint-plugin-testing-library

在本节中,我们将学习如何在我们的应用程序中安装和配置eslint-plugin-testing-library。使用以下命令安装插件:

npm install --save-dev eslint-plugin-testing-library

上述命令将eslint-plugin-testing-library作为开发依赖项安装到你的项目中。现在插件已安装,我们可以将其添加到我们的 ESLint 配置文件中:

    "overrides": [
    {
      "files": ["*.test.js"],
      "extends": [
        "plugin:testing-library/react"
      ]

在前面的代码中,我们在 ESLint 配置中创建了一个"overrides"部分,以针对以.test.js结尾的任何文件。然后,我们在配置文件中的extends数组中添加了plugin:testing-library/react。我们添加了插件的 React 版本以获得 React 特定的规则和来自 DOM Testing Library 的基本规则。该插件应用了一组特定的 linting 规则,这些规则专门针对 React 应用程序。例如,no-dom-import规则不允许直接从 DOM Testing Library 导入,这很有用,因为 React Testing Library 重新导出 DOM Testing Library 的所有内容,消除了直接导入的需要。

注意

请参阅支持规则(github.com/testing-library/eslint-plugin-testing-library#supported-rules)以获取 React 特定应用的规则完整列表。

注意react-app入口也被包含在数组中。react-app入口添加了由create-react-app设置的 ESLint 规则。现在我们在项目中设置了插件,我们可以编写测试。我们将测试一个允许用户选择编程语言的下拉组件:

图 6.3 – 下拉组件

图 6.3 – 下拉组件

在前面的屏幕截图中,你可以看到一个下拉列表,列出了用户可以点击以选择四种编程语言。当用户选择一种语言时,我们会得到以下内容:

图 6.4 – 选定的下拉选项

图 6.4 – 选定的下拉选项

在这里,你可以查看文本你选择了:JavaScript,这是当用户选择JavaScript选项时出现的。我们可以编写一个测试来验证所选语言是否显示在屏幕上:

test('LanguageDropdown, given selected menu item, displays   selection', async () => {
  render(<LanguageDropdown />)
  user.click(screen.getByRole('button', { name: /programming language/I }))
  user.click(screen.getByRole''menuite'', { name: /javascript/i }))
  user.click(screen.getByRole''menuite'', { name: /javascript/i }))

在前面的代码中,首先,我们在 DOM 中渲染了LanguageDropdown组件。接下来,我们点击了编程语言按钮。然后,我们从菜单选项中选择JavaScript。接下来,我们将验证所选选项是否显示在屏幕上:

  const selection = await waitFor(() =>
    screen.getByRole('heading', { name: /you selected:       javascript/i })
  )

在前面的代码中,我们使用 React Testing Library 的waitFor方法来获取包含所选选项的文本的元素。当需要等待 DOM 中的元素时,可以使用waitFor方法。然而,根据eslint-plugin-testing-library,在这种情况下使用waitFor并不是选择元素的最佳方式:

图 6.5 – findByRole 代码检查建议

图 6.5 – findByRole 代码检查建议

图 6.5 – findByRole 代码检查建议

在之前的屏幕截图中,包含waitFor方法的代码被 ESLint 下划线标注,引起了我们的注意。当我们悬停在waitFor方法代码上时,我们得到反馈,表明首选的查询是findByRole,这是通过eslint-plugin-testing-libraryprefer-find-by规则实现的。

第五章,“使用 React Testing Library 重构遗留应用程序”,我们学习了如何使用findByRole查询来选择需要时间出现在屏幕上的元素。prefer-find-by规则是一个可修复的规则;这意味着我们可以选择让问题代码自动为我们修复。

自动修复问题的简单方法是将代码编辑器设置为在保存文件时自动解决任何可修复的问题。请参阅您各自代码编辑器的文档以获取说明。如果由于某种原因,您的编辑器没有“保存时修复”功能,您可以在命令行中运行eslint --fix或通过git使用预提交钩子。作为最后的手段,您始终可以选择参考与eslint-plugin-testing-library相关的文档,其中包括prefer-find-by规则和其他规则的建议。一旦我们重构了问题代码,我们得到以下输出:

  const selection = await screen.findByRole('heading', {
    name: /you selected: javascript/i
  })
  expect(selection).toBeInTheDocument()
})

在前面的代码中,将waitFor代码替换为findByRole查询方法。该代码具有更简洁的语法,其行为类似于waitFor代码,并满足代码检查规则。最后,我们断言所选代码在文档中。

一些规则在eslint-plugin-testing-library的 React 版本中不是自动启用的。例如,在 React Testing Library 的先前版本中,常见的选择器编写方式如下:

  const { getByRole } = render(<LanguageDropdown />)
  user.click(getByRole('button', { name: /programming     language/i }))
  user.click(getByRole('menuitem', { name: /javascript/i }))

在前面的代码中,我们通过解构渲染的组件来访问查询方法。最新的 React Testing Library 版本建议使用 screen 对象来访问查询方法,以获得更好的用户体验。screen 对象允许您使用编辑器的自动完成功能来访问查询方法,而不是通过渲染组件进行显式的解构。我们可以将 prefer-screen-queries 规则添加到我们的 ESLint 配置文件中,以强制执行这种选择查询方法的方式:

  "rules": {
    "testing-library/prefer-screen-queries": "error"
  }

在前面的代码中,我们在配置文件中添加了一个 "rules" 键。当我们要添加特定的规则来强制执行我们的代码时,会使用 "rules" 键。在 "rules" 键内部,我们添加了 "testing-library/prefer-screen-queries" 键,并将其设置为 "error"。如果我们有一个项目设置了一个运行 ESLint 的 linting 脚本来跨文件运行,错误将触发一个退出代码来停止文件执行,从而清楚地表明当前代码不适合使用。

现在,有了这个规则,使用解构的先前代码将被 ESLint 标记:

图 6.6 – prefer-screen-queries 检查器建议

图 6.6 – prefer-screen-queries 检查器建议

在前面的屏幕截图中,getByRole 被 ESLint 下划线标记,以引起我们对查询问题的注意。当我们悬停在查询上时,我们会得到反馈,表明首选的方法使用 screen 通过 eslint-plugin-testing-libraryprefer-screen-queries 规则来查询 DOM 元素。

与前一个示例中的 prefer-find-by 规则不同,prefer-screen-queries 不是一个可修复的规则。这意味着我们需要手动修复代码。当我们重构代码时,我们会得到以下结果:

render(<LanguageDropdown />)
user.click(screen.getByRole('button', { name: /programming   language/i }))
user.click(screen.getByRole('menuitem', { name: /javascript/i }))

在前面的代码中,DOM 选择器已经被重构为使用 screen 对象,满足了 prefer-screen-queries 规则。与使用解构查询方法的版本相比,代码看起来也更简洁。

在某些情况下,我们可能希望规则在 ESLint 在项目文件中运行时提供警告而不是错误。警告不会停止代码执行;然而,它将作为对用户的一个提醒,在提交代码之前移除文件中突出显示的代码。例如,在构建测试时,使用 debug 方法查看当前 DOM 的状态是很常见的:

  render(<LanguageDropdown />)
  screen.debug()

在前面的代码中,debug 方法用于在渲染 LanguageDropdown 组件后,将当前 DOM 输出记录到控制台。debug 方法将在编辑器中突出显示,如下所示:

图 6.7 – no-debug 检查器建议

图 6.7 – no-debug 检查器建议

在前面的截图上,ESLint 用下划线标记了 debug,以引起我们对查询问题的注意。当我们悬停在查询上时,我们得到反馈,表明应该通过 eslint-plugin-testing-libraryno-debug 规则来移除该方法。我们常常忘记在提交工作之前移除控制台的日志代码,因此 no-debug 规则作为一个有用的提醒来移除它。

现在,你知道如何安装和配置 ESLint 与 eslint-plugin-testing-library,以帮助避免问题并在编写测试时遵循最佳实践。

在下一节中,我们将通过安装另一个针对 jest-dom 特定的插件来更进一步。

安装和配置 eslint-plugin-jest-dom

在上一节中,我们安装并配置了 ESLint 和 eslint-plugin-testing-library。在本节中,我们将教你如何安装和配置 eslint-plugin-jest-dom,确保我们使用 jest-dom 遵循最佳实践。使用以下命令安装插件:

npm install --save-dev eslint-plugin-jest-dom

之前的命令将 eslint-plugin-jest-dom 作为项目中的开发依赖项安装。现在插件已安装,我们可以将其添加到我们的 ESLint 配置文件中:

{
  "extends": ["react-app", "plugin:jest-dom/recommended"]
}

在前面的代码中,我们在配置文件中的 extends 数组中添加了 plugin:jest-dom/recommended。该插件的 recommended 配置用于自动包含一组标准规则,以强制执行 jest-dom 的最佳实践。我们将测试一个允许用户选择他们首选编程语言的 checkbox 组件:

图 6.8 – 语言复选框组件

图 6.8 – 语言复选框组件

在前面的截图上,你可以看到有四个编程语言的复选框,用户可以从中选择。当用户选择一种语言时,我们得到以下信息:

图 6.9 – 已选语言复选框

图 6.9 – 已选语言复选框

在前面的截图上,用户选择了 JavaScript,这导致相关的复选框被选中,文本颜色变为 绿色,字体粗细变为 粗体。我们可以编写一个测试来验证所选语言的复选框被选中,并且具有与用户在屏幕上看到的文本颜色和字体粗细相匹配的预期类:

test('LanguageCheckbox, given selected item, item is checked',   () => {
  render(<LanguageCheckBox />)
  const jsCheckbox = screen.getByRole('checkbox', { name: /    javascript/i })
  user.click(jsCheckbox)

在前面的代码中,我们将 LanguageCheckBox 组件渲染到 DOM 中。接下来,我们获取 jsCheckbox 变量,并点击它。接下来,我们将对预期的输出进行断言。首先,我们尝试使用 toHaveAttribute Jest 匹配器:

  expect(jsCheckbox).toHaveAttribute("checked");

在前面的代码中,我们使用 toHaveAttribute 来验证在点击后复选框是否有 checked 属性。然而,我们的测试将因为这个匹配器而失败,因为它只寻找添加到元素中的显式 checked 属性,这些元素通常用于我们想要预选中复选框的情况。在我们的情况下,我们正在测试用户点击结果 DOM 中复选框的结果,因此我们需要一个不同的匹配器。接下来,我们尝试使用 toHaveProperty Jest 匹配器:

  expect(jsCheckbox).toHaveProperty("checked", true);

在前面的代码中,我们使用 toHaveProperty Jest 匹配器来验证复选框的 checked 属性被设置为 true。这个匹配器在技术上是可行的,但它读起来不是很清楚。此外,当我们悬停在匹配器上时,我们得到以下输出:

图 6.10 – prefer-checked 检查器建议

图 6.10 – B16887.jpg

图 6.10 – prefer-checked 检查器建议

在前面的截图上,toHaveProperty 匹配器被 ESLint 下划线标注以引起我们对匹配器问题的注意。当我们悬停在匹配器上时,我们得到反馈表明应该通过 eslint-plugin-jest-domprefer-checked 规则将其替换为 jest-domtoBeChecked 匹配器。该规则可以自动修复,并且如果我们的代码编辑器设置正确,它将为我们重构匹配器。当我们重构我们的匹配器时,我们得到以下输出:

  expect(jsCheckbox).toBeChecked()

在前面的代码中,我们使用 toBeChecked jest-dom 匹配器来验证复选框是否被选中。现在我们有一个匹配器,它消除了先前匹配器版本中的任何问题,并且读起来要好得多。接下来,我们将断言预期的类:

expect(screen.getByText(/javascript/i).className).  toContain('text-success font-weight-bold')

在前面的代码中,我们通过 javascript 文本访问元素内部的 className 属性以验证它是否包含 text-successfont-weight-bold 类。然而,当我们悬停在 toContain 上时,我们得到以下反馈:

图 6.11 – prefer-to-have-class 检查器建议

图 6.11 – B16887.jpg

图 6.11 – prefer-to-have-class 检查器建议

在前面的截图上,toContain 匹配器被 ESLint 下划线标注以引起我们对匹配器问题的注意。当我们悬停在匹配器上时,我们得到反馈表明应该通过 jest-domtoHaveClass 匹配器替换它,这是通过 eslint-plugin-jest-domprefer-to-have-class 规则实现的。与前面的例子类似,prefer-to-have-class 规则可以自动修复,并且如果我们的代码编辑器设置正确,它将为我们重构匹配器。当我们重构代码时,我们得到以下输出:

  expect(screen.getByText(/javascript/i)).toHaveClass(
    'text-success font-weight-bold'
  )

在前面的代码中,我们将代码重构为使用 jest-domtoHaveClass 匹配器。现在我们有一个比原始示例更容易实现和阅读的匹配器。

现在你已经了解了如何安装和使用 eslint-plugin-jest-dom 插件来使用遵循 jest-dom 最佳实践的断言匹配器。在下一节中,我们将学习如何安装和使用一个包来提高我们组件源代码的易访问性。

使用 jest-axe 测试易访问性

在本节中,我们将学习如何使用一个旨在帮助我们改进功能易访问性的工具。有许多工具可以帮助通过自动化审计和报告问题的过程来提高易访问性,例如 Wave (wave.webaim.org/) 和 Lighthouse (developers.google.com/web/tools/lighthouse)。然而,没有单个工具可以保证整个应用程序的易访问性。易访问性审计工具是有帮助的,但它们不能取代由人类进行的手动易访问性审计的需求。例如,当在文本行中首次使用缩写时,应包括相关的扩展版本:

Structured Query Language (SQL) is used to manage data in relational databases.

在前面的句子中,扩展版本 结构化查询语言 与其缩写形式 SQL 一起包含。该句子需要手动检查以验证易访问性。我们将学习如何使用 jest-axe,这是一个为 Jest 添加自定义匹配器并具有与 ESLint 类似行为的工具。该工具有助于在您的代码中查找和报告常见的易访问性问题,例如没有替代文本的图像按钮或没有相关标签的 inputs。使用以下命令安装此工具:

npm install --save-dev jest-axe

之前的命令在项目中安装了 jest-axe 作为开发依赖。现在工具已安装,我们可以在测试中使用它。首先,我们将测试图像按钮的易访问性:

图 6.12 – 一个不可访问的图像按钮

图 6.12 – 一个不可访问的图像按钮

在前面的屏幕截图中,我们有一个作为 提交 按钮行为的图像。以下是该图像按钮的源代码:

import loginImg from './image/login.png'
<input src={loginImg} type="image" />

在前面的代码中,我们将导入一个图像并将其作为 image 类型的输入的 source。现在我们将编写一个测试来验证该元素对用户是可访问的:

import { render } from '@testing-library/react'
import { axe } from 'jest-axe'
import 'jest-axe/extend-expect'
import NoAccessibility from './NoAccessibility'

在前面的代码中,首先,我们从 React Testing Library 中导入 render 方法。然后,我们从 jest-axe 中导入 axe 方法。axe 方法是我们将用来审计我们组件的易访问性的方法。接下来,我们导入 jest-axe/extend-expect,它为 Jest 添加了一个特殊的匹配器,以便以可读的格式报告审计结果。最后,我们导入 NoAccessibility 组件进行测试。接下来,我们将编写主要的测试代码:

  const { container } = render(<NoAccessibility />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()

在前面的代码中,首先,我们从渲染的组件中解构 container。与查询方法不同,我们可以在不违反 DOM 测试库的最佳实践的情况下从渲染的组件中解构 container,因为它不在 screen 对象上。container 是包裹你的待测试 React 组件的 div 元素。

接下来,我们将 container 作为参数传递给 axe 方法,并将其存储在 results 变量中。axe 方法将在我们的待测试组件上运行可访问性审计。最后,我们使用 toHaveNoViolations 匹配器断言结果没有可访问性问题。如果没有发现违规,测试将通过。

然而,如果发现违规,测试将失败并提供反馈以解决这些问题。当我们运行测试时,我们得到以下输出:

图 6.13 – 不可访问图像按钮测试输出

图 6.13 – 不可访问图像按钮测试输出

前面的屏幕截图显示,在 NoAccessibility 组件中发现了可访问性违规,导致测试失败并提供反馈。首先,反馈表明 input 元素是问题的来源。接下来,我们看到整个元素打印在屏幕上。然后,我们得到 "Image buttons must have alternate text (input-image-alt)" 消息,告知我们为什么该元素未能通过审计。接下来,我们得到一些可以实施的建议来解决该问题。最后,我们得到一个超链接,我们可以通过它来深入了解问题。我们将通过提供 alt 属性来解决该问题:

<input src={loginImg} type="image" alt="login" />

在前面的代码中,我们添加了一个值为 loginalt 属性。现在,当我们重新运行我们的测试时,我们得到以下结果:

图 6.14 – 可访问图像按钮测试输出

图 6.14 – 可访问图像按钮测试输出

在前面的屏幕截图中,测试结果显示,在可访问性审计中,NoAccessibility 没有违反规则,测试通过且没有违反规则。接下来,我们将测试包含图像的列表的可访问性:

图 6.15 – 不可访问列表

图 6.15 – 不可访问列表

在前面的屏幕截图中,我们有一个包含 image 元素的未有序列表。以下是列表的源代码:

      <ul>
        <li>Building with React</li>
        <li>Testing with React Testing Library</li>
        <img
          src="img/200?random&gravity=center"
          alt="tulips"
        />
      </ul>

在前面的代码中,我们有一个包含两个 list item 子元素和一个 image 子元素的未有序列表元素。我们的测试代码将与之前图像按钮的测试代码相同。这里唯一的区别是我们传递给 render 方法的组件。因此,对于这个例子,我们只关注测试结果:

图 6.16 – 不可访问列表测试结果

图 6.16 – 不可访问列表测试结果

上一张截图显示,在无序列表组件中发现了可访问性违规,这导致测试失败并给出反馈。首先,反馈表明问题源是一个 ul 元素。接下来,我们看到整个元素在屏幕上打印出来。然后,我们得到 "<ul> 和 <ol> 必须仅直接包含 <li>、<script> 或 <template> 元素(列表)" 的消息,这有助于我们理解为什么该元素未能通过审核。

接下来,我们得到有关如何解决问题的建议。最后,我们得到一个超链接,我们可以点击以深入了解问题。我们将通过将图像移动到 li 元素内来解决问题:

      <ul>
        <li>Building with React</li>
        <li>Testing with React Testing Library</li>
        <li>
          <img
            src="img/200?random&               gravity=center"
            alt="tulips"
          />
        </li>
      </ul>

在之前的代码中,我们将 image 元素包裹在一个 li 元素内。当我们重新运行测试时,测试将通过并返回与我们在图像按钮之前测试中看到的结果相似的结果。现在你知道如何使用 jest-axe 来提高使用 Jest 的 React 应用的可访问性。重要的是要重申,自动化可访问性工具有助于提高我们应用程序为各种最终用户工作的能力。然而,它们不能捕捉到所有问题,也不能替代人工审核。

接下来,我们将学习如何使用工具来通过 React Testing Library 加速我们的元素选择。

使用 Testing Playground 选择最佳查询

在本节中,我们将学习如何使用 Testing Playground。这是一个使你更容易确定正确的 DOM Testing Library 查询选择器的工具。Testing Playground 允许你将 HTML 粘贴到一个交互式网站上,这样你就可以在浏览器中渲染元素时点击它们。这使得你可以了解哪些 DOM Testing Library 查询可以用来选择特定的元素。

工具总是根据 DOM Testing Library 对具有多种选择方式的元素的查询推荐顺序建议查询。此外,该工具允许你将选择器复制到测试代码中使用。我们将探讨两种使用 Testing Playground 的方法:首先是通过网站,其次是通过 Chrome 扩展程序。

使用 Testing Playground 网站选择查询

在本节中,我们将学习如何通过其网站使用 Testing Playground。在本书前面的示例中,我们使用了 debug 方法在编写测试时将组件的结果 HTML 记录到控制台。debug 方法的局限性之一是它没有功能可以让你将输出记录到浏览器并测试不同的查询方法来选择元素。

我们可以在测试文件中使用 logTestingPlaygroundURL 方法将结果 HTML 记录到浏览器中的 Testing Playground (testing-playground.com/),并利用该网站的查询选择器功能。例如,我们可能正在为以下 MoreInfoPopover 组件选择元素进行测试:

图 6.17 – Popover 组件

图 6.17 – 弹出组件

在前面的截图上,我们有一个带有文本button元素的button元素,使用 DOM 测试库查询,因此我们开始测试如下:

import { render, screen } from '@testing-library/react'
import MoreInfoPopover from './MoreInfoPopover'
it('logs output to Testing Playground', () => {
  render(<MoreInfoPopover />)
  screen.logTestingPlaygroundURL()
})

在前面的代码中,我们导入了 React 测试库的renderscreen方法以及待测试的组件。在主测试代码内部,首先,我们在 DOM 中渲染组件。接下来,我们调用logTestingPlaygroundURL方法。当我们运行测试时,我们得到以下输出:

图 6.18 – 测试游乐场链接

图 6.18 – 测试游乐场链接

在前面的截图上,我们有一个唯一的链接,可以访问测试游乐场网站并查看我们的组件渲染的 HTML。当我们点击链接时,我们应该看到以下类似的内容:

图 6.19 – 测试游乐场 HTML 结构

图 6.19 – 测试游乐场 HTML 结构

在前面的截图上,链接将我们导航到测试游乐场网站。首先,我们看到一个包含我们组件 HTML 结构的部分。接下来,我们看到渲染的浏览器输出,如下所示:

图 6.20 – 测试游乐场浏览器输出

图 6.20 – 测试游乐场浏览器输出

在前面的截图上,我们可以看到一个包含我们组件浏览器输出的部分。注意,我们没有看到包含相关样式的完整结果。测试游乐场网站仅显示 HTML 内容。接下来,我们看到一个建议查询部分,如下所示:

图 6.21 – 测试游乐场建议查询

图 6.21 – 测试游乐场建议查询

在前面的截图上,我们在浏览器输出部分点击了一个button元素。getByRole查询是根据 HTML 结构选择按钮的最佳方式。此外,我们还可以看到这太棒了。发货的消息,这表明我们应该进一步使用此查询。

以下截图显示了其他可选的选项,用于选择元素:

图 6.22 – 测试游乐场查询优先级选项

图 6.22 – 测试游乐场查询优先级选项

在前面的代码中,我们可以看到多个选项,按照优先级顺序选择元素。根据button元素的 HTML 结构,我们可以选择两种方式来选择元素 – 通过其角色通过其文本值。其他列出的查询对于按钮不可用,因此显示不可用。如果我们决定选择文本查询选项,我们应该看到以下截图类似的内容:

图 6.23 – 测试游乐场文本查询选项

图 6.23 – 测试游乐场文本查询选项

在前面的屏幕截图中,我们可以看到用于选择按钮的文本getByText,它不是选择所选元素的最佳查询。一旦我们决定要抓取的查询,我们就可以点击建议查询框最右侧的图标来复制选择测试中元素的所需代码。

现在你已经知道了如何使用logTestingPlaygroundURL方法通过 Testing Playground 网站选择元素。使用 Testing Playground 网站有一个显著的限制。当我们点击更多信息按钮时,我们应该看到一个弹出窗口出现在按钮下方。由于它只复制 HTML 而不是相关的 JavaScript 来渲染按钮点击的结果,我们无法使用 Testing Playground 网站执行此操作。

在下一节中,我们将学习如何使用 Testing Playground Chrome 扩展程序来克服这个限制。

使用 Testing Playground Chrome 扩展程序选择查询

在本节中,我们将安装和使用 Testing Playground Chrome 扩展程序来克服使用 Testing Playground 网站的限制。此扩展程序的好处是允许你在运行应用程序的同一浏览器中本地使用 Testing Playground 功能。该扩展程序目前仅适用于 Google Chrome 浏览器,所以如果需要,请确保安装它。

通过 Chrome Web Store 安装Testing Playground Chrome 扩展程序(chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano)。一旦扩展程序安装完成,就会在你的Chrome 开发者工具中添加一个新的Testing Playground标签页。

返回到上一节中的MoreInfoPopover组件,我们可以编写一个测试来验证当用户点击更多信息按钮时,弹出窗口是否显示:

test('MoreInfoPopover, given clicked button, displays popover',   () => {
  render(<MoreInfoPopover />)

在前面的代码中,我们在 DOM 中渲染了MoreInfoPopover。接下来,我们将使用 Testing Playground 扩展程序来找到按钮的首选查询选择器:

![Figure 6.24 – The Testing Playground Chrome extension]

![img/Figure_6.24_B16887.jpg]

图 6.24 – Testing Playground Chrome 扩展程序

在前面的屏幕截图中,我们可以看到一个为getByRole查询选择器添加的标签页。当我们复制选择器时,我们得到要添加到我们的测试中的查询代码:

  screen.getByRole('button', {  name: /more info/i})

在前面的代码中,我们复制了getByRole选择器以访问更多信息按钮。接下来,我们将使用扩展程序来帮助选择弹出窗口,该弹出窗口在点击按钮后显示:

Figure 6.25 – The popover query selector

图 6.25 – 弹出查询选择器

在前面的屏幕截图中,getByRole是在浏览器中选择popover元素后建议的查询。现在我们有了编写剩余测试代码所需的所有选择器:

  user.click(screen.getByRole('button', { name: /more info/i     }))
  const popover = await screen.findByRole('heading', { name: /     lorem ipsum/i })
  expect(popover).toBeInTheDocument()

在前面的代码中,首先,我们通过其标题点击 popover 元素并将其存储在一个变量中。请注意,我们使用了 findByRole 而不是 getByRole 查询。Testing Playground 只提供 getBy* 查询,因此可能需要根据情况修改复制的查询。最后,我们断言 popover 元素在 DOM 中。当我们运行测试时,我们得到以下结果:

图 6.26 – 弹出测试结果

图 6.26 – 弹出测试结果

在前面的屏幕截图中,结果显示 MoreInfoPopover, given clicked button, displays popover 测试按预期通过。现在您知道了如何安装和使用 Testing Playground Chrome 扩展来增强编写测试时的工作流程。Testing Playground 网站和扩展是处理 DOM 测试库时的优秀辅助工具。

在下一节中,我们将学习如何使用一个工具,该工具将加快编写测试时从开始到完成的结果反馈。

使用 Wallaby.js 提高我们的测试生产力

在本节中,我们将学习如何通过使用 Wallaby.js 生产力工具来提高我们的生产力。Wallaby.js 通过在后台通过无头 Chrome 浏览器自动运行您的测试来实现。还有在 Node.js 或 Phantom.js 等其他环境中运行测试的选项,并带有自定义配置文件。Wallaby.js 通过在代码编辑器内提供即时测试结果来帮助加快您的工作流程,因此您可以在无需保存和运行测试脚本查看结果的情况下进行输入。

Wallaby.js 提供了许多功能,例如以下内容:

  • 时间旅行调试:这允许您轻松地导航代码以定位错误源。

  • 测试故事查看器:这提供了在单个紧凑屏幕上查看与您的测试相关联的代码的能力。

  • 内联代码覆盖率:这会告知您代码编辑器内每行代码的测试覆盖率。

    注意

    请参考 Wallaby.js 文档网站上的 功能 部分 (wallabyjs.com/#features) 以获取功能列表的完整列表。

安装和配置 Wallaby.js

在本节中,我们将学习如何为 Visual Studio Code 编辑器安装和设置 Wallaby.js。请参考 Wallaby.js 文档网站上的 安装 部分 (wallabyjs.com/download/) 以获取完整的安装选项列表。要开始,请通过 VSCode 市场 place (marketplace.visualstudio.com/items?itemName=WallabyJs.wallaby-vscode) 将 Wallaby.js VSCode 扩展添加到您的编辑器中。一旦扩展被安装,我们就可以配置它以在我们的项目中工作。

配置 Wallaby.js 最快最简单的方法是使用自动配置。使用特定版本的工具(如create-react-app 版本 3或更高版本,或Jest 版本 24或更高版本)的项目符合自动配置条件。

对于不符合自动配置的项目,请参考 Wallaby.js 文档中的“配置文件”部分(wallabyjs.com/docs/intro/config.html?editor=vsc#configuration-file),根据您的项目设置获取具体的配置信息。

使用命令面板在 VSCode 中启动 Wallaby.js 并使用自动配置:

图 6.27 – 选择配置

图 6.27 – Wallaby.js 配置文件

图 6.27 – 选择配置

在前面的屏幕截图中,我们在命令面板中输入wallaby以显示可用的 Wallaby.js 命令。我们将点击Wallaby.js: 选择配置选项:

![图 6.28 – 自动配置选项图 6.28 – Wallaby.js 配置文件

图 6.28 – 自动配置选项

在前面的屏幕截图中,我们已选择自动配置 <项目目录>自动配置 <自定义目录>选项。我们将选择<项目目录>以使用当前项目的目录。一旦选择配置,Wallaby.js 将启动并运行我们的测试,直接在代码编辑器的测试文件中提供反馈,如下面的屏幕截图所示:

图 6.29 – Wallaby.js 增强测试输出

图 6.29 – Wallaby.js 增强测试输出

图 6.29 – Wallaby.js 增强测试输出

在前面的屏幕截图中,我们可以看到一个在“使用测试游乐场选择最佳查询”部分创建的测试,并增强了 Wallaby.js 的功能。首先,我们看到行号左侧有绿色的方块形状,表示所有测试行都已通过。接下来,我们看到 Wallaby.js 的调试查看故事分析聚焦功能的链接,我们可以点击它们来从特定功能的视角分析测试。

最后,我们看到测试运行时间45ms记录在测试旁边。现在您已经了解了如何安装和配置 Wallaby.js。您也应该了解 Wallaby.js 直接在测试文件中添加的基本增强功能。

在下一节中,我们将介绍如何使用 Wallaby.js 的交互式测试输出功能编写测试。

使用交互式测试输出编写测试

在本章的“使用测试游乐场选择最佳查询”部分,我们为MoreInfoPopover组件编写了MoreInfoPopover, given clicked button, displays popover测试。现在让我们来了解一下如何使用 Wallaby.js 创建相同的测试。

首先,我们将测试组件渲染到 DOM 中,并使用debug方法记录当前 HTML 输出的状态:

![图 6.30 – Wallaby.js 内联调试输出图片

图 6.30 – Wallaby.js 内联调试输出

在前面的屏幕截图中,我们使用了 Wallaby.js 的 //? 命令将 debug 的结果直接记录在代码编辑器中。当我们将鼠标悬停在方法上时,输出会自动显示在 debug 的右侧。此功能加快了我们的工作流程,因为通常情况下,我们不得不从命令行执行测试运行器来查看输出。

接下来,我们将添加查询以选择 DOM 元素:

![图 6.31 – 查询错误]

图片

图 6.31 – 查询错误

在前面的屏幕截图中,我们有一个测试失败,这是由于 React Testing Library 没有找到名为 lorem ipsumheading 元素。Wallaby.js 以两种方式提高了我们发现错误的能力。首先,我们在测试名称左侧和具体错误发生的行号处看到一个红色的方块形状。内联代码通知帮助我们快速确定我们应该关注的位置以定位错误的根源。其次,当我们悬停在 test 方法上时,React Testing Library 的测试结果输出会直接在代码编辑器中显示。

此功能加快了我们的工作流程,因为 Wallaby.js 在我们向测试添加新代码时重新运行我们的测试并提供反馈。此外,我们甚至不必保存测试文件就能获得反馈。我们还可以在 Wallaby.js 测试 控制台中查看测试反馈:

图 6.32 – Wallaby.js 测试控制台

图 6.32 – Wallaby.js 测试控制台

在前面的屏幕截图中,我们可以看到与在测试文件中更新代码时直接在编辑器中看到的类似的 React Testing Library 反馈,但现在它是一个扩展视图。此外,我们还可以看到失败测试与通过测试的数量对比,以及可点击的链接“启动覆盖率 & 测试资源管理器”,这是一个允许您查看每个文件的视觉测试覆盖率的特性,以及“搜索测试”,这是一个允许您快速在项目中搜索任何测试的特性。

在 Wallaby.js 的编辑器功能帮助下调试失败后,我们了解到名为 lorem ipsumheading 元素并未立即显示。利用我们对 Testing Library 查询的了解,我们可以确定该元素应该使用异步的 findBy* 查询来选择:

  const popover = await screen.findByRole('heading', { name: /     lorem ipsum/i })

在前面的代码中,我们将选择器更新为 findByRole。在更新选择器后,我们立即在编辑器中获得了反馈:

![图 6.33 – 查询重构]

图片

图 6.33 – 查询重构

在前面的屏幕截图中,我们看到所有行号左侧都有绿色的正方形形状。这表明我们已经成功重构了测试代码到工作状态。我们还编写了一个断言来验证测试是否按预期通过。现在您知道了如何使用 Wallaby.js 获取即时编辑反馈和测试调试功能。当您需要节省运行和调试测试的时间时,Wallaby.js 是一个非常有用的工具。

摘要

本章向您介绍了使用 ESLint 插件遵循 DOM 测试库和 jest-dom 最佳实践的益处。您已经了解了可访问代码的概念,并使用 jest-axe 提高了您应用程序的可访问性。您还学习了如何通过测试游乐场加速确定最佳查询方法的过程。最后,您学习了如何通过 Wallaby.js 提高测试编写效率。

在下一章中,您将学习如何使用流行的 Cypress 框架进行端到端 UI 测试。

问题

  1. 将 React 特定的 eslint-plugin-testing-library 版本安装到项目中,并添加额外的规则。

  2. 创建使用不遵循 jest-dom 最佳实践的匹配器的 jest 断言示例。然后,在项目中安装并配置 eslint-plugin-jest-dom,并使用它作为指导来纠正突出显示的问题。

  3. 创建一些存在可访问性问题的组件,安装并运行 jest-axe 对这些组件进行测试,并使用反馈来修复它们。

  4. 访问您最喜欢的三个网站,并使用测试游乐场查看您可以使用 DOM 测试库首选的 byRole* 查询选择多少个元素。

  5. 安装 Wallaby.js 并记录使用其编辑器功能编写测试的速度有多快。

第七章:使用 Cypress 进行端到端 UI 测试

在前面的章节中,我们学习了如何使用 React Testing Library 在组件级别测试应用程序。在本章中,我们将学习如何通过使用 Cypress 执行端到端测试来在系统级别测试应用程序。端到端测试在帮助团队获得信心,确保应用程序在生产环境中按预期为最终用户工作方面发挥着至关重要的作用。通过在测试策略中包含端到端测试,团队可以了解应用程序在所有依赖项协同工作时如何表现。Cypress 是一个现代的 JavaScript 端到端测试框架,可以处理在浏览器中运行的任何内容,包括使用 React、Angular 和 Vue 等流行框架构建的应用程序。Cypress 的功能允许团队在几分钟内安装、编写、运行和调试测试。

除了系统级测试外,它还提供了编写单元和集成测试的能力,这使得框架非常适合开发人员和质量工程师。此外,Cypress 与 Selenium 等工具不同,它直接在浏览器中运行测试,而不是需要浏览器驱动程序,在执行命令和断言之前自动等待,在运行时为每个测试命令提供视觉反馈,并通过 Cypress Dashboard 访问记录的测试运行。

本章我们将涵盖以下主要内容:

  • 在现有项目中安装 Cypress

  • 使用 cypress-testing-library 增强 Cypress DOM 查询

  • 使用 Cypress 实现测试驱动开发

  • 复习 Cypress 设计模式

  • 使用 Cypress 执行 API 测试

  • 使用 Cucumber 实现的 Gherkin 风格测试

本章中获得的知识将为您的测试策略添加额外的策略,以补充与 React Testing Library 学习的技能。

技术要求

对于本章的示例,您需要在您的机器上安装 Node.js。我们将使用 create-react-app CLI 工具和 Next.js React 框架 (nextjs.org/) 来展示所有代码示例。如果需要,请在开始本章之前熟悉 Next.js。本章将提供代码片段以帮助您理解待测试的代码,但目标是理解如何测试代码。

您可以在此处找到本章的代码示例:github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter07

开始使用 Cypress

在本节中,您将学习如何在现有项目中安装和设置 Cypress。我们还将编写一个用户流程的测试。在命令行中使用以下命令来安装 Cypress:

npm install cypress --save-dev

上述命令将在您的项目中将 Cypress 安装为开发依赖项。Cypress 安装完成后,运行以下命令:

npx cypress open

前面的命令运行 Cypress 交互式测试运行器。测试运行器允许我们手动执行诸如选择要运行的特定测试、选择用于测试执行的浏览器以及查看与每个相关 Cypress 命令一起的浏览器输出。当我们第一次以交互模式运行 Cypress 时,它为 Cypress 项目创建了一个建议的文件夹结构:

图 7.1 – 第一次 Cypress 运行

图 7.1 – 第一次 Cypress 运行

在前面的屏幕截图中,Cypress 通知我们它已自动在我们项目的根目录中为我们创建了一个 cypress 文件夹结构,包括以下子文件夹 - fixturesintegrationpluginssupport。这些子文件夹使我们能够快速启动并运行,而无需进行任何手动配置。fixtures 文件夹用于创建在测试中通常用于模拟网络数据的静态数据。integration 文件夹用于创建测试文件。在 integration 文件夹内部,Cypress 提供了一个 examples 文件夹,其中包含使用 Cypress 测试应用程序的多个示例。

plugins 文件夹用于以多种方式扩展 Cypress 的行为,例如通过编程方式更改配置文件,在测试运行后生成 HTML 格式的报告,或添加对自动化视觉测试的支持,仅举几例。Cypress 提供了许多开箱即用的命令,如 clicktype 和来自第三方工具(如 Mocha (mochajs.org/))、Chai (www.chaijs.com/) 和 jQuery (jquery.com/)) 的断言。

support 文件夹用于创建自定义命令或使用如项目文件夹根目录中的 cypress.json 文件等工具添加第三方命令。cypress.json 文件用于设置全局设置,例如 Cypress 在测试中使用的全局基础 URL,为元素在 DOM 中出现设置自定义超时,或者甚至将我们的测试文件文件夹位置从 integration 更改为 e2e,例如。我们可以在 cypress.json 文件中配置许多设置。

Cypress 测试运行器的右上角有一个下拉列表,允许您选择用于测试运行的浏览器:

图 7.2 – Cypress 浏览器下拉菜单

图 7.2 – Cypress 浏览器下拉菜单

在前面的屏幕截图中,可供使用的浏览器版本有 Chrome 88Firefox 80Edge 88Electron 87,可用于测试运行。可用的浏览器基于用户机器上安装的与 Cypress 兼容的浏览器。Cypress 支持的浏览器是 Firefox 和 Chrome 家族的浏览器,如 Edge 和 Electron。Electron 浏览器在 Cypress 中默认可用,也用于在无头模式下运行测试,这意味着没有浏览器 UI。

要执行测试,只需从可用测试列表中单击测试名称:

图 7.3 – 示例测试运行

图 7.3 – 示例测试运行

在前面的屏幕截图中,运行了位于 examples 文件夹中的 actions.spec.js 测试文件。屏幕的右侧显示了在测试的每个步骤中浏览器中应用程序的状态。屏幕的左侧显示了测试文件中每个测试的结果。如果我们想的话,我们可以点击每个测试,悬停在每个 Cypress 命令上,并查看在命令执行前后产生的 DOM 状态。能够悬停在每个命令上查看产生的 DOM 输出是一个很棒的功能。

与其他端到端测试框架相比,Cypress 使得调试更加容易。例如,如果 Cypress 在我们的测试中找不到浏览器指定的元素,它会提供有用的错误信息:

图 7.4 – Cypress 错误输出

图 7.4 – Cypress 错误输出

在前面的屏幕截图中,Cypress 通过告知我们在测试运行器内部 4 秒后找不到名为 firSDFstName 的输入元素来提供反馈。Cypress 还允许我们点击一个链接,在错误发生的行打开我们的代码编辑器。

现在我们已经了解了使用 Cypress 测试运行器安装、运行和执行测试的基本知识,我们将编写一个结账流程测试。当用户结账时,应用程序会经过四个屏幕。第一个屏幕是送货地址:

图 7.5 – 送货地址结账屏幕

图 7.5 – 送货地址结账屏幕

在前面的屏幕截图中,显示了一个用户可以输入他们的送货地址信息的表单。第二个屏幕是支付详情:

图 7.6 – 支付详情结账屏幕

图 7.6 – 支付详情结账屏幕

在前面的屏幕截图中,显示了一个用户可以输入他们的支付信息的表单。第三个屏幕是查看订单:

图 7.7 – 查看您的订单结账屏幕

图 7.7 – 查看您的订单结账屏幕

在前面的屏幕截图中,显示了一个总结,显示了在之前的屏幕上输入的所有表单值。注意,为了演示目的,购买的物品T 恤衫牛仔布牛仔裤耐克自由跑鞋在应用程序中是硬编码的,不会是我们将要编写的测试的重点。最后一个屏幕是订单提交屏幕:

图 7.8 – 订单提交结账屏幕

图 7.8 – 订单提交结账屏幕

在前面的屏幕截图中,显示了一个确认信息,显示了一条感谢信息、一个订单号以及通知客户有关订单更新的电子邮件通信信息。

为了演示目的,订单号是硬编码的,不会是我们测试的重点。现在我们了解了用户流程,我们可以编写测试代码:

import user from '../support/user'
describe('Checkout Flow', () => {
  it('allows a user to enter address and payment info and place         an order', () => {
    cy.visit('/')

在前面的代码中,我们首先导入一个user对象用于测试。user对象简单地提供了一些假值,以便输入到每个form输入中,这样我们就不必为每个值硬编码。接下来,我们通过全局cy变量使用visit命令来访问应用程序。

所有的 Cypress 方法都是通过cy变量链式调用的。请注意,在visit方法中使用的'/'代表相对于我们测试的基础 URL 的 URL。通过使用相对 URL,我们不必在我们的测试中输入完整的 URL。我们可以通过cypress.json文件设置baseURL属性:

{
  "baseUrl": "http://localhost:3000"
}

在前面的代码中,我们将baseUrl设置为http://localhost:3000,这样当我们想要访问索引页或其他相对于索引页的页面时,可以使用'/'

接下来,我们将编写代码来完成运输地址屏幕:

    cy.get('input[name="firstName"]').type(user.firstName)
    cy.get('input[name="lastName"]').type(user.lastName)
    cy.get('input[name="address1"]').type(user.address1)
    cy.get('input[name="city"]').type(user.city)
    cy.get('input[name="state"]').type(user.state)
    cy.get('input[name="zipCode"]').type(user.zipCode)
    cy.get('input[name="country"]').type(user.country)
    cy.contains(/next/i).click()

在前面的代码中,我们使用get命令通过其name属性选择每个输入元素。我们还使用type命令为每个输入输入一个值。接下来,我们使用contains命令选择带有文本next的按钮元素,并使用click命令点击它。

接下来,我们将为支付详细信息屏幕输入值:

    cy.get('input[name="cardType"]').type(user.cardType)
    cy.get('input[name="cardHolder"]').type(user.cardHolder)
    cy.get('input[name="cardNumber"]').type(user.cardNumber)
    cy.get('input[name="expiryDate"]').type(user.expiryDate)
    cy.get('input[name="cardCvv"]').type(user.cardCvv)
    cy.contains(/next/i).click()

在前面的代码中,我们使用gettype命令选择和输入每个输入的值。然后,我们使用contains命令点击下一个按钮。

接下来,我们将在查看您的订单屏幕上验证输入的运输和支付详细信息:

    cy.contains(`${user.firstName} 
      ${user.lastName}`).should('be.visible')
    cy.contains(user.address1).should('be.visible')
    cy.contains(`${user.city}, ${user.state} 
     ${user.zipCode}`).should(
      'be.visible'
    )
    cy.contains(user.country).should('be.visible')
    cy.contains(user.cardType).should('be.visible')
    cy.contains(user.cardHolder).should('be.visible')
    cy.contains(user.cardNumber).should('be.visible')
    cy.contains(user.expiryDate).should('be.visible')
    cy.contains(/place order/i).click()

在前面的代码中,我们使用contains命令通过之前屏幕上输入的表单值选择每个元素。我们还使用should命令断言每个元素在屏幕上是可见的。然后,我们使用contains命令选择带有文本place order的按钮,并使用click命令点击它。

最后,我们验证应用程序是否成功跳转到了订单提交屏幕:

     cy.contains(/thank you for your 
       order/i).should('be.visible')

在前面的代码中,我们使用containsshould命令来验证带有文本npx cypress open command的元素,直接在命令行中,正如在本节开头所学的,但我们也可以创建一个npm脚本:

    "cy:open": "cypress open",

在前面的代码中,我们创建了一个cy:open脚本来运行 Cypress 测试运行器。我们也可以创建另一个脚本来在无头模式下运行测试:

    "cy:run": "cypress run",

我们创建了一个cy:run脚本来通过前面的代码中的cypress run命令在无头模式下运行 Cypress。在不需要使用交互模式的情况下,例如通过cy:open交互模式运行,我们会得到以下输出:

![图 7.9 – 结账流程测试结果]

图片

图 7.9 – 结账流程测试结果

在前面的屏幕截图中,测试运行指示checkOutFlow测试按预期通过。现在你知道了如何安装和使用 Cypress 来测试用户流程。在下一节中,我们将安装一个插件来增强我们的元素选择器命令。

使用 Cypress 测试库增强 Cypress 命令

在上一节中,我们学习了如何使用 Cypress 安装和编写用户流程测试。在本节中,我们将学习如何安装和配置 Cypress 测试库以添加增强的查询选择器。Cypress 测试库将允许我们在 Cypress 中使用 DOM 测试库查询方法。使用以下命令安装库:

npm install --save-dev @testing-library/cypress

上述代码将 @testing-library/cypress 作为开发依赖项安装到你的项目中。在库安装完成后,我们可以将其添加到 Cypress 的 commands 文件中:

import '@testing-library/cypress/add-commands'

在上述代码中,我们通过 Cypress 测试库扩展了 Cypress 命令。现在我们已经安装了 Cypress 测试库,我们可以在测试中使用它。需要注意的是,仅包含 DOM 测试库中的 findBy* 方法以支持 Cypress 的重试功能,该功能在超时之前会重试命令多次。

在本章的 Cypress 入门 部分,我们为结账流程编写了一个测试。我们可以使用 Cypress 测试库中的代码重构该测试中的元素查询。例如,我们可以这样重构 收货地址 屏幕的代码:

    cy.findByRole('textbox', { name: /first name/i 
      }).type(user.firstName)
    cy.findByRole('textbox', { name: /last name/i 
      }).type(user.lastName)
    cy.findByRole('textbox', { name: /address line 1/i 
      }).type(user.address1)
    cy.findByRole('textbox', { name: /city/i 
      }).type(user.city)
    cy.findByRole('textbox', { name: /state/i 
      }).type(user.state)
    cy.findByRole('textbox', { name: /postal code/i 
      }).type(user.zipCode)
    cy.findByRole('textbox', { name: /country/i 
      }).type(user.country)
    cy.findByText(/next/i).click()

在前面的代码中,我们将所有选择器更新为通过 findByRole 查询查找 input 元素。ARIA 属性被辅助技术使用人员用来定位元素。我们还更新了 findByText 查询的选择器。相同的重构模式也用于 支付详情查看您的订单 屏幕。最后,我们可以这样重构订单提交屏幕的代码:

        cy.findByRole('heading', { name: /thank you for 
          your order/i }).should(
      'be.visible'
    )
    cy.findByRole('heading', { name: /your order number is 
      #2001539/i }).should(
      'be.visible'
    )

在前面的代码中,我们将两个选择器更新为通过 findByRole 查询使用标题角色查找元素。我们的测试代码现在以更可访问的方式查询元素,这增加了我们确信应用程序将为所有用户工作的信心,包括使用屏幕阅读器等辅助技术的用户。此外,当在测试运行器屏幕中查看每一行时,测试代码的阅读性也更好。

现在你已经知道了如何安装 Cypress 测试库并使用避免使用实现细节的查询来重构现有测试。在下一节中,我们将学习如何使用 Cypress 进行测试驱动开发来向博客应用添加功能。

Cypress 驱动的开发

在上一节中,我们安装了 Cypress 测试库并对一个现有的结账流程测试进行了重构。在本节中,我们将使用 Cypress 驱动一个使用 Next.js 创建的现有博客应用的新功能开发。Next.js 是一个流行的框架,它为团队构建静态或服务器端渲染的 React 应用提供了愉悦的体验。

Next.js 提供的示例功能包括开箱即用的路由、内置的 CSS 支持和 API 路由。请参阅 Next.js 文档(nextjs.org/)以获取更多详细信息。MY BLOG应用程序目前有两个页面,一个主页显示所有博客帖子,一个页面用于显示博客详情。显示帖子列表的页面如下所示:

![图 7.10 – 博客主页图片 7.10

图 7.10 – 博客主页

在前面的屏幕截图中,主页显示了两个博客帖子,我爱 React我爱 Angular。博客数据存储在 MongoDB 数据库中,并在应用程序加载后通过 API 发送到前端。每个博客帖子从上到下显示一个类别、标题、发布日期、摘要和一个继续阅读链接。

要查看博客的详细信息,用户可以点击博客标题或继续阅读链接。例如,点击我爱 React标题后,我们看到以下内容:

![图 7.11 – 博客详情页面图片 7.11

图 7.11 – 博客详情页面

我们可以看到发送到 API 的POST请求的完整内容或直接添加新帖子到数据库。

我们需要添加一个功能,允许用户通过 UI 添加新帖子。我们可以使用 Cypress 编写测试以验证预期行为,并逐步构建 UI,直到功能完成且测试通过。以下测试显示了最终的预期行为:

import fakePost from '../support/generateBlogPost'

describe('Blog Flow', () => {
    let post = {}
  beforeEach(()=> (post = fakePost()))
  it('allows a user to create a new blog post', () => {
    cy.visit('/')
    cy.findByRole('link', { name: /new post/i }).click()

在前面的代码中,首先,我们导入fakePost,这是一个自定义方法,将为每次测试运行生成唯一的测试数据,并将其设置为变量post的值。我们不希望创建相同的博客帖子,所以自定义方法通过始终创建唯一数据来帮助。接下来,我们访问主页并点击名为新建帖子的链接。新建帖子链接应该将我们导航到一个可以输入新帖子值的页面。

接下来,我们测试为新帖子输入值的代码:

    cy.findByRole('textbox', { name: /title/i 
      }).type(post.title)
    cy.findByRole('textbox', { name: /category/i 
      }).type(post.category)
    cy.findByRole('textbox', { name: /image link/i 
      }).type(post.image_url)
    cy.findByRole('textbox', { name: /content/i 
      }).type(post.content)

在前面的代码中,我们通过其唯一的名称找到每个textbox元素,并通过自定义的post方法输入相关值。最后,我们创建测试的最后部分:

    cy.findByRole('button', { name: /submit/i }).click()
    cy.findByRole('link', { name: post.title 
     }).should('be.visible')
  })
})

在前面的代码中,我们点击提交按钮。一旦我们点击提交按钮,数据应该被发送到 API,保存到数据库,然后应用程序应该将我们导航回主页。最后,一旦在主页上,我们验证我们创建的帖子的标题是否可见在屏幕上。

我们将使用 Cypress 测试运行器来运行测试,以利用其交互功能,并在构建功能的过程中保持其开启状态。当运行测试时,我们的测试将如预期那样失败:

图 7.12 – 博客流程测试失败

图 7.12 – 博客流程测试失败

在之前的屏幕截图中,第一步成功导航到具有名称 New Postlink 元素,但在第二个测试步骤中 4 秒后未找到。4 秒是 Cypress 在超时前继续查询元素默认的时间。

我们还看到了 DOM 测试库提供的有用信息,告知我们哪些可访问元素在 DOM 中可见。此外,我们可以在测试失败的点查看浏览器,并看到 New Post 链接不可见。现在我们可以更新 UI 以使第二个测试步骤通过:

<Link href="/add">
<a className="font-bold inline-block px-4 py-2 text-3xl">
  New Post
</a>
</Link>

在之前的代码中,我们添加了一个链接,该链接将用户导航到一个被 Link 组件包裹的 hyperlink 元素。Link 组件允许客户端路由导航。测试运行器在保存测试文件时自动重新运行。由于我们已经编写了所有必要的测试代码,我们可以通过保存文件来触发测试运行。

我们需要在每次 UI 更改后执行此操作。现在,当测试运行时,我们得到以下输出:

图 7.13 – 博客流程添加页面失败

图 7.13 – 博客流程添加页面失败

在之前的屏幕截图中,我们的测试代码现在可以成功打开 title,但在 input 元素名称为 title 且具有 textbox 角色的步骤中未找到 role

<label htmlFor="title">Title</label>
 <input
   type="text"
   autoFocus
   id="title"
   name="title"
   placeholder="Blog Title"
   value={newBlog.title}
   onChange={handleChange}
 />

在之前的代码中,我们添加了一个 Title 标签 元素和一个关联的 text 类型的 input 元素。尽管在上次代码中没有演示,我们也继续添加了与 Title input 元素结构相似的 CategoryImage linkContent 输入元素。现在,当我们触发测试运行时,我们得到以下输出:

图 7.14 – 博客流程添加页面输入元素重构

图 7.14 – 博客流程添加页面输入元素重构

在之前的屏幕截图中,我们的测试代码现在可以成功打开 TitleCategoryImage linkContentinput 元素,但在 Submit 按钮步骤中未找到 Submit 按钮:

<button>Submit</button>

我们创建并添加了一个 Submit 按钮,该按钮是 form 元素的一部分,当点击时,会调用一个方法将表单数据发送到 API,最终发送到数据库。尽管这不是我们测试的重点,但我们还在 UI 中添加了一个 cancel 按钮。现在,当我们触发测试运行时,我们得到以下输出:

图 7.15 – 博客流程添加页面完成重构

图 7.15 – 博客流程添加页面完成重构

在之前的屏幕截图中,输出指示测试最终通过。我们可以在屏幕右侧看到由我们的测试创建的新博客文章。通过最后的重构,我们已经完成了所有允许用户通过 UI 添加新帖子的功能步骤。

对于我们的下一个功能,我们希望用户能够通过 UI 删除博客帖子。我们将向博客详情页添加一个 delete 链接,当点击时会对 API 发送 DELETE 请求。应用程序的当前状态仅允许通过向 API 发送 DELETE 请求或直接在数据库中直接删除博客帖子。我们可以更新之前的测试以在创建后执行删除新博客帖子的操作,如下所示:

    cy.findByRole('link', { name: post.title }).click()
    cy.findByText(/delete post>/i).click()
    cy.findByRole('link', { name: post.title 
     }).should('not.exist')

在前面的代码中,首先,我们点击要删除的博客帖子的标题以导航到其详情页。接下来,我们找到并点击带有文本 delete post 的链接。最后,我们验证帖子不再在 主页 上的博客帖子列表中。当我们通过保存文件来触发测试运行时,我们得到以下输出:

图 7.16 – 博客流程删除帖子测试失败

图 7.16 – 博客流程删除帖子测试失败

在上一张截图,输出指示测试在步骤 delete post 找不到时失败。我们可以通过创建缺失的元素来更新 UI:

<a onClick={handleDelete}>Delete post&#62;</a>;

在前面的代码中,我们添加了一个带有文本 Delete posthyperlink 元素。当点击超链接时,它会调用 handleDelete 方法向 API 发送 DELETE 请求,并最终从数据库中删除博客帖子。当我们保存测试文件以触发测试运行时,我们得到以下输出:

图 7.17 – 博客流程删除帖子完成重构

图 7.17 – 博客流程删除帖子完成重构

在上一张截图,输出指示测试最终通过,博客帖子已被删除。通过添加 delete 链接,我们已经完成了所有允许用户通过 UI 删除博客帖子的功能步骤。现在你知道如何使用 Cypress 驱动的开发来开发功能。

当你希望在构建功能时看到应用程序的特定状态时,这种方法可能是有益的。在下一节中,我们将介绍 Cypress 设计模式。

使用 Cypress 设计模式编写测试

在上一节中,我们学习了如何使用 Cypress 驱动博客应用程序新功能的开发。在本节中,我们将探讨两种设计模式来结构我们的 Cypress 代码。设计模式通过提供解决方案来帮助团队解决诸如编写可维护的代码或设计响应式网站等问题。首先,我们将查看页面对象模型(Page Object Model),然后是自定义命令。

在 Cypress 中创建页面对象

应用程序中每个页面的 class 表示,包括用于选择和与各种页面元素交互的自定义方法。使用 POM 模型的优势在于将多行测试代码抽象到单个方法中。

此外,页面对象作为在特定页面上执行操作的单一真相来源。在 Cypress 驱动的开发 部分,我们添加了一个功能,允许用户通过 UI 创建新的博客文章。我们可以使用 POM 模式重构测试代码。首先,我们将为 主页 创建一个页面对象:

class HomePage {
  navigateToHomePage() {
    cy.visit('/')
  }
  navigateToAddPage() {
    cy.findByRole('link', { name: /new post/i }).click()
  }
  getBlogPost(post) {
    return cy.findByRole('link', { name: post.title })
  }
}
export const homePage = new HomePage()

在前面的代码中,首先,我们创建了 navigateToHomePagenavigateToAddPagegetBlogPost 方法的页面对象。然后,我们导出了一个新实例以供测试文件使用。接下来,我们将为 添加 页创建一个页面对象:

class AddPage {
  createNewPost(newPost) {
    cy.findByRole('textbox', { name: /title/i 
     }).type(newPost.title)
    cy.findByRole('textbox', { name: /category/i 
     }).type(newPost.category)
    cy.findByRole('textbox', { name: /image link/i 
     }).type(newPost.image_url)
    cy.findByRole('textbox', { name: /content/i 
     }).type(newPost.content)
    cy.findByRole('button', { name: /submit/i }).click()
  }
}
export const addPage = new AddPage()

在前面的代码中,我们为 createNewPost 方法创建了一个页面对象,该方法接受一个包含要输入的新文章数据的 newPost 对象。页面对象被导出以供测试文件使用。现在,我们有了代表 主页添加 页面的页面对象,我们可以在测试中使用它们:

import post from '../support/generateBlogPost'
import { addPage } from './pages/AddPage'
import { homePage } from './pages/HomePage'

在前面的代码中,首先,我们导入了模拟的 post 方法以在测试中生成唯一的文章数据。接下来,我们导入了 addPagehomePage 页面对象。接下来,我们将编写主要的测试代码:

      it('POM: allows a user to create a new blog post', ()
       => {
    homePage.navigateToHomePage()
    homePage.navigateToAddPage()
    addPage.createNewPost(post)
    homePage.getBlogPost(post).should('be.visible')
  })

在前面的代码中,首先,我们导航到 post 方法。最后,我们在 主页 上获取新的文章并验证它在屏幕上可见。

Cypress 驱动的开发 部分,我们添加了另一个功能,允许通过 UI 删除博客文章。我们可以在页面对象中添加一个用于此功能的方法,并验证测试的行为。首先,我们将向 homePage 页面对象添加一个新方法:

navigateToPostDetail(post) {
 cy.findByRole('link', { name: post.title }).click()
}

在前面的代码中,我们添加了一个 navigateToPostDetail 方法,该方法在调用时接受一个 post 参数。接下来,我们将为 文章详情 页创建一个页面对象:

class PostDetailPage {
  deletePost() {
    cy.findByText(/delete post>/i).click()
  }
}
export const postDetailPage = new PostDetailPage()

在前面的代码中,我们为 deletePost 方法创建了一个页面对象。我们还导出了一个页面对象的实例以供测试使用。现在,我们可以在现有的测试中使用新的页面对象方法:

import { postDetailPage } from './pages/PostDetailPage'

在前面的代码中,首先,我们以与其他页面对象类似的方式导入了 postDetailPage 页面对象。接下来,我们将添加删除文章的相关方法:

    homePage.navigateToPostDetail(post)
    postDetailPage.deletePost()
    homePage.getBlogPost(post).should('not.exist')

在前面的代码中,我们调用了 navigateToPostDetaildeletePost 方法,并验证该文章不再出现在 主页 上。现在,我们将测试代码重构为页面对象的任务已经完成。我们的测试代码更短,并且抽象了许多测试步骤的细节。

然而,如果我们把 添加博客文章删除博客文章 功能拆分成两个不同的测试,我们的页面对象设计就会存在一个问题。第一个测试将创建一个博客文章:

  it('POM: allows a user to create a new blog post', () => {
    homePage.navigateToHomePage()
    homePage.navigateToAddPage()
    addPage.createNewPost(post)
    homePage.getBlogPost(post).should('be.visible')
  })

在前面的代码中,测试 'POM: 允许用户创建新的博客文章' 创建了一个博客文章。接下来,我们将创建删除博客文章的测试:

  it('POM: allows a user to delete a new blog post', () => {
    homePage.navigateToHomePage()
    homePage.navigateToAddPage()
    addPage.createNewPost(post)
    homePage.navigateToPostDetail(post)
    postDetailPage.deletePost()
    homePage.getBlogPost(post).should('not.exist')
  })

在前面的代码中,测试 'POM: 允许用户删除一篇新的博客文章' 用于删除博客文章。删除 测试的问题是我们必须从上一个测试中复制许多相同的测试步骤以及最重要的测试动作来删除文章。作为测试的最佳实践,我们希望避免在多个测试中重复编写相同的测试步骤。

在下一节中,我们将学习如何通过自定义 Cypress 命令来解决此问题。

在 Cypress 中创建自定义命令

在前一节中,我们学习了如何使用 POM 模式编写测试。然而,我们遇到了一个问题,即我们必须在不同的测试中编写相同的测试步骤。Cypress 提供了一个自定义命令功能来解决此问题。自定义命令允许我们向 Cypress 添加额外的命令。在 使用 Cypress 测试库增强 Cypress 命令 部分,我们添加了第三方自定义命令。现在我们将学习如何编写我们自己的自定义命令。首先,我们将创建一个自定义方法来创建新的博客文章:

Cypress.Commands.add('createBlogPost', post => {
  cy.visit('/')
  cy.findByRole('link', { name: /new post/i }).click()
  cy.findByRole('textbox', { name: /title/i 
   }).type(post.title)
  cy.findByRole('textbox', { name: /category/i 
   }).type(post.category)
  cy.findByRole('textbox', { name: /image link/i 
   }).type(post.image_url)
  cy.findByRole('textbox', { name: /content/i 
   }).type(post.content)
  cy.findByRole('button', { name: /submit/i }).click()
   })

在前面的代码中,我们通过 commands.js 文件内的 Commands.add 方法向 Cypress 添加了一个自定义的 createBlogPost 命令。接下来,我们将在我们的测试中使用这个自定义方法:

  it('Custom Command: allows a user to delete a new blog 
    post', () => {
    cy.createBlogPost(post)
    homePage.navigateToPostDetail(post)
    postDetailPage.deletePost()
    homePage.getBlogPost(post).should('not.exist')
  })

在前面的代码中,我们用我们创建的自定义 createBlogPost 方法替换了创建新博客文章的先前代码。自定义方法消除了显式编写相同的代码行来创建博客文章的需要。我们可以在需要时在未来的任何测试中使用自定义方法。然而,对于我们的特定测试,即删除博客文章,我们可以更进一步。

尽管我们的自定义 createBlogPost 方法消除了编写重复代码行的需要,但我们仍然在通过 UI 执行相同的步骤来创建新的博客文章。在多个测试中执行相同的步骤是糟糕的测试实践,因为我们正在重复已经测试过的步骤。如果我们能够控制访问我们应用程序的 API,我们可以通过 UI 减少重复步骤。

Cypress 提供了一个 HTTP 客户端,我们可以用它直接与 API 通信。使用 客户端,我们可以绕过 UI 以避免重复已测试的步骤并加快我们的测试速度。我们可以这样重构我们的自定义 createBlogPost 方法:

  cy.request('POST', '/api/add', post).then(response => {
    expect(response.body.message).to.equal(
      `The blog "${post.title}" was successfully added`
    )
  })

在前面的代码中,我们使用 request 方法向 /api/add API 发送一个 POST 请求,并发送一个包含新文章值的 post 对象。然后我们断言服务器返回消息 The blog "blog title here" was successfully added,表示新文章已添加到数据库中。注意,消息中的 "blog title here" 将在请求时被替换为实际的博客文章标题。现在我们可以更新我们的测试代码:

    cy.createBlogPost(post)
    homePage.navigateToHomePage()
    homePage.navigateToPostDetail(post)
    postDetailPage.deletePost()
    homePage.getBlogPost(post).should('not.exist')

在前面的代码中,我们的测试看起来几乎与上一个版本相同。唯一的区别是实现了createBlogPost方法,并添加了navigateToHomePage方法。然而,现在测试将运行得更快,因为我们跳过了通过 UI 创建新的博客帖子。尽管我们在本节中使用了 POM 模式以及自定义命令,但应注意的是,我们完全可以仅使用自定义命令。

我们只需要在一个独特的测试中测试添加博客帖子删除博客帖子功能,以增加它们按预期为用户工作的信心。如果标记为关键用户流程,这些测试可以在回归测试套件中再次运行,以确保在添加新功能时这些功能仍然正常工作。我们可以编写 Cypress 命令直接与应用程序交互,而不使用 POM 模式,并在需要重新执行相同步骤的情况下使用自定义命令。

现在你已经知道了如何通过实现 POM 模式(Page Object Model)和自定义 Cypress 命令来构建可维护的测试代码,并减少重复步骤。

在下一节中,我们将通过测试我们的应用程序的 API 路由来加深我们对 Cypress request client的了解。

使用 Cypress 测试 API

在前面的章节中,我们学习了如何使用 POM 和自定义命令设计模式来构建测试代码。我们还了解到可以使用 Cypress 直接与我们的应用程序 API 交互。在本节中,我们将基于前一节的所学知识,测试在“Cypress 驱动开发”部分中先前引入的博客应用程序的 API。

博客应用程序接受四个 API 请求:一个用于获取所有帖子的GET请求,一个用于添加帖子的POST请求,一个用于获取单个帖子的POST请求,以及一个用于删除帖子的DELETE请求。首先,我们将测试获取所有帖子的GET请求:

import fakePost from '../support/generateBlogPost';
  const post = fakePost()

const getAllPosts = () => cy.request('/api/posts').its('body.
posts');
const deletePost = (post) =>
  cy.request('DELETE', `/api/delete/${post.id}`, {
    id: post.id,
    name: post.title,
  });

const deleteAllPosts = () => getAllPosts().each(deletePost);
beforeEach(deleteAllPosts);

在前面的代码中,首先,我们导入用于为每个测试运行生成动态帖子数据的fakePost方法,并将其分配给变量post。接下来,我们创建了三个测试设置方法:getAllPostsdeletePostdeleteAllPosts。在每个测试运行之前,我们希望从一个空数据库开始。

deleteAllPosts方法将通过getAllPosts从数据库获取所有当前帖子,getAllPosts调用deletePost来删除每个帖子。最后,我们将deleteAllPosts传递给beforeEach,这样在每个测试运行之前都会调用deleteAllPosts。接下来,我们将编写获取所有帖子请求的主要代码:

    cy.request('POST', '/api/add', {
      title: post.title,
      category: post.category,
      image_url: post.image_url,
      content: post.content
    })
    cy.request('/api/posts').as('posts')
    cy.get('@posts').its('status').should('equal', 200)
    cy.get('@posts').its('body.posts.length').should('equal', 
  1)

在前面的代码中,我们首先使用request方法向 API 添加一个新的博客帖子以保存到数据库中。接下来,我们使用request从数据库中获取所有帖子。由于我们在测试之前清空了数据库,我们应该从数据库中接收到我们刚刚创建的那个博客帖子。

我们使用 as 方法,这是一个 Cypress 功能,允许我们将代码行保存为别名。然后,我们使用 get 方法通过在别名名称前使用所需的 @ 符号来访问别名,以验证 API 服务器响应的状态码是 200。最后,我们断言 posts 体的长度为 1。接下来,我们将测试 创建新博客帖子 请求:

    cy.request('POST', '/api/add', post).as('newPost')
    cy.get('@newPost').its('status').should('equal', 200)
    cy.get('@newPost')
      .its('body.message')
      .should('be.equal', `The blog "${post.title}" was 
        successfully added`)

在前面的代码中,首先,我们创建了一个新的博客帖子并将其结果保存为名为 newPost 的别名。然后,我们验证 API 响应状态码为 200,并且响应消息是 "The blog "title here" was successfully added",其中 "title here" 将等于测试中的实际标题。接下来,我们将测试 删除帖子 请求:

    cy.request('POST', '/api/add', post)
getAllPosts().each(post =>
  cy
    .request('DELETE', `/api/delete/${post.id}`, {
      id: post.id,
      title: post.title
    })
    .then(response => {
      expect(response.status).equal(200)
      expect(response.body.message).equal(
        `post "${post.title}" successfully deleted`
      )
    })
)

在前面的代码中,我们添加了一个新的帖子,类似于我们在之前的测试中所做的。然后,我们使用 getAllPosts 请求所有当前帖子,这里只有一个,并对每个帖子发送一个 DELETE 请求从应用程序中删除。然后,我们验证 API 发送的状态码为 200,表示删除成功。

最后,我们验证 API 发送了一个提供文本确认帖子已被删除的响应消息。对于最后的测试,我们将验证 获取单个帖子 请求:

    cy.request('POST', '/api/add', post)
    getAllPosts().each(post =>
      cy
        .request(`/api/post/${post.id}`)
        .its('body.post.title')
        .should('equal', post.title)
    )
  })

在前面的代码中,首先,我们创建了一个与之前测试类似的新帖子。然后,我们获取所有帖子并验证从 API 返回的 title 与创建的帖子的 title 匹配。现在你知道如何使用 Cypress 测试 API 了。了解到 Cypress 提供了在同一个框架中执行 API 和 UI 的端到端测试功能,真是太好了。

在下一节中,我们将学习如何使用 Cucumber 创建 Gherkin 风格的测试场景。

使用 Cucumber 编写 Gherkin 风格的测试

在上一节中,我们学习了如何使用 Cypress 测试 API 响应。在本节中,我们将学习如何使用 Cucumber 创建 Gherkin 风格的测试。Gherkin 是一种行为驱动开发语言,由 Cucumber 使用,以纯英文格式描述测试场景的行为。用 Gherkin 编写的测试也使得软件团队更容易与业务领导沟通,并为测试案例提供上下文。

Gherkin 使用以下关键字:FeatureScenarioGivenWhenThenFeature 用于描述要构建的事物,例如登录页面。Scenario 描述了该功能的用户流程。例如,用户可以输入用户名、密码并点击 登录 以导航到其个人资料页面。

GivenWhenThen 关键字描述了不同阶段的场景。我们可以这样为登录功能编写一个完整的 Gherkin 测试:

Feature: Login
  Scenario: A user can enter a username, password, and 
    click login to navigate to their profile page.
    Given I am on the login page
    When I enter a username
    When I enter a password
    When I click "login"
    Then I am navigated to my profile page

在前面的代码中,我们为登录功能创建了一个 Gherkin 测试。我们可以使用 cypress-cucumber-preprocessor 插件使用 Cypress 编写 Gherkin 风格的测试。使用以下命令安装插件:

npm install --save-dev cypress-cucumber-preprocessor

之前的命令将 cucumber 插件作为开发依赖项安装到您的项目中。一旦插件安装完成,我们就可以为我们的 Cypress 项目配置它:

const cucumber = require('cypress-cucumber-
  preprocessor').default
module.exports = (on, config) => {
  on('file:preprocessor', cucumber())
}

在前面的代码中,我们将 cucumber 插件添加到 Cypress 插件文件中。现在,我们可以在测试中使用 cucumber 插件的功能。接下来,我们将添加插件的 feature 文件类型到我们的全局配置文件:

{
  "testFiles": "**/*.feature"
}

在前面的代码中,我们配置 Cypress 使用具有 feature 扩展名的文件作为测试文件。接下来,我们将向 package.json 文件中添加一个部分,专门用于加载项目中 cucumber 插件的配置,并告诉插件在哪里找到我们的功能文件:

  "cypress-cucumber-preprocessor": {
    "nonGlobalStepDefinitions": true,
    "stepDefinitions": "./cypress/e2e"
  }

在前面的代码中,我们在 package.json 文件中添加了必要的配置代码。现在 Cucumber 已在我们的项目中配置,我们将使用它来编写针对之前在 Cypress 驱动的开发 部分中介绍的博客应用程序创建和删除博客文章的用户流程的测试。首先,我们将创建一个功能文件:

Feature: Blog Application
  Scenario: A user can create a blog post. 
    Given I am on the home page
    When I click the "New Post" link
    When I fill out the new blog form
    When I click "Submit"
    Then I see the new post on the home page

在前面的代码中,我们为用户创建博客文章的场景创建了一个功能文件。接下来,我们将编写与 Gherkin 步骤相关的代码:

import { Given, Then, When } from 'cypress-cucumber-
  preprocessor/steps'
import post from '../../support/generateBlogPost'
const currentPost = post
Given('I am on the home page', () => {
  cy.visit('/')
})
When('I click the "New Post" link', () => {
  cy.findByRole('link', { name: /new post/i }).click()
})

在前面的代码中,首先,我们从 Cypress Cucumber 库中导入 GivenThenWhen 方法。接下来,我们导入假的 post 方法以生成测试数据。由于每个测试步骤都将存在于它自己的方法中,我们将 fake post 数据存储起来以保持测试过程中相同的帖子。然后,我们使用 Given 方法创建第一个测试步骤。步骤名称:我在主页上 必须与功能文件中的相同单词匹配。在 Given 方法内部,我们编写与步骤相关的 Cypress 代码。接下来,使用 When 方法创建下一个步骤。接下来,我们将添加以下步骤定义:

When('I fill out the new blog form', () => {
  cy.findByRole('textbox', { name: /title/i 
   }).type(currentPost.title)
  cy.findByRole('textbox', { name: /category/i 
   }).type(currentPost.category)
  cy.findByRole('textbox', { name: /image link/i 
   }).type(currentPost.image_url)
  cy.findByRole('textbox', { name: /content/i 
   }).type(currentPost.content)
})
When('I click "Submit"', () => {
  cy.findByRole('button', { name: /submit/i }).click()
})

在前面的代码中,我们使用 When 方法编写了与 我填写新博客表单我点击 "提交" 步骤相关的代码。最后,我们使用 Then 方法创建最终的步骤定义:

Then('I see the new post on the home page', () => {
  cy.findByRole('link', { name: currentPost.title 
   }).should('be.visible')
})

在前面的代码中,我们使用 Then 方法为 我在主页上看到新帖子 步骤创建相关的代码。我们将为下一个测试的 删除博客文章 用户流程创建一个 Cucumber 测试。

首先,我们创建 Gherkin 功能场景:

  Scenario: A user can delete a blog post. 
    Given I am on the home page
    When I click the blog post name link
    When I click the delete link
    Then the post is removed from the home page

在前面的代码中,我们创建了一个删除博客文章的场景。接下来,我们将编写相关的步骤定义:

When('I click the blog post name link', () => {
  cy.findByRole('link', { name: currentPost.title 
   }).click()
})
When('I click the delete link', () => {
  cy.findByText(/delete post>/i).click()
})

在前面的代码中,我们使用 When 方法为 我点击博客文章名称链接我点击删除链接 步骤添加相关的测试代码。最后,我们使用 Then 方法创建 帖子已从主页上移除 步骤:

Then('the post is removed from the home page', () => {
  cy.findByRole('link', { name: currentPost.title 
   }).should('not.exist')
})

在前面的代码中,我们将与最后一个步骤相关的测试代码添加到验证已删除的帖子是否从 我在主页上 步骤中移除。Cucumber 足够智能,可以使用与功能文件中文本字符串匹配的任何步骤定义。

现在,你已经知道了如何在 Cypress 中使用 Cucumber 编写 Gherkin 风格的测试。你可以用 Cucumber 做其他事情,比如添加标签来运行特定的测试,以及创建数据表,允许你为类似的 Gherkin 步骤测试多个参数。

使用 React Developer Tools 与 Cypress

在上一节中,我们学习了如何使用 Cucumber 编写测试。在本节中,我们将学习如何为开发安装 React Developer Tools。React Developer Tools 是开发 React 应用程序时非常有用的工具。它允许你检查 DOM 中渲染的组件层次结构,并执行诸如查看和编辑组件属性和状态等操作。有可用的 Chrome 和 Firefox 扩展程序来安装 React Developer Tools。还有一个独立的 Electron 应用程序版本,这在需要调试 Safari 或移动浏览器中的 React 应用程序时非常有用。我们还将学习如何使用 Cypress 与独立版本一起使用。

使用以下命令通过命令行安装:

npm install --save-dev react-devtools

前面的命令将在你的项目中将 react-devtools 安装为开发依赖项。接下来,我们需要添加一个将 react-devtools 连接到应用程序的脚本。如果你正在构建 Next.js 应用程序,请将特殊 <script src="img/localhost:8097"> 脚本安装到 _document.js 文件中的 Head 组件中:

<Head>
  <script src="img/localhost:8097"></script>
</Head>

在前面的代码中,我们将脚本添加到 Head 组件内部。该脚本确保 React Developer Tools 连接到你的 Next.js 应用程序。如果你正在使用 create-react-app 构建应用程序,请将特殊脚本 <script src="img/localhost:8097"> 安装在 public 文件夹中 index.html 文件的 head 元素中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="img/localhost:8097"></script>

在前面的代码中,我们将脚本作为 head 元素内的第一件事添加。我们需要记住在将应用程序部署到生产环境之前删除特殊的 react-devtools 脚本,因为它是一个开发工具,会给我们的生产版本应用程序添加不必要的代码。

在脚本添加后,接下来我们将创建一个 npm 脚本在 package.json 文件中,以启动工具:

"scripts": {
  "devtools": "react-devtools"

在前面的代码中,我们添加了一个 devtools 脚本来运行 react-devtools。现在我们有了运行工具的脚本,我们需要做的最后一件事是启动我们的应用程序、Cypress 交互工具和 react-devtools:每个都在命令行中的单独标签页。

对于 Next.js 应用程序,请使用以下命令:

npm run dev

我们运行了前面的命令来启动 Next.js 应用程序的开发模式。对于 create-react-app 应用程序,请使用以下命令:

npm start

我们运行了前面的命令来启动 create-react-app 应用程序的开发模式。在 使用 Cypress 入门 部分,我们创建了一个 "cy:open" 脚本来以交互模式启动 Cypress。我们可以这样运行脚本:

npm run cy:open

在前面的命令中,我们运行了脚本以启动 Cypress。接下来我们需要做的是运行 react-devtools 脚本:

npm run devtools

在前面的命令中,我们运行了脚本以启动 react-devtools。当运行时,react-devtools 在我们的电脑上打开其应用程序:

图 7.18 – React Developer Tools 应用程序

图 7.18 – React Developer Tools 应用程序

在前面的屏幕截图中,react-devtools 打开并监听我们的应用程序运行以连接到它。一旦我们通过交互模式运行任何 Cypress 测试,应用程序的组件树就会在 react-devtools 应用程序内部填充:

图 7.19 – React Developer Tools 组件树视图

图 7.19 – React Developer Tools 组件树视图

在前面的屏幕截图中,react-devtools 应用程序显示了正在运行的测试的结果组件树。随着应用程序的运行,我们有许多可用的工具,例如点击组件名称来查看相关信息:

![图 7.20 – React Developer Tools 组件详情图片

图 7.20 – React Developer Tools 组件详情

在前面的屏幕截图中,我们在 react-devtools 屏幕的左侧选择了一个 Link 组件。当我们点击该组件时,它会在 react-devtools 屏幕的右侧显示相关信息,例如 propshooks。我们还在屏幕的右侧看到了 Cypress 交互模式屏幕。

现在,你知道如何使用 Cypress 与 React Developer Tools。除了 Cypress 提供的调试工具外,你现在还有一个额外的工具来调试运行 Cypress 的 React 应用程序。

摘要

在本章中,你学习了使用 Cypress 测试应用程序的新策略。你可以编写端到端测试来验证应用程序的关键用户流程。你学习了如何实现 API 测试。你现在知道使用 Cypress 驱动的开发来创建新特性的好处。你理解了 POM 和自定义命令设计模式来构建和组织测试代码。

最后,你学会了如何使用 Cucumber 编写 Gherkin 风格的测试,以增强与非技术团队成员的沟通。

恭喜你,你已经到达了我们的旅程的终点,现在你了解了许多简化测试 React 应用程序的策略和工具!在这本书中学到的概念和技能将帮助你在未来处理任何 JavaScript 项目时编写高质量的代码。

祝你好运,并且始终记住,没有伟大的软件是建立在伟大的测试基础之上的。

问题

  1. 找到一个以前的项目,并使用 Cypress 安装并编写一系列端到端测试。

  2. 创建一个 CRUD API 并使用 Cypress 测试它。

  3. 构建一个全栈 React 应用程序,并尽可能多地使用本书中学到的不同策略编写测试。

第八章:答案

在这里,你可以找到每章末尾提供的问题的答案:

第一章

所有问题都是开放式回答。

第二章

  1. render方法。

  2. screen对象。

  3. 表示性组件。

  4. debug方法。

  5. 这是一个开放式回答问题。

第三章

  1. user-event模块与用户在 DOM 上执行操作时发生的事件非常相似,例如keydownkeyup事件。

  2. MSW 允许你测试向 API 发起 HTTP 请求的组件。这是通过在请求到达互联网之前拦截请求,并返回可控制的模拟数据以进行测试来实现的。

  3. 模拟函数是一个测试替身,用于进行断言。例如,我们可以使用模拟函数来验证当用户点击按钮时,方法是否被调用。

  4. 风险在于,用模拟版本替换实际依赖项来测试组件不允许你测试与真实生产依赖项集成时的结果行为。

  5. 这是一个开放式回答问题。

  6. 当你期望元素存在于 DOM 的当前状态时,请使用getBy*查询。当元素的存在取决于使元素在 DOM 中出现的延迟的异步操作时,请使用findBy*查询。当你想验证元素不在 DOM 中时,请使用queryBy*查询。

第四章

  1. 测试集成组件允许你在组件相互交互时通过验证生产行为来降低风险。使用隔离方法,我们会用假数据和响应替换真实依赖项,因此不能降低太多风险。此外,在许多情况下,使用集成测试方法,你可以用更少的测试覆盖更多的代码。

  2. 你应该只在其他首选查询方法,如getBy*findBy*无法用于选择元素时,才使用data-testid属性作为最后的手段。

  3. act方法确保你的测试行为更接近 React 更新浏览器 DOM 的方式。在需要手动进行组件更新的情况下使用act,例如解决 React 在测试中否则不会意识到的 Promise。React Testing Library 自动将组件包裹在act中,在大多数情况下,如点击事件,无需手动包裹所有更新组件的代码。

第五章

  1. 与 Enzyme 或 ReactTestUtils 不同,React Testing Library 允许你编写避免实现细节并从最终用户视角模拟 DOM 交互的测试。

  2. 在 Jest 的监视模式下运行测试允许你在快速添加新代码时了解回归何时发生。在监视模式下运行测试在使用 TDD 方法构建组件时也非常有益。

  3. 当你需要用不同的值多次执行相同的测试时,请使用each方法。

第六章

所有问题都是开放式回答。

第七章

所有问题都是开放式回答。

Image85643

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。

第九章:为什么订阅?

  • 使用来自超过 4,000 位行业专业人士的实用电子书和视频,花更少的时间学习,更多的时间编码

  • 通过为您量身定制的 Skill Plans 提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。

www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

其他您可能喜欢的书籍

如果您喜欢这本书,您可能对 Packt 出版的这些其他书籍也感兴趣:

![Mastering Adobe Photoshop ElementsUI Testing with Puppeteer(https://www.packtpub.com/product/ui-testing-with-puppeteer/9781800206786)

使用 Puppeteer 进行 UI 测试

Dario Kondratiuk

ISBN: 978-1-80020-678-6

  • 理解浏览器自动化的基础知识

  • 探索使用 Puppeteer 进行端到端测试及其最佳实践

  • 将 CSS 选择器和 XPath 表达式应用于网络自动化

  • 了解作为开发者如何利用网络自动化的力量

  • 模拟 Puppeteer 的不同用例,例如网络速度测试和地理位置

  • 掌握网络抓取和网络内容生成的技术和最佳实践

Mastering Adobe Captivate 2019 - Fifth Edition(https://www.packtpub.com/product/end-to-end-web-testing-with-cypress/9781839213854)

端到端 Web 测试与 Cypress

Waweru Mwaura

ISBN: 978-1-83921-385-4

  • 掌握 Cypress 并了解其相对于 Selenium 的优势

  • 探索用于编写完整 Web 应用测试的常见 Cypress 命令、工具和技术

  • 设置和配置 Cypress 进行跨浏览器测试

  • 理解如何与元素和动画一起工作,编写非故障测试

  • 了解在测试中实现和处理导航请求的技术

  • 使用 Applitools eyes 实现视觉回归测试

Packt 正在寻找像您这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一个一般性申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

留下评论 - 让其他读者了解你的想法

请通过在购买书籍的网站上留下评论的方式,与大家分享你对这本书的看法。如果你是从亚马逊购买的这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以通过你的客观意见来做出购买决定,我们也可以了解客户对我们产品的看法,我们的作者也可以看到他们对与我们合作创作的书籍的反馈。这只需你几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都是宝贵的。谢谢!

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报