React-测试库的测试简化指南-全-
React 测试库的测试简化指南(全)
原文:
zh.annas-archive.org/md5/78e0742bd6864a92b3933cdf20ea2c00
译者:飞龙
前言
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并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
在支持选项卡中选择。
-
点击代码下载。
-
在搜索框中输入书籍名称并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 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 中,我们可以使用 ReactDOM
的 render
方法将组件放入一个附加到 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 – 订阅表单组件
因为你想确保表单能正常工作,所以你会考虑潜在订阅者在与表单交互时可能采取的步骤。你心想,如果我是用户,首先,我可能会寻找一个名为“电子邮件地址”的标签或一个上面写着“输入电子邮件”的占位符。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 提供了 describe
、it
、test
和 expect
函数来组织和执行测试。你可以把 describe
函数看作是一个测试套件。使用 describe
函数来组织特定组件的相关测试。it
和 test
函数是针对特定测试的。it
和 test
函数是可以互换的函数,用于存放和运行单个测试用例的代码。使用 expect
函数来断言预期的输出。Jest 还提供了模拟函数来处理测试范围之外的代码和覆盖率报告。使用 Jest 编写的测试可以像测试纯函数的输出一样简单。
在以下示例中,我们使用 test
和 expect
函数来断言提供的名称中的字符总数:
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 – 配置组件
让我们看看 Profile
组件的代码。我们创建一个带有 showDetails
属性的 state
对象,初始值设置为 true
。接下来,我们创建一个 setDetails
方法,该方法将更新 showDetails
的 state
值:
import React, { Component } from 'react';
export default class Profile extends Component {
state = { showDetails: true };
setDetails = () => {
this.setState((prevState) => ({ showDetails: !prevState. showDetails }));
};
在 render
方法内部,我们显示传递给组件的 name
和 title
属性:
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
图像只是一个占位符图像,但理想情况下应该接受传递的值,例如 name
和 title
属性值。
最后,我们有一个按钮,当点击时将调用 setDetails
方法。按钮的文本设置为 Hide Details
或 Display 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
元素,稍后将与测试组件一起使用。然后,我们使用 ReactDOM
的 render
方法将带有传入属性的 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 部分。
特定测试的所有代码都位于 it
或 test
方法内部:
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 的项目中:
-
使用
npm
安装此包:npm install –-save-dev @testing-library/jest-dom
-
将以下片段添加到您的测试文件顶部:
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-dom
的 toBeInTheDocument()
方法重构我们的代码,使代码更具描述性:
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 – 登录组件
在前面的屏幕截图中,我们有一个登录表单功能,允许用户输入用户名和密码,并检查 disabled
状态,直到用户为 username
和 password
字段输入值。在这个例子中,登录表单目前仍在开发中,所以目前当用户点击 登录 按钮时没有任何操作。然而,我们可以编写一个测试来验证当用户输入凭据时,登录 按钮被启用:
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
元素来设置我们的测试。接下来,我们获取所有表单元素,包括 username
、password
、rememberMe
和 login
按钮,并将它们放入变量中。
接下来,我们将对 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
对象,并赋予其值以用于测试。接下来,我们使用fireEvent
向username
和password
字段添加值,并最终点击rememberMe
复选框。然后,我们将进行断言:
expect(loginBtn.hasAttribute('disabled')).toBe(false);
在前面的代码中,我们断言loginBtn
有一个设置为false
的disabled
属性。然而,我们可以使用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
组件的username
和password
凭证。我们还可以断言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 – 登录组件假阴性测试
在前面的屏幕截图中,结果显示测试在期望为true
的点接收到了一个假值。结果是假阴性,因为源代码是正确的,但我们的测试代码是错误的。
错误信息并没有明确指出断言代码中存在错别字,导致我们的测试没有收到预期的结果。我们可能会通过错误地使用not
属性与jest-dom
的toBeDisabled
方法进行类似的测试代码错误:
expect(loginBtn).not.toBeDisabled();
fireEvent.change(username, { target: { value: fakeData.username } });
fireEvent.change(password, { target: { value: fakeData.password } });
fireEvent.click(rememberMe);
在前面的代码中,我们错误地断言在测试中应该被禁用的登录按钮没有被禁用。前面代码中的测试代码错误会导致以下测试结果输出:
图 1.6 – 第二个登录组件假阴性测试
在前面的屏幕截图中,结果显示测试失败,但我们还收到了有助于定位错误并在调试时提供反馈的有用信息。测试输出告诉我们接收到的元素已被禁用,并将元素记录到控制台以查看所有属性。disabled
属性在element
输出中显示,这有助于我们理解我们需要调试测试代码以了解为什么我们没有收到预期的结果。
现在你已经知道了jest-dom
的方法如何提供更好的上下文特定错误消息以更快地解决问题。在下一节中,我们将了解在测试中包含实现细节的缺点。
测试实现细节
实现细节包括组件状态当前值或显式调用 DOM 中按钮附加的方法。实现细节是当用户使用组件时从用户抽象出来的组件的内部部分。作为一个类比,我们可以想到驾驶汽车的经历。为了移动,你必须使用钥匙启动汽车,将汽车置于驱动状态,并踩下油门。你不需要知道车辆引擎盖下的一切是如何连接的。你可能甚至不关心。你唯一关心的是,当你执行上述行为时,你可以驾驶汽车。
在本节中,你将探索在测试中关注实现细节的缺点。我们将向你展示包含实现细节的测试示例。最后,你将了解如何在测试时将注意力从考虑实现细节中转移开。
关注实现细节的测试的问题
当你编写关注代码内部细节的测试时,你会创建一个场景,增加你每次更改这些细节时重构测试的机会。例如,如果你有一个状态对象属性名为value
,并且编写一个测试来断言state.value === 3
,那么当将状态属性名称更改为currentValue
时,该测试将失败。让你的测试代码依赖于状态对象属性名称是一个问题,因为它增加了大量的不必要的额外维护并减慢了你的工作流程。
另一个问题在于执行这个测试用例会产生一个错误的阴性结果,因为其功能并没有改变;只是状态名称发生了变化。你的测试应该让你对与应用程序中与用户行为相关的最有价值部分按预期工作充满信心,并迅速让你知道为什么这不是这种情况。
从最有价值的测试者角度——即实际用户的角度——测试实现细节并不能验证应用程序代码。例如,如果你构建了一个账户创建表单组件并将其部署到生产环境中,与表单通过 UI 交互的最终用户将关注填写表单并点击onChange
方法或当用户输入新文本时对状态对象属性usernameVal
的更新。
然而,如果你测试当用户填写表单并点击提交按钮时,预期的结果会发生,那么你可以降低用户的风险。用户不会直接与方法和状态对象交互;因此,我们的测试可以通过关注用户如何在 UI 中与表单交互而更有价值。
在另一个示例中,使用相同的组件,一个软件工程师是一个用户,他将在应用程序代码中添加账户创建表单以及所需的依赖项。工程师用户关心当他们尝试使用组件时,组件是否按预期渲染。同样,你可以测试第一个示例中提到的相同实现细节。
然而,如果你测试当工程师使用所需数据渲染表单时数据是存在的,那么你可以更有信心地认为组件将按预期为用户工作。记住,这并不意味着你应该永远不测试代码的实现细节。在大多数情况下,关注用户的测试比关注实现细节的测试更有信心。
接下来,我们将展示一个测试实现细节的示例,以进一步说明这一点。
集中于实现细节的测试示例
让我们通过一个示例测试来进一步说明测试组件实现细节的问题。这个测试将断言使用类式 React 组件创建的Profile
组件的细节。Profile
组件接受员工信息,并在 DOM 中以卡片式元素的形式显示。用户可以点击按钮来在屏幕上隐藏或显示员工的详细信息。以下是组件 DOM 输出的截图:
图 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
方法内部,我们显示传递给组件的name
和title
属性:
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
图像只是一个占位符图像,但理想情况下应该接受传递的值,例如name
和title
属性值。
最后,我们有一个按钮,当点击时会调用setDetails
方法。按钮的文本根据state.showDetails
的值设置为Hide Details
或Display 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 – 计数器组件
尝试想出尽可能多的软件工程师或最终用户在使用表单时可能执行的黑盒场景。
这里有一些示例场景:
-
当
Counter
组件被渲染时,计数器在 DOM 中显示。 -
当用户点击添加按钮时,当前值增加 1。
-
当用户点击减去按钮时,当前值减少 1。
这些场景确保与关注状态变化或方法调用等事物相比,我们的应用程序按预期为用户工作。
现在您知道如何摆脱以实现细节为重点的测试用例,而是专注于实际用户。我们已经在本章的前几节中看到了许多应用用户关注测试方法的例子。
摘要
在本章中,你了解了 DOM Testing Library 以及它是如何设计来帮助你编写以用户为中心的测试的。你现在理解了 DOM Testing Library 的设计如何帮助你获得信心,确保你的软件按预期为用户工作。你学习了如何安装 Jest,并理解它是一个测试运行器,我们将使用它来测试 React 代码。你了解了 jest-dom
。你知道它可以为你的测试断言添加更好的错误消息和描述性的 DOM 匹配器。你现在可以在使用 Jest 的项目中安装和使用 jest-dom
。最后,你对实现细节为中心的测试的缺点有了理解。
在下一章中,我们将学习如何使用 React Testing Library 安装和开始编写 React 组件的测试。
问题
-
安装本章中提到的所有工具,并编写一个简单的测试。
-
在线搜索关注实现细节的测试示例。识别所有实现细节,并使用 DOM Testing Library 创建测试的重构版本。
-
在 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-event
和 jest-dom
工具。我们之前在 第一章,探索 React 测试库 中介绍了 jest-dom
。我们将在 第三章,使用 React 测试库测试复杂组件 中介绍 user-event
工具。
因此,如果你至少使用 create-react-app
的 3.3.0 版本,你将获得一个带有 React 测试库、user-event
和 jest-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
对象公开了许多方法,例如getByText
或getByRole
,用于查询 DOM 中的元素,类似于我们可以在测试中使用的实际用户。例如,我们可能有一个渲染以下 DOM 输出的组件:
图 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 组件测试结果
在上一张截图,结果显示显示标题测试通过。现在你知道如何使用 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 – 旅行组件
由于该组件仅显示几行静态硬编码的文本,因此它是一个很好的快照测试候选者。在以下示例中,我们使用快照测试来测试 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
组件。然后,我们使用对象解构从渲染的组件中获取 container
。container
代表组件的最终 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 – 失败的旅行快照测试
上一张截图显示测试失败,并显示了哪些行发生了变化。"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>
在前面的代码片段中,该组件有一个包含每个员工 Name
、Department
和 Title
标题的表格。以下是表格体:
<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 – 表格组件
Table
组件显示与预期对象数组形状匹配的员工行,该数组具有 Name、Department 和 Title 属性。我们可以测试该组件是否正确接受并显示 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-dom
的 toHaveAttribute
断言方法:
it('has the correct class', () => {
render(<Table employees={fakeEmployees} />)
expect(screen.getByRole('table')).toHaveAttribute(
'class',
'table table-striped'
)
})
在前面的代码片段中,我们创建了一个测试来验证表格组件具有正确的类属性。首先,我们使用员工渲染 Table
组件。接下来,我们使用 screen
对象的 getByRole
方法选择 table
元素。最后,我们断言组件具有值为 table table-striped
的 class
属性。通过使用 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)方法来驱动测试的创建。
问题
-
用来将 React 组件放置到 DOM 中的方法是什么?
-
命名那个附加了查询 DOM 元素方法的对象。
-
哪些类型的组件适合进行快照测试?
-
用于记录组件 DOM 输出的方法是什么?
-
创建并测试一个接受对象数组作为 props 的展示组件。
第三章:使用 React Testing Library 测试复杂组件
在第二章 使用 React Testing Library 工作中,我们学习了如何测试表现性组件。然而,大多数功能都是设计来允许用户操作,这些操作会导致状态和结果的改变。在将代码发送到生产环境供最终用户使用之前,测试尽可能多的用户操作场景对于降低风险至关重要。在本章结束时,你将学习如何使用fireEvent
和user-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 提供了两个库来模拟用户操作,即fireEvent
和user-event
,我们将在接下来的章节中看到。
使用fireEvent
模块模拟用户操作
我们可以使用fireEvent
模块来模拟用户在组件生成的 DOM 输出上的操作。例如,我们可以构建一个可重用的Vote
组件,渲染以下 DOM 输出:
图 3.1 – Vote 组件
在前面的截图上,数字10代表点赞评分。我们有两个按钮,用户可以点击它们来投票并更改点赞评分:一个点赞按钮和一个踩按钮。还有一个免责声明告知用户他们只能投票一次。当用户点击点赞按钮时,他们将看到以下输出:
图 3.2 – 点赞按钮投票
在前面的截图上,点赞评分从10增加到11。当用户点击踩按钮时,他们将看到以下输出:
图 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 中导入fireEvent
、render
和screen
方法。接下来,我们导入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
。接下来,我们使用fireEvent
的click
方法点击名为thumbs up
的按钮。然后,我们断言文档中totalGlobalLikes
的值更新为11
。最后,我们断言点赞按钮的背景色已变为绿色。
在许多情况下,使用 fireEvent
是完全可行的。然而,它确实有一些限制。例如,当用户执行诸如在输入框中输入文本等操作时,会发生许多事件,例如 keydown
和 keyup
。现在,fireEvent
有方法来实现这些单个动作,但它没有一种方法可以按顺序一起处理它们。
接下来,我们将学习如何使用 user-event
库来解决 fireEvent
模块的局限性。
使用 user-event
模拟用户操作
user-event
库是 fireEvent
的增强版本。在上一节中,我们了解到 fireEvent
有方法来模拟用户在输入框中输入文本时发生的各种事件。user-event
库有许多方法,例如 click
或 type
,可以自动模拟用户在 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-event
的 click
方法点击了 点赞
按钮。我们的测试提供了更多的价值,因为我们更接近地模拟了用户的 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 – 投票组件测试结果
上一张截图显示了 Vote.test.js
文件。
在另一个例子中,我们可能会为员工创建一个接受他们名字的输入组件:
![图 3.5 – 员工电子邮件输入
图 3.5 – 员工电子邮件输入
当员工输入他们的名字时,组件将其追加到公司的网站名称,并将结果显示在屏幕上:
图 3.6 – 完成的员工电子邮件输入
如果员工输入由空格分隔的姓名和姓氏,则姓名会与一个 .
连接:
图 3.7 – 连接的员工电子邮件输入
我们可以使用 user-event
的 type
方法模拟在员工电子邮件组件中输入,并对结果进行断言,如下所示:
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()
})
在前面的代码中,我们导入了 render
、screen
和 user-event
模块。然后,我们导入了 EmployeeEmail
组件。我们在屏幕上渲染该组件。然后,我们获取输入元素并将其存储在变量 input 中。接下来,我们使用 user-event
中的 type
方法将 jane doe
输入到输入框中。最后,我们断言文本 jane.doe@software-plus.com
在 DOM 中。
当我们运行测试时,我们会得到以下输出,表明场景按预期通过:
图 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
组件,它接受 handleVote
、hasVoted
、imgSrc
和 altText
属性,这些属性通过 props
对象传递。父组件会向下传递这些属性。对于本节的目的,我们的主要关注点是 handleVote
属性。当点击按钮时,由于点击事件触发,会调用 handleVote
方法。当此方法在 Vote
组件内部运行时,结果是更新 totalGlobalLikes
的本地版本。按钮的最终屏幕输出如下:
图 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 中导入render
和screen
方法。然后,我们导入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 – 投票按钮组件测试结果
上一张截图显示了VoteBtn.test.js
文件。需要注意的是,尽管我们能够验证事件处理器被调用,但我们无法确认按钮在被点击后状态是否变为禁用。我们需要包含父组件并编写集成测试来验证该行为。我们将在第四章中学习如何处理这些场景,应用程序中的集成测试和第三方库。
现在你已经知道了如何使用测试替身测试隔离组件中的事件处理器。在本节中,我们学习了如何模拟和测试用户交互。我们学习了如何使用fireEvent
和user-event
来模拟动作。我们还学习了如何使用测试替身来测试事件处理器。在本节中学到的技能将有助于你在下一节中学习如何测试与 API 交互的组件。
测试与 API 交互的组件
本节将基于我们之前章节中学到的测试事件处理器的知识,通过查看如何测试发送和接收 API 数据的组件来构建。在我们的组件单元测试中,我们可以通过使用充当测试替身的工具来代替真实 API,从而减少应用风险。使用测试替身代替实际 API,我们可以避免缓慢的互联网连接或接收导致不可预测测试结果动态数据。
我们将学习如何安装和使用模拟服务工作者(MSW)作为测试替身,在测试中捕获组件发起的 API 请求并返回模拟数据。我们将测试一个用于用户从 API 搜索饮料数据的组件。我们还将学习如何将 MSW 用作开发服务器。本节中的概念将帮助我们了解如何验证前端和 API 服务器之间的通信。
使用 fetch 请求 API 数据
我们可以创建一个组件,允许用户从 TheCockTailDB (www.thecocktaildb.com
)搜索饮料,TheCockTailDB 是一个免费的开源服务,将扮演后端 API 的角色。我们的组件将访问该服务并请求数据。当组件首次渲染时,用户会看到一个输入字段和一个搜索按钮:
图 3.11 – 饮料搜索组件
当用户搜索饮料时,API 返回类似以下的数据:
图 3.12 – 饮料搜索结果
在前面的屏幕截图中,用户搜索了gin
并从 API 收到了一系列结果。如果用户搜索的饮料没有返回结果,屏幕上会显示没有找到饮料的消息:
图 3.13 – 没有饮料搜索结果
如果用户尝试搜索,但 API 服务器不可访问,则显示服务不可用的消息:
图 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'
}
]
在前面的代码块中,我们导入了 rest
从 msw
。rest
对象允许我们指定 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 导入 render
和 screen
。然后,我们导入 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 – 无饮料搜索失败的测试结果
前面的截图显示了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 – 无饮料搜索通过测试结果
上述截图显示了 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
中导入了rest
和setupWorker
。在本章的“使用 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
,所以我们只需要使用npm
的start
脚本来启动应用程序,这在构建create-react-app
应用程序时很典型。
现在你已经知道如何使用 MSW 创建模拟服务器来测试请求 API 数据的组件。你还创建了一个 MSW 服务器,在开发环境中以假响应进行响应。此外,你现在知道何时在getBy*
查询之外使用findBy*
和queryBy*
查询。
在本节中,我们学习了如何安装和使用 MSW。我们测试了一个用于从 API 搜索饮料数据的组件。最后,我们学习了如何将 MSW 用作开发服务器。接下来,我们将学习如何使用测试驱动开发方法编写测试。
实施测试驱动开发
测试驱动开发(TDD)意味着先编写单元测试,然后构建代码以通过测试。TDD 方法允许你思考代码是否适合你想要编写的测试。这个过程提供了一个关注最少代码以使测试通过的角度。TDD 也被称为红、绿、重构。红代表失败的测试,绿代表通过的测试,正如其名,重构意味着在保持通过测试的同时重构代码。典型的 TDD 工作流程如下:
-
编写一个测试。
-
运行测试,预期它会失败。
-
编写最少的代码以使测试通过。
-
重新运行测试以验证它是否通过。
-
根据需要重构代码。
-
如有必要,重复步骤 2 到 5。
我们可以使用 React Testing Library 通过 TDD 方法驱动 React 组件的开发。首先,我们将使用 TDD 来构建本章前一个部分中引入的Vote
组件。然后,我们将使用 TDD 来创建一个Registration
组件。
使用 TDD 构建投票组件
在独立测试调用事件处理器的组件部分,我们首先构建了Vote Button
组件,然后编写了测试。在本节中,我们将使用 TDD 来构建组件。首先,我们规划出组件在渲染到 DOM 时应有的外观以及用户应采取的操作。我们决定组件将是一个图像按钮。父组件应将图像源和图像 alt 文本作为props
传递给组件。
组件还将接受一个用于hasVoted
属性的布尔值,以设置按钮的状态为enabled
或disabled
。如果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
函数并将它们分配给stubHandleVote
和stubAltText
变量。我们在变量名前加上stub,因为我们只是在测试中将它们用作依赖占位符。变量名也提供了更多关于它们在测试中用途的上下文。
接下来,我们使用传入的props
值渲染组件。然后,我们获取image
和button
元素并将它们分配给相关变量。接下来,我们将进行断言:
expect(image).toBeInTheDocument()
expect(button).toBeInTheDocument()
expect(button).toBeEnabled()
})
在前面的代码中,我们断言image
和button
元素在 DOM 中。我们还断言按钮状态为enabled
,这意味着用户可以点击它。我们创建了一个Vote Button
组件的文件,如下所示:
const VoteBtn = props => {
return null
}
export default VoteBtn
在前面的代码中,我们创建了一个VoteBtn
组件,该组件目前不返回任何代码以在 DOM 中渲染。我们还导出组件以在其他文件中使用。当我们运行测试时,我们从测试结果中得到了以下输出:
图 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
在前面的屏幕截图中,我们将编写允许用户点击按钮以调用一个方法来处理在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
在前面的代码中,传入组件的jest
函数被要求至少调用一次,但它从未被调用。接下来,我们将通过向组件添加实现来解决这个问题:
<button onClick={props.handleVote} disabled={props. hasVoted}>
在前面的代码中,我们添加了一个onClick
事件处理器,当按钮被点击时,它将调用作为属性传递给组件的handleVote
方法。现在当我们运行测试时,我们得到以下输出:
![图 3.20 – TDD 投票按钮测试步骤 4
图 3.20 – TDD 投票按钮测试步骤 4
在前面的屏幕截图中,投票按钮
已被实现并测试,我们已使用 TDD 方法完成该功能的构建。
在下一节中,我们将使用 TDD 创建一个注册组件。
使用 TDD 构建注册表单
在上一节中,我们使用 TDD 构建了一个Vote
组件。在本节中,我们将使用 TDD 构建用于创建网站用户账户的组件。然后,一旦我们构建了使测试通过的最小功能,我们还将重构组件的实现并验证测试是否继续通过。该组件将包含一个heading
元素、email
和password
字段,并且应该调用handleSubmit
方法。组件的最终版本应该如下所示:
图 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} />)
在前面的代码中,我们创建 mockHandleRegister
和 mockValues
变量。这些变量将在测试的后续部分被断言。然后,我们将测试组件渲染到 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
对象中的值输入到 email
和 password
字段中。注意 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
在前面的屏幕截图中,DOM 中的 email
字段元素。接下来,我们将通过创建 email
字段来解决错误。我们还将创建 password
字段和 提交 按钮:
state = {
email: '',
password: ''
}
handleChange = event => {
const { id, value } = event.target
this.setState(prevState => {
return {
...prevState,
[id]: value
}
})
}
在前面的代码中,首先,我们创建一个 state
对象来存储 email
和 password
字段输入的值。接下来,我们创建一个 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
元素来包裹 heading
和 form
元素。在 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
在前面的代码中,我们的测试仍然失败,但原因不同。现在测试可以输入值并提交表单,但 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
前面的截图显示,我们的测试最终通过了。技术上,我们可以在这里停止,因为我们的代码使测试通过。然而,我们可以通过将组件代码从类组件转换为函数组件来使代码更简洁。只要行为保持不变,我们的测试应该继续通过。我们可以像这样重构组件:
const Register = props => {
const [values, setValues] = React.useState({
email: '',
password: ''
})
在前面的代码中,首先,我们将类转换为函数。然后,我们使用 useState
钩子来管理表单值状态。接下来,我们将重构我们的 handleChange
和 handleSubmit
方法:
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
在前面的截图中,经过重构后我们的测试仍然通过。refactor
组件使我们的代码变得更加整洁。现在你了解了如何使用 React Testing Library 通过 TDD 来构建组件。在本节中,我们使用 TDD 来驱动投票和注册功能的创建。React Testing Library 提供的测试结果反馈使得开发过程更加愉快。
摘要
在本章中,你学习了如何安装和使用模块来模拟组件在最终 DOM 输出上的用户操作。你现在可以使用一个用户友好的工具安装和测试与 API 交互的功能。你理解了如何使用模拟函数将组件与事件处理程序依赖项隔离来测试组件。最后,你学习了如何结合 React Testing Library 实现构建功能的 TDD 方法。
在下一章中,我们将通过学习集成测试的好处来深入探讨。我们还将学习如何测试使用流行第三方库的 React 组件。
问题
-
为什么你应该在测试中优先选择
user-event
而不是fireEvent
来模拟用户操作? -
解释 MSW 如何允许你测试向 API 发起请求的组件。
-
模拟函数是什么?
-
解释使用模拟函数单独测试组件的应用风险。
-
用你自己的话描述 TDD 工作流程。
-
解释何时使用
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 组件测试结果
图 4.1 – Vote 组件测试结果
上述截图显示了Vote.test.js
测试文件。
现在你已经理解了与依赖项集成测试组件的优势。然而,在某些情况下,使用集成方法可能不是最佳策略。在下一节中,我们将探讨一个场景,其中单独测试组件比集成测试更有价值。
规划更适合单独测试的测试场景
在前面的章节中,我们学习了测试与依赖项集成组件的优势。然而,有些情况下使用隔离测试方法更为合适。在第三章的实现测试驱动开发部分,[《使用 React 测试库测试复杂组件》]中,我们构建了一个注册表单。作为参考,组件的输出如下:
图 4.2 – 注册表单
在前面的屏幕截图中,我们看到注册组件允许用户提交电子邮件地址和密码。测试使用了隔离方法,并验证了在表单提交时调用 handleRegister
方法的快乐路径。假设添加了一个新功能,其中成功消息从服务器发送到前端,并在注册成功时替换屏幕上的表单:
图 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
。由于Vote
在LikesProvider
内部渲染,它可以查看和更新LikesProvider
提供的状态。为了测试Context
Provider
组件的消费者,我们需要一种方法在测试中访问Context Provider
。我们将使用零售应用程序来演示如何实现这些要求。
测试使用 Context 的Retail
组件
在本节中,我们将测试一个Retail
组件,该组件使用由RetailContext
组件提供的状态。Retail
组件的 UI 如下所示:
图 4.4 – Retail
组件 UI
上述截图显示了Retail
组件的初始屏幕输出。显示了一列服装产品和购物车。还有一个带有文本Retail Store的板块,点击后会显示产品详情:
图 4.5 – 产品详情
上述截图显示了用户点击后的男士休闲高级修身 T 恤的详情。用户可以点击添加到收藏按钮来收藏该商品:
图 4.6 – 收藏的产品详情
上述截图显示,一旦点击按钮,文本添加到收藏会变为已添加到收藏。最后,用户可以输入数量并点击添加到购物车按钮将产品添加到购物车中:
图 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
组件包括三个独立的子组件 – ProductList
、ProductDetail
和 Cart
– 它们集成在一起以消费和管理 RetailContext
状态:
const Retail = () => {
return (
<div className="container-fluid">
<div className="row mt-3">
<ProductDetail />
<Cart />
</div>
<ProductList />
</div>
)
}
Retail
组件在前面代码中的 div
元素内渲染 ProductList
、ProductDetail
和 Cart
组件作为子组件。
我们将使用隔离单元测试和集成测试的组合来验证 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 – 购物车组件测试结果
前面的截图显示了测试的 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
为我们的测试生成随机的 productName
、price
和 fashion 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 组件。最后,断言产品 title
和 price
在 DOM 中。现在我们已经验证了 Product
组件能够按预期接受并渲染 prop 数据到 DOM 中。当我们为 Product
组件运行测试时,我们得到以下输出:
图 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
组件测试结果
上一张截图显示了测试中 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()
})
在前面的代码中,我们断言第一个产品的标题在屏幕上显示两次。最后,我们断言产品的 description
和 price
数据在屏幕上显示。
对于我们的下一个测试,我们将验证用户能否将产品添加到购物车。我们可以编写以下测试代码:
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 中。现在我们确信用户可以使用 Retail
与 Product
、ProductDetail
和 Cart
组件集成来添加商品到购物车。
作为挑战,尝试编写以下测试场景的代码:用户可以更新购物车中商品的数量、用户不能提交大于 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 组件测试
前面的截图显示了测试 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
方法,并将其命名为 rtlRender
。rtlRender
名称是 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
包含了 products
、cartItems
、favorites
和 showProductDetails
的所有初始状态值。产品数据是由 faker
创建的值的对象数组。接下来,我们将创建一个自定义的 render
方法,以替代 React Testing Library 的 render
方法:
function render(
ui,
{
initialState,
store = configureStore({
reducer: { retail: retailReducer },
preloadedState: initialState
}),
...renderOptions
} = {}
) {
在前面的代码片段中,该方法接受两个参数作为参数。首先,ui
参数接受要测试的组件作为子组件,并将其包裹在自定义方法中。下一个参数是一个具有许多属性的对象。首先,initialState
接受我们可以传递到测试文件中的组件内的自定义测试数据。接下来,store
使用 configureStore
方法设置 Redux 存储,包括 reducer
和 preloadedState
。reducer
属性是一个对象,它接受我们创建的 reducers
来管理应用程序状态。preloadedState
属性接受传递到测试文件组件中的 initialState
。最后,任何其他传入的参数都由 renderOptions
处理。
接下来,我们将创建 Wrapper
方法:
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}
在前面的代码中,Wrapper
接受 children
,它将是被测试的组件。接下来,该方法返回带有 store
和 children
传入的 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
组件。接下来,我们从自定义方法文件中导入 render
、screen
和 fakeStore
方法。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 组件
上述截图显示了预期的测试 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 Client
(www.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:4000
。ApolloClient
还将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'
在前面的代码中,我们导入了MockedProvider
。MockedProvider
是 Apollo 的一个特定方法,我们可以用于测试。使用MockedProvider
,我们不需要创建任何自定义的Provider
组件,就像我们在本章的测试使用 Redux 的组件部分中所做的那样。
接下来,我们从 React Testing Library 中导入act
、render
和screen
。act
方法将让 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 中的错误
上一张截图显示了当我们不使用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 – 表组件测试结果
现在,你已经知道了如何使用 Apollo Client 测试消耗 GraphQL 服务器数据的组件。随着 GraphQL 继续获得人气,将我们讨论的测试策略放入你的工具箱中,以快速验证预期行为将是有帮助的。
在下一节中,我们将学习如何测试使用流行的 Material-UI 组件库进行前端开发的组件。
测试使用 Material-UI 的组件
在本节中,我们将学习如何测试使用 Material-UI 组件库的组件。在大多数情况下,您可以使用 React Testing Library 直接选择 Material-UI 组件渲染的 DOM 元素。然而,有时添加作为结果 DOM 元素属性渲染的组件属性是有帮助的。我们将学习如何添加属性以测试 Vote
和 Customer 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 的 Box
、Button
和 ThumbUpIcon
组件来快速构建一个带有 CSS(层叠样式表)样式的 点赞 按钮,使其看起来很漂亮。
接下来,我们将构建组件的其余部分:
<Typography variant="h3" align="center">
{totalLikes}
</Typography>
<Button
onClick={() => voteDislike()}
disabled={hasVotedDislike}
variant="contained"
color="primary"
>
<ThumbDownAltIcon />
</Button>
</Box>
</div>
在前面的代码中,我们使用了 Material-UI 的 Typography
、Button
和 ThumbDownAltIcon
组件来构建点赞按钮,并在屏幕上显示总点赞数。当我们将在浏览器中渲染组件时,我们得到以下输出:
![图 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 投票测试结果
上一张截图显示,测试 给定多个 "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
)且我们希望避免使用 class
或 ID
选择器时的一种最后手段。我们可以通过将 data-testid
作为属性附加到 DOM 元素上来使用它:
<h5 data-testid="product-type">Electronics</h5>
我们在之前的代码片段中添加了一个 "product-type"
data-testid
,以唯一选择标题元素。在本节中,我们将测试一个接受客户数据并渲染以下内容的 CustomerTable
组件:
图 4.17 – Material-UI 表格组件
上一张截图显示了一个包含多行客户数据的表格。用户可以使用 da
,以下结果将被显示:
图 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 表格测试结果
之前的截图显示,测试给定数据,渲染表格行、给定单个匹配查询,返回单个结果和给定非匹配查询,没有返回结果都按预期通过。作为挑战,尝试编写一个针对场景给定多匹配查询,返回多个结果的测试。之前的测试场景解决方案可以在第四章的代码示例中找到(github.com/PacktPublishing/Simplify-Testing-with-React-Testing-Library/tree/master/Chapter04/ch_04_mui
)。
本节内容已为您提供技能,通过添加aria-label
和data-testid
属性,在需要时使用 React Testing Library 获取特定 Material-UI 组件。
摘要
在本章中,您已经学习了如何使用集成测试方法测试组件,而不是使用带有模拟依赖项的单元测试方法。您知道如何测试使用 Context API 管理应用程序状态的组件。您还学习了如何在使用第三方 Redux 库的项目中创建自定义方法来测试组件。最后,您学习了如何向使用流行的 Material-UI 库构建的组件添加属性。
在下一章中,我们将学习如何重构遗留项目的测试。
问题
-
解释测试集成组件与独立测试的优点。
-
你应该在何时使用
data-testid
属性来获取组件? -
你应该在何时使用 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.2 – 设置预算收入
上一张截图显示了一个允许用户输入并提交数字以更新 收入 值的模型。用户还可以为各种类别创建预算:
图 5.3 – 添加预算类别
前面的截图显示了一个允许用户选择类别和金额并添加新预算的模型。该模型还显示了一条消息,告知用户预算的可接受值。一旦用户创建了一个新的预算,该预算就会被添加到屏幕上:
图 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
金额为$1。setOneDollarIncome
函数将减少后续测试中的重复代码。接下来,我们将编写主要的测试代码:
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
获取文本为$1
的span
元素并将其存储在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
方法允许相同的测试在多个不同的值上多次运行。budgetAmount
、spending
和leftOver
变量代表每个测试迭代的测试值。我们在变量下有三行数据,用于传递给每个测试运行中的变量。接下来,我们在测试中安排和执行操作:
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
组件并调用 setOneDollarIncome
和 createCarBudget
函数来安排我们的测试。然后,我们点击垃圾桶图标。最后,我们断言 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
组件并调用与上一个测试类似的 setOneDollarIncome
和 createCarBudget
函数来安排我们的测试。然后,我们点击右箭头图标。最后,我们断言屏幕上存在文本 $5 of $5
。
作为一项挑战,尝试编写以下测试场景的代码:Budget,给定预算,显示详细信息
。此测试场景的解决方案可以在 第五章,使用 React 测试库重构遗留应用程序 的代码示例中找到。
当我们运行我们的测试时,我们收到以下输出:
图 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 – 预算应用失败的测试结果
在前面的屏幕截图中,测试结果表明依赖项更新破坏了我们的测试。结果提供了有关为什么每个测试失败的相关详细信息。以下是在控制台显示的测试结果信息的概述:
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 – 预算应用更新依赖测试结果
上述截图显示,在根据错误信息更新源代码后,所有测试现在都已通过。现在你知道了如何更新生产依赖项并向旧应用程序添加测试。在使用 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
方法。最后,进行断言以验证具有id
为income
的h3
元素的文本值等于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");
在之前的代码中,我们断言具有id
为spending
的h3
元素的文本值等于Spending: $5
。最后,我们断言具有id
为leftover
的span
元素的文本值等于$-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();
});
在前面的代码中,我们断言具有 id
为 chart
的 div
元素是 truthy
,这意味着它在 DOM 中被找到。正如我们在本章的 创建回归测试套件 部分中看到的,使用 React 测试库编写的预算应用程序的所有测试用例在运行时都将按预期通过。
现在所有酶测试都已重构为 React 测试库,我们可以从 package.json
文件中移除 enzyme
和 enzyme-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';
在前面的代码中,我们导入了 React
、ReactDOM
和 act
方法。从 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 中的 income
、spending
和 leftover
元素。然后,我们使用 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 – 通过设置收入测试
结果显示,SetIncome, given initial render, displays budget summary values
测试在之前的代码中按预期通过。现在你已了解如何使用 ReactTestUtils
模块重构测试。本节学到的技能将帮助你将遗留的测试代码重构为使用现代测试工具。
重构测试以符合常见的测试最佳实践
在上一节中,我们学习了如何使用 ReactTestUtils 重构测试。在本节中,我们将介绍一些场景,其中我们可以重构现有的测试代码,使其更健壮和易于维护。我们将使用以下反馈表单应用程序来展示示例:
图 5.9 – 反馈表单
在前面的屏幕截图中,我们有一个表单,用户可以填写 姓名 和 电子邮件 字段,以及选择评分、输入评论,最后提交他们的信息。如果用户尝试提交包含必填字段空白值的表单,则会显示错误消息:
![图 5.10 – 反馈表单错误验证
图 5.10 – 反馈表单错误验证
在前面的屏幕截图中,每个带有空值的输入下都显示了表单验证错误。最后,当用户提交包含有效输入数据的表单时,会显示一条感谢信息:
图 5.11 – 提交反馈表单
在前面的屏幕截图中,显示了消息 我们感谢您的回复,John Doe! 消息中的 John Doe 部分是表单中 姓名 输入元素的输入值。我们将重构的第一个测试是验证当输入无效电子邮件时,会显示错误消息:
test.each`
value
${'a'}
${'a@b'}
${'a@b.c'}
`('displays error message', async ({ value }) => {
在前面的代码中,首先,使用了 Jest 的each
方法用不同的值运行相同的测试:a
、a@b
和a@b.c
。接下来,我们看到测试名称,displays error message
。测试名称比较模糊,因为它没有提供足够的细节来描述测试的上下文。使用测试命名约定来消除模糊测试名称的问题是很常见的。有许多流行的命名约定,例如when_stateUnderTest_expect_expectedBehavior
和given_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()
})
}
)
在前面的代码中,使用了对象解构方法来访问getByRole
和getByText
查询方法。然而,解构方法需要您手动跟踪在构建测试代码时添加或删除哪些查询。如第一章中提到的,探索 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 – 反馈表单测试结果
在前面的屏幕截图中,所有重构的测试用例都按预期通过。现在你知道如何重构测试以使用命名约定和将多个断言从单个测试中拆分到单独测试中的测试最佳实践。你还学习了如何将用 React 测试库编写的遗留测试重构为现代方法。
摘要
在本章中,你学习了如何减轻遗留应用程序更新生产依赖项的负担。你学习了如何使用现代 React 测试库工具重构遗留测试。你还学习了一些测试最佳实践。本章获得的知识应该让你有信心,你可以成功重构过时的代码到当前版本而不会出现重大问题。你也应该能够重构测试代码以使其更易于维护。
在下一章中,我们将学习关于测试的额外工具和插件。
问题
-
解释与 Enzyme 或
ReactTestUtils
等工具相比使用 React 测试库的好处。 -
解释在 Jest 的监视模式下运行测试的好处。
-
当你在编写测试时应该何时使用 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-library
和eslint-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 函数名称拼写错误
在之前的代码中,控制台输出指示 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 输出
在之前的命令中,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.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 代码检查建议
在之前的屏幕截图中,包含waitFor
方法的代码被 ESLint 下划线标注,引起了我们的注意。当我们悬停在waitFor
方法代码上时,我们得到反馈,表明首选的查询是findByRole
,这是通过eslint-plugin-testing-library
的prefer-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 检查器建议
在前面的屏幕截图中,getByRole
被 ESLint 下划线标记,以引起我们对查询问题的注意。当我们悬停在查询上时,我们会得到反馈,表明首选的方法使用 screen
通过 eslint-plugin-testing-library
的 prefer-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 检查器建议
在前面的截图上,ESLint 用下划线标记了 debug
,以引起我们对查询问题的注意。当我们悬停在查询上时,我们得到反馈,表明应该通过 eslint-plugin-testing-library
的 no-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.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 检查器建议
在前面的截图上,toHaveProperty
匹配器被 ESLint 下划线标注以引起我们对匹配器问题的注意。当我们悬停在匹配器上时,我们得到反馈表明应该通过 eslint-plugin-jest-dom
的 prefer-checked
规则将其替换为 jest-dom
的 toBeChecked
匹配器。该规则可以自动修复,并且如果我们的代码编辑器设置正确,它将为我们重构匹配器。当我们重构我们的匹配器时,我们得到以下输出:
expect(jsCheckbox).toBeChecked()
在前面的代码中,我们使用 toBeChecked
jest-dom
匹配器来验证复选框是否被选中。现在我们有一个匹配器,它消除了先前匹配器版本中的任何问题,并且读起来要好得多。接下来,我们将断言预期的类:
expect(screen.getByText(/javascript/i).className). toContain('text-success font-weight-bold')
在前面的代码中,我们通过 javascript
文本访问元素内部的 className
属性以验证它是否包含 text-success
和 font-weight-bold
类。然而,当我们悬停在 toContain
上时,我们得到以下反馈:
图 6.11 – prefer-to-have-class 检查器建议
在前面的截图上,toContain
匹配器被 ESLint 下划线标注以引起我们对匹配器问题的注意。当我们悬停在匹配器上时,我们得到反馈表明应该通过 jest-dom
的 toHaveClass
匹配器替换它,这是通过 eslint-plugin-jest-dom
的 prefer-to-have-class
规则实现的。与前面的例子类似,prefer-to-have-class
规则可以自动修复,并且如果我们的代码编辑器设置正确,它将为我们重构匹配器。当我们重构代码时,我们得到以下输出:
expect(screen.getByText(/javascript/i)).toHaveClass(
'text-success font-weight-bold'
)
在前面的代码中,我们将代码重构为使用 jest-dom
的 toHaveClass
匹配器。现在我们有一个比原始示例更容易实现和阅读的匹配器。
现在你已经了解了如何安装和使用 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 – 一个不可访问的图像按钮
在前面的屏幕截图中,我们有一个作为 提交 按钮行为的图像。以下是该图像按钮的源代码:
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 – 不可访问图像按钮测试输出
前面的屏幕截图显示,在 NoAccessibility
组件中发现了可访问性违规,导致测试失败并提供反馈。首先,反馈表明 input
元素是问题的来源。接下来,我们看到整个元素打印在屏幕上。然后,我们得到 "Image buttons must have alternate text (input-image-alt)"
消息,告知我们为什么该元素未能通过审计。接下来,我们得到一些可以实施的建议来解决该问题。最后,我们得到一个超链接,我们可以通过它来深入了解问题。我们将通过提供 alt
属性来解决该问题:
<input src={loginImg} type="image" alt="login" />
在前面的代码中,我们添加了一个值为 login
的 alt
属性。现在,当我们重新运行我们的测试时,我们得到以下结果:
图 6.14 – 可访问图像按钮测试输出
在前面的屏幕截图中,测试结果显示,在可访问性审计中,NoAccessibility
没有违反规则,测试通过且没有违反规则。接下来,我们将测试包含图像的列表的可访问性:
图 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 – 不可访问列表测试结果
上一张截图显示,在无序列表组件中发现了可访问性违规,这导致测试失败并给出反馈。首先,反馈表明问题源是一个 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 – 弹出组件
在前面的截图上,我们有一个带有文本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 测试库的render
和screen
方法以及待测试的组件。在主测试代码内部,首先,我们在 DOM 中渲染组件。接下来,我们调用logTestingPlaygroundURL
方法。当我们运行测试时,我们得到以下输出:
图 6.18 – 测试游乐场链接
在前面的截图上,我们有一个唯一的链接,可以访问测试游乐场网站并查看我们的组件渲染的 HTML。当我们点击链接时,我们应该看到以下类似的内容:
图 6.19 – 测试游乐场 HTML 结构
在前面的截图上,链接将我们导航到测试游乐场网站。首先,我们看到一个包含我们组件 HTML 结构的部分。接下来,我们看到渲染的浏览器输出,如下所示:
图 6.20 – 测试游乐场浏览器输出
在前面的截图上,我们可以看到一个包含我们组件浏览器输出的部分。注意,我们没有看到包含相关样式的完整结果。测试游乐场网站仅显示 HTML 内容。接下来,我们看到一个建议查询部分,如下所示:
图 6.21 – 测试游乐场建议查询
在前面的截图上,我们在浏览器输出部分点击了一个button
元素。getByRole
查询是根据 HTML 结构选择按钮的最佳方式。此外,我们还可以看到这太棒了。发货的消息,这表明我们应该进一步使用此查询。
以下截图显示了其他可选的选项,用于选择元素:
图 6.22 – 测试游乐场查询优先级选项
在前面的代码中,我们可以看到多个选项,按照优先级顺序选择元素。根据button
元素的 HTML 结构,我们可以选择两种方式来选择元素 – 通过其角色 和 通过其文本值。其他列出的查询对于按钮不可用,因此显示不可用。如果我们决定选择文本查询选项,我们应该看到以下截图类似的内容:
图 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
选择器以访问更多信息按钮。接下来,我们将使用扩展程序来帮助选择弹出窗口,该弹出窗口在点击按钮后显示:
图 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 – 弹出测试结果
在前面的屏幕截图中,结果显示 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 – 选择配置
在前面的屏幕截图中,我们在命令面板中输入wallaby
以显示可用的 Wallaby.js 命令。我们将点击Wallaby.js: 选择配置选项:
![图 6.28 – 自动配置选项
图 6.28 – 自动配置选项
在前面的屏幕截图中,我们已选择自动配置 <项目目录>和自动配置 <自定义目录>选项。我们将选择<项目目录>以使用当前项目的目录。一旦选择配置,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 ipsum
的 heading
元素。Wallaby.js 以两种方式提高了我们发现错误的能力。首先,我们在测试名称左侧和具体错误发生的行号处看到一个红色的方块形状。内联代码通知帮助我们快速确定我们应该关注的位置以定位错误的根源。其次,当我们悬停在 test
方法上时,React Testing Library 的测试结果输出会直接在代码编辑器中显示。
此功能加快了我们的工作流程,因为 Wallaby.js 在我们向测试添加新代码时重新运行我们的测试并提供反馈。此外,我们甚至不必保存测试文件就能获得反馈。我们还可以在 Wallaby.js 测试 控制台中查看测试反馈:
图 6.32 – Wallaby.js 测试控制台
在前面的屏幕截图中,我们可以看到与在测试文件中更新代码时直接在编辑器中看到的类似的 React Testing Library 反馈,但现在它是一个扩展视图。此外,我们还可以看到失败测试与通过测试的数量对比,以及可点击的链接“启动覆盖率 & 测试资源管理器”,这是一个允许您查看每个文件的视觉测试覆盖率的特性,以及“搜索测试”,这是一个允许您快速在项目中搜索任何测试的特性。
在 Wallaby.js 的编辑器功能帮助下调试失败后,我们了解到名为 lorem ipsum
的 heading
元素并未立即显示。利用我们对 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 测试。
问题
-
将 React 特定的
eslint-plugin-testing-library
版本安装到项目中,并添加额外的规则。 -
创建使用不遵循
jest-dom
最佳实践的匹配器的jest
断言示例。然后,在项目中安装并配置eslint-plugin-jest-dom
,并使用它作为指导来纠正突出显示的问题。 -
创建一些存在可访问性问题的组件,安装并运行
jest-axe
对这些组件进行测试,并使用反馈来修复它们。 -
访问您最喜欢的三个网站,并使用测试游乐场查看您可以使用 DOM 测试库首选的
byRole*
查询选择多少个元素。 -
安装 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 运行
在前面的屏幕截图中,Cypress 通知我们它已自动在我们项目的根目录中为我们创建了一个 cypress
文件夹结构,包括以下子文件夹 - fixtures
、integration
、plugins
和 support
。这些子文件夹使我们能够快速启动并运行,而无需进行任何手动配置。fixtures
文件夹用于创建在测试中通常用于模拟网络数据的静态数据。integration
文件夹用于创建测试文件。在 integration
文件夹内部,Cypress 提供了一个 examples
文件夹,其中包含使用 Cypress 测试应用程序的多个示例。
plugins
文件夹用于以多种方式扩展 Cypress 的行为,例如通过编程方式更改配置文件,在测试运行后生成 HTML 格式的报告,或添加对自动化视觉测试的支持,仅举几例。Cypress 提供了许多开箱即用的命令,如 click
、type
和来自第三方工具(如 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 浏览器下拉菜单
在前面的屏幕截图中,可供使用的浏览器版本有 Chrome 88、Firefox 80、Edge 88 和 Electron 87,可用于测试运行。可用的浏览器基于用户机器上安装的与 Cypress 兼容的浏览器。Cypress 支持的浏览器是 Firefox 和 Chrome 家族的浏览器,如 Edge 和 Electron。Electron 浏览器在 Cypress 中默认可用,也用于在无头模式下运行测试,这意味着没有浏览器 UI。
要执行测试,只需从可用测试列表中单击测试名称:
图 7.3 – 示例测试运行
在前面的屏幕截图中,运行了位于 examples
文件夹中的 actions.spec.js
测试文件。屏幕的右侧显示了在测试的每个步骤中浏览器中应用程序的状态。屏幕的左侧显示了测试文件中每个测试的结果。如果我们想的话,我们可以点击每个测试,悬停在每个 Cypress 命令上,并查看在命令执行前后产生的 DOM 状态。能够悬停在每个命令上查看产生的 DOM 输出是一个很棒的功能。
与其他端到端测试框架相比,Cypress 使得调试更加容易。例如,如果 Cypress 在我们的测试中找不到浏览器指定的元素,它会提供有用的错误信息:
图 7.4 – Cypress 错误输出
在前面的屏幕截图中,Cypress 通过告知我们在测试运行器内部 4 秒后找不到名为 firSDFstName
的输入元素来提供反馈。Cypress 还允许我们点击一个链接,在错误发生的行打开我们的代码编辑器。
现在我们已经了解了使用 Cypress 测试运行器安装、运行和执行测试的基本知识,我们将编写一个结账流程测试。当用户结账时,应用程序会经过四个屏幕。第一个屏幕是送货地址:
图 7.5 – 送货地址结账屏幕
在前面的屏幕截图中,显示了一个用户可以输入他们的送货地址信息的表单。第二个屏幕是支付详情:
图 7.6 – 支付详情结账屏幕
在前面的屏幕截图中,显示了一个用户可以输入他们的支付信息的表单。第三个屏幕是查看订单:
图 7.7 – 查看您的订单结账屏幕
在前面的屏幕截图中,显示了一个总结,显示了在之前的屏幕上输入的所有表单值。注意,为了演示目的,购买的物品T 恤衫、牛仔布牛仔裤和耐克自由跑鞋在应用程序中是硬编码的,不会是我们将要编写的测试的重点。最后一个屏幕是订单提交屏幕:
图 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()
在前面的代码中,我们使用get
和type
命令选择和输入每个输入的值。然后,我们使用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')
在前面的代码中,我们使用contains
和should
命令来验证带有文本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 – 博客主页
在前面的屏幕截图中,主页显示了两个博客帖子,我爱 React和我爱 Angular。博客数据存储在 MongoDB 数据库中,并在应用程序加载后通过 API 发送到前端。每个博客帖子从上到下显示一个类别、标题、发布日期、摘要和一个继续阅读链接。
要查看博客的详细信息,用户可以点击博客标题或继续阅读链接。例如,点击我爱 React标题后,我们看到以下内容:
![图 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 – 博客流程测试失败
在之前的屏幕截图中,第一步成功导航到具有名称 New Post
的 link
元素,但在第二个测试步骤中 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 – 博客流程添加页面失败
在之前的屏幕截图中,我们的测试代码现在可以成功打开 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
元素结构相似的 Category
、Image link
和 Content
输入元素。现在,当我们触发测试运行时,我们得到以下输出:
图 7.14 – 博客流程添加页面输入元素重构
在之前的屏幕截图中,我们的测试代码现在可以成功打开 Title
、Category
、Image link
和 Content
的 input
元素,但在 Submit
按钮步骤中未找到 Submit
按钮:
<button>Submit</button>
我们创建并添加了一个 Submit
按钮,该按钮是 form
元素的一部分,当点击时,会调用一个方法将表单数据发送到 API,最终发送到数据库。尽管这不是我们测试的重点,但我们还在 UI 中添加了一个 cancel
按钮。现在,当我们触发测试运行时,我们得到以下输出:
图 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 – 博客流程删除帖子测试失败
在上一张截图,输出指示测试在步骤 delete post
找不到时失败。我们可以通过创建缺失的元素来更新 UI:
<a onClick={handleDelete}>Delete post></a>;
在前面的代码中,我们添加了一个带有文本 Delete post
的 hyperlink
元素。当点击超链接时,它会调用 handleDelete
方法向 API 发送 DELETE
请求,并最终从数据库中删除博客帖子。当我们保存测试文件以触发测试运行时,我们得到以下输出:
图 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()
在前面的代码中,首先,我们创建了 navigateToHomePage
、navigateToAddPage
和 getBlogPost
方法的页面对象。然后,我们导出了一个新实例以供测试文件使用。接下来,我们将为 添加 页创建一个页面对象:
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
方法以在测试中生成唯一的文章数据。接下来,我们导入了 addPage
和 homePage
页面对象。接下来,我们将编写主要的测试代码:
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')
在前面的代码中,我们调用了 navigateToPostDetail
和 deletePost
方法,并验证该文章不再出现在 主页 上。现在,我们将测试代码重构为页面对象的任务已经完成。我们的测试代码更短,并且抽象了许多测试步骤的细节。
然而,如果我们把 添加博客文章 和 删除博客文章 功能拆分成两个不同的测试,我们的页面对象设计就会存在一个问题。第一个测试将创建一个博客文章:
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
。接下来,我们创建了三个测试设置方法:getAllPosts
、deletePost
和deleteAllPosts
。在每个测试运行之前,我们希望从一个空数据库开始。
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 使用以下关键字:Feature
、Scenario
、Given
、When
和 Then
。Feature
用于描述要构建的事物,例如登录页面。Scenario
描述了该功能的用户流程。例如,用户可以输入用户名、密码并点击 登录 以导航到其个人资料页面。
Given
、When
和 Then
关键字描述了不同阶段的场景。我们可以这样为登录功能编写一个完整的 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 库中导入 Given
、Then
和 When
方法。接下来,我们导入假的 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 应用程序
在前面的屏幕截图中,react-devtools
打开并监听我们的应用程序运行以连接到它。一旦我们通过交互模式运行任何 Cypress 测试,应用程序的组件树就会在 react-devtools
应用程序内部填充:
图 7.19 – React Developer Tools 组件树视图
在前面的屏幕截图中,react-devtools
应用程序显示了正在运行的测试的结果组件树。随着应用程序的运行,我们有许多可用的工具,例如点击组件名称来查看相关信息:
![图 7.20 – React Developer Tools 组件详情
图 7.20 – React Developer Tools 组件详情
在前面的屏幕截图中,我们在 react-devtools
屏幕的左侧选择了一个 Link
组件。当我们点击该组件时,它会在 react-devtools
屏幕的右侧显示相关信息,例如 props
和 hooks
。我们还在屏幕的右侧看到了 Cypress 交互模式屏幕。
现在,你知道如何使用 Cypress 与 React Developer Tools。除了 Cypress 提供的调试工具外,你现在还有一个额外的工具来调试运行 Cypress 的 React 应用程序。
摘要
在本章中,你学习了使用 Cypress 测试应用程序的新策略。你可以编写端到端测试来验证应用程序的关键用户流程。你学习了如何实现 API 测试。你现在知道使用 Cypress 驱动的开发来创建新特性的好处。你理解了 POM 和自定义命令设计模式来构建和组织测试代码。
最后,你学会了如何使用 Cucumber 编写 Gherkin 风格的测试,以增强与非技术团队成员的沟通。
恭喜你,你已经到达了我们的旅程的终点,现在你了解了许多简化测试 React 应用程序的策略和工具!在这本书中学到的概念和技能将帮助你在未来处理任何 JavaScript 项目时编写高质量的代码。
祝你好运,并且始终记住,没有伟大的软件是建立在伟大的测试基础之上的。
问题
-
找到一个以前的项目,并使用 Cypress 安装并编写一系列端到端测试。
-
创建一个 CRUD API 并使用 Cypress 测试它。
-
构建一个全栈 React 应用程序,并尽可能多地使用本书中学到的不同策略编写测试。
第八章:答案
在这里,你可以找到每章末尾提供的问题的答案:
第一章
所有问题都是开放式回答。
第二章
-
render方法。
-
screen对象。
-
表示性组件。
-
debug方法。
-
这是一个开放式回答问题。
第三章
-
user-event模块与用户在 DOM 上执行操作时发生的事件非常相似,例如keydown和keyup事件。
-
MSW 允许你测试向 API 发起 HTTP 请求的组件。这是通过在请求到达互联网之前拦截请求,并返回可控制的模拟数据以进行测试来实现的。
-
模拟函数是一个测试替身,用于进行断言。例如,我们可以使用模拟函数来验证当用户点击按钮时,方法是否被调用。
-
风险在于,用模拟版本替换实际依赖项来测试组件不允许你测试与真实生产依赖项集成时的结果行为。
-
这是一个开放式回答问题。
-
当你期望元素存在于 DOM 的当前状态时,请使用
getBy*
查询。当元素的存在取决于使元素在 DOM 中出现的延迟的异步操作时,请使用findBy*
查询。当你想验证元素不在 DOM 中时,请使用queryBy*
查询。
第四章
-
测试集成组件允许你在组件相互交互时通过验证生产行为来降低风险。使用隔离方法,我们会用假数据和响应替换真实依赖项,因此不能降低太多风险。此外,在许多情况下,使用集成测试方法,你可以用更少的测试覆盖更多的代码。
-
你应该只在其他首选查询方法,如
getBy*
和findBy*
无法用于选择元素时,才使用data-testid
属性作为最后的手段。 -
act
方法确保你的测试行为更接近 React 更新浏览器 DOM 的方式。在需要手动进行组件更新的情况下使用act
,例如解决 React 在测试中否则不会意识到的 Promise。React Testing Library 自动将组件包裹在act
中,在大多数情况下,如点击事件,无需手动包裹所有更新组件的代码。
第五章
-
与 Enzyme 或 ReactTestUtils 不同,React Testing Library 允许你编写避免实现细节并从最终用户视角模拟 DOM 交互的测试。
-
在 Jest 的监视模式下运行测试允许你在快速添加新代码时了解回归何时发生。在监视模式下运行测试在使用 TDD 方法构建组件时也非常有益。
-
当你需要用不同的值多次执行相同的测试时,请使用
each
方法。
第六章
所有问题都是开放式回答。
第七章
所有问题都是开放式回答。
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
第九章:为什么订阅?
-
使用来自超过 4,000 位行业专业人士的实用电子书和视频,花更少的时间学习,更多的时间编码
-
通过为您量身定制的 Skill Plans 提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于快速访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
其他您可能喜欢的书籍
如果您喜欢这本书,您可能对 Packt 出版的这些其他书籍也感兴趣:
![Mastering Adobe Photoshop Elements(https://www.packtpub.com/product/ui-testing-with-puppeteer/9781800206786)
使用 Puppeteer 进行 UI 测试
Dario Kondratiuk
ISBN: 978-1-80020-678-6
-
理解浏览器自动化的基础知识
-
探索使用 Puppeteer 进行端到端测试及其最佳实践
-
将 CSS 选择器和 XPath 表达式应用于网络自动化
-
了解作为开发者如何利用网络自动化的力量
-
模拟 Puppeteer 的不同用例,例如网络速度测试和地理位置
-
掌握网络抓取和网络内容生成的技术和最佳实践
(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 来说都是宝贵的。谢谢!