精通-React-测试驱动开发第二版-全-
精通 React 测试驱动开发第二版(全)
原文:
zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0译者:飞龙
前言
这是一本关于教条的书籍。我的教条。它是一套原则、实践和仪式,我发现它们在构建 React 应用程序时极为有益。我试图在我的日常工作中应用这些想法,并且我非常相信它们,以至于我抓住每一个机会向他人传授它们。这就是我写这本书的原因:向你展示那些帮助我在自己的职业生涯中取得成功的想法。
正如任何教条一样,你有权自己做出判断。有些人会不喜欢这本书的每一部分。有些人会喜欢这本书的每一部分。还有更多的人会吸收一些内容而忘记其他内容。这些都很好。我唯一要求的是,你在跟随的同时保持开放的心态,并准备好挑战你自己的教条。
测试驱动开发(TDD)并非起源于 JavaScript 社区。然而,完全有可能用 TDD 来测试 JavaScript 代码。尽管 TDD 在 React 社区中并不常见,但没有任何理由它不应该被采用。事实上,React 作为一个用户界面平台,由于其优雅的函数组件和状态模型,非常适合 TDD。
那么,TDD 是什么,为什么你应该使用它呢?TDD 是一种编写软件的过程,它涉及在编写任何代码之前编写测试或规范。其从业者遵循它,因为他们相信它有助于他们以更低的成本构建和设计更高品质、寿命更长的软件。他们认为它提供了一种关于设计和规范沟通的机制,同时也是一个坚如磐石的回归测试套件。目前没有多少经验数据可以证明这些说法的真实性,所以你能做的最好的事情就是亲自尝试,并自己做出判断。
对我来说,也许最重要的是,我发现 TDD 消除了对修改我的软件的恐惧,使我的工作日比以前轻松得多。我不担心在我的工作中引入错误或回归,因为测试保护了我。
TDD 通常用玩具示例来教授:待办事项列表、温度转换器、井字棋等等。这本书教授两个真实世界的应用。通常,测试会变得复杂。我们将遇到许多具有挑战性的场景,并为所有这些场景找到解决方案。这本书中包含超过 500 个测试,每个测试都会教你一些东西。
在我们开始之前,有一些建议要提。
这是一本关于第一性原理的书。我相信学习 TDD(测试驱动开发)就是深入了解这个过程。因此,我们将不会使用 React Testing Library。相反,我们将构建自己的测试助手。我并不是建议你在日常工作中避免使用这些工具——我自己也在使用它们——但我建议,在学习过程中不使用它们是一次值得的冒险。这样做的好处是,能更深入地理解和意识到这些测试库为你做了什么。
JavaScript 和 React 的景观变化如此之快,以至于我无法保证这本书会保持很长时间的时效性。这也是我使用第一性原理方法的原因之一。我的希望是,当事情真的发生变化时,你仍然可以使用这本书,并将你学到的知识应用到那些新场景中。
这本书的另一个主题是系统重构,这可能看起来相当费时,但它是 TDD 和其他良好设计实践的基础。我在这些页面中提供了许多这样的例子,但为了简洁起见,我有时会直接跳到最终的、重构后的解决方案。例如,我有时会选择在编写之前提取方法,而在现实世界中,我通常会内联编写方法,只有在包含的方法(或测试)变得过长时才提取。
另一个主题是作弊,这在许多 TDD 书籍中是找不到的。这是对 TDD 工作流程的一种认可,即你可以围绕它建立自己的规则。一旦你学习并实践了一段时间的严格 TDD 版本,你就可以了解哪些作弊技巧可以用来节省时间。哪些测试在长期来看不会提供太多价值?你如何加快重复性测试?所以,一个作弊几乎就像是在别人明天来看你的代码时,你以一种不会很明显的方式走捷径。例如,你可能一次实现三个测试,而不是一个接一个地实现。
在这本书的第二版中,我加大了对 TDD 而不是 React 特性的教学力度。除了更新代码示例以与 React 18 兼容外,几乎没有使用新的 React 特性。相反,测试已经得到了大幅改进;它们更简单、更小,并利用自定义的 Jest 匹配器(这些匹配器本身也是通过测试驱动的)。第一版的读者会注意到,我改变了组件模拟的方法;这一版依赖于jest.mock函数的模块模拟。这本书不再教授浅渲染。还有一些其他的小改动,比如避免使用ReactTestUtils.Simulate模块。章节组织也得到了改善,一些早期的章节被拆分并简化了。我希望你会同意,这一版比第一版好得多。
这本书面向的对象
如果你是一名 React 程序员,这本书就是为你准备的。我的目标是向你展示 TDD 如何提高你的工作效率。
如果你已经对 TDD 有所了解,我希望你还能从比较你自己的流程和我的流程中学到很多东西。
如果你还不了解 React,你将受益于花些时间在 React 网站上运行入门指南。话虽如此,TDD 是一个解释新技术的绝佳平台,而且完全有可能你只需通过这本书就能掌握 React。
这本书涵盖的内容
第一章, 测试驱动开发的第一步,介绍了 Jest 和 TDD 周期。
第二章, 渲染列表和详情视图,使用 TDD 周期构建一个简单的页面,显示客户信息。
第三章, 重构测试套件,介绍了你可以简化测试的一些基本方法。
第四章, 使用 React 测试驱动数据输入,涵盖了使用 React 组件状态来管理文本输入字段的显示和保存。
第五章, 添加复杂表单交互,探讨了带有下拉菜单和单选按钮的更复杂表单设置。
第六章, 探索测试替身,介绍了测试协作对象所需的各种测试替身类型,以及如何使用它们来测试驱动表单提交。
第七章, 测试 useEffect 和模拟组件,探讨了在组件挂载时使用测试替身获取数据,以及如何在测试父组件时使用模块模拟来阻止该行为。
第八章, 构建应用程序组件,通过一个“根”组件将用户旅程串联起来,将所有内容结合起来。
第九章, 表单验证,继续通过添加客户端和服务器端验证以及添加一个指示器来显示数据正在提交,来构建表单。
第十章, 过滤和搜索数据,展示了如何构建一个具有一些复杂交互要求的搜索组件,以及复杂的 fetch 请求要求。
第十一章, 测试驱动 React Router,介绍了 React Router 库,用于简化用户旅程内的导航。
第十二章, 测试驱动 Redux,介绍了 Redux 到我们的应用程序。
第十三章, 测试驱动 GraphQL,介绍了 Relay 库,用于与我们的应用程序后端提供的 GraphQL 端点进行通信。
第十四章, 构建 Logo 解释器,介绍了一个有趣的应用程序,我们将通过构建 React 组件和 Redux 中间件的功能来开始探索:撤销/重做、使用LocalStorage API 跨浏览器会话持久化状态,以及程序化管理字段焦点。
第十五章, 添加动画,涵盖了使用浏览器的requestAnimationFrame API 添加动画到我们的应用程序,所有这些都是在测试驱动的方法下完成的。
第十六章, 使用 WebSocket,为我们的应用程序后端添加了对 WebSocket 通信的支持。
第十七章,编写你的第一个 Cucumber 测试,介绍了 Cucumber 和 Puppeteer,我们将使用它们来为现有功能构建 BDD 测试。
第十八章,由 Cucumber 测试引导的功能添加,通过首先使用 Cucumber 构建 BDD 测试,然后下降到单元测试,将验收测试集成到我们的开发过程中。
第十九章,在更广泛的测试领域中理解 TDD,通过查看你所学的知识如何与其他测试和质量实践相结合来结束本书。
为了充分利用这本书
阅读这本书有两种方法。
第一种是当你面临特定的测试挑战时将其作为参考。使用索引找到你想要的内容,然后转到那一页。
第二种,也是我建议你开始的方法,是逐步跟随教程,在过程中构建你自己的代码库。配套的 GitHub 仓库为每个章节(如 Chapter01)都有一个目录,然后在该目录下,有三个目录:
-
Start,这是章节的起点,如果你在跟随学习,你应该从这里开始。 -
Exercises,这是章节末尾开始练习的点。如果你正在尝试每个章节的练习,你应该从这里开始。(注意,并非每个章节都有练习。) -
Complete,其中包含所有练习的完整解决方案。
你至少需要稍微熟悉 branch、checkout、clone、commit、diff 和 merge 命令,这些命令应该是足够的。
查看 GitHub 仓库中的 README.md 文件以获取更多信息和工作与代码库的说明。
如果你使用这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub 下载这本书的示例代码文件:github.com/packtPublishing/Mastering-React-Test-Driven-Development-Second-Edition/。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图和图表的彩色图像的 PDF 文件。你可以从这里下载:packt.link/5dqQx。
使用的约定
在这本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在第一个测试中,将单词appendChild更改为replaceChildren。”
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“演示者点击了开始共享按钮。”
小贴士或重要提示
它们看起来像这样。
代码片段约定
代码块设置如下:
it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; render(<Appointment customer={customer} />); expect(document.body.textContent).toContain("Ashley");});
关于本书中出现的代码片段,有两件重要的事情需要了解。
第一点是,一些代码示例展示了现有代码段落的修改。当这种情况发生时,更改的行会以粗体显示,而其他行只是简单地提供上下文:
export const Appointment = ({ customer }) => ( <div>{customer.firstName}</div>);
第二点是,通常,一些代码示例会省略行以保持上下文清晰。当这种情况发生时,您会看到一条带有三个点的线进行标记:
if (!anyErrors(validationResult)) {
...
} else {
setValidationErrors(validationResult);
}
有时,这种情况也适用于函数参数:
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await window.fetch(...);
setSubmitting(false);
...
}
任何命令行输入或输出都应如下编写:
npx relay-compiler
JavaScript 约定
本书几乎完全使用箭头函数来定义函数。唯一的例外是我们编写生成器函数时,必须使用标准函数的语法。如果您不熟悉箭头函数,它们看起来像这样,定义了一个名为inc的单参数函数:
const inc = arg => arg + 1;
它们可以出现在一行上,也可以分成两行:
const inc = arg =>
arg + 1;
有多个参数的函数,其参数将被括号包围:
const add = (a, b) => a + b;
如果一个函数有多个语句,那么函数体将被括号包围:
const dailyTimeSlots = (salonOpensAt, salonClosesAt) => {
...
return timeIncrements(totalSlots, startTime, increment);};
如果函数返回一个对象,那么该对象必须被括号包围,这样运行时就不会认为它正在执行一个代码块:
setAppointment(appointment => ({ ...appointment, [name]: value });
本书大量使用解构技术,以使代码库尽可能简洁。例如,对象解构通常发生在函数参数中:
const handleSelectBoxChange = (
{ target: { value, name } }
) => {
...
};
这相当于说:
const handleSelectBoxChange = (event) => {
const target = event.target;
const value = target.value;
const name = target.name;
...
};
返回值也可以以相同的方式进行解构:
const [customer, setCustomer] = useState({});
这相当于以下内容:
const customerState = useState({});
const customer = customerState[0];
const setCustomer = customerState[1];
联系我们
我们欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件的主题中提及书名。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并在邮件中附上材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你对撰写或参与一本书感兴趣,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《精通 React 测试驱动开发》,我们很乐意听听你的想法!请点击此处直接访问此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一部分 – 探索 TDD 工作流程
第一部分介绍了你需要测试驱动 React 应用程序的所有基本技巧。随着你构建更多应用程序,你将创建一组库函数,这些函数有助于简化并加速你的测试。目标是给你提供理论和实践建议,帮助你将测试驱动开发工作流程应用到日常工作中。
本部分包括以下章节:
-
第一章,测试驱动开发的初步步骤
-
第二章,渲染列表和详情视图
-
第三章,重构测试套件
-
第四章,测试驱动数据输入
-
第五章,添加复杂表单交互
-
第六章,探索测试替身
-
第七章,测试 useEffect 和模拟组件
-
第八章,构建应用程序组件
第一章:测试驱动开发的入门步骤
这本书通过测试驱动的方法一步步讲解如何构建 React 应用程序。我们将涉及 React 体验的许多不同方面,包括构建表单、组合界面和动画元素。也许更重要的是,我们将在学习一系列测试技术的同时完成这些任务。
你可能已经使用过 React 测试库,如 React Testing Library 或 Enzyme,但这本书不使用它们。相反,我们将从基本原则开始:根据我们的需求直接构建我们自己的测试函数集。这样,我们可以专注于构成所有优秀测试套件的关键成分。这些成分——如超级小测试、测试替身和工厂方法等想法——已有几十年历史,并适用于所有现代编程语言和运行时环境。这就是为什么这本书不使用测试库;实际上并没有必要。你将学到的知识无论你使用哪个测试库都将对你有用。
另一方面,测试驱动开发(TDD)是一种学习新框架和库的有效技术。这使得这本书非常适合 React 及其生态系统。这本书将让你以你可能从未体验过的方式探索 React,并利用 React Router 和 Redux 构建 GraphQL 接口。
如果你刚开始接触 TDD 流程,你可能会觉得它有点过于严格。这是一种细致和有纪律的软件开发风格。你会 wonder 为什么我们要付出如此巨大的努力来构建一个应用程序。对于那些掌握它的人来说,以这种方式指定我们的软件将获得巨大的价值,如下所示:
-
通过对产品规格的清晰描述,我们获得了在不担心变化的情况下调整代码的能力。
-
我们默认获得自动回归测试。
-
我们的测试充当了我们代码的注释,而这些注释在我们运行它们时是可验证的。
-
我们获得了一种与同事沟通我们的决策过程的方法。
你很快就会开始认识到你对正在工作的代码所拥有的更高层次的信任和信心。如果你和我们一样,你可能会对这种感觉上瘾,并发现没有它很难工作。
本书的第一部分和第二部分涉及为美发沙龙构建预约系统——没有什么太过革命性的,但作为示例应用程序来说,它提供了足够的范围。我们将在本章中开始这个项目。第三部分和第四部分使用一个完全不同的应用程序:一个标志解释器。构建它为探索 React 生态系统提供了有趣的方式。
本章将涵盖以下主题:
-
从头开始创建新的 React 项目
-
使用你的第一个测试显示数据
-
重构你的工作
-
编写优秀的测试
到本章结束时,您将很好地了解在构建简单的 React 组件时 TDD 流程的样子。您将看到如何编写测试、如何使其通过以及如何重构您的工作。
技术要求
在本章的后面部分,您将需要安装 Node 包管理器(npm)以及一系列的包。您需要确保您的机器能够运行 Node.js 环境。
您还需要访问终端。
此外,您还应该选择一个好的编辑器或 集成开发环境(IDE)来与您的代码一起工作。
本章的代码文件可以在以下链接中找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter01.
从零开始创建新的 React 项目
在本节中,我们将组装您编写 TDD React 应用程序所需的所有必要组件。
您可能已经遇到了 create-react-app 包,许多人用它来创建初始的 React 项目,但我们将不会使用它。您将要学习的第一个 TDD 原则是 create-react-app 包添加了大量与我们所做无关的样板代码——例如 favicon.ico 文件、示例标志和 CSS 文件。虽然这些无疑是有用的,但 YAGNI 的基本思想是,如果它不符合所需的规范,那么它就不会被包含在内。
YAGNI 的思考方式是,任何不必要的都是简单的 技术债务 —— 它们只是在那里闲置,未使用,阻碍了您。
一旦您看到从零开始启动 React 项目是多么容易,您就再也不会使用 create-react-app 了!
在以下小节中,我们将安装 NPM、Jest、React 和 Babel。
安装 npm
遵循 TDD 流程意味着频繁地运行测试——非常 频繁。测试是通过命令行使用 npm test 命令运行的。所以,让我们先安装 npm。
您可以通过打开终端窗口(如果您使用的是 Windows,则为命令提示符)并输入以下命令来检查您是否已经安装了它:
npm -v
如果命令找不到,请访问 Node.js 网站 nodejs.org 以获取安装说明。
如果您已经安装了 npm,我们建议您确保您使用的是最新版本。您可以在命令行中通过输入以下命令来完成此操作:
npm install npm@latest -g
现在您已经准备好了。您可以使用 npm 命令来创建您的项目。
创建新的 Jest 项目
现在已经安装了 npm,我们可以通过以下步骤创建我们的项目:
-
如果您正在跟随本书的 Git 仓库,请打开终端并导航到您已克隆的仓库目录。否则,只需导航到您通常存储工作项目的位置。
-
使用
mkdir appointments创建一个新的目录,然后使用cd appointments将其设置为当前目录。 -
输入
npm init命令。这将通过生成模板package.json文件来初始化一个新的 npm 项目。您将被提示输入有关项目的一些信息,但您只需接受所有默认设置 除了test command问题,对于这个问题,您应该输入jest。这将使您能够通过使用npm test快捷命令来运行测试。
手动编辑 package.json 文件
在按照说明操作时,如果错过了测试命令的提示,不要担心;您可以在之后通过将 "test": "jest" 添加到生成的 package.json 文件的 scripts 部分来设置它。
-
现在继续使用
npm install --save-dev jest安装 Jest。NPM 将然后下载并安装所有内容。完成后,您应该会看到如下消息:added 325 packages, and audited 326 packages in 24s
Jest 的替代方案
本书将向您介绍的一些 TDD 实践将适用于各种测试运行器,而不仅仅是 Jest。一个例子是 Mocha 测试运行器。如果您对使用 Mocha 与本书结合感兴趣,请查看 reacttdd.com/migrating-from-jest-to-mocha 的指南。
提交早且频繁
虽然我们刚刚开始,但现在是时候提交您所做的工作了。TDD 流程提供了自然的提交停止点——每次您看到一个新的测试通过时,您就可以提交。这样,您的仓库将充满许多小提交。您可能不习惯这样做——您可能更倾向于“每天一个提交”。这是一个尝试新事物的绝佳机会!
提交 早且频繁 可以简化提交信息。如果您在一个提交中只有一个测试,那么您可以使用测试描述作为您的提交信息。无需思考。此外,详细的提交历史记录有助于您在改变主意时回溯。
因此,当您有一个通过测试时,要习惯于输入 git commit。
当您接近一个功能开发的尾声时,您可以使用 git rebase 来压缩您的提交,这样您的 Git 历史记录就会保持整洁。
假设您正在使用 Git 来跟踪您的工作,请继续输入以下命令以 commit 您到目前为止所做的工作:
git init
echo "node_modules" > .gitignore
git add .
git commit -m "Blank project with Jest dependency"
您现在已经“存入”了那个更改,您可以安全地将它放在一边,继续处理接下来的两个依赖项,即 React 和 Babel。
引入 React 和 Babel
让我们安装 React。这是两个可以使用此命令安装的包:
npm install --save react react-dom
接下来,我们需要 Babel,它为我们转换了一些不同的事物:React 的 JavaScript 语法扩展 (JSX) 模板语法、模块模拟(我们将在 第七章,测试 useEffect 和模拟组件)以及我们将使用的各种草案 ECMAScript 构造。
重要提示
以下信息适用于 Babel 7。如果您使用的是后续版本,您可能需要相应地调整安装说明。
现在,Jest 已经包含了 Babel——用于上述模块模拟——所以我们只需要按照以下方式安装预设和插件:
npm install --save-dev @babel/preset-env @babel/preset-react
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime
Babel 预设是一组插件。每个插件都启用 ECMAScript 标准或预处理器(如 JSX)的特定功能。
配置 Babel
通常,env预设应该配置为目标执行环境。对于本书的目的来说,这不是必要的。有关更多信息,请参阅本章末尾的进一步阅读部分。
我们需要启用我们刚刚安装的包。创建一个新的文件,.babelrc,并添加以下代码:
{
"presets": ["@babel/env", "@babel/react"],
"plugins": ["@babel/transform-runtime"]
}
现在,Babel 和 React 都准备好了可以使用。
小贴士
在这个阶段,您可能希望将源代码提交到 Git。
在本节中,您已安装 NPM,初始化了新的 Git 仓库,并安装了构建 React 应用程序所需的包依赖项。您已经准备好编写一些测试了。
在您的第一个测试中显示数据
现在,我们将第一次使用TDD 周期,您将在我们通过周期每个步骤的过程中了解它。
我们将开始构建一个预约视图,以显示预约的详细信息。这是一个名为Appointment的 React 组件,它将传递一个表示美发沙龙预约的数据结构。我们可以想象它看起来有点像以下示例:
{
customer: {
firstName: "Ashley",
lastName: "Jones",
phoneNumber: "(123) 555-0123"
},
stylist: "Jay Speares",
startsAt: "2019-02-02 09:30",
service: "Cut",
notes: ""
}
我们无法在完成本章之前显示所有这些信息;事实上,我们只会显示客户的firstName,并使用startsAt时间戳来排序今天的预约列表。
在接下来的几个小节中,您将编写第一个 Jest 测试,并完成所有必要的步骤使其通过。
编写失败的测试
测试究竟*是什么?为了回答这个问题,让我们写一个。执行以下步骤:
-
在您的项目目录中,键入以下命令:
mkdir test touch test/Appointment.test.js -
在您喜欢的编辑器或 IDE 中打开
test/Appointment.test.js文件,并输入以下代码:describe("Appointment", () => { });
describe函数定义了一个测试套件,它只是一个具有给定名称的测试集合。第一个参数是您正在测试的单元的名称。它可以是 React 组件、函数或模块。第二个参数是一个函数,在其中您定义您的测试。describe函数的目的是描述这个命名“事物”的工作方式——无论这个“事物”是什么。
全局 Jest 函数
当您运行npm test命令时,所有的 Jest 函数(如describe)都已经作为全局命名空间中的必需和可用函数。您不需要导入任何内容。
对于 React 组件,给describe块取与组件本身相同的名称是一个好习惯。
您应该在何处放置您的测试?
如果你尝试使用 create-react-app 模板,你会注意到它包含一个单独的单元测试文件,App.test.js,它位于源文件 App.js 相同的目录中。
我们更喜欢将测试文件与应用程序源文件分开。测试文件放在名为 test 的目录中,源文件放在名为 src 的目录中。这两种方法实际上并没有真正的客观优势。然而,请注意,你可能不会在生产文件和测试文件之间有一个一对一的映射。你可以选择以不同于组织源文件的方式组织你的测试文件。
让我们用 Jest 运行这个测试。你可能会认为现在运行测试是没有意义的,因为我们还没有写测试,但这样做会给我们关于下一步做什么的有价值信息。在使用 TDD 时,在每一个机会运行测试运行器是正常的。
在命令行中再次运行 npm test 命令。你会看到以下输出:
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
这是有道理的——我们还没有编写任何测试,只是写了一个 describe 块来存放它们。至少我们还没有任何语法错误!
小贴士
如果你看到了以下内容:
> echo "Error: no test specified" && exit 1
你需要在你的 package.json 文件中将 Jest 设置为测试命令的值。参见上面 创建一个新的 Jest 项目 中的 步骤 3。
编写你的第一个期望
将你的 describe 调用更改为以下内容:
describe("Appointment", () => {
it("renders the customer first name", () => {
});
});
it 函数定义了一个单独的测试。第一个参数是测试的描述,并且总是以现在时态动词开头,以便用普通英语阅读。函数名中的 it 指的是你用来命名测试套件的名词(在这个例子中,是 Appointment)。实际上,如果你现在运行测试,使用 npm test,下面的输出(如下所示)将很有意义:
PASS test/Appointment.test.js
Appointment
✓ renders the customer first name (1ms)
你可以将 describe 和 it 描述一起读作一个句子:Appointment 渲染客户的首字母。你应该努力使所有测试都能以这种方式阅读。
随着我们添加更多的测试,Jest 将会显示一个通过测试的小清单。
Jest 的测试函数
你可能已经使用了 Jest 的 test 函数,它与 it 等效。我们更喜欢 it,因为它读起来更好,并作为如何简洁描述我们的测试的有用指南。
你可能也看到人们从 “应该…” 开始他们的测试描述。我并不认为这有什么意义,它只是我们不得不输入的一个额外单词。不如直接使用一个精心挑选的动词来跟随 “it”。
空测试,就像我们刚才写的,总是通过。现在让我们添加一个 期望 到我们的测试中,如下所示:
it("renders the customer first name", () => {
expect(document.body.textContent).toContain("Ashley");
});
这个 expect 调用是一个流畅 API 的例子。像测试描述一样,它读起来像普通英语。你可以这样读:
我期望 document.body.textContent 包含 字符串 Ashley。
每个期望都有一个 Ashley,接收到的值是存储在 document.body.textContent 中的任何内容。换句话说,如果 document.body.textContent 中包含 Ashley 这个词,则期望通过。
toContain 函数被称为 matcher,有许多不同的 matcher 以不同的方式工作。你可以(并且应该)编写自己的 matcher。你将在 第三章,重构测试套件 中发现如何做到这一点。为你的项目编写特定的 matcher 是编写清晰、简洁测试的重要部分。
在我们运行这个测试之前,花一分钟时间思考一下代码。你可能已经猜到测试会失败。问题是,它会以什么方式失败?
运行 npm test 命令并找出:
FAIL test/Appointment.test.js
Appointment
✕ renders the customer first name (1 ms)
● Appointment › renders the customer first name
The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
Consider using the "jsdom" test environment.
ReferenceError: document is not defined
1 | describe("Appointment", () => {
2 | it("renders the customer first name", () => {
> 3 | expect(document.body.textContent).toContain("Ashley");
| ^
4 | });
5 | })
6 |
at Object.<anonymous> (test/Appointment.test.js:3:12)
我们遇到了第一个失败!
这可能不是你预期的失败。结果证明,我们还有一些设置要处理。Jest 有助于告诉我们它认为我们需要什么,它是正确的;我们需要指定一个 jsdom 测试环境。
一个 jsdom 测试环境,它实例化一个新的 JSDOM 对象并设置全局和文档对象,将 Node.js 转换成了一个类似浏览器的环境。
jsdom 是一个包含在 Node.js 上运行的、无头实现 文档对象模型 (DOM) 的包。实际上,它将 Node.js 转换成了一个类似浏览器的环境,该环境响应通常的 DOM API,例如我们在这次测试中试图访问的文档 API。
Jest 提供了一个预包装的 jsdom 测试环境,这将确保我们的测试在具有这些 DOM API 准备好的情况下运行。我们只需要安装它并指导 Jest 使用它。
在你的命令提示符中运行以下命令:
npm install --save-dev jest-environment-jsdom
现在,我们需要打开 package.json 并在底部添加以下部分:
{
...,
"jest": {
"testEnvironment": "jsdom"
}
}
然后,我们再次运行 npm test,得到以下输出:
FAIL test/Appointment.test.js
Appointment
✕ renders the customer first name (10ms)
● Appointment › renders the customer first name
expect(received).toContain(expected)
Expected substring: "Ashley"
Received string: ""
1 | describe("Appointment", () => {
2 | it("renders the customer first name", () => {
> 3 | expect(document.body.textContent).toContain("Ashley");
| ^
4 | });
5 | });
6 |
at Object.toContain (test/Appointment.test.js:3:39)
测试输出中有四个部分对我们来说是相关的:
-
失败测试的名称
-
预期的答案
-
实际的答案
-
错误发生的源代码位置
所有这些都有助于我们确定测试失败的原因:document.body.textContent 是空的。鉴于我们还没有编写任何 React 代码,这并不奇怪。
在测试中从内部渲染 React 组件
为了让这个测试通过,我们将在期望之上编写一些代码,这些代码将调用我们的生产代码。
让我们从那个期望开始逆向工作。我们知道我们想要构建一个 React 组件来渲染这个文本(这就是我们之前指定的 Appointment 组件)。如果我们想象我们已经定义了那个组件,我们将如何让 React 在我们的测试中从内部渲染它?
我们只是做我们自己在应用程序入口点会做的事情。我们以这种方式渲染我们的根组件:
ReactDOM.createRoot(container).render(component);
前面的函数用 React 渲染我们的 component 构造的新元素替换 DOM container 元素,在我们的例子中,这个 component 将被称为 Appointment。
createRoot 函数
createRoot 函数是 React 18 中的新功能。将其与 render 的调用链式调用对于大多数测试来说就足够了,但在 第七章**,测试 useEffect 和组件模拟,你将对其进行一些调整以支持单个测试中的重新渲染。
为了在我们的测试中调用它,我们需要定义 component 和 container。然后测试将具有以下形状:
it("renders the customer first name", () => {
const component = ???
const container = ???
ReactDOM.createRoot(container).render(component);
expect(document.body.textContent).toContain("Ashley");
});
component 的值很容易确定;它将是一个 Appointment 的实例,即我们要测试的组件。我们指定它接受一个客户作为属性,所以现在让我们写出它可能的样子。这是一个接受 customer 作为属性的 JSX 片段:
const customer = { firstName: "Ashley" };
const component = <Appointment customer={customer} />;
如果你之前从未做过任何 TDD,这可能会显得有些奇怪。为什么我们要为尚未构建的组件编写测试代码?嗯,这部分的目的是 TDD 的一个要点——我们让测试驱动我们的设计。在本节的开始,我们提出了关于 Appointment 组件将要做什么的口头规范。现在,我们有一个具体的、书面的规范,可以通过运行测试自动验证。
简化测试数据
在我们考虑设计的时候,我们为我们的预约制定了一个完整的对象格式。你可能会认为这里客户的定义非常稀疏,因为它只包含一个名字,但我们不需要其他任何东西来进行关于客户名字的测试。
我们已经确定了 component。那么,关于 container 呢?我们可以使用 DOM 创建一个 container 元素,如下所示:
const container = document.createElement("div");
document.createElement 的调用给我们提供了一个新的 HTML 元素,我们将用它作为我们的渲染根。然而,我们还需要将其附加到当前文档的 body 上。这是因为某些 DOM 事件只有在我们的元素是文档树的一部分时才会注册。因此,我们还需要使用以下代码行:
document.body.appendChild(container);
现在我们的期望应该能够捕获我们渲染的任何内容,因为它被渲染为 document.body 的一部分。
警告
我们不会长时间使用 appendChild;在本章的后面部分,我们将用更合适的东西替换它。我们不推荐在自己的测试套件中使用 appendChild,原因将在后面变得清楚!
让我们把所有这些放在一起:
-
将
test/Appointments.test.js中的测试更改如下:it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.appendChild(container); ReactDOM.createRoot(container).render(component); expect(document.body.textContent).toContain( "Ashley" ); }); -
由于我们同时使用
ReactDOM命名空间和 JSX,我们需要在测试文件的顶部包含这两个标准的 React 导入,以便它能够正常工作,如下所示:import React from "react"; import ReactDOM from "react-dom/client"; -
好吧,运行测试;它会失败。在输出中,你会看到以下代码:
ReferenceError: Appointment is not defined 5 | it("renders the customer first name", () => { 6 | const customer = { firstName: "Ashley" }; > 7 | const component = ( 8 | <Appointment customer={customer} /> | ^ 9 | );
这与之前看到的测试失败略有不同。这是一个运行时异常,而不是期望失败。幸运的是,这个异常告诉我们确切需要做什么,就像测试期望一样。现在是时候构建 Appointment 了。
让它通过
我们现在准备好让失败的测试通过。执行以下步骤:
-
在
test/Appointment.test.js中,在两个 React 导入下方添加一个新的import语句,如下所示:import { Appointment } from "../src/Appointment"; -
使用
npm test运行测试。这次您会得到一个不同的错误,关键信息如下:Cannot find module '../src/Appointment' from 'Appointment.test.js'
默认导出
虽然 Appointment 被定义为导出,但它没有被定义为默认导出。这意味着我们必须使用花括号形式的导入(import { ... })来导入它。我们倾向于避免使用默认导出,因为这样做可以保持组件名称及其使用的一致性。如果我们更改组件的名称,那么所有导入它的地方都会中断,直到我们也更改它们。默认导出不是这种情况。一旦您的名称不一致,跟踪组件的使用就变得更加困难——您不能简单地使用文本搜索来找到它们。
-
让我们创建那个模块。在您的命令提示符中输入以下代码:
mkdir src touch src/Appointment.js -
在您的编辑器中,将以下内容添加到
src/Appointment.js文件中:export const Appointment = () => {};
为什么我们创建了一个没有实际创建实现的 Appointment 壳?这看起来可能有些无意义,但 TDD 的另一个核心原则是总是做最简单的事情来通过测试。我们可以将这句话重新表述为总是做最简单的事情来修复你正在工作的错误。
记得我们提到我们仔细倾听测试运行器告诉我们的话吗?在这种情况下,测试运行器说“无法”找到模块 Appointment,所以需要创建那个模块,我们已经创建了,然后立即停止。在我们做任何其他事情之前,我们需要运行我们的测试,以了解下一步要做什么。
再次运行 npm test,你应该得到以下测试失败:
● Appointment › renders the customer first name
expect(received).toContain(expected)
Expected substring: "Ashley"
Received string: ""
12 | ReactDOM.createRoot(...).render(component);
13 |
> 14 | expect(document.body.textContent).toContain(
| ^
15 | "Ashley"
16 | );
17 | });
at Object.<anonymous> (test/Appointment.test.js:14:39)
为了修复测试,让我们将 Appointment 定义更改为以下内容:
export const Appointment = () => "Ashley";
你可能正在想,“那不是一个组件!没有 JSX。” 正确。 “而且它甚至没有使用 customer 属性!” 也正确。但 React 仍然会渲染它,理论上,它应该使测试通过;所以,在实践中,这至少是一个足够好的实现,至少目前是这样。
我们总是编写最少的代码,以确保测试通过。
但它是否通过了?再次运行 npm test 并查看输出:
● Appointment › renders the customer first name
expect(received).toContain(expected)
Expected substring: "Ashley"
Received string: ""
12 | ReactDOM.createRoot(...).render(component);
13 |
> 14 | expect(document.body.textContent).toContain(
15 | ^
16 | "Ashley"
17 | );
| });
不,它没有通过。这有点令人困惑。我们确实定义了一个有效的 React 组件。我们也告诉 React 在我们的容器中渲染它。发生了什么?
利用 act 测试助手
在这种类似 React 测试的情况下,答案通常与运行时环境的异步特性有关。从 React 18 开始,渲染函数是异步的:函数调用会在 React 修改 DOM 之前返回。因此,期望会在 DOM 修改之前运行。
React 为我们的测试提供了一个辅助函数,该函数会在异步渲染完成后暂停。它被称为 act,您只需将其包装在任意的 React API 调用周围。要使用 act,请执行以下步骤:
-
前往
test/Appointment.test.js的顶部并添加以下代码行:import { act } from "react-dom/test-utils"; -
然后,将包含
render调用的行更改为以下内容:act(() => ReactDOM.createRoot(container).render(component) ); -
现在再次运行您的测试,您应该会看到一个通过测试,但上面会打印出一个奇怪的警告,如下所示:
> jest console.error Warning: The current testing environment is not configured to support act(...) at printWarning (node_modules/react-dom/cjs/react-dom.development.js:86:30)
React 希望我们在使用act时明确。这是因为有一些情况下act是没有意义的——但对于单元测试,我们几乎肯定想要使用它。
理解act函数
虽然我们在这里使用它,但act函数对于测试 React 不是必需的。关于此函数的详细讨论以及如何使用它,请访问reacttdd.com/understanding-act。
-
让我们继续启用
act函数。打开package.json并修改您的jest属性,使其如下所示:{ ..., "jest": { "testEnvironment": "jsdom", "globals": { "IS_REACT_ACT_ENVIRONMENT": true } } } -
现在再次运行您的测试,使用
npm test,您应该会看到如下的输出:> jest PASS test/Appointment.test.js Appointment ✓ renders the customer first name (13 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.355 s Ran all test suites.
最后,您的测试通过了,没有任何警告!
在下一节中,您将了解到如何移除通过添加第二个测试而引入的硬编码字符串值。
通过三角定位移除硬编码
现在我们已经克服了这个小障碍,让我们再次思考测试中的问题。我们做了一系列奇怪的杂技动作,只是为了让这个测试通过。其中一件奇怪的事情是在 React 组件中使用硬编码的Ashley值,尽管我们已经费尽心思在我们的测试中定义了一个客户属性并将其传递进去。
我们这样做是因为我们想要坚持我们的规则,只做能让测试通过的最简单的事情。为了到达真正的实现,我们需要添加更多的测试。
这个过程被称为三角定位。我们添加更多的测试来构建更真实的实现。我们的测试越具体,我们的生产代码就需要越泛化。
乒乓编程
这就是为什么使用 TDD 进行结对编程可以如此有趣的原因之一。结对可以玩乒乓。有时,你的搭档会写一个你可以轻易解决的测试,可能通过硬编码,然后你通过三角定位强迫他们做两个测试的艰难工作。他们需要移除硬编码并添加泛化。
让我们通过以下步骤进行三角定位:
-
将您的第一个测试复制一份,粘贴在第一个测试的下面,并更改测试描述以及
Ashley的名字为Jordan,如下所示:it("renders another customer first name", () => { const customer = { firstName: "Jordan" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.appendChild(container); act(() => ReactDOM.createRoot(container).render(component) ); expect(document.body.textContent).toContain( "Jordan" ); }); -
使用
npm test运行测试。我们预计这个测试会失败,并且它确实失败了。但仔细检查代码。这是您期望看到的吗?看看以下代码中的Received string的值:FAIL test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✕ renders another customer first name (8ms) ● Appointment › renders another customer first name expect(received).toContain(expected) Expected substring: "Jordan" Received string: "AshleyAshley"
文档体中包含文本AshleyAshley。这种重复的文本是表明我们的测试不是彼此独立的。组件被渲染了两次,一次对应每个测试。这是正确的,但文档在每次测试运行之间并没有被清除。
这是一个问题。当涉及到单元测试时,我们希望所有测试都是相互独立的。如果它们不是,一个测试的输出可能会影响后续测试的功能。一个测试可能因为上一个测试的动作而通过,导致假阳性。即使测试确实失败了,由于初始状态未知,你将花费时间来确定问题是由于测试的初始状态引起的,而不是测试场景本身。
我们需要改变方向并修复这个问题,以免我们陷入麻烦。
测试独立性
单元测试应该相互独立。实现这一点的最简单方法是在测试之间不共享任何状态。每个测试应该只使用它自己创建的变量。
退回原点
我们知道document。这是由jsdom环境提供的单个全局document对象,这与正常网页浏览器的操作方式一致:有一个单一的document对象。但不幸的是,我们的两个测试使用appendChild将内容添加到它们之间共享的单个文档中。它们各自没有得到自己的单独实例。
一个简单的解决方案是将appendChild替换为replaceChildren,如下所示:
document.body.replaceChildren(container);
这将在执行追加之前清除document.body中的所有内容。
但存在问题。我们正在进行一个红色测试。在我们处于红色状态时,我们绝不应该重构、重做或以其他方式改变方向。
虽然这全部都是高度人为的——我们本可以从一开始就使用replaceChildren。但我们不仅证明了replaceChildren的必要性,我们还将发现处理这类场景的重要技术。
我们必须跳过这个正在工作的测试,修复之前的测试,然后重新启用跳过的测试。现在让我们通过执行以下步骤来完成:
-
在你刚刚编写的第一个测试中,将
it更改为it.skip。现在按照以下方式对第二个测试做同样的操作:it.skip("renders another customer first name", () => { ... }); -
运行测试。你会看到 Jest 忽略了第二个测试,而第一个测试仍然通过,如下所示:
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (19ms) ○ skipped 1 test Test Suites: 1 passed, 1 total Tests: 1 skipped, 1 passed, 2 total -
在第一个测试中,按照以下方式将
appendChild更改为replaceChildren:it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.replaceChildren(container); ReactDOM.createRoot(container).render(component); expect(document.body.textContent).toContain( "Ashley" ); }); -
使用
npm test重新运行测试。它应该仍然通过。
是时候通过从函数名中移除.skip来将跳过的测试重新引入了。
-
在这个测试中执行与第一个测试相同的更新:将
appendChild更改为replaceChildren,如下所示:it("renders another customer first name", () => { const customer = { firstName: "Jordan" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.replaceChildren(container); act(() => ReactDOM.createRoot(container).render(component) ); expect(document.body.textContent).toContain( "Jordan" ); }); -
现在运行测试应该会给我们原本预期的错误。不再有重复的文本内容,如下所示:
FAIL test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✕ renders another customer first name (8ms) ● Appointment › renders another customer first name expect(received).toContain(expected) Expected substring: "Jordan" Received string: "Ashley" -
为了使测试通过,我们需要引入属性并在我们的组件中使用它。将
Appointment的定义更改为如下,解构函数参数以提取客户属性:export const Appointment = ({ customer }) => ( <div>{customer.firstName}</div> ); -
运行测试。我们预计这个测试现在会通过:
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (21ms) ✓ renders another customer first name (2ms)
干得好!我们的通过测试已经完成,并且我们已经成功定位并移除了硬编码。
在本节中,你编写了两个测试,在这个过程中,你发现了并克服了我们编写 React 组件自动化测试时面临的一些挑战。
现在我们已经让测试工作正常,我们可以更仔细地查看我们编写的代码。
重构你的工作
现在你已经得到了一个绿色的测试,是时候重构你的工作了。重构是调整代码结构而不改变其功能的过程。这对于保持代码库处于良好、可维护的状态至关重要。
很遗憾,重构步骤总是被遗忘的步骤。冲动是直接进入下一个功能。我们无法强调花时间简单地停下来 凝视 代码并思考改进方法的重要性。练习你的重构技能是成为开发者提升水平的一个可靠方法。
俗语“欲速则不达”在编程中就像在生活中一样适用。如果你养成了跳过重构阶段的习惯,你的代码质量可能会随着时间的推移而下降,这使得它更难工作,因此构建新功能的速度会变慢。
TDD 循环帮助你建立良好的个人纪律和习惯,例如持续重构。这可能需要前期更多的努力,但你会收获一个随着时间推移仍然可维护的代码库的回报。
不要重复自己
测试代码需要与生产代码一样多的关注和照顾。当你重构测试时,你将依赖的第一大原则是不要重复自己(DRY)。“干燥测试”是所有 TDD 实践者经常重复的一个短语。
关键点是你希望你的测试尽可能简洁。当你看到存在于多个测试中的重复代码时,这是一个很好的迹象,表明你可以将这段重复代码提取出来。有几种不同的方法可以做到这一点,我们将在本章中介绍其中的一些。
你将在 第三章“重构测试套件”中看到进一步干燥测试的技术。
在测试之间共享设置代码
当测试包含相同的设置说明时,我们可以将这些说明提升为共享的 beforeEach 块。此块中的代码在每个测试之前执行。
我们的两个测试都使用了相同的两个变量:container 和 customer。其中第一个,container,在每个测试中都是相同初始化的。这使得它成为 beforeEach 块的良好候选者。
执行以下步骤以引入你的第一个 beforeEach 块:
-
由于
container需要在beforeEach块和每个测试中访问,我们必须在外部的describe范围内声明它。由于我们将在beforeEach块中设置其值,这也意味着我们需要使用let而不是const。在第一个测试上方添加以下代码行:let container; -
在以下声明下方添加以下代码:
beforeEach(() => { container = document.createElement("div"); document.body.replaceChildren(container); }); -
从你的两个测试中各自删除相应的两行。注意,由于我们在
describe块的作用域中定义了container,因此在beforeEach块中设置的值将在测试执行时对测试可用。
使用 let 而不是 const
当你在 describe 范围内使用 let 定义时,要小心。这些变量在每次测试执行之间默认不会被清除,并且共享的状态将影响每个测试的结果。一个很好的经验法则是,你应在 describe 范围内声明的任何变量都应在相应的 beforeEach 块中分配新值,或者在每个测试的第一部分,就像我们在这里所做的那样。
要更详细地了解在测试套件中使用 let 的方法,请访问 reacttdd.com/use-of-let。
在第三章**,重构测试套件中,我们将探讨一种在多个测试套件之间共享此设置代码的方法。
提取方法
两个测试中的 render 调用相同。考虑到它被 act 调用所包裹,它的长度也相当长。因此,提取整个操作并给它一个更有意义的名称是有意义的。
而不是直接提取出来,我们可以创建一个新的函数,该函数将 Appointment 组件作为其参数。为什么这样做有用的解释将在之后给出,但现在让我们执行以下步骤:
-
在第一个测试上方写下以下定义。注意,它仍然需要位于
describe块内,因为它使用了container变量:const render = component => act(() => ReactDOM.createRoot(container).render(component) ); -
现在,将每个测试中的
render调用替换为以下代码行:render(<Appointment customer={customer} />); -
在前面的步骤中,我们内联了 JSX,直接将其传递给
render。这意味着你现在可以删除以const component开头的行。例如,你的第一个测试应该看起来像以下示例:it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; render(<Appointment customer={customer} />); expect(document.body.textContent).toContain( "Ashley" ); }); -
重新运行你的测试并验证它们是否仍然通过。
在你的测试中突出差异
你想要突出的测试部分是不同测试之间的差异部分。通常,一些代码保持不变(例如 container 和渲染组件所需的步骤),而一些代码则不同(例如本例中的 customer)。尽你所能隐藏相同的部分,突出不同的部分。这样,就可以清楚地知道测试具体在测试什么。
本节介绍了几种重构代码的简单方法。随着本书的进展,我们将探讨许多不同的方法,这些方法可以重构生产源代码和测试代码。
编写优秀的测试
现在你已经编写了一些测试,让我们暂时离开键盘,讨论一下你迄今为止所看到的内容。
你的第一个测试看起来像以下示例:
it("renders the customer first name", () => {
const customer = { firstName: "Ashley" };
render(<Appointment customer={customer} />);
expect(document.body.textContent).toContain("Ashley");
});
这份文档简洁且易于阅读。
一个好的测试有三个明显的部分:
-
安排:设置测试依赖项
-
执行操作:在测试中执行生产代码
-
断言:检查期望是否得到满足
这一点理解得如此透彻,以至于被称为安排、行动、断言(AAA)模式,本书中的所有测试都遵循此模式。
一个优秀的测试不仅很好,而且还有以下特点:
-
简短
-
描述性
-
与其他测试独立
-
没有副作用
在本节的剩余部分,我们将讨论你已使用的 TDD 周期,以及如何设置你的开发环境以方便进行 TDD。
红色、绿色、重构
TDD 的核心是我们在前面看到的红色、绿色、重构周期。

图 1.1 – TDD 周期
TDD 周期的步骤是:
-
编写失败的测试:编写一个简短的测试来描述你想要的功能。执行你的测试并观察它失败。如果它没有失败,那么它是一个不必要的测试;删除它并编写另一个。
-
使其通过:通过编写最简单的能工作的生产代码来使测试通过。不用担心寻找整洁的代码结构;你可以稍后整理它。
-
重构你的代码:停下来,放慢速度,抵制继续进行下一个功能的冲动。努力使你的代码——无论是生产代码还是测试代码——尽可能干净。
这就是全部内容。你已经在前面两个部分中看到了这个周期的实际应用,我们将在本书的其余部分继续使用它。
简化你的测试过程
想想你到目前为止在这本书上投入的努力。你做了哪些最多的动作?它们如下:
-
在
src/Appointment.js和test/Appointment.test.js之间切换 -
运行
npm test并分析输出
确保你可以快速执行这些操作。
首先,你应该在你的编辑器中使用分屏功能。如果你还没有这样做,利用这个机会学习如何操作。在一侧加载你的生产模块,在另一侧加载相应的单元测试文件。
这是我们设置的一个图片;我们使用nvim和tmux:

图 1.2 – 在终端中运行 tmux 和 vim 的典型 TDD 设置
你可以看到我们还在底部有一个小测试窗口来显示测试输出。
Jest 也可以监视你的文件,并在它们更改时自动运行测试。要启用此功能,将package.json中的test命令更改为jest --watchAll。这将在检测到任何更改时重新运行所有测试。
监视文件变化
Jest 的监视模式有一个选项,可以只运行已更改文件中的测试,但由于你的 React 应用将由许多不同的文件组成,每个文件都相互关联,因此最好运行所有内容,因为许多模块可能会出现故障。
摘要
测试就像我们学习中的安全带;我们可以在理解的基础上构建小块知识,层层叠加,不断向上,无需担心跌落。
在本章中,你已经学到了很多关于 TDD 体验的知识。
首先,你从头开始设置一个 React 项目,只引入运行所需的最小依赖。你已经使用 Jest 的describe、it和beforeEach函数编写了两个测试。你发现了act辅助函数,它确保在测试期望执行之前,所有的 React 渲染都已经完成。
你还看到了很多测试想法。最重要的是,你已经练习了 TDD 的“红-绿-重构”循环。你还使用了三角测量法,并学习了安排、执行、断言模式。
此外,我们还加入了一些设计原则,以供参考:DRY(不要重复自己)和 YAGNI(你不需要它,直到你需要它)。
虽然这是一个很好的开始,但旅程才刚刚开始。在接下来的章节中,我们将测试一个更复杂的组件。
进一步阅读
查看 Babel 网页,了解如何正确配置 Babel 的env预设。这对于实际应用非常重要,但我们在这章中跳过了它。你可以通过以下链接找到它:
babeljs.io/docs/en/babel-preset-env。
React 的act函数是在 React 17 中引入的,并在 React 18 中进行了更新。它表面上看似复杂。有关此函数如何使用的更多讨论,请参阅以下链接中的博客文章:reacttdd.com/understanding-act。
这本书并没有充分利用 Jest 的watch功能。在 Jest 的最近版本中,这个功能进行了一些有趣的更新,例如可以选择要监视的文件。如果你发现重新运行测试很困难,你可能想尝试一下。更多信息请参阅以下链接:jestjs.io/docs/en/cli#watch。
第二章:渲染列表和详情视图
上一章介绍了核心 TDD 周期:红、绿、重构。你有机会尝试两个简单的测试。现在,是时候将其应用到更大的 React 组件上了。
目前,你的应用程序只显示一条数据项:客户的姓名。在本章中,你将扩展它,以便查看当天所有的预约。你将能够选择一个时间段,并查看该时间段的预约详情。我们将通过绘制草图来开始本章,以帮助我们规划如何构建组件。然后,我们将开始实现列表视图并显示预约详情。
一旦我们使组件处于良好的状态,我们将使用 webpack 构建入口点,然后运行应用程序以进行一些手动测试。
本章将涵盖以下主题:
-
绘制草图
-
创建新组件
-
指定列表项内容
-
选择要查看的数据
-
手动测试我们的更改
到本章结束时,你将使用你已学到的 TDD 过程编写一个相当大的 React 组件。你还将看到应用程序的首次运行。
技术要求
本章的代码文件可以在github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter02找到。
绘制草图
让我们从更多的预先设计开始。我们有一个Appointment组件,它接受一个预约并显示它。我们将围绕它构建一个AppointmentsDayView组件,该组件接受一个appointment对象的数组,并将它们显示为列表。它还将显示一个单独的Appointment:当前选定的预约。要选择一个预约,用户只需点击他们感兴趣的一天中的时间。

图 2.1 – 我们预约系统 UI 的草图
预先设计
当你使用 TDD 来构建新功能时,进行一点预先设计非常重要,这样你才能对实现的方向有一个大致的了解。
那就是我们现在需要的所有设计;让我们直接开始构建新的AppointmentsDayView组件。
创建新组件
在本节中,我们将创建AppointmentsDayView的基本形式:一天中的预约时间列表。我们目前不会为它构建任何交互行为。
我们将把我们的新组件添加到我们一直在使用的同一个文件中,因为到目前为止那里没有多少代码。执行以下步骤:
放置组件
我们并不总是需要为每个组件创建一个新的文件,尤其是当组件是短的功能组件时,比如我们的Appointment组件(一个单行函数)。将相关的组件或组件的小子树组合在一个地方可能会有所帮助。
-
在
test/Appointment.test.js中,在第一个describe块下面创建一个新的describe块,包含一个单独的测试。这个测试检查我们是否渲染了一个具有特定 ID 的div。在这个情况下,这是很重要的,因为我们加载了一个 CSS 文件,它会查找这个元素。这个测试中的期望使用了 DOM 方法querySelector。这个方法在 DOM 树中搜索一个带有提供标签的单个元素:describe("AppointmentsDayView", () => { let container; beforeEach(() => { container = document.createElement("div"); document.body.replaceChildren(container); }); const render = (component) => act(() => ReactDOM.createRoot(container).render(component) ); it("renders a div with the right id", () => { render(<AppointmentsDayView appointments={[]} />); expect( document.querySelector( "div#appointmentsDayView" ) ).not.toBeNull(); }); });
注意
通常情况下,没有必要将你的组件包裹在一个带有 ID 或类的div中。我们倾向于这样做,因为我们想将 CSS 附加到由组件渲染的整个 HTML 元素组上,正如你稍后将会看到的,对于AppointmentsDayView来说就是这样。
这个测试使用了第一个describe块中的相同的render函数,以及相同的let container声明和beforeEach块。换句话说,我们已经引入了重复的代码。通过从我们的第一个测试套件中复制代码,我们在清理代码后直接制造了一团糟!嗯,在我们处于 TDD 周期的第一阶段时,我们可以这样做。一旦测试通过,我们就可以考虑代码的正确结构了。
-
运行
npm test并查看输出:FAIL test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✓ renders another customer first name (2ms) AppointmentsDayView ✕ renders a div with the right id (7ms) ● AppointmentsDayView › renders a div with the right id ReferenceError: AppointmentsDayView is not defined
让我们通过以下步骤来使这个测试通过:
-
为了解决这个问题,请将测试文件中的最后一个
import语句更改为以下内容:import { Appointment, AppointmentsDayView, } from "../src/Appointment"; -
在
src/Appointment.js中,在Appointment下面添加以下功能组件,如图所示:export const AppointmentsDayView = () => {}; -
再次运行你的测试。你将看到如下输出:
● AppointmentsDayView › renders a div with the right id expect(received).not.toBeNull() -
最后,一个测试失败了!让我们按照以下方式放置那个
div:export const AppointmentsDayView = () => ( <div id="appointmentsDayView"></div> ); -
你的测试现在应该通过了。让我们继续下一个测试。在
test/Appointment.test.js中,在最后一个测试下面添加以下文本,仍然在AppointmentsDayView的describe块内:it("renders an ol element to display appointments", () => { render(<AppointmentsDayView appointments={[]} />); const listElement = document.querySelector("ol"); expect(listElement).not.toBeNull(); }); -
再次运行你的测试,你将看到以下文本所示的输出:
● AppointmentsDayView › renders an ol element to display appointments expect(received).not.toBeNull() Received: null -
为了使测试通过,添加以下
ol元素:export const AppointmentsDayView = () => ( <div id="appointmentsDayView"> <ol /> </div> ); -
好的,现在让我们用每个预约的项目填充那个
ol列表。为此,我们需要(至少)两个作为appointments属性值的预约。添加下一个测试,如图所示:it("renders an li for each appointment", () => { const today = new Date(); const twoAppointments = [ { startsAt: today.setHours(12, 0) }, { startsAt: today.setHours(13, 0) }, ]; render( <AppointmentsDayView appointments={twoAppointments} /> ); const listChildren = document.querySelectorAll("ol > li"); expect(listChildren).toHaveLength(2); });
测试日期和时间
在测试中,today常量被定义为new Date()。两个记录中的每一个都使用这个作为基准日期。当我们处理日期时,非常重要的一点是我们应该基于同一时间点来安排所有事件,而不是多次从系统中获取当前时间。这样做是一个潜在的微妙错误。
-
再次运行
npm test,你将看到以下输出:● AppointmentsDayView › renders an li for each appointment expect(received).toHaveLength(expected) Expected length: 2 Received length: 0 Received object: [] -
为了解决这个问题,我们遍历提供的
appointments属性,并渲染一个空的li元素:export const AppointmentsDayView = ( { appointments } ) => ( <div id="appointmentsDayView"> <ol> {appointments.map(() => ( <li /> ))} </ol> </div> );
忽略未使用的函数参数
map 函数将为传递给它的函数提供一个 appointment 参数。由于我们目前还没有使用这个参数,我们不需要在函数签名中提及它——我们只需假装我们的函数没有参数即可,因此括号是空的。别担心,我们将在后续测试中需要这个参数,那时我们会添加它。
-
太好了,让我们看看 Jest 怎么想。再次运行
npm test:console.error Warning: Each child in a list should have a unique "key" prop. Check the render method of AppointmentsDayView. ... PASS test/Appointment.test.js Appointment ✓ renders the customer first name (19ms) ✓ renders another customer first name (2ms) AppointmentsDayView ✓ renders a div with the right id (7ms) ✓ renders an ol element to display appointments (16ms) ✓ renders an li for each appointment (16ms) -
我们的测试通过了,但我们收到了 React 的警告。它告诉我们要在每个子元素上设置一个键值。我们可以使用
startsAt作为键,如下所示:<ol> {appointments.map(appointment => ( <li key={appointment.startsAt} /> ))} </ol>
测试键值
在 React 中测试键值没有简单的方法。为了做到这一点,我们需要依赖于内部 React 属性,这可能会引入风险,即如果 React 团队更改这些属性,测试可能会中断。
我们能做的就是设置一个键来消除这个警告信息。在一个理想的世界里,我们会有一个使用每个 li 键的 startsAt 时间戳的测试。让我们假设我们已经有了那个测试。
本节介绍了如何渲染列表的基本结构和其列表项。接下来,是时候填充这些项了。
指定列表项内容
在本节中,你将添加一个使用示例预约数组的测试,以指定列表项应显示每个预约的时间,然后你将使用该测试来支持实现。
让我们从测试开始:
-
在新的
describe块中创建第四个测试,如下所示:it("renders the time of each appointment", () => { const today = new Date(); const twoAppointments = [ { startsAt: today.setHours(12, 0) }, { startsAt: today.setHours(13, 0) }, ]; render( <AppointmentsDayView appointments={twoAppointments} /> ); const listChildren = document.querySelectorAll("li"); expect(listChildren[0].textContent).toEqual( "12:00" ); expect(listChildren[1].textContent).toEqual( "13:00" ); });
Jest 将显示以下错误:
● AppointmentsDayView › renders the time of each appointment
expect(received).toEqual(expected) // deep equality
Expected: "12:00"
Received: ""
toEqual 匹配器
这个匹配器是 toContain 的更严格版本。期望只有在文本内容是精确匹配的情况下才会通过。在这种情况下,我们认为使用 toEqual 是有意义的。然而,通常最好尽可能宽松地设定期望。严格的期望往往会在你对代码库进行最轻微的更改时崩溃。
-
将以下函数添加到
src/Appointment.js中,该函数将 Unix 时间戳(我们从setHours的返回值中获取)转换为一天中的时间。你可以在文件的任何位置放置它;我们通常喜欢在使用之前定义常量,所以这应该放在文件顶部:const appointmentTimeOfDay = (startsAt) => { const [h, m] = new Date(startsAt) .toTimeString() .split(":"); return `${h}:${m}`; }
理解语法
这个函数使用了 解构赋值 和 模板字符串,这些是你可以用来使你的函数更简洁的语言特性。
良好的单元测试可以帮助我们学习高级语言语法。如果我们对函数的功能不确定,我们可以查找帮助我们弄清楚这些的测试。
-
使用前面的函数按如下方式更新
AppointmentsDayView:<ol> {appointments.map(appointment => ( <li key={appointment.startsAt}> {appointmentTimeOfDay(appointment.startsAt)} </li> ))} </ol> -
运行测试应该显示一切正常:
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (19ms) ✓ renders another customer first name (2ms) AppointmentsDayView ✓ renders a div with the right id (7ms) ✓ renders an ol element to display appointments (16ms) ✓ renders an li for each appointment (6ms) ✓ renders the time of each appointment (3ms)
这是一个很好的重构机会。最后两个 AppointmentsDayView 测试使用了相同的 twoAppointments 属性值。这个定义和 today 常量可以被提升到 describe 范围内,就像我们在 Appointment 测试中对 customer 做的那样。然而,这次它们可以保持为 const 声明,因为它们永远不会改变。
- 为了做到这一点,将
today和twoAppointments的定义从其中一个测试移动到describe块的顶部,在beforeEach之上。然后,从两个测试中删除这些定义。
这个测试就到这里。接下来,是时候专注于添加点击行为。
选择要查看的数据
让我们在页面上添加一些动态行为。我们将使每个列表项都成为一个用户可以点击以查看该预约的链接。
在思考我们的设计时,我们需要以下几个部分:
-
我们
li中的button元素 -
附着到那个
button元素的onClick处理程序 -
组件状态用于记录当前正在查看的预约
当我们测试 React 动作时,我们通过观察这些动作的后果来进行。在这种情况下,我们可以点击一个按钮,然后检查相应的预约现在是否已渲染在屏幕上。
我们将把这个部分分成两部分:首先,我们将指定组件应该如何初始显示,其次,我们将处理一个用于更改内容的点击事件。
初始数据选择
让我们首先断言每个li元素都有一个button元素:
-
如果今天没有预约,我们希望向用户显示一条消息。在
AppointmentsDayView的describe块中添加以下测试:it("initially shows a message saying there are no appointments today", () => { render(<AppointmentsDayView appointments={[]} />); expect(document.body.textContent).toContain( "There are no appointments scheduled for today." ); }); -
通过在渲染输出的底部添加一条消息来使测试通过。我们目前不需要检查空的
appointments数组;我们需要另一个测试来验证这一点。消息如下:return ( <div id="appointmentsDayView"> ... <p>There are no appointments scheduled for today.</p> </div> ); -
当组件首次加载时,我们应该显示当天的第一个预约。一个检查这一点的简单方法是在页面上查找客户的第一个名字。添加下一个测试,如下所示:
it("selects the first appointment by default", () => { render( <AppointmentsDayView appointments={twoAppointments} /> ); expect(document.body.textContent).toContain( "Ashley" ); }); -
由于我们正在寻找客户的姓名,我们需要确保它在
twoAppointments数组中可用。现在更新它,包括客户的第一个名字如下:const twoAppointments = [ { startsAt: today.setHours(12, 0), customer: { firstName: "Ashley" }, }, { startsAt: today.setHours(13, 0), customer: { firstName: "Jordan" }, }, ]; -
通过修改
Appointment组件来使测试通过。将div组件的最后一行修改如下:<div id="appointmentsDayView"> ... {appointments.length === 0 ? ( <p>There are no appointments scheduled for today.</p> ) : ( <Appointment {...appointments[0]} /> )} </div>
现在我们已经准备好让用户进行选择了。
向功能组件添加事件
我们即将为我们的组件添加状态。该组件将为每个预约显示一个按钮。当按钮被点击时,组件将存储它所引用的预约的数组索引。为此,我们将使用useState钩子。
什么是钩子?
useState钩子存储了函数多次渲染之间的数据。对useState的调用返回存储中的当前值和一个设置函数,允许它被设置。
如果你刚开始接触钩子,请查看本章末尾的进一步阅读部分。或者,你也可以只是跟随并看看你通过阅读测试能学到多少!
我们将首先断言每个li元素都有一个button元素:
-
在你添加的最后一个测试下面添加以下测试。第二个期望是独特的,因为它正在检查按钮元素的
type属性是否为button。如果你之前没有见过,当使用button元素时,通过设置type属性来定义其角色是惯用的,就像这个测试中所示:it("has a button element in each li", () => { render( <AppointmentsDayView appointments={twoAppointments} /> ); const buttons = document.querySelectorAll("li > button"); expect(buttons).toHaveLength(2); expect(buttons[0].type).toEqual("button"); });
测试元素定位
我们不需要过于关注检查button元素在其父元素中的内容或位置。例如,如果我们把一个空的button子元素放在li的末尾,这个测试就会通过。但幸运的是,做正确的事情和做错误的事情一样简单,所以我们可以选择做正确的事情。要使这个测试通过,我们只需要将现有内容包裹在新的标签中。
-
通过在
AppointmentsDayView组件中将约会时间包裹在button元素中来使测试通过,如下所示:... <li key={appointment.startsAt}> <button type="button"> {appointmentTimeOfDay(appointment.startsAt)} </button> </li> ... -
我们现在可以测试当按钮被点击时会发生什么。回到
test/Appointment.test.js,添加以下内容作为下一个测试。这个测试使用 DOM 元素的click函数来引发一个 DOM 点击事件:it("renders another appointment when selected", () => { render( <AppointmentsDayView appointments={twoAppointments} /> ); const button = document.querySelectorAll("button")[1]; act(() => button.click()); expect(document.body.textContent).toContain( "Jordan" ); });
合成事件和 Simulate
使用click函数的替代方法是使用 React 测试工具的Simulate命名空间来引发Simulate。与使用 DOM API 引发事件相比,Simulate要简单一些,但它对于测试也是不必要的。当 DOM API 足够用时,没有必要使用额外的 API。也许更重要的是,我们还想让我们的测试尽可能反映真实的浏览器环境。
-
继续运行测试。输出将如下所示:
● AppointmentsDayView › renders appointment when selected expect(received).toContain(expected) Expected substring: "Jordan" Received string: "12:0013:00Ashley"
注意接收到的字符串中的全文。我们之所以获取列表的文本内容,是因为我们在期望中使用了document.body.textContent而不是更具体的内容。
期望的特定性
不要太在意客户名字在屏幕上的位置。测试document.body.textContent就像说“我想这个文本出现在某个地方,但我不在乎它在哪里。”通常,这足以进行测试。稍后,我们将看到在特定位置期望文本的技术。
为了使测试通过,我们现在需要做很多事情。我们需要引入状态,并添加处理程序。执行以下步骤:
-
将文件顶部的导入更新为拉入
useState函数,如下所示:import React, { useState } from "react"; -
将常量定义包裹在花括号中,然后按照以下方式返回现有值:
export const AppointmentsDayView = ( { appointments } ) => { return ( <div id="appointmentsDayView"> ... </div> ); }; -
在
return语句上方添加以下代码行:const [selectedAppointment, setSelectedAppointment] = useState(0); -
我们现在可以使用
selectedAppointment而不是硬编码一个索引来选择正确的约会。在选择约会时,将返回值更改为使用这个新的状态值,如下所示:<div id="appointmentsDayView"> ... <Appointment {...appointments[selectedAppointment]} /> </div> -
将
map调用修改为包括其参数中的索引。让我们将其命名为i,如下所示:{appointments.map((appointment, i) => ( <li key={appointment.startsAt}> <button type="button"> {appointmentTimeOfDay(appointment.startsAt)} </button> </li> ))} -
现在从
button元素的onClick处理程序中调用setSelectedAppointment,如下所示:<button type="button" onClick={() => setSelectedAppointment(i)} > -
运行你的测试,你应该会发现它们都是绿色的:
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✓ renders another customer first name (2ms) AppointmentsDayView ✓ renders a div with the right id (7ms) ✓ renders multiple appointments in an ol element (16ms) ✓ renders each appointment in an li (4ms) ✓ initially shows a message saying there are no appointments today (6ms) ✓ selects the first element by default (2ms) ✓ has a button element in each li (2ms) ✓ renders another appointment when selected (3ms)
我们在本节中涵盖了大量的细节,从指定视图的初始状态开始,到添加 button 元素并处理其 onClick 事件。
我们现在有足够的功能,可以尝试一下,看看我们目前处于什么位置。
手动测试我们的更改
“手动测试”这个词应该让每个 TDDer 都感到恐惧,因为它会占用 如此 多的时间。尽可能避免它。当然,我们无法完全避免它 - 当我们完成一个完整的功能后,我们需要检查我们是否做了正确的事情。
目前为止,我们尚不能运行我们的应用程序。为了做到这一点,我们需要添加一个入口点,然后使用 webpack 打包我们的代码。
添加入口点
React 应用程序由在根处渲染的组件层次结构组成。我们的应用程序入口点应该渲染此根组件。
我们通常 不 对入口点进行测试驱动,因为任何加载我们整个应用程序的测试,随着我们添加越来越多的依赖项,都可能变得非常脆弱。在 第四部分,使用 Cucumber 进行行为驱动开发 中,我们将探讨使用 Cucumber 测试编写一些将 确实 覆盖入口点的测试。
由于我们没有进行测试驱动,我们遵循以下几条一般规则:
-
尽量简短
-
仅将其用于实例化根组件的依赖项并调用
render
在我们运行应用程序之前,我们需要一些示例数据。创建一个名为 src/sampleData.js 的文件,并填充以下代码:
const today = new Date();
const at = (hours) => today.setHours(hours, 0);
export const sampleAppointments = [
{ startsAt: at(9), customer: { firstName: "Charlie" } },
{ startsAt: at(10), customer: { firstName: "Frankie" } },
{ startsAt: at(11), customer: { firstName: "Casey" } },
{ startsAt: at(12), customer: { firstName: "Ashley" } },
{ startsAt: at(13), customer: { firstName: "Jordan" } },
{ startsAt: at(14), customer: { firstName: "Jay" } },
{ startsAt: at(15), customer: { firstName: "Alex" } },
{ startsAt: at(16), customer: { firstName: "Jules" } },
{ startsAt: at(17), customer: { firstName: "Stevie" } },
];
重要提示
GitHub 仓库中的 Chapter02/Complete 目录包含一个更完整的示例数据集。
此列表也不需要测试驱动,以下是一些原因:
-
这是一个没有行为的静态数据列表。测试都是关于指定行为的,这里没有。
-
一旦我们开始使用我们的后端 API 拉取数据,此模块将被移除。
提示
TDD 经常是一种实用主义的选择。有时,不进行测试驱动是正确的事情。
创建一个新文件,src/index.js,并输入以下代码:
import React from "react";
import ReactDOM from "react-dom/client";
import { AppointmentsDayView } from "./Appointment";
import { sampleAppointments } from "./sampleData";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<AppointmentsDayView appointments={sampleAppointments} />
);
这就是您所需要的。
使用 webpack 整合所有内容
当 Jest 在测试环境中运行时,它会使用 Babel 将所有我们的代码进行转译。但当我们通过我们的网站提供代码时怎么办?Jest 将无法帮助我们。
正是 webpack 的用武之地,我们现在可以介绍它,帮助我们快速手动测试,如下所示:
-
使用以下命令安装 webpack:
npm install --save-dev webpack webpack-cli babel-loader -
将以下代码添加到您的
package.json文件的scripts部分:"build": "webpack", -
您还需要为 webpack 设置一些配置。在项目根目录中创建
webpack.config.js文件,并包含以下内容:const path = require("path"); const webpack = require("webpack"); module.exports = { mode: "development", module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, loader: "babel-loader", }, ], }, };
此配置适用于开发模式下的 webpack。有关设置生产构建的信息,请参阅 webpack 文档。
-
在您的源目录中,运行以下命令:
mkdir dist touch dist/index.xhtml -
将以下内容添加到您刚刚创建的文件中:
<!DOCTYPE html> <html> <head> <title>Appointments</title> </head> <body> <div id="root"></div> <script src="img/main.js"></script> </body> </html> -
您现在可以使用以下命令运行构建:
npm run build
你应该看到如下输出:
modules by path ./src/*.js 2.56 KiB
./src/index.js 321 bytes [built] [code generated]
./src/Appointment.js 1.54 KiB [built] [code generated]
./src/sampleData.js 724 bytes [built] [code generated]
webpack 5.65.0 compiled successfully in 1045 ms
- 在你的浏览器中打开
dist/index.xhtml,欣赏你的作品!
以下截图显示了完成 练习 后的应用程序,其中添加了 CSS 和扩展的示例数据。要包含 CSS,你需要从 Chapter02/Complete 目录中提取 dist/index.xhtml 和 dist/styles.css。

图 2.2 – 到目前为止的应用程序
在你将代码提交到 Git 之前...
确保按照以下方式将 dist/main.js 添加到你的 .gitignore 文件中:
echo "dist/main.js" >> .gitignore
main.js 文件是由 webpack 生成的,就像大多数生成的文件一样,你不应该将其提交到版本控制中。
在这个阶段,你可能还想添加 README.md 文件来提醒自己如何运行测试以及如何构建应用程序。
现在,你已经看到了如何在创建入口点时暂时放下 TDD:因为入口点很小且不太可能频繁更改,所以我们选择不对其进行测试驱动。
摘要
在本章中,你已经能够多次练习 TDD 循环,并感受到如何使用测试作为指南来构建一个功能。
我们首先设计了一个快速的原型,这帮助我们决定了我们的行动方案。我们构建了一个容器组件(AppointmentsDayView),它显示了一系列的预约时间,并且能够根据点击的预约时间显示单个 Appointment 组件。
我们随后着手建立一个基本的列表结构,然后扩展它以显示初始的 Appointment 组件,最后添加了 onClick 行为。
这种测试策略,即从基本结构开始,然后是初始视图,最后是事件行为,是测试组件的典型策略。
我们距离完全构建我们的应用程序还有一段距离。任何应用程序的前几个测试总是最难的,并且需要最长时间来编写。我们现在已经越过了这个障碍,所以从这里开始我们将更快地前进。
练习
-
将
Appointment.js和Appointment.test.js重命名为AppointmentsDayView.js和AppointmentsDayView.test.js。如果多个组件构成一个层次结构,将它们包含在一个文件中是可以的,但你应该始终以该层次结构的根组件命名文件。 -
通过在页面上显示以下字段来完成
Appointment组件。你应该使用tableHTML 元素来给数据一些视觉结构。这不应该影响你编写测试的方式。应该显示的字段如下:-
客户的姓氏,使用
lastName字段 -
客户电话号码,使用
phoneNumber字段 -
美容师姓名,使用
stylist字段 -
美容院服务,使用
service字段 -
预约备注,使用
notes字段
-
-
在
Appointment组件中添加一个标题,以清楚地显示正在查看的预约时间。 -
存在一些重复的样本数据。我们在测试中使用了样本数据,同时我们也在
src/sampleData.js中创建了sampleAppointments,我们用它来手动测试我们的应用程序。你认为这样做值得吗?如果是,为什么?如果不是,为什么?
进一步阅读
Hooks 是 React 中相对较新的功能。传统上,React 使用类来构建具有状态的组件。要了解 Hooks 的工作原理,请查看以下链接中的 React 官方全面文档:
reactjs.org/docs/hooks-overview.xhtml.
第三章:重构测试套件
到目前为止,你已经编写了一些测试。尽管它们可能已经足够简单,但它们可以更简单。
构建一个可维护的测试套件非常重要:一个快速且痛苦程度低的构建和适应的测试套件。一种大致衡量可维护性的方法是通过查看每个测试中的代码行数。为了与之前看到的进行比较,在 Ruby 语言中,超过三行的测试被认为是一个长测试!
本章将探讨一些你可以使你的测试套件更简洁的方法。我们将通过将常用代码提取到一个模块中,该模块可以在所有测试套件中重用来实现这一点。我们还将创建一个自定义的 Jest 匹配器。
何时是提取可重用代码的正确时机?
到目前为止,你已经在其中编写了一个模块,该模块包含两个测试套件。可以说,现在寻找提取重复代码的机会还为时过早。在非教育环境中,你可能希望在第三个或第四个测试套件之前才开始寻找任何重复代码。
本章将涵盖以下主题:
-
提取可重用的渲染逻辑
-
使用 TDD 创建 Jest 匹配器
-
提取 DOM 辅助函数
到本章结束时,你将学会如何以批判性的眼光对待你的测试套件,以确保其可维护性。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter03。
提取可重用的渲染逻辑
在本节中,我们将提取一个模块,为每个测试初始化一个唯一的 DOM 容器元素。然后,我们将构建一个使用此容器元素的渲染函数。
我们构建的两个测试套件都包含相同的beforeEach块,该块在每个测试之前运行:
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.replaceChildren(container);
});
如果我们能够以某种方式告诉 Jest,任何测试 React 组件的测试套件都应该始终使用这个beforeEach块并使container变量可用于我们的测试,那岂不是很好?
在这里,我们将提取一个新的模块,导出两个东西:container变量和initializeReactContainer函数。这不会节省我们任何打字时间,但它将隐藏讨厌的let声明,并为createElement的调用提供一个描述性的名称。
描述性命名的小函数的重要性
通常,提取只包含一行代码的函数是有帮助的。好处是你可以给它一个描述性的名称,这个名称可以作为注释说明这一行代码的作用。这比使用实际的注释更好,因为名称会随着你使用代码而移动。
在这种情况下,对 document.createElement 的调用可能会让未来的软件维护者感到困惑。想象一下,这是一个从未对 React 代码进行过单元测试的人。他们可能会问,“为什么测试为每个测试创建一个新的 DOM 元素?”你可以通过给它一个名字,比如 initializeReactContainer,来部分回答这个问题。它并不提供完整的答案来说明为什么它是必要的,但它确实暗示了一些关于“初始化”的概念。
让我们继续提取这段代码:
-
创建一个名为
test/reactTestExtensions.js的新文件。这个文件最终将包含我们将在 React 组件测试中使用的所有辅助方法。 -
将以下内容添加到文件中。该函数在模块内部隐式地更新
container变量。然后该变量被导出——我们的测试套件可以像访问“只读”常量一样访问这个变量:export let container; export const initializeReactContainer = () => { container = document.createElement("div"); document.body.replaceChildren(container); } -
移动到
test/AppointmentsDayView.test.js文件。在现有的导入下面添加以下导入:import { initializeReactContainer, container, } from "./reactTestExtensions"; -
现在,将两个
beforeEach块——记住每个describe块中都有一个——替换为以下代码:beforeEach(() => { initializeReactContainer(); }); -
从两个
describe块的顶部删除let container定义。 -
运行
npm test并验证你的测试是否仍然通过。
现在,我们继续处理 render 函数?让我们将其移动到我们的新模块中。这次,它是一个直接的复制和替换工作:
-
从一个
describe块中复制render的定义。 -
将其粘贴到
reactTestExtensions.js文件中。为了参考,这里再次列出:export const render = (component) => act(() => ReactDOM.createRoot(container).render(component) ); -
你还需要在文件顶部添加以下导入:
import ReactDOM from "react-dom/client"; import { act } from "react-dom/test-utils"; -
在你的测试文件中,你现在可以更改测试扩展的导入,使其包括新的
render函数,然后删除container导入:import { initializeReactContainer, render, } from "./reactTestExtensions"; -
从两个测试套件中删除两个
render定义。 -
运行
npm test并验证你的测试是否仍然通过。
到目前为止,我们已经提取了两个函数。我们还有一个函数要做:click 函数。然而,我们还可以创建一个额外的“动作”函数:click。现在就让我们来做这件事:
-
在你的测试扩展文件中创建
click函数,如下所示:export const click = (element) => act(() => element.click()); -
在你的测试文件中,调整你的导入:
import { initializeReactContainer, container, render, click, } from "./reactTestExtensions"; -
在你的测试套件中,将每个
click函数的调用替换为以下行:click(button); -
act导入在测试套件中不再需要。请从你的测试文件中删除该导入。 -
运行
npm test并验证你的测试是否仍然通过。
在测试代码中避免使用 act 函数
act 函数在测试中引起了很多杂乱,这并不有助于我们追求简洁。幸运的是,我们可以将其推入我们的扩展模块,然后就可以结束了。
记得我们的测试应该始终遵循的 安排-执行-断言 模式吗?好吧,我们现在已经从 安排 和 执行 部分提取了一切。
我们在这里采取的方法,即使用导出的container变量,并不是唯一值得探索的方法。例如,您可以创建一个describe的包装函数,该函数自动包含一个beforeEach块并构建一个在describe块作用域内可访问的container变量。您可以将其命名为类似describeReactComponent的名称。
这种方法的优点是它涉及的代码要少得多——您将不会处理所有那些导入,并且可以在测试套件中删除您的beforeEach块。缺点是它非常巧妙,这并不总是维护性的好事情。它有点神奇,需要一定的先验知识。
话虽如此,如果您觉得这种方法吸引您,我鼓励您尝试一下。
在下一节中,我们将开始处理测试的断言部分。
使用 TDD 创建 Jest 匹配器
在我们之前的测试中,我们使用了各种expect函数调用:
expect(appointmentTable()).not.toBeNull();
在本节中,您将使用测试驱动的方法构建一个匹配器,以确保它正在做正确的事情。在构建测试套件的过程中,您将了解 Jest 匹配器 API。
您已经看到了相当多的匹配器:toBeNull、toContain、toEqual和toHaveLength。您也看到了它们如何通过not来否定。
匹配器是构建表达性强且简洁的测试的强大方式。您应该花些时间学习 Jest 提供的所有匹配器。
Jest 匹配器库
有很多不同的匹配器库作为 npm 包提供。尽管我们在这本书中不会使用它们(因为我们是从第一原理构建一切的),但您应该利用这些库。请参阅本章末尾的进一步阅读部分,以获取在测试 React 组件时对您有用的库列表。
通常,您会想构建匹配器。至少有几次情况会促使您这样做:
-
您所编写的期望语句可能相当冗长、篇幅较长,或者用普通语言读起来并不顺畅。
-
一些测试反复重复相同的期望组。这是您有一个可以编码到单个匹配器中的业务概念,该匹配器将专门针对您的项目的迹象。
第二点很有趣。如果您在多个测试中多次编写相同的期望,您应该像对待生产源代码中的重复代码一样对待它。您会将其提取到一个函数中。在这里,匹配器起到了相同的作用,只不过使用匹配器而不是函数可以帮助您记住这一行代码是关于您软件的特殊事实声明:一个规范。
每个测试一个期望
你通常应该只为每个测试设定一个期望。“未来的你”会感谢你保持事情简单!(在第五章,添加复杂表单交互中,我们将探讨一个多个期望有益的情况。)
你可能会听到这个指南,立刻感到惊恐。你可能想象会有无数的小测试爆炸。但如果你准备好编写匹配器,你可以为每个测试设定一个期望,同时仍然保持测试数量在可控范围内。
我们在本节将要构建的匹配器被称为 toContainText。它将替换以下期望:
expect(appointmentTable().textContent).toContain("Ashley");
它将替换成以下形式,这稍微更容易阅读:
expect(appointmentTable()).toContainText("Ashley");
下面是终端上的输出效果:

图 3.1 – 当 toContainText 匹配器失败时的输出
让我们开始吧:
-
创建一个名为
test/matchers的新目录。这是匹配器的源代码和测试将存放的地方。 -
创建新的
test/matchers/toContainText.test.js文件。 -
按照下面的示例编写第一个测试。这个测试引入了一些新概念。首先,它显示
matcher是一个接受两个参数的函数:实际元素和要匹配的数据。其次,它显示该函数返回一个具有pass属性的对象。如果匹配器成功“匹配”——换句话说,它通过了,那么这个属性就是true:import { toContainText } from "./toContainText"; describe("toContainText matcher", () => { it("returns pass is true when text is found in the given DOM element", () => { const domElement = { textContent: "text to find" }; const result = toContainText( domElement, "text to find" ); expect(result.pass).toBe(true); }); }); -
创建另一个新文件,命名为
test/matchers/toContainText.js。这个第一个测试很容易通过:export const toContainText = ( received, expectedText ) => ({ pass: true }); -
我们需要三管齐下才能到达真正的实现。按照下面的示例编写下一个测试:
it("return pass is false when the text is not found in the given DOM element", () => { const domElement = { textContent: "" }; const result = toContainText( domElement, "text to find" ); expect(result.pass).toBe(false); }); -
现在,继续实现我们的匹配器,如下所示。在这个阶段,你有一个正在工作的匹配器——它只需要被连接到 Jest:
export const toContainText = ( received, expectedText ) => ({ pass: received.textContent.includes(expectedText) }); -
在我们使用这个功能之前,先填充一下预期返回值的第二个属性:
message。这是一个很好的实践。下面的测试显示,我们期望消息包含匹配文本本身,作为对程序员的实用提醒:it("returns a message that contains the source line if no match", () => { const domElement = { textContent: "" }; const result = toContainText( domElement, "text to find" ); expect( stripTerminalColor(result.message()) ).toContain( `expect(element).toContainText("text to find")` ); });
理解消息函数
message 函数的要求很复杂。在基本层面上,它是一个当期望失败时显示的有用字符串。然而,它不仅仅是一个字符串——它是一个返回字符串的函数。这是一个性能特性:message 的值不需要在失败之前被评估。但更复杂的是,消息应该根据期望是否被否定而改变。如果 pass 是 false,那么 message 函数应该假设匹配器是在“正面”意义上被调用的——换句话说,没有 .not 修饰符。但如果 pass 是 true,并且 message 函数最终被调用,那么可以安全地假设它已经被否定。我们需要为这个否定情况编写另一个测试,这个测试稍后会出现。
-
此函数使用一个
stripTerminalColor函数,我们现在应该在测试套件上方定义它。它的目的是移除任何添加颜色的 ASCII 转义码:const stripTerminalColor = (text) => text.replace(/\x1B\[\d+m/g, "");
测试 ASCII 转义码
正如您已经看到的,当 Jest 打印出测试失败时,您会看到一大堆红色和绿色的彩色文本。这是通过在文本字符串中打印 ASCII 转义码来实现的。
这是一个难以测试的事情。因此,我们做出了实用主义的选择,不去麻烦测试颜色。相反,stripTerminalColor 函数从字符串中移除这些转义码,这样您就可以测试文本输出,就像它是纯文本一样。
-
通过使用 Jest 的
matcherHint和printExpected函数使该测试通过,如下所示。matcherHint函数的工作方式并不特别清晰,但希望您可以通过运行测试并看到最后一个通过来说服自己它确实做了我们期望的事情!printExpected函数给我们的值添加引号并将其颜色改为绿色。import { matcherHint, printExpected, } from "jest-matcher-utils"; export const toContainText = ( received, expectedText ) => { const pass = received.textContent.includes(expectedText); const message = () => matcherHint( "toContainText", "element", printExpected(expectedText), { } ); return { pass, message }; };
了解 Jest 的匹配器实用工具
在撰写本文时,我发现了解 Jest 匹配器实用函数的最佳方式是阅读它们的源代码。如果您愿意,也可以完全避免使用它们 - 没有义务使用它们。
-
现在是复杂部分。添加以下测试,它指定了使用否定匹配器时的失败期望场景。消息应该反映匹配器已被否定,如下所示:
it("returns a message that contains the source line if negated match", () => { const domElement = { textContent: "text to find" }; const result = toContainText( domElement, "text to find" ); expect( stripTerminalColor(result.message()) ).toContain( `expect(container).not.toContainText("text to find")` ); }); -
要使其通过,向
matcherHint传递一个新选项:... matcherHint( "toContainText", "element", printExpected(expectedText), { isNot: pass } ); ... -
需要添加最后一个测试。我们可以打印出元素的
textContent属性的实际值,这有助于在发生测试失败时进行调试。添加以下测试:it("returns a message that contains the actual text", () => { const domElement = { textContent: "text to find" }; const result = toContainText( domElement, "text to find" ); expect( stripTerminalColor(result.message()) ).toContain(`Actual text: "text to find"`); }); -
通过调整您的匹配器代码使其通过,如下所示。注意新
printReceived函数的使用,它与printExpected函数相同,只是它将文本颜色改为红色:import { matcherHint, printExpected, printReceived, } from "jest-matcher-utils"; export const toContainText = ( received, expectedText ) => { const pass = received.textContent.includes(expectedText); const sourceHint = () => matcherHint( "toContainText", "element", printExpected(expectedText), { isNot: pass } ); const actualTextHint = () => "Actual text: " + printReceived(received.textContent); const message = () => [sourceHint(), actualTextHint()].join("\n\n"); return { pass, message }; }; -
是时候将测试插入 Jest 中了。为此,创建一个名为
test/domMatchers.js的新文件,内容如下:import { toContainText } from "./matchers/toContainText"; expect.extend({ toContainText, }); -
打开
package.json并更新您的 Jest 配置,以便在运行测试之前加载此文件:"jest": { ..., "setupFilesAfterEnv": ["./test/domMatchers.js"] } -
您的新匹配器已准备好使用。打开
test/AppointmentsDayView.test.js并更改所有使用expect(<element>.textContent).toEqual(<text>)和expect(<element>.textContent).toContain(<text>)形式的测试。它们应该替换为expect(<element>).toContainText(<text>)。 -
运行您的测试;您应该看到它们仍然全部通过。花点时间玩一下,看看您的匹配器是如何工作的。首先,将其中一个期望的文本值更改为错误的内容,并观察匹配器失败。看看输出消息的样子。然后,将期望值改回正确的内容,但通过将其更改为
.not.toContainText来否定匹配器。最后,将您的代码恢复到全绿色状态。
为什么我们要进行匹配器的测试驱动?
您应该为任何不仅仅是简单地调用其他函数或设置变量的代码编写测试。在本章的开始,您提取了 render 和 click 等函数。这些函数不需要测试,因为您只是将同一行代码从一个文件移植到另一个文件。但这个匹配器做了一些更复杂的事情——它必须返回一个符合 Jest 所需模式的对象。它还使用了 Jest 的实用函数来构建有用的消息。这种复杂性需要测试。
如果您正在为库构建匹配器,您应该对匹配器的实现更加小心。例如,我们没有麻烦去检查接收到的值是否是 HTML 元素。这没关系,因为这个匹配器只存在于我们的代码库中,我们控制了它的使用方式。当您将匹配器打包用于其他项目时,您也应该验证函数输入是否是您期望看到的值。
您现在已经成功驱动测试了您的第一个匹配器。随着本书的进展,您将有更多机会练习这项技能。现在,我们将继续进行清理工作的最后一部分:创建一些流畅的 DOM 辅助函数。
提取 DOM 辅助函数
在本节中,我们将提取一些小函数,这将帮助我们的测试变得更加易读。与刚刚构建的匹配器相比,这将更加直接。
reactTestExtensions.js 模块已经包含了您使用过的三个函数:initializeReactContainer、render 和 click。
现在,我们将添加四个新的:element、elements、typesOf 和 textOf。这些函数旨在帮助您的测试读起来更像普通英语。让我们看一个例子。以下是我们的测试期望之一:
const listChildren = document.querySelectorAll("li");
expect(listChildren[0].textContent).toEqual("12:00");
expect(listChildren[1].textContent).toEqual("13:00");
我们可以引入一个函数 elements,它是 document.querySelectorAll 的简短版本。较短的名称意味着我们可以去掉额外的变量:
expect(elements("li")[0].textContent).toEqual("12:00");
expect(elements("li")[1].textContent).toEqual("13:00");
这段代码现在调用 querySelectorAll 两次——所以它比以前做了更多的工作——但它也更短、更易读。我们可以更进一步。我们可以通过匹配 elements 数组本身来将这缩减为一个 expect 调用。由于我们需要 textContent,我们将简单地构建一个名为 textOf 的映射函数,它接受输入数组并返回其中每个元素的 textContent 属性:
expect(textOf(elements("li"))).toEqual(["12:00", "13:00"]);
当 toEqual 匹配器应用于数组时,将检查每个数组具有相同数量的元素,并且每个元素出现在相同的位置。
我们已经将原始的三行代码缩减为仅仅一行!
让我们继续构建这些新的辅助函数:
-
打开
test/reactTestExtensions.js并在文件底部添加以下定义。您会注意到元素使用了Array.from。这样做是为了使结果数组可以被typesOf和textOf映射。export const element = (selector) => document.querySelector(selector); export const elements = (selector) => Array.from(document.querySelectorAll(selector)); export const typesOf = (elements) => elements.map((element) => element.type); export const textOf = (elements) => elements.map((element) => element.textContent); -
打开
test/AppointmentsDayView.test.js并将扩展导入更改为包括所有这些新函数:import { initializeReactContainer, render, click, element, elements, textOf, typesOf, } from "./reactTestExtensions"; -
现在,进行搜索并替换
document.querySelectorAll,将每个出现的位置替换为elements。运行npm test并验证测试是否仍然通过。 -
搜索并替换
document.querySelector,将每个出现的位置替换为element。再次运行你的测试并检查一切是否正常。 -
你将看到测试在预约时间渲染。用这个期望替换现有的期望:
expect(textOf(elements("li"))).toEqual([ "12:00", "13:00" ]); -
找到
"has a button element in each li"测试,并用以下单个期望替换现有的期望。注意,如果你的期望测试整个数组,那么对数组长度的期望就不再必要了:expect(typesOf(elements("li > *"))).toEqual([ "button", "button", ]); -
最后三个测试使用
elements("button")[1]提取屏幕上的第二个按钮。将这个定义向上推,紧接在beforeEach块下方,并给它一个更描述性的名称:const secondButton = () => elements("button")[1]; -
现在,你可以在三个测试中使用这个功能。现在就更新它们。例如,中间的测试可以更新如下:
click(secondButton()); expect(secondButton().className).toContain("toggled"); -
作为最后的润色,将出现在某些测试中的
listChild和listElement变量内联化——换句话说,移除变量的使用,并在期望中直接调用函数。例如,"renders an ol element to display appointments"测试可以按照以下方式重写期望:expect(element("ol")).not.toBeNull(); -
再次运行
npm test并验证一切是否仍然正常。
并非所有辅助函数都需要提取
你会注意到你提取的辅助函数都非常通用——它们没有提及正在测试的具体组件。尽可能保持辅助函数的通用性是好的。另一方面,有时拥有非常本地化的辅助函数很有帮助。在你的测试套件中,你已经有一个名为appointmentsTable的和一个名为secondButton的。这些应该保留在测试套件中,因为它们是本地化的。
在本节中,你看到了我们简化测试套件的最终技术,即提取流畅的辅助函数,这些函数有助于保持期望简短,并使它们读起来像普通的英语。
你还看到了在数组上运行期望而不是对单个项目有期望的技巧。这并不总是合适的行动方案。你将在第五章中看到这个例子,添加复杂表单交互。
摘要
本章重点介绍了改进我们的测试套件。可读性至关重要。你的测试充当了软件的规范。每个组件测试都必须清楚地说明组件的期望。当测试失败时,你希望尽可能快地了解为什么它失败了。
你已经看到,这些优先级通常与我们对良好代码的通常想法相冲突。例如,在我们的测试中,我们愿意牺牲性能,如果这使测试更易读。
如果您以前使用过 React 测试,想想平均测试的长度。在本章中,您已经看到了几种保持测试简短的方法:构建特定领域的匹配器和提取用于查询 DOM 的小函数。
您还学会了如何提取 React 初始化代码以避免测试套件中的杂乱。
在下一章,我们将回到为我们的应用添加新功能:使用表单进行数据录入。
练习
使用您刚刚学到的技术,创建一个名为 toHaveClass 的新匹配器,以替换以下期望:
expect(secondButton().className).toContain("toggled");
在您的新匹配器设置完成后,它应该如下所示:
expect(secondButton()).toHaveClass("toggled");
此匹配器也有否定形式:
expect(secondButton().className).not.toContain("toggled");
您的匹配器应该适用于此表单并显示适当的失败信息。
进一步阅读
为了了解更多关于本章所涉及的主题,请查看以下资源:
-
以下 GitHub 仓库包含用于测试 React 组件的有用匹配器:
github.com/jest-community/jest-extended -
以下链接提供了 Jest 匹配器工具的来源,我发现这对于了解如何编写简单的匹配器很有用:
github.com/facebook/jest/tree/main/packages/jest-matcher-utils
第四章:测试驱动数据输入
在本章中,你将探索 React 表单和受控组件。
表单是构建 Web 应用程序的重要组成部分,是用户输入数据的主要方式。如果我们想确保我们的应用程序正常工作,那么不可避免地,这意味着我们需要为我们的表单编写自动化测试。更重要的是,在 React 中使表单工作需要大量的配置,这使得它们得到良好的测试变得尤为重要。
表单的自动化测试全部关于用户的行为:输入文本、点击按钮,以及表单完成时提交。
我们将构建一个新的组件,CustomerForm,当添加或修改客户时我们将使用它。它将包含三个文本字段:名字、姓氏和电话号码。
在构建这个表单的过程中,你将更深入地了解测试复杂的 DOM 元素树。你将学习如何使用参数化测试重复一组测试而不重复代码。
本章将涵盖以下主题:
-
添加表单元素
-
接受文本输入
-
提交表单
-
为多个表单字段复制测试
到本章结束时,你将能够理解使用 React 进行 HTML 表单的测试驱动开发。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter04。
添加表单元素
HTML 表单是一系列字段,被包裹在 form 元素中。尽管我们主要对字段感兴趣,但我们仍需要从 form 元素本身开始。这就是本节我们将要构建的内容。
让我们按照以下步骤创建我们的第一个表单:
-
创建一个名为
test/CustomerForm.test.js的新文件,并添加以下脚手架。它包含你在前几章中看到的所有常用导入和组件测试初始化:import React from "react"; import { initializeReactContainer, render, element, } from "./reactTestExtensions"; import { CustomerForm } from "../src/CustomerForm"; describe("CustomerForm", () => { beforeEach(() => { initializeReactContainer(); }); }); -
现在你已经准备好创建你的第一个测试了。将以下测试添加到
describe块中:it("renders a form", () => { render(<CustomerForm />); expect(element("form")).not.toBeNull(); }); -
我们有一个完整的测试,所以让我们运行它看看会发生什么:
FAIL test/CustomerForm.test.js ● Test suite failed to run Cannot find module '../src/CustomerForm' from 'CustomerForm.test.js'
失败告诉我们它找不到该模块。那是因为我们还没有创建它。
-
因此,创建一个名为
src/CustomerForm.js的空白文件。再次运行你的测试应该会给出以下输出:FAIL test/CustomerForm.test.js ● CustomerForm › renders a form Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports. 8 | 9 | export const render = (component) => > 10 | act(() => 11 | ReactDOM.createRoot(...).render(...) | ^ 12 | ); 11 | 12 | export const click = (element) => 13 | act(() => element.click());
测试辅助代码的堆栈跟踪
Jest 的堆栈跟踪指向我们扩展代码中的失败,而不是测试本身。如果我们的代码在一个 npm 模块中,Jest 会跳过测试输出中的那些测试行。幸运的是,错误信息足够有帮助。
-
为了修复这个问题,我们需要添加一个与我们在测试文件顶部编写的导入匹配的导出。将以下行添加到
src/CustomerForm.js:export const CustomerForm = () => null; -
运行一些测试给出了实际的期望失败:
● CustomerForm › renders a form expect(received).not.toBeNull() Received: null
这可以通过让组件返回一些内容来修复:
import React from "react";
export const CustomerForm = () => <form />;
在继续之前,让我们提取一个用于查找 form 元素的辅助函数。正如前一章所述,这可能是过早的,因为我们现在只有一个测试使用这段代码。然而,当我们编写表单提交测试时,我们会感激有这个辅助函数。
-
打开
test/reactTestExtensions.js并添加以下函数:export const form = (id) => element("form"); -
通过添加以下
import修改你的测试文件。你可以保留element的导入,因为我们将在下一节中使用它:import { initializeReactContainer, render, element, form, } from "./reactTestExtensions"; -
最后,更新你的测试以使用辅助函数,如下所示。之后,你的测试应该仍然通过:
it("renders a form", () => { render(<CustomerForm />); expect(form()).not.toBeNull(); });
这就是创建基本 form 元素的全部内容。有了这个包装器,我们现在可以添加我们的第一个字段元素:一个文本框。
接受文本输入
在本节中,我们将添加一个文本框,以便添加或编辑客户的第一个名字。
添加一个文本字段比添加 form 元素更复杂。首先,有元素本身,它有一个需要测试的 type 属性。然后,我们需要用初始值初始化元素。最后,我们需要添加一个标签,以便清楚地表示字段的意义。
让我们从在页面上渲染一个 HTML 文本输入字段开始:
-
将以下测试添加到
test/CustomerForm.test.js中。它包含三个期望(本章末尾有一个练习,你可以按照它来提取这些期望作为一个单独的匹配器):it("renders the first name field as a text box", () => { render(<CustomerForm />); const field = form().elements.firstName; expect(field).not.toBeNull(); expect(field.tagName).toEqual("INPUT"); expect(field.type).toEqual("text"); });
依赖于 DOM 的表单 API
这个测试使用了表单 API:任何表单元素都允许你使用 elements 索引器访问其所有输入元素。你给出元素的 name 属性(在这个例子中,是 firstName),然后返回该元素。
这意味着我们必须检查返回的元素的标签。我们想确保它是一个 <input> 元素。如果我们没有使用表单 API,一个替代方案将是使用 elements("input")[0],它返回页面上第一个输入元素。这将使对元素 tagName 属性的期望变得不必要。
-
让我们加快速度。我们将一次使所有期望通过。更新
CustomerForm以包括一个单独的输入字段,如下所示:export const CustomerForm = () => ( <form <input type="text" name="firstName" /> </form> ); -
由于这个表单将在修改现有客户以及添加新客户时使用,我们需要设计一种方法将现有客户数据放入组件中。我们将通过设置包含表单数据的
original属性来实现这一点。添加以下测试:it("includes the existing value for the first name", () => { const customer = { firstName: "Ashley" }; render(<CustomerForm original={customer} />); const field = form().elements.firstName; expect(field.value).toEqual("Ashley"); }); -
要使这个测试通过,将组件定义更改为以下内容。我们将使用一个属性来传递之前的
firstName值:export const CustomerForm = ({ original }) => ( <form <input type="text" name="firstName" value={original.firstName} /> </form> ); -
再次运行测试后,你会发现尽管这个测试现在通过了,但前两个测试失败了,因为它们没有指定
original属性。更重要的是,我们有一个警告:Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. -
为了修复初始测试,创建一个新的常量
blankCustomer,它将作为我们的“基础”客户。对于不关心特定字段值的测试来说,这完全足够,比如我们的前两个测试。将此定义添加到beforeEach块之上:const blankCustomer = { firstName: "", };
对于指定一个空对象作为原始属性,有什么看法吗?
在这个对象定义中,我们将 firstName 的值设置为空字符串。你可能认为 undefined 或 null 是更好的候选值。这样,我们可以避免定义这样的对象,只需传递一个空对象 {}。不幸的是,当你尝试将受控组件的初始值设置为 undefined 时,React 会警告你,这是我们想要避免的。这不是什么大问题,而且除了这个之外,空字符串对于文本框来说是一个更现实的默认值。
-
更新前两个测试,使它们以设置
original属性的方式渲染,如下所示。在这个更改到位后,你应该有三个通过测试,但警告仍然存在:it("renders a form", () => { render(<CustomerForm original={blankCustomer} />); expect(form()).not.toBeNull(); }); it("renders the first name field as a text box", () => { render(<CustomerForm original={blankCustomer} />); const field = form().elements.firstName; expect(field).not.toBeNull(); expect(field.tagName).toEqual("INPUT"); expect(field.type).toEqual("text"); }); -
为了消除警告,将单词
readOnly添加到输入标签中。你可能认为我们当然不希望有一个只读字段?你说得对,但我们需要一个进一步的测试,用于修改输入值,然后我们才能避免使用readOnly关键字。我们将在稍后添加那个测试:<input type="text" name="firstName" value={original.firstName} readOnly />
小贴士
总是认为 React 警告是测试失败。在没有先修复任何警告的情况下不要继续进行。
-
最后两个测试包括以下行,它进入表单以提取
firstName字段:const field = form().elements.firstName;
让我们将这个功能提升到 test/reactTestExtensions.js 文件中。打开该文件,在 form 定义之后添加以下定义:
export const field = (fieldName) =>
form().elements[fieldName];
-
然后,将其导入到
test/CustomerForm.js:import { initializeReactContainer, render, element, form, field, } from "./reactTestExtensions"; -
更改你写的最后一个测试,使其使用新的辅助函数:
it("includes the existing value for the first name", () => { const customer = { firstName: "Ashley" }; render(<CustomerForm original={customer} />); expect(field("firstName").value).toEqual("Ashley"); }); -
以相同的方式更新第一个测试:
it("renders the first name field as a text box", () => { render(<CustomerForm original={blankCustomer} />); expect(field("firstName")).not.toBeNull(); expect(field("firstName")).toEqual("INPUT"); expect(field("firstName")).toEqual("text"); }); -
接下来,我们将为该字段添加一个标签。添加以下测试,它使用
element辅助函数:it("renders a label for the first name field", () => { render(<CustomerForm original={blankCustomer} />); const label = element("label[for=firstName]"); expect(label).not.toBeNull(); }); -
通过将新元素插入到
CustomerForm的 JSX 中来使这个测试通过:<form <label htmlFor="firstName" /> ... </form>
htmlFor 属性
JSX 的 htmlFor 属性设置了 HTML 的 for 属性。for 在 JSX 中不能使用,因为它是一个保留的 JavaScript 关键字。该属性用于表示标签与具有给定 ID 的表单元素相匹配——在这种情况下,firstName。
-
让我们在那个标签中添加一些文本内容:
it("renders 'First name' as the first name label content", () => { render(<CustomerForm original={blankCustomer} />); const label = element("label[for=firstName]"); expect(label).toContainText("First name"); }); -
更新
label元素以使测试通过:<form <label htmlFor="firstName">First name</label> ... </form> -
最后,我们需要确保我们的输入有一个与标签的
htmlFor值匹配的 ID,以便它们可以匹配。添加以下测试:it("assigns an id that matches the label id to the first name field", () => { render(<CustomerForm original={blankCustomer} />); expect(field("firstName").id).toEqual("firstName"); }); -
使其通过就像添加新的属性一样简单:
<form> <label htmlFor="firstName">First name</label> <input type="text" name="firstName" id="firstName" value={firstName} readOnly /> </form>
我们现在几乎已经为这个字段创建了所有需要的东西:输入字段本身、它的初始值和它的标签。但我们没有处理值更改的行为——这就是为什么我们有 readOnly 标志的原因。
仅在提交表单并更新数据的情况下更改行为才有意义:如果你无法提交表单,更改字段值就没有意义。这就是我们将在下一节中讨论的内容。
提交表单
对于本章,我们将定义“提交表单”为“调用当前customer对象的onSubmit回调函数”。onSubmit回调函数是我们将要传递的属性。
本节将介绍一种测试表单提交的方法。在第六章 探索测试替身 中,我们将更新这个调用为global.fetch,将我们的客户数据发送到应用程序的后端 API。
我们需要几个不同的测试来指定这种行为,每个测试都是逐步构建我们需要的功能。首先,我们将有一个测试来确保表单有一个提交按钮。然后,我们将编写一个测试来点击该按钮而不对表单进行任何更改。我们还需要另一个测试来检查提交表单不会导致页面导航发生。最后,在文本框的值更新后,我们将结束一个测试提交。
无更改提交
让我们从在表单中创建一个按钮开始。点击它将导致表单提交:
-
首先,添加一个测试来检查页面上是否存在提交按钮:
it("renders a submit button", () => { render(<CustomerForm original={blankCustomer} />); const button = element("input[type=submit]"); expect(button).not.toBeNull(); }); -
为了使它通过,在表单的 JSX 底部添加以下单行:
<form> ... <input type="submit" value="Add" /> </form> -
以下测试引入了一个新概念,所以我们将将其分解为其组成部分。首先,创建一个新的测试,
starting,如下所示:it("saves existing first name when submitted", () => { expect.hasAssertions(); });
hasAssertions期望告诉 Jest 它应该期望至少发生一个断言。它告诉 Jest 至少有一个断言必须在测试的作用域内运行;否则,测试就失败了。你将在下一步中看到为什么这很重要。
-
将以下测试部分添加到大纲中,在
hasAssertions调用下方:const customer = { firstName: "Ashley" }; render( <CustomerForm original={customer} onSubmit={({ firstName }) => expect(firstName).toEqual("Ashley") } /> );
这个函数调用是render调用本身和onSubmit处理程序的混合。这是我们希望 React 在表单提交时调用的处理程序。
-
通过在
render调用下方添加以下行来完成测试。这是我们的测试的执行阶段,在这个测试中是测试的最后一个阶段:const button = element("input[type=submit]"); click(button);
使用hasAssertions避免假阳性
你现在可以明白为什么我们需要hasAssertions。测试是按照顺序编写的,断言定义在onSubmit处理程序中。如果我们没有使用hasAssertions,这个测试现在就会通过,因为我们从未调用onSubmit。
我不建议编写这样的测试。在第六章 探索测试替身 中,我们将发现hasAssertions。我们在这里使用的方法是有效的 TDD 实践;它只是有点混乱,所以你最终会想要重构它。
-
现在,你需要导入
click:import { initializeReactContainer, render, element, form, field, click, } from "./reactTestExtensions"; -
尽管测试设置很复杂,但使这个测试通过是直接的。更改组件定义,使其如下所示:
export const CustomerForm = ({ original, onSubmit }) => ( <form onSubmit={() => onSubmit(original)}> ... </form> ); -
现在,使用
npm test运行测试。你会发现测试通过了,但我们有一个新的警告,如下所示:console.error Error: Not implemented: HTMLFormElement.prototype.submit at module.exports (.../node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17)
有些地方不太对劲。这个警告强调了我们需要注意的非常重要的事情。让我们停下来,仔细看看。
阻止默认提交操作
这个未实现控制台错误来自 JSDOM 包。HTML 表单在提交时有一个默认行为:它们会导航到另一个页面,这个页面由form元素的action属性指定。JSDOM 没有实现页面导航,这就是为什么我们会得到一个未实现错误。
在我们正在构建的典型 React 应用程序中,我们不想让浏览器导航。我们希望停留在同一页面上,并允许 React 使用提交操作的结果更新页面。
要做到这一点,我们需要从onSubmit属性中获取event参数,并在其上调用preventDefault:
event.preventDefault();
由于这是生产代码,我们需要一个测试来验证这种行为。我们可以通过检查事件的defaultPrevented属性来完成:
expect(event.defaultPrevented).toBe(true);
现在,问题变成了,我们如何在测试中获取这个Event?
我们需要自己创建event对象,并直接使用表单元素的dispatchEvent DOM 函数将其派发。这个事件需要标记为cancelable,这将允许我们在其上调用preventDefault。
为什么点击提交按钮不起作用
在最后几项测试中,我们故意构建了一个可以点击以提交表单的提交按钮。虽然这对我们其他所有测试都有效,但对于这个特定的测试,它并不有效。这是因为 JSDOM 会将一个click事件内部转换为submit事件。如果我们无法访问 JSDOM 创建的submit事件对象,我们就无法获取它。因此,我们需要直接触发submit事件。
这不是一个问题。记住,在我们的测试套件中,我们努力模拟真实浏览器的行为——通过点击提交按钮来提交表单——但有一个测试工作方式不同并不是世界末日。
让我们把所有这些放在一起并修复警告:
-
打开
test/reactTestExtensions.js,在click定义下方添加以下内容。我们将在下一个测试中使用它:export const submit = (formElement) => { const event = new Event("submit", { bubbles: true, cancelable: true, }); act(() => formElement.dispatchEvent(event)); return event; };
为什么我们需要 bubbles 属性?
如果这一切还不够复杂,我们还需要确保事件冒泡;否则,它不会到达我们的事件处理器。
当 JSDOM(或浏览器)派发一个事件时,它会遍历元素层次结构,寻找处理该事件的处理器,从事件派发的元素开始,通过父链接向上到根节点。这被称为冒泡。
为什么我们需要确保这个事件冒泡?因为 React 有自己的事件处理系统,它由事件到达 React 根元素触发。在 React 处理之前,submit事件必须冒泡到我们的container元素。
-
将新的辅助函数导入到
test/CustomerForm.test.js:import { ..., submit, } from "./reactTestExtensions"; -
将以下测试添加到
CustomerForm测试套件的底部。它指定在表单提交时应调用preventDefault:it("prevents the default action when submitting the form", () => { render( <CustomerForm original={blankCustomer} onSubmit={() => {}} /> ); const event = submit(form()); expect(event.defaultPrevented).toBe(true); }); -
为了让这个通过,首先,更新
CustomerForm使其具有显式的返回值:export const CustomerForm = ({ original, onSubmit }) => { return ( <form onSubmit={() => onSubmit(original)}> ... </form> ); }; -
在返回之上添加一个新函数
handleSubmit,并更新表单使其调用该函数:export const CustomerForm = ({ original, onSubmit }) => { const handleSubmit = (event) => { event.preventDefault(); onSubmit(original); }; return ( <form onSubmit={handleSubmit}> </form> ); }; -
运行你的测试并确保它们都通过。
提交更改的值
现在是时候在我们的组件中引入一些状态了。我们将指定当使用文本字段更新客户的姓氏时应发生什么。
我们即将要做的事情中最复杂的部分是派发 DOM change事件。在浏览器中,这个事件在每次按键后都会派发,通知 JavaScript 应用程序文本字段的内容已更改。接收此事件的处理器可以查询target元素的value属性以找出当前值。
关键的是,我们在派发change事件之前负责设置value属性。我们通过调用value属性的 setter 来实现这一点。
对于我们这些测试人员来说,不幸的是,React 有一个为浏览器环境设计的更改跟踪行为,而不是 Node 测试环境。在我们的测试中,这种更改跟踪逻辑抑制了像我们测试将要派发的那样的事件。我们需要绕过这种逻辑,我们可以使用一个名为originalValueProperty的助手函数来实现,如下所示:
const originalValueProperty = (reactElement) => {
const prototype =
Object.getPrototypeOf(reactElement);
return Object.getOwnPropertyDescriptor(
prototype,
"value"
);
};
正如你将在下一节中看到的,我们将使用这个函数来绕过 React 的更改跟踪,并让它像浏览器一样处理我们的事件。
仅模拟最终更改
我们不会为每次按键创建一个change事件,而是只制造最终的实例。由于事件处理器始终可以访问元素的完整值,它可以忽略所有中间事件,只处理接收到的最后一个事件。
让我们从一点重构开始:
-
我们将使用提交按钮来提交表单。我们已经在之前的测试中找到了访问该按钮的方法:
const button = element("input[type=submit]");
让我们将这个定义移动到test/reactTestExtensions.js,这样我们就可以在未来的测试中使用它。现在打开那个文件,并将此定义添加到末尾:
export const submitButton = () =>
element("input[type=submit]");
-
返回到
test/CustomerForm.test.js,并将新助手添加到导入中:import { ..., submitButton, } from "./reactTestExtensions"; -
更新
渲染提交按钮测试,使其使用那个新助手,如下所示:it("renders a submit button", () => { render(<CustomerForm original={blankCustomer} />); expect(submitButton()).not.toBeNull(); });
助手提取舞蹈
为什么我们只写一个变量(例如const button = ...)在测试中(如我们刚才对submitButton所做的),然后稍后将其提取为函数呢?
按照这种方法是一种系统地构建助手函数库的方法,这意味着你不必太过于考虑“正确”的设计。首先,从一个变量开始。如果你发现你会在第二次或第三次使用那个变量,那么将其提取为一个函数。没什么大不了的。
-
是时候编写下一个测试了。这与上一个测试非常相似,但现在,我们需要使用一个新的
change辅助函数。我们将在下一步定义它:it("saves new first name when submitted", () => { expect.hasAssertions(); render( <CustomerForm original={blankCustomer} onSubmit={({ firstName }) => expect(firstName).toEqual("Jamie") } /> ); change(field("firstName"), "Jamie"); click(submitButton()); }); -
此函数使用本节开头讨论的新
change辅助函数。将以下定义添加到test/reactTestExtensions.js中:const originalValueProperty = (reactElement) => { const prototype = Object.getPrototypeOf(reactElement); return Object.getOwnPropertyDescriptor( prototype, "value" ); }; export const change = (target, value) => { originalValueProperty(target).set.call( target, value ); const event = new Event("change", { target, bubbles: true, }); act(() => target.dispatchEvent(event)); };
确定 React 和 JSDOM 之间的交互
这里展示的change函数的实现并不明显。正如我们之前在bubbles属性中看到的,React 在 DOM 的常规事件系统之上做了一些相当巧妙的事情。
对 React 的工作原理有一个高级的认识是有帮助的。我还发现使用 Node 调试器逐步通过 JSDOM 和 React 源代码来找出流程中断的地方很有帮助。
-
要使这个通过,转到
src/CustomerForm.js并将useState导入模块,通过修改现有的 React 导入:import React, { useState } from "react"; -
将客户常量定义改为通过调用
useState来分配。默认状态是customer的原始值:const [ customer, setCustomer ] = useState(original); -
创建一个新的箭头函数,它将充当我们的事件处理程序。你可以在上一步添加的
useState行之后放置这个函数:const handleChangeFirstName = ({ target }) => setCustomer((customer) => ({ ...customer, firstName: target.value })); -
在返回的 JSX 中,修改
input元素,如下所示。我们将readOnly属性替换为onChange属性,并将其连接到我们刚刚创建的处理程序。现在,value属性也需要更新,以便它可以使用 React 组件状态而不是组件属性:<input type="text" name="firstName" id="firstName" value={customer.firstName} onChange={handleChangeFirstName} /> -
好吧,运行测试;现在它应该通过了。
通过这样,你已经学会了如何测试驱动changeDOM 事件,以及如何将其与 React 组件状态连接起来以保存用户的输入。接下来,是时候重复这个过程来处理另外两个文本框了。
为多个表单字段复制测试
到目前为止,我们已经编写了一套测试,完全定义了firstName文本字段。现在,我们想要添加两个更多字段,这些字段本质上与firstName字段相同,但具有不同的id值和标签。
在你伸手去复制粘贴之前,停下来想想你即将添加到你的测试和生产代码中的重复内容。我们有六个测试定义了名字。这意味着我们将最终得到 18 个测试来定义三个字段。那将是很多没有任何分组或抽象的测试。
因此,让我们同时做这两件事——也就是说,将我们的测试分组并抽象出一个为我们生成测试的函数。
嵌套describe块
我们可以嵌套describe块,将类似的测试拆分为逻辑上下文。我们可以制定一个命名这些describe块的约定。顶级块以表单本身命名,而第二级describe块则以表单字段命名。
这是我们希望它们最终的样子:
describe("CustomerForm", () => {
describe("first name field", () => {
// ... tests ...
};
describe("last name field", () => {
// ... tests ...
};
describe("phone number field", () => {
// ... tests ...
};
});
在此结构已建立的情况下,您可以通过删除字段的名称来简化it描述性文本。例如,"renders the first name field as a text box"变为"renders as a text box",因为它已经被"first name field" describe块所限定。由于 Jest 在测试输出中在测试名称之前显示describe块名称的方式,这些内容仍然读起来像一句普通的英语句子,但没有冗余的词汇。在刚才给出的例子中,Jest 将显示CustomerForm first name field renders as a text box。
现在让我们为第一个字段(姓名字段)做这个操作。将六个现有的测试包裹在一个describe块中,然后重命名测试,如下所示:
describe("first name field", () => {
it("renders as a text box" ... );
it("includes the existing value" ... );
it("renders a label" ... );
it("assigns an id that matches the label id" ... );
it("saves existing value when submitted" ... );
it("saves new value when submitted" ... );
});
注意不要将preventsDefault测试包含在内,因为它不是字段特定的。您可能需要调整测试文件中测试的位置。
这就涵盖了测试分组。现在,让我们看看如何使用测试生成函数来减少重复。
生成参数化测试
一些编程语言,如 Java 和 C#,需要特殊的框架支持来构建参数化测试。但在 JavaScript 中,我们可以非常容易地自己实现参数化,因为我们的测试定义只是函数调用。我们可以利用这一点,将现有的六个测试作为接受参数值的函数提取出来。
这种类型的更改需要一些勤奋的重构。我们将前两个测试一起做,然后您可以重复这些步骤来完成剩下的五个测试,或者跳到 GitHub 仓库中的下一个标签:
-
从
renders as a text box开始,将整个it调用包裹在一个箭头函数中,然后直接调用该函数,如下所示:const itRendersAsATextBox = () => it("renders as a text box", () => { render(<CustomerForm original={blankCustomer} />); expect(field("firstName")).not.toBeNull(); expect(field("firstName").tagName).toEqual( "INPUT" ); expect(field("firstName").type).toEqual("text"); }); itRendersAsATextBox(); -
验证所有测试是否通过。
-
通过将
firstName字符串提升为函数参数来参数化此函数。然后,您需要将firstName字符串传递给函数调用本身,如下所示:const itRendersAsATextBox = (fieldName) => it("renders as a text box", () => { render(<CustomerForm original={blankCustomer} />); expect(field(fieldName)).not.toBeNull(); expect(field(fieldName).tagName).toEqual("INPUT"); expect(field(fieldName).type).toEqual("text"); }); itRendersAsATextBox("firstName"); -
再次,验证您的测试是否通过。
-
将
itRendersAsATextBox函数提升一级,进入父describe作用域。这将允许您在后续的describe块中使用它。 -
对于下一个测试,
includes the existing value,使用相同的程序:const itIncludesTheExistingValue = ( fieldName, existing ) => it("includes the existing value", () => { const customer = { [fieldName]: existing }; render(<CustomerForm original={customer} />); expect(field(fieldName).value).toEqual(existing); }); itIncludesTheExistingValue("firstName", "Ashley"); -
验证测试通过,然后将
itIncludesTheExistingValue提升一级,进入父describe作用域。 -
对于标签测试,也可以在一个函数中包含,第二个测试可以在其测试定义中使用一个参数,如下所示:
const itRendersALabel = (fieldName, text) => { it("renders a label for the text box", () => { render(<CustomerForm original={blankCustomer} />); const label = element(`label[for=${fieldName}]`); expect(label).not.toBeNull(); }); it(`renders '${text}' as the label content`, () => { render(<CustomerForm original={blankCustomer} />); const label = element(`label[for=${fieldName}]`); expect(label).toContainText(text); }); }; -
对剩下的三个测试重复相同的步骤:
const itAssignsAnIdThatMatchesTheLabelId = ( fieldName ) => ... const itSubmitsExistingValue = (fieldName, value) => ... const itSubmitsNewValue = (fieldName, value) => ...
重要提示
检查完整的解决方案,这可以在Chapter04/Complete目录中找到。
-
所做的一切完成后,您的
describe块将简洁地描述姓名字段的功能:describe("first name field", () => { itRendersAsATextBox("firstName"); itIncludesTheExistingValue("firstName", "Ashley"); itRendersALabel("firstName", "First name"); itAssignsAnIdThatMatchesTheLabelId("firstName"); itSubmitsExistingValue("firstName", "Ashley"); itSubmitsNewValue("firstName", "Jamie"); });
退后一步,看看describe块的新形式。现在,理解这个字段应该如何工作的规范变得非常快。
解决一批测试
现在,我们想要复制最后名字字段的那六个测试。但我们如何着手呢?我们一个一个地测试,就像我们处理第一个名字字段时一样。然而,这次我们应该更快,因为我们的测试是一行代码,而生产代码是复制粘贴的工作。
因此,例如,第一个测试将是这样的:
describe("last name field", () => {
itRendersAsATextBox("lastName");
});
你需要更新blankCustomer,使其包含新字段:
const blankCustomer = {
firstName: "",
lastName: "",
};
通过在firstName输入字段下方添加以下行,可以使该测试通过:
<input type="text" name="lastName" />
这只是输入字段的开始;你需要在添加接下来的几个测试时完成它。
继续添加剩余的五个测试,以及它们的实现。然后,为电话号码字段重复这个过程。在添加电话号码的提交测试时,确保提供一个由数字组成的字符串值,例如"012345"。在本书的后面部分,我们将添加验证,如果现在不使用正确的值,这些验证将失败。
跳过
你可能会想一次性解决所有 12 个新测试。如果你很有信心,那就试试吧!
如果你想要查看文件中所有测试的列表,你必须使用单个文件调用 Jest。运行npm test test/CustomerForm.test.js命令以查看其外观。或者,你可以运行npx jest --verbose来运行所有测试,并带有完整的测试列表:
PASS test/CustomerForm.test.js
CustomerForm
✓ renders a form (28ms)
first name field
✓ renders as a text box (4ms)
✓ includes the existing value (3ms)
✓ renders a label (2ms)
✓ saves existing value when submitted (4ms)
✓ saves new value when submitted (5ms)
last name field
✓ renders as a text box (3ms)
✓ includes the existing value (2ms)
✓ renders a label (6ms)
✓ saves existing value when submitted (2ms)
✓ saves new value when submitted (3ms)
phone number field
✓ renders as a text box (2ms)
✓ includes the existing value (2ms)
✓ renders a label (2ms)
✓ saves existing value when submitted (3ms)
✓ saves new value when submitted (2ms)
修改handleChange使其与多个字段一起工作
是时候进行小重构了。添加所有三个字段后,你将拥有三个非常相似的onChange事件处理程序:
const handleChangeFirstName = ({ target }) =>
setCustomer((customer) => ({
...customer,
firstName: target.value
}));
const handleChangeLastName = ({ target }) =>
setCustomer((customer) => ({
...customer,
lastName: target.value
}));
const handleChangePhoneNumber = ({ target }) =>
setCustomer((customer) => ({
...customer,
phoneNumber: target.value
}));
你可以通过使用target上的name属性来简化这些函数,该属性与字段 ID 相匹配:
const handleChange = ({ target }) =>
setCustomer(customer => ({
...customer,
[target.name]: target.value
}));
测试它
到这一阶段,你的AppointmentsDayView实例已经完成。现在是一个真正尝试它的好时机。
更新src/index.js中的入口点,使其渲染一个新的CustomerForm实例,而不是AppointmentsDayView。通过这样做,你应该准备好手动测试:

图 4.1 – 完成的 CustomerForm
有了这个,你已经学会了一种快速在多个表单字段间复制规范的方法:因为describe和it是普通的函数,你可以像对待任何其他函数一样对待它们,并在它们周围构建自己的结构。
摘要
在本章中,你学习了如何创建带有文本框的 HTML 表单。你为form元素和input元素(类型为text和submit)编写了测试。
尽管文本框可能是最基础的输入元素,但我们利用这个机会深入研究了测试驱动的 React。我们发现通过 JSDOM 引发submit和change事件的复杂性,例如确保在事件上调用event.preventDefault()以避免浏览器页面转换。
我们在 Jest 上也做得更深入。我们将常见的测试逻辑提取到模块中,使用了嵌套的 describe 块,并使用 DOM 的表单 API 构建断言。
在下一章中,我们将测试一个更复杂的表单示例:一个包含下拉框和单选按钮的表单。
练习
以下是一些供你尝试的练习:
-
将
labelFor辅助函数提取到test/reactTestExtensions.js中。它应该这样使用:expect(labelFor(fieldName)).not.toBeNull(); -
添加一个
toBeInputFieldOfType匹配器,以替换itRendersAsATextBox函数中的三个期望。它应该这样使用:expect(field(fieldName)).toBeInputFieldOfType("text");
第五章:添加复杂表单交互
是时候将你所学应用到更复杂的 HTML 设置中了。在本章中,我们将测试一个新的组件:AppointmentForm。它包含一个下拉框,用于选择所需的服务,以及一组单选按钮,形成一个用于选择预约时间的日历视图。
结合布局和表单输入,本章中的代码展示了 TDD 如何为你提供一个工作结构,使复杂场景变得简单易懂:你将使用测试来扩展组件成为组件层次结构,随着组件开始增长,将功能从主组件中分离出来。
在本章中,我们将涵盖以下主题:
-
从下拉框中选择一个值
-
构建日历视图
-
测试单选按钮组
-
减少构建组件时的努力
到本章结束时,你将学会如何将测试驱动开发应用于复杂用户输入场景。这些技术对所有类型的表单组件都很有用,而不仅仅是下拉框和单选按钮。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter05。
从下拉框中选择一个值
让我们从创建一个名为AppointmentForm的组件来预订新预约开始。
第一字段是一个下拉框,用于选择客户所需的服务:剪发、染色、吹干等。让我们现在创建它:
-
创建一个新文件,
test/AppointmentForm.test.js,包含以下测试和设置:import React from "react"; import { initializeReactContainer, render, field, form, } from "./reactTestExtensions"; import { AppointmentForm } from "../src/AppointmentForm"; describe("AppointmentForm", () => { beforeEach(() => { initializeReactContainer(); }); it("renders a form", () => { render(<AppointmentForm />); expect(form()).not.toBeNull(); }); }); -
通过实现并创建一个新文件,
src/AppointmentForm.js,如下所示,使这个测试通过:import React from "react"; export const AppointmentForm = () => <form />; -
为服务字段创建一个嵌套的
describe块。我们将立即跳到这一点,因为我们知道这个表单将包含多个字段:describe("service field", () => { }); -
将以下测试添加到
describe块中:it("renders as a select box", () => { render(<AppointmentForm />); expect(field("service").not.toBeNull(); expect(field("service").tagName).toEqual("SELECT"); }); -
要使这个测试通过,修改
AppointmentForm组件,如下所示:export const AppointmentForm = () => ( <form <select name="service" /> </form> ); -
运行测试并确保它们全部通过。
通过这样,我们已经为新下拉框字段完成了基本的脚手架,使其准备好填充option元素。
提供下拉框选项
我们的沙龙提供一系列沙龙服务。我们应该确保它们都在应用程序中列出。我们可以从定义我们的期望开始测试,如下所示:
it("lists all salon services", () => {
const selectableServices = [
"Cut",
"Blow-dry",
"Cut & color",
"Beard trim",
"Cut & beard trim",
"Extensions"
];
...
});
如果我们这样做,我们最终会在测试代码和生产代码中重复相同的数组服务。我们可以通过将单元测试集中在下拉框的行为上而不是填充它的静态数据来避免这种重复:下拉框应该做什么?
结果表明,我们只需要在数组中指定两个项目就可以指定我们的选择框的功能。保持数组简短还有另一个很好的原因,那就是这有助于我们集中测试的重点:行为,而不是数据。
这就留下了一个问题,当我们需要六个项目用于生产代码时,我们如何在测试中只使用两个项目?
我们将通过向AppointmentForm引入一个新的属性selectableServices来实现这一点。我们的测试可以选择指定一个值,如果需要的话。在我们的生产代码中,我们可以为组件的defaultProps指定一个值。
defaultProps是 React 提供的一种巧妙机制,用于设置当所需的属性未明确提供时将使用的默认属性值。
对于那些不关心选择框值的测试,我们可以避免传递属性并在测试中完全忽略它。对于那些确实关心的测试,我们可以为测试提供一个简短的两个项目数组。
我们如何验证实际的选择框值?
测试静态数据确实会发生,但不是在我们的单元测试中。这种测试可以在验收测试中进行,我们将在第四部分使用 Cucumber 的行为驱动开发中探讨。
我们将从确保第一个值是一个空白条目开始测试。这是当用户创建新的预约时最初选择的值:没有选择任何选项。让我们现在编写这个测试:
-
在
AppointmentForm测试套件的末尾添加以下测试。它指定选择框中的第一个项目是空白,这意味着用户不会自动从我们的服务列表中分配一个选择:it("has a blank value as the first value", () => { render(<AppointmentForm />); const firstOption = field("service").childNodes[0]; expect(firstOption.value).toEqual(""); }); -
通过向现有的
select元素添加一个空白的option元素来使这个测试通过:export const AppointmentForm = () => ( <form <select name="service"> <option /> </select> </form> ); -
在你的测试中,在
beforeEach块之后添加这个新的辅助函数。我们将在下一个测试中使用它来构建选择框选项的所有标签的数组:const labelsOfAllOptions = (element) => Array.from( element.childNodes, (node) => node.textContent ); -
添加以下测试。这使用了新的属性
selectableServices,它简单地是可用选项的数组:it("lists all salon services", () => { const services = ["Cut", "Blow-dry"]; render( <AppointmentForm selectableServices={services} /> ); expect( labelsOfAllOptions(field("service")) ).toEqual(expect.arrayContaining(services)); });
选择测试数据
我已经为预期的服务使用了“真实”数据:Cut和Blow-dry。使用非真实名称,如Service A和Service B也是可以的。通常,这可以提供更详细的描述。这两种方法都是有效的。
-
让我们使这个测试通过。更改组件定义,如下所示:
export const AppointmentForm = ({ selectableServices }) => ( <form> <select name="service"> <option /> {selectableServices.map(s => ( <option key={s}>{s}</option> ))} </select> </form> ); -
检查最新的测试现在是否通过。然而,你会看到我们之前的测试因为引入了新的属性而失败了。
-
我们可以使用
defaultProps使这些测试再次通过。在src/AppointmentForm.js中AppointmentForm函数定义下方,添加以下内容:AppointmentForm.defaultProps = { selectableServices: [ "Cut", "Blow-dry", "Cut & color", "Beard trim", "Cut & beard trim", "Extensions", ] }; -
运行你的测试并验证它们是否通过。
这就是全部内容。通过这样,我们学习了如何使用简短的两个项目数组来定义我们组件的行为,并将真实数据保存为defaultProps。
预选一个值
让我们确保如果我们在编辑现有的预约,我们的组件会预选已经保存的值:
-
在
describe块的顶部定义一个findOption箭头函数。这个函数在 DOM 树中搜索特定的文本节点:const findOption = (selectBox, textContent) => { const options = Array.from(selectBox.childNodes); return options.find( option => option.textContent === textContent ); }; -
在我们的下一个测试中,我们可以找到这个节点,然后检查它是否被选中:
it("pre-selects the existing value", () => { const services = ["Cut", "Blow-dry"]; const appointment = { service: "Blow-dry" }; render( <AppointmentForm selectableServices={services} original={appointment} /> ); const option = findOption( field("service"), "Blow-dry" ); expect(option.selected).toBe(true); }); -
要使这个通过,请设置根
select标签的值属性:<select name="service" value={original.service} readOnly>
可访问的富互联网应用程序(ARIA)标签
如果你有过构建 React 应用程序的经验,你可能期望在 select 元素上设置 aria-label 属性。然而,本章的练习之一是为这个 select 框添加一个标签元素,这将确保浏览器隐式设置 ARIA 标签。
-
你需要更改你的组件属性,使其包括新的
service属性:export const AppointmentForm = ({ original, selectableServices }) => -
运行你的测试。尽管这个测试现在通过了,但你将发现之前的测试失败了,因为原始属性尚未设置。要修复它们,首先,定义一个新的常量
blankAppointment,就在你的beforeEach块之上。我们将在每个失败的测试中使用它:const blankAppointment = { service: "", }; -
更新你的先前测试,以便它们使用这个新常量作为
original属性的值。例如,AppointmentForm的第一个测试将如下所示:it("renders a form", () => { render( <AppointmentForm original={blankAppointment} /> ); expect(form()).not.toBeNull(); }); -
再次使用
npm test运行测试;所有测试应该都通过。(如果它们没有通过,请返回并检查每个测试是否都有一个original属性值。) -
让我们以一小部分重构来结束。你的最后两个测试都有相同的服务定义。将其从每个测试中提取出来,放在
blankAppointment定义之上。确保你从两个测试中删除该行:describe("AppointmentForm", () => { const blankAppointment = { service: "", }; const services = ["Cut", "Blow-dry"]; ... });
这完成了这个测试,但如果我们要有一个完全功能的下拉框,还需要添加更多功能。完成这些测试被留作本章末尾的练习之一。它们的工作方式与 CustomerForm 中的文本框测试相同。
如果你比较我们的下拉框测试和文本框测试,你会看到它们是相似的,但有一些额外的技术:我们使用了 defaultProps 来分离生产数据定义和测试行为,并定义了几个本地化助手方法,labelsOfAllOptions 和 findOption,以帮助缩短我们的测试。
让我们继续到我们表单的下一个项目:预约的时间。
构建日历视图
在本节中,我们将学习如何使用我们现有的助手,例如 element 和 elements,结合 CSS 选择器,来选择我们 HTML 布局中感兴趣的具体元素。
但首先,让我们做一些规划。
我们希望 AppointmentForm 能够以网格的形式显示未来 7 天的可用时间段,列代表天数,行代表 30 分钟的时间段,就像标准的日历视图一样。用户将能够快速找到一个适合他们的时间段,然后在提交表单之前选择正确的单选按钮:

图 5.1 – 我们日历视图的视觉设计
这是我们试图构建的 HTML 结构示例。我们可以将其用作我们编写 React 组件时的指南:
<table id="time-slots">
<thead>
<tr>
<th></th>
<th>Oct 11</th>
<th>Oct 12</th>
<th>Oct 13</th>
</tr>
</thead>
<tbody>
<tr>
<th>9:00</th>
<td>
<input type="option" name="timeSlot" value="..." />
</td>
</tr>
<!-- ... two more cells ... -->
</tbody>
</table>
在接下来的几节中,我们将对table元素本身进行测试驱动,然后为一天中的时间添加一个标题列,然后为一周中的日子添加一个标题。
添加表格
让我们从构建table本身开始:
-
在
test/AppointmentForm.test.js的底部创建一个嵌套的describe块,并添加一个新的测试:describe("time slot table", () => { it("renders a table for time slots with an id", () => { render( <AppointmentForm original={blankAppointment} /> ); expect( element("table#time-slots") ).not.toBeNull(); }); }); -
你需要将
element辅助函数拉入你的导入中:import { initializeReactContainer, render, field, form, element, } from "./reactTestExtensions"; -
为了使这个测试通过,转到
src/AppointmentForm.js并定义一个新的TimeSlotTable组件,在AppointmentForm定义之上。我们不需要将其标记为导出,因为它只将被AppointmentForm引用:const TimeSlotTable = () => <table id="time-slots" />;
为什么添加一个 ID?
ID 很重要,因为这是应用程序的 CSS 用来查找table元素的方式。尽管这在本章中没有涉及,如果你使用 CSS 并且它基于元素 ID 定义选择器,那么你应该将这些 ID 视为一种技术规范,你的代码必须满足。这就是为什么我们为它们编写单元测试的原因。
-
将此组件添加到你的
AppointmentFormJSX 中,正好在select标签下方:<form> ... <TimeSlotTable /> </form>;
运行测试并验证它们是否全部通过。
这就是table元素的全部内容。现在,让我们将一些数据放入第一列。
添加标题列
对于下一个测试,我们将测试显示时间列表的左侧标题列。我们将引入两个新的属性salonOpensAt和salonClosesAt,它们会通知组件每天显示哪个时间。按照以下步骤操作:
-
添加以下测试:
it("renders a time slot for every half an hour between open and close times", () => { render( <AppointmentForm original={blankAppointment} salonOpensAt={9} salonClosesAt={11} /> ); const timesOfDayHeadings = elements("tbody >* th"); expect(timesOfDayHeadings[0]).toContainText( "09:00" ); expect(timesOfDayHeadings[1]).toContainText( "09:30" ); expect(timesOfDayHeadings[3]).toContainText( "10:30" ); });
断言数组模式
在这个例子中,我们正在检查数组中的三个条目的textContent,尽管数组中有四个条目。
对于所有数组条目都相同的属性,只需要在其中一个条目上测试。对于每个条目都不同的属性,例如textContent,需要根据需要测试的模式数量在两个或三个条目上测试。
对于这个测试,我想测试它是否在正确的时间开始和结束,并且每个时间槽增加 30 分钟。我可以通过对数组条目 0、1 和 3 的断言来实现这一点。
这个测试“违反”了我们每个测试只有一个预期的规则。然而,在这种情况下,我认为这是可以接受的。另一种方法可能是使用textOf辅助函数。
-
你需要将
elements辅助函数拉入你的导入中:import { initializeReactContainer, render, field, form, element, elements, } from "./reactTestExtensions"; -
为了使这个测试通过,在
TimeSlotTable组件之上添加以下函数。它们计算每日时间槽的列表:const timeIncrements = ( numTimes, startTime, increment ) => Array(numTimes) .fill([startTime]) .reduce((acc, _, i) => acc.concat([startTime + i * increment]) ); const dailyTimeSlots = ( salonOpensAt, salonClosesAt ) => { const totalSlots = (salonClosesAt – salonOpensAt) * 2; const startTime = new Date() .setHours(salonOpensAt, 0, 0, 0); const increment = 30 * 60 * 1000; return timeIncrements( totalSlots, startTime, increment ); }; -
定义
toTimeValue函数,如下所示:const toTimeValue = timestamp => new Date(timestamp).toTimeString().substring(0, 5); -
现在,你可以使用这两个函数。更新
TimeSlotTable,使其如下所示:const TimeSlotTable = ({ salonOpensAt, salonClosesAt }) => { const timeSlots = dailyTimeSlots( salonOpensAt, salonClosesAt); return ( <table id="time-slots"> <tbody> {timeSlots.map(timeSlot => ( <tr key={timeSlot}> <th>{toTimeValue(timeSlot)}</th> </tr> ))} </tbody> </table> ); }; -
在
AppointmentForm的 JSX 中,将salonOpensAt和salonClosesAt属性传递给TimeSlotTable:export const AppointmentForm = ({ original, selectableServices, service, salonOpensAt, salonClosesAt }) => ( <form> ... <TimeSlotTable salonOpensAt={salonOpensAt} salonClosesAt={salonClosesAt} /> </form> ); -
为
salonOpensAt和salonsCloseAt填充defaultProps:AppointmentForm.defaultProps = { salonOpensAt: 9, salonClosesAt: 19, selectableServices: [ ... ] }; -
运行测试并确保一切通过。
这就是添加左侧标题列的全部内容。
添加标题行
那么,列标题怎么办?在本节中,我们将创建一个新的顶部行,包含这些单元格,并确保在左上角留出一个空单元格,因为左列包含时间标题而不是数据。按照以下步骤操作:
-
添加以下测试:
it("renders an empty cell at the start of the header row", () => render( <AppointmentForm original={blankAppointment} /> ); const headerRow = element("thead > tr"); expect(headerRow.firstChild).toContainText(""); }); -
修改表格 JSX,使其包含一个新的表格行:
<table id="time-slots"> <thead> <tr> <th /> </tr> </thead> <tbody> ... </tbody> </table> -
对于标题行的其余部分,我们将从今天开始显示 7 天。
AppointmentForm需要接受一个新的属性today,这是表格中要显示的第一天。分配给该属性的值存储在一个名为specificDate的变量中。这个名字被选择用来强调这个选定的日期会影响渲染的日期输出,例如,"Sat 01":it("renders a week of available dates", () => { const specificDate = new Date(2018, 11, 1); render( <AppointmentForm original={blankAppointment} today={specificDate} /> ); const dates = elements( "thead >* th:not(:first-child)" ); expect(dates).toHaveLength(7); expect(dates[0]).toContainText("Sat 01"); expect(dates[1]).toContainText("Sun 02"); expect(dates[6]).toContainText("Fri 07"); });
为什么要将日期传递给组件?
当你在测试处理日期和时间的组件时,你几乎总是想要一种方式来控制组件将看到的日期和时间值,就像我们在这次测试中所做的那样。你很少想只使用现实世界的时间,因为这可能会在未来导致间歇性故障。例如,你的测试可能假设一年中至少有 30 天,这只有在 12 个月中的 11 个月是正确的。将月份固定在特定月份比在二月到来时出现意外故障要好。
关于这个话题的深入讨论,请查看reacttdd.com/controlling-time。
-
要实现这个过渡,首先,创建一个函数来列出我们想要的 7 天,就像我们处理时间段时做的那样。你可以把这个函数放在
toTimeValue函数之后:const weeklyDateValues = (startDate) => { const midnight = startDate.setHours(0, 0, 0, 0); const increment = 24 * 60 * 60 * 1000; return timeIncrements(7, midnight, increment); }; -
定义
toShortDate函数,将我们的日期格式化为短字符串:const toShortDate = (timestamp) => { const [day, , dayOfMonth] = new Date(timestamp) .toDateString() .split(" "); return `${day} ${dayOfMonth}`; }; -
修改
TimeSlotTable,使其接受新的today属性并使用这两个新函数:const TimeSlotTable = ({ salonOpensAt, salonClosesAt, today }) => { const dates = weeklyDateValues(today); ... return ( <table id="time-slots"> <thead> <tr> <th /> {dates.map(d => ( <th key={d}>{toShortDate(d)}</th> ))} </tr> </thead> ... </table> ) }; -
在
AppointmentForm内部,将today属性从AppointmentForm传递给TimeSlotTable:export const AppointmentForm = ({ original, selectableServices, service, salonOpensAt, salonClosesAt, today }) => { ... return <form> <TimeSlotTable ... salonOpensAt={salonOpensAt} salonClosesAt={salonClosesAt} today={today} /> </form>; }; -
最后,为
today添加一个defaultProp。通过调用Date构造函数将其设置为当前日期:AppointmentForm.defaultProps = { today: new Date(), ... } -
运行测试。它们应该都是绿色的。
这样,我们就完成了表格布局。你已经看到了如何编写指定表格结构本身的测试,并填写了标题列和标题行。在下一节中,我们将用单选按钮填充表格单元格。
测试驱动单选按钮组
现在我们已经放置了带有标题的表格,是时候给每个表格单元格添加单选按钮了。并不是所有单元格都会有单选按钮——只有代表可用时间段单元格才会有单选按钮。
这意味着我们需要向AppointmentForm传递另一个新的属性,这将帮助我们确定要显示哪些时间段。这个属性是availableTimeSlots,它是一个对象数组,列出了仍然可用的时段。按照以下步骤操作:
-
添加以下测试,它为
availableTimeSlots属性设置一个值,然后检查是否为每个时间段渲染了单选按钮:it("renders radio buttons in the correct table cell positions", () => { const oneDayInMs = 24 * 60 * 60 * 1000; const today = new Date(); const tomorrow = new Date( today.getTime() + oneDayInMs ); const availableTimeSlots = [ { startsAt: today.setHours(9, 0, 0, 0) }, { startsAt: today.setHours(9, 30, 0, 0) }, { startsAt: tomorrow.setHours(9, 30, 0, 0) }, ]; render( <AppointmentForm original={blankAppointment} availableTimeSlots={availableTimeSlots} today={today} /> ); expect(cellsWithRadioButtons()).toEqual([0, 7, 8]); }); -
注意,此测试使用了一个
cellsWithRadioButtons辅助函数,我们现在需要定义它。你可以将它放在测试上方;没有必要将其移动到扩展模块中,因为它只针对这个组件:const cellsWithRadioButtons = () => elements("input[type=radio]").map((el) => elements("td").indexOf(el.parentNode) ); -
此测试检查今天前两个时间段内是否有单选按钮。这些按钮将位于单元格 0 和 7,因为
elements按页面顺序返回匹配的元素。我们可以通过在AppointmentForm的渲染方法中添加以下内容来非常简单地使此测试通过,就在每个tr中的th下面:{timeSlots.map(timeSlot => <tr key={timeSlot}> <th>{toTimeValue(timeSlot)}</th> {dates.map(date => ( <td key={date}> <input type="radio" /> </td> ))} </tr> )}
到目前为止,你的测试将通过。
尽管我们的测试需要它,但我们不需要在产品代码中使用availableTimeSlots!相反,我们只是在每个单元格中放了一个单选按钮!这显然是“错误的”。然而,如果你回想起我们只实现使测试通过的最简单规则,那么这就有意义了。我们现在需要另一个测试来证明相反的情况——在availableTimeSlots给定的情况下,某些单选按钮不存在。
隐藏输入控件
我们如何得到正确的实现?我们可以通过测试没有可用的时间段将不会渲染任何单选按钮来实现:
-
添加以下测试:
it("does not render radio buttons for unavailable time slots", () => { render( <AppointmentForm original={blankAppointment} availableTimeSlots={[]} /> ); expect( elements("input[type=radio]") ).toHaveLength(0); }); -
要使它通过,首先,转到
src/AppointmentForm.js并在TimeSlotTable组件上方定义mergeDateAndTime函数。这个函数从列标题中获取日期,以及从行标题中获取时间,并将它们转换成我们可以用来与availableTimeSlots中的startsAt字段进行比较的时间戳:const mergeDateAndTime = (date, timeSlot) => { const time = new Date(timeSlot); return new Date(date).setHours( time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds() ); }; -
更新
TimeSlotTable使其接受新的availableTimeSlots属性:const TimeSlotTable = ({ salonOpensAt, salonClosesAt, today, availableTimeSlots }) => { ... }; -
用 JSX 条件替换
TimeSlotTable中现有的单选按钮元素:{dates.map(date => <td key={date}> {availableTimeSlots.some(availableTimeSlot => availableTimeSlot.startsAt === mergeDateAndTime(date, timeSlot) ) ? <input type="radio" /> : null } </td> )} -
此外,更新
AppointmentForm使其接受新的属性,并将其传递给TimeSlotTable:export const AppointmentForm = ({ original, selectableServices, service, salonOpensAt, salonClosesAt, today, availableTimeSlots }) => { ... return ( <form> ... <TimeSlotTable salonOpensAt={salonOpensAt} salonClosesAt={salonClosesAt} today={today} availableTimeSlots={availableTimeSlots} /> </form> ); }; -
虽然你的测试现在将通过,但其余的将失败:它们需要一个
availableTimeSlots属性的值。为此,首先,在AppointmentForm的顶部添加以下定义:describe("AppointmentForm", () => { const today = new Date(); const availableTimeSlots = [ { startsAt: today.setHours(9, 0, 0, 0) }, { startsAt: today.setHours(9, 30, 0, 0) }, ]; -
遍历每个测试并更新每个渲染调用,以指定一个值为
availableTimeSlots的availableTimeSlots属性。例如,第一个测试应该有以下的渲染调用:render( <AppointmentForm original={blankAppointment} availableTimeSlots={availableTimeSlots} /> );
处理属性的合理默认值
在每个测试中为新的属性添加默认值并不是什么有趣的事情。在本章的后面,你将学习如何通过引入一个testProps对象来分组合理的默认属性值,以避免在测试中出现属性爆炸。
-
让我们继续下一个测试。我们必须确保每个单选按钮都有正确的值。我们将使用每个单选按钮的
startsAt值。单选按钮的值是字符串,但预约对象的属性startsAt是数字。我们将使用标准库函数parseInt将按钮值转换回可用的数字:it("sets radio button values to the startsAt value of the corresponding appointment", () => { render( <AppointmentForm original={blankAppointment} availableTimeSlots={availableTimeSlots} today={today} /> ); const allRadioValues = elements( "input[type=radio]" ).map(({ value }) => parseInt(value)); const allSlotTimes = availableTimeSlots.map( ({ startsAt }) => startsAt ); expect(allRadioValues).toEqual(allSlotTimes); });
在测试中定义常量
有时候,在测试中保留常量而不是将它们作为辅助函数提取出来更可取。在这种情况下,这些辅助函数仅由这个测试使用,并且它们所做的事情非常具体。将它们保留在行内可以帮助你理解函数在做什么,而无需在文件中搜索函数定义。
-
在你的生产代码中,将包含原始
mergeDateAndTime调用的三元表达式提取到一个新的组件中。注意向input元素添加新的name和value属性:const RadioButtonIfAvailable = ({ availableTimeSlots, date, timeSlot, }) => { const startsAt = mergeDateAndTime(date, timeSlot); if ( availableTimeSlots.some( (timeSlot) => timeSlot.startsAt === startsAt ) ) { return ( <input name="startsAt" type="radio" value={startsAt} /> ); } return null; };
名称属性
具有相同name属性的无线电按钮属于同一组。点击一个单选按钮将选中该按钮并取消选中组中的所有其他按钮。
-
你现在可以在
TimeSlotTable中使用这个组件,用这个功能组件的实例替换现有的三元表达式。在此之后,你的测试应该通过:{dates.map(date => <td key={date}> <RadioButtonIfAvailable availableTimeSlots={availableTimeSlots} date={date} timeSlot={timeSlot} /> </td> )}
现在你已经正确显示了单选按钮,是时候给它们添加一些行为。
在一组中选择单选按钮
让我们看看如何使用输入元素上的checked属性来确保为我们的单选按钮设置正确的初始值。
为了做到这一点,我们将使用一个名为startsAtField的辅助函数,它接受一个索引并返回该位置的单选按钮。为了做到这一点,所有单选按钮都必须具有相同的名称。这意味着单选按钮被组合成一个组,这意味着一次只能选择一个。按照以下步骤操作:
-
首先,在时间表表的
describe块顶部添加startsAtField辅助函数:const startsAtField = (index) => elements("input[name=startsAt]")[index]; -
添加以下测试。它传递了一个现有的预约,其
startsAt值设置为availableTimeSlots列表中的第二个项目。选择第二个项目而不是第一个项目并不是严格必要的(因为默认情况下,所有单选按钮都将被取消选中),但它可以帮助未来的维护者突出显示已经选择并正在检查的特定值:it("pre-selects the existing value", () => { const appointment = { startsAt: availableTimeSlots[1].startsAt, }; render( <AppointmentForm original={appointment} availableTimeSlots={availableTimeSlots} today={today} /> ); expect(startsAtField(1).checked).toEqual(true); }); -
要实现这个传递,首先,向
TimeSlotTable添加一个新的checkedTimeSlot属性,其值为原始的startsAt值:<TimeSlotTable salonOpensAt={salonOpensAt} salonClosesAt={salonClosesAt} today={today availableTimeSlots={availableTimeSlots} checkedTimeSlot={appointment.startsAt} /> -
更新
TimeSlotTable,使其利用这个新属性,将其传递给RadioButtonIfAvailable:const TimeSlotTable = ({ ..., checkedTimeSlot, }) => { ... <RadioButtonIfAvailable availableTimeSlots={availableTimeSlots} date={date} timeSlot={timeSlot} checkedTimeSlot={checkedTimeSlot} /> ... }; -
现在,你可以在
RadioButtonIfAvailable中使用它,在输入元素上设置isChecked属性,如这里所示。在此更改之后,你的测试应该通过:const RadioButtonIfAvailable = ({ ..., checkedTimeSlot, }) => { const startsAt = mergeDateAndTime(date, timeSlot); if ( availableTimeSlots.some( (a) => a.startsAt === startsAt ) ) { const isChecked = startsAt === checkedTimeSlot; return ( <input name="startsAt" type="radio" value={startsAt} checked={isChecked} /> ); } return null; };
设置初始值的操作就到这里。接下来,我们将组件与onChange行为连接起来。
通过组件层次结构处理字段更改
在本章中,我们逐渐构建了一个组件层次结构:AppointmentForm渲染一个TimeSlotTable组件,该组件渲染了一堆RadioButtonIfAvailable组件,这些组件可能会(也可能不会)渲染单选按钮输入元素。
最后的挑战是如何从输入元素获取onChange事件并将其传递回AppointmentForm,这将控制预约对象。
本节中的代码将使用useCallback钩子。这是一种性能优化的形式:我们无法编写测试来指定这种行为。一个很好的经验法则是,如果你正在将函数作为属性传递,那么你应该考虑使用useCallback。
useCallback钩子
useCallback钩子返回的TimeSlotTable会在父组件每次重新渲染时重新渲染,因为不同的引用会导致它认为需要重新渲染。
input元素上的事件处理器不需要使用useCallback,因为事件处理器属性是集中处理的;这些属性的更改不需要重新渲染。
useCallback的第二个参数是useCallback更新的依赖项集合。在这种情况下,它是[],一个空数组,因为它不依赖于任何可能会改变的属性或其他函数。函数的参数,如target不计,而setAppointment是一个保证在重新渲染中保持恒定的函数。
在本章末尾的进一步阅读部分查看有关useCallback的更多信息链接。
由于我们还没有对提交AppointmentForm进行任何工作,我们需要从这里开始。让我们为表单的提交按钮添加一个测试:
-
将以下测试添加到你的
AppointmentForm测试套件中,该测试用于检查提交按钮的存在。这可以放在测试套件的顶部,就在renders a form测试下面:it("renders a submit button", () => { render( <AppointmentForm original={blankAppointment} /> ); expect(submitButton()).not.toBeNull(); }); -
你还需要将
submitButton辅助函数导入到你的测试中:import { initializeReactContainer, render, field, form, element, elements, submitButton, } from "./reactTestExtensions"; -
为了使这一步通过,请在你的
AppointmentForm底部添加按钮:<form> ... <input type="submit" value="Add" /> </form> -
对于下一个测试,让我们提交表单并检查我们是否得到了提交的原始
startsAt值。我们将使用我们在上一章中看到的相同expect.hasAssertions技术。测试验证onSubmit属性是否以原始的、未更改的startsAt值被调用:it("saves existing value when submitted", () => { expect.hasAssertions(); const appointment = { startsAt: availableTimeSlots[1].startsAt, }; render( <AppointmentForm original={appointment} availableTimeSlots={availableTimeSlots} today={today} onSubmit={({ startsAt }) => expect(startsAt).toEqual( availableTimeSlots[1].startsAt ) } /> ); click(submitButton()); }); -
由于这个测试使用了
click辅助函数,你需要导入它:import { initializeReactContainer, render, field, form, element, elements, submitButton, click, } from "./reactTestExtensions"; -
对于这个测试,我们只需要将表单的
onSubmit事件处理器设置好。在这个阶段,它将简单地提交没有任何注册更改的original对象。更新AppointmentForm组件,如下所示:export const AppointmentForm = ({ ..., onSubmit, }) => { const handleSubmit = (event) => { event.preventDefault(); onSubmit(original); }; return ( <form onSubmit={handleSubmit}> ... </form> ); }; -
那个测试通过后,让我们添加最后的测试。这个测试使用的是
click动作而不是change,我们之前用于文本框和选择框。我们将点击所需的单选按钮,就像用户一样:it("saves new value when submitted", () => { expect.hasAssertions(); const appointment = { startsAt: availableTimeSlots[0].startsAt, }; render( <AppointmentForm original={appointment} availableTimeSlots={availableTimeSlots} today={today} onSubmit={({ startsAt }) => expect(startsAt).toEqual( availableTimeSlots[1].startsAt ) } /> ); click(startsAtField(1)); click(submitButton()); }); -
现在,有趣的部分开始了。从上到下工作:我们首先定义一个新的
appointment状态对象,然后使用一个新的事件处理器,当点击单选按钮时修改当前预约。移动到src/AppointmentForm.js并更新你的 React 导入,使其如下所示:import React, { useState, useCallback } from "react"; -
引入一个新的
appointment状态对象,并将你的checkedTimeSlot属性更新为使用此对象,而不是使用original属性值:export const AppointmentForm = ({ ... }) => { const [appointment, setAppointment] = useState(original); ... return ( <form> ... <TimeSlotTable ... checkedTimeSlot={appointment.startsAt} /> ... </form> ); }; -
更新
handleSubmit函数,使其使用appointment而不是original:const handleSubmit = (event) => { event.preventDefault(); onSubmit(appointment); };
阻止默认行为的调用
我避免编写 preventDefault 的测试,因为我们之前已经讨论过。在实际应用中,我几乎肯定会再次添加这个测试。
-
现在,是时候为新的事件处理程序了。这是利用
useCallback来安全地将其传递给TimeSlotTable及其超集的函数。在之前步骤中添加的useState调用下方添加以下定义。处理程序使用parseInt在我们的单选按钮的字符串值和我们将存储的数字时间戳值之间进行转换:const handleStartsAtChange = useCallback( ({ target: { value } }) => setAppointment((appointment) => ({ ...appointment, startsAt: parseInt(value), })), [] ); -
我们需要将事件处理程序编织到
input元素中,就像我们处理checkedTimeSlot一样。首先,将它传递给TimeSlotTable:<TimeSlotTable salonOpensAt={salonOpensAt} salonClosesAt={salonClosesAt} today={today} availableTimeSlots={availableTimeSlots} checkedTimeSlot={appointment.startsAt} handleChange={handleStartsAtChange} /> -
然后,更新
TimeSlotTable,将那个属性传递给RadioButtonIfAvailable:const TimeSlotTable = ({ ..., handleChange, }) => { ..., <RadioButtonIfAvailable availableTimeSlots={availableTimeSlots} date={date} timeSlot={timeSlot} checkedTimeSlot={checkedTimeSlot} handleChange={handleChange} /> ... }; -
最后,在
RadioButtonIfAvailable中,从输入字段中移除readOnly属性,并设置onChange代替它:const RadioButtonIfAvailable = ({ availableTimeSlots, date, timeSlot, checkedTimeSlot, handleChange }) => { ... return ( <input name="startsAt" type="radio" value={startsAt} checked={isChecked} onChange={handleChange} /> ); ... };
到目前为止,你的测试应该通过,你的时间段表应该完全可用。
本节涵盖了大量的代码:条件渲染 input 元素,以及单选按钮元素的细节,例如为组提供 name 并使用 onChecked 属性,然后通过组件层次结构传递其 onChange 事件。
这是个手动测试你构建内容的好时机。你需要更新 src/index.js,使其加载 AppointmentForm 以及示例数据。这些更改包含在 Chapter05/Complete 目录中:

图 5.2 – 显示的 AppointmentForm
你现在已经完成了构建单选按钮表所需的工作。现在是时候进行重构了。
构建组件时的效率提升
让我们看看几种简单的方法来减少我们刚刚构建的测试套件所需的时间和代码量:首先,提取构建函数,其次,提取对象以存储我们组件属性的有意义默认值。
提取时间和日期函数的测试数据构建器
你已经看到我们可以如何将可重用的函数提取到它们自己的命名空间中,例如 render、click 和 element DOM 函数。这是一个特殊情况,即 builder 函数,它构建你在测试的 安排 和 行动 阶段将使用的对象。
这些函数的目的不仅仅是去除重复,还包括简化并帮助理解。
我们已经在测试套件中有一个候选者,如下代码所示:
const today = new Date();
today.setHours(9, 0, 0, 0);
我们将更新我们的测试套件,使其使用一个名为 todayAt 的构建函数,这将节省一些输入:
todayAt(9);
我们还将提取 today 值作为常量,因为我们也会使用它。
领域对象的构建器
通常,您会为代码库中的域对象创建构建函数。在我们的例子中,那将是customer或appointment对象,甚至是具有单个startsAt字段的时段对象。我们的代码库还没有发展到需要这一点,所以我们将从我们使用的Date对象的构建函数开始。我们将在本书的后面写更多的构建函数。
让我们开始吧:
-
创建一个新目录,
test/builders。这是我们所有builder函数将存放的地方。 -
创建一个新文件,
test/builders/time.js。这是我们放置所有与时间相关内容的地方。 -
在您的新文件中添加以下常量:
export const today = new Date(); -
在
test/AppointmentForm.test.js中,在您的其他导入下面添加以下导入:import { today } from "./builders/time"; -
从测试套件中删除
today常量的定义。 -
在
test/builders/time.js中,添加以下todayAt的定义。请注意,这确实允许我们指定小时、分钟、秒和毫秒,如果我们选择的话,但它为每个未指定的默认值为0。我们将在一个测试中使用这种完整形式。我们还必须通过调用date构造函数来复制today常量。这确保了我们不会意外地修改任何调用此函数的today常量:export const todayAt = ( hours, minutes = 0, seconds = 0, milliseconds = 0 ) => new Date(today).setHours( hours, minutes, seconds, milliseconds );
构建函数的不变性
如果您的命名空间使用共享的常量值,就像我们在这里使用today一样,请确保您的函数不会意外地修改它们。
-
在
test/AppointmentForm.test.js中,更新您的导入,使其包括新函数:import { today, todayAt } from "./builders/time"; -
是时候进行搜索和替换了!找到以下所有出现:
today.setHours(9, 0, 0, 0)
用以下内容替换它:
todayAt(9)
-
找到以下所有出现:
today.setHours(9, 30, 0, 0)
用以下内容替换它:
todayAt(9, 30)
-
确保您的测试仍然可以通过。
-
将这些行从测试套件移动到
test/builders/time.js中:const oneDayInMs = 24 * 60 * 60 * 1000; const tomorrow = new Date( today.getTime() + oneDayInMs ); -
而不是直接使用
tomorrow常量,让我们为它编写一个tomorrowAt辅助函数:export const tomorrowAt = ( hours, minutes = 0, seconds = 0, milliseconds = 0 ) => new Date(tomorrow).setHours( hours, minutes, seconds, milliseconds ); -
更新您的导入,使其包括新函数:
import { today, todayAt, tomorrowAt } from "./builders/time"; -
从测试套件中删除
oneDayInMs和tomorrow的定义。 -
找到以下表达式:
tomorrow.setHours(9, 30, 0, 0)
用以下代码替换它:
tomorrowAt(9, 30)
- 再次运行测试;它们应该可以通过。
我们将在第七章中再次使用这些辅助工具,测试 useEffect 和模拟组件。然而,在我们完成这一章之前,我们还可以进行一次提取。
提取测试属性对象
测试属性对象是一个设置合理默认值的对象,您可以使用它来减少您的render语句的大小。例如,看看以下渲染调用:
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
today={today}
/>
);
根据测试的不同,这些属性中的一些(或全部)可能对测试不相关。original属性是必要的,这样我们的渲染函数在渲染现有字段值时不会崩溃。但如果我们测试的是显示页面上的标签,我们就不关心这一点——这也是我们创建blankAppointment常量的一个原因。同样,availableTimeSlots和today属性可能对测试不相关。
不仅如此,通常,我们的组件最终可能需要大量属性,这些属性对于测试功能是必要的。这可能导致你的测试非常冗长。
属性太多?
你即将看到的技巧是处理许多必需属性的一种方法。但是,拥有很多属性(比如说,超过四五个)可能意味着你的组件设计可以改进。这些属性能否合并成一个复杂类型?或者应该将组件拆分成两个或更多组件?
这是另一个倾听你的测试的例子。如果测试难以编写,退一步看看你的组件设计。
我们可以在describe块顶部定义一个名为testProps的对象:
const testProps = {
original: { ... },
availableTimeSlots: [ ... ],
today: ...
}
这样就可以在render调用中使用它,如下所示:
render(<AppointmentForm {...testProps} />);
如果测试依赖于一个属性,比如如果其期望提到了props值的一部分,那么你不应该依赖于testProps对象中隐藏的值。这些值是合理的默认值。你的测试中的值应该突出显示,就像这个例子一样:
const appointment = {
...blankAppointment,
service: "Blow-dry"
};
render(
<AppointmentForm {...testProps} original={appointment} />
);
const option = findOption(field("service"), "Blow-dry");
expect(option.selected).toBe(true);
注意,在testProps之后,original属性仍然包含在render调用中。
有时候,你可能会明确地包含一个属性,即使其值与testProps值相同。这是为了在测试中突出其使用。我们将在本节中看到一个例子。
何时使用显式属性
作为一条经验法则,如果属性用于你的测试断言,或者如果属性值对于测试所测试的场景至关重要,那么即使其值与在testProps中定义的值相同,也应该在render调用中明确包含该属性。
让我们更新AppointmentForm测试套件,使其使用一个testProps对象:
-
在你的测试套件中找到
services、availableTimeSlots和blankAppointment的定义。这些定义应该接近顶部。 -
在其他定义之后添加以下
testProps定义:const testProps = { today, selectableServices: services, availableTimeSlots, original: blankAppointment, }; -
套件中的第一个测试看起来是这样的:
it("renders a form", () => { render( <AppointmentForm original={blankAppointment} availableTimeSlots={availableTimeSlots} /> ); expect(form()).not.toBeNull(); });
这可以更新为如下所示:
it("renders a form", () => {
render(<AppointmentForm {...testProps} />);
expect(form()).not.toBeNull();
});
-
接下来的两个测试,
渲染提交按钮和渲染为选择框,可以使用相同的更改。现在就去做吧。 -
接下来,我们有以下测试:
it("has a blank value as the first value", () => { render( <AppointmentForm original={blankAppointment} availableTimeSlots={availableTimeSlots} /> ); const firstOption = field("service").childNodes[0]; expect(firstOption.value).toEqual(""); });
由于这个测试依赖于为service字段传递一个空白值,所以让我们保留原始属性:
it("has a blank value as the first value", () => {
render(
<AppointmentForm
{...testProps}
original={blankAppointment}
/>
);
const firstOption = field("service").childNodes[0];
expect(firstOption.value).toEqual("");
});
我们有效地隐藏了availableTimeSlots属性,这在之前是噪音。
-
接下来,我们有一个使用
selectableServices的测试:it("lists all salon services", () => { const services = ["Cut", "Blow-dry"]; render( <AppointmentForm original={blankAppointment} selectableServices={services} availableTimeSlots={availableTimeSlots} /> ); expect( labelsOfAllOptions(field("service")) ).toEqual(expect.arrayContaining(services)); });
此测试在其期望中使用services常量,因此这是一个迹象表明我们需要将其作为一个显式的 prop。将其更改为以下内容:
it("lists all salon services", () => {
const services = ["Cut", "Blow-dry"];
render(
<AppointmentForm
{...testProps}
selectableServices={services}
/>
);
expect(
labelsOfAllOptions(field("service"))
).toEqual(expect.arrayContaining(services));
});
-
在下一个测试中,我们只需要去掉
availableTimeSlots,因为services和appointments都在测试本身中定义:it("pre-selects the existing value", () => { const services = ["Cut", "Blow-dry"]; const appointment = { service: "Blow-dry" }; render( <AppointmentForm {...testProps} original={appointment} selectableServices={services} /> ); const option = findOption( field("service"), "Blow-dry" ); expect(option.selected).toBe(true); });
此测试套件中剩余的测试位于嵌套的describe块中的时间槽表中。更新这一点留作练习。
你现在已经学习了更多清理测试套件的方法:提取测试数据构建器和提取testProps对象。记住,使用testProps对象并不总是正确的事情;可能更好的做法是重构你的组件,使其接受更少的 props。
摘要
在本章中,你学习了如何使用两种类型的 HTML 表单元素:选择框和单选按钮。
我们构建的组件具有一定的复杂性,主要由于用于显示日历视图的组件层次结构,但也因为我们需要的一些日期和时间函数来帮助显示该视图。
这就是它的复杂程度:编写 React 组件测试不应该比本章中更困难。
仔细审查我们的测试,最大的问题是使用expect.hasAssertions和异常的安排-断言-行动顺序。在第六章 探索测试替身 中,我们将发现如何简化这些测试并将它们恢复到安排-行动-断言顺序。
练习
以下是一些供你尝试的练习:
-
在
renders as a select box测试中替换两个期望,添加一个toBeElementWithTag匹配器。它应该像这样使用:expect(field("service")).toBeElementWithTag("select"); -
完成对
AppointmentForm选择框剩余测试的补充:-
渲染一个标签
-
分配一个与标签 ID 匹配的 ID
-
提交时保存现有值
-
提交时保存新值
-
这些测试实际上与CustomerForm的测试相同,包括使用change辅助函数。如果你想要挑战,你可以尝试将这些表单测试辅助函数提取到一个自己的模块中,该模块在CustomerForm和AppointmentForm之间共享。
-
更新时间槽表测试,使其使用
testProps对象。 -
更新
AppointmentsDayView组件,使其在适当的情况下使用todayAt构建器。 -
在选择时间槽之前添加选择风格的能力。这应该是一个基于所需服务进行筛选的选择框,因为并非所有造型师都具备提供所有服务的能力。你需要决定一个合适的数据结构来存储这些数据。修改
availableTimeSlots,使其列出每个时间可用的造型师,并更新表格以反映所选造型师及其在一周内的可用性。
进一步阅读
useCallback 钩子在您通过组件层次结构传递事件处理程序时非常有用。请查看 React 文档以获取有关确保正确使用方法的提示:https://reactjs.org/docs/hooks-reference.xhtml#usecallback.
第六章:探索测试替身
在本章中,我们将查看 TDD 谜题中最复杂的一部分:测试替身。
Jest 提供了一系列方便的测试替身函数,例如jest.spyOn和jest.fn。不幸的是,正确使用测试替身有点像一门黑暗的艺术。如果您不知道自己在做什么,可能会得到复杂、脆弱的测试。也许这就是为什么 Jest 没有将其作为其框架的一级特性来推广。
不要被吓倒:测试替身是一种高度有效且多功能的工具。诀窍是限制您的使用范围在一个小型、定义良好的模式集中,您将在接下来的几章中了解到这些模式。
在本章中,我们将构建自己的手工艺品测试替身函数集。它们几乎与 Jest 函数一样工作,但具有更简单(且更笨拙)的接口。目标是去除这些函数的魔力,向您展示它们是如何构建的以及如何使用它们来简化您的测试。
在您迄今为止构建的测试套件中,一些测试没有使用正常的expect.hasAssertions。在一个真实的代码库中,我会始终避免使用此函数,而是使用测试替身,这有助于将测试重新排序为 AAA 顺序。我们将从这里开始:重构现有测试以使用我们手工制作的测试替身,然后将其替换为 Jest 自己的测试替身函数。
本章将涵盖以下主题:
-
什么是测试替身?
-
使用间谍提交表单
-
监控 Fetch API
-
模拟
fetch响应 -
迁移到 Jest 内置的测试替身支持
到本章结束时,您将学会如何有效地使用 Jest 的测试替身功能。
技术要求
本章的代码文件可以在此处找到:
本章及以后的代码示例包含额外的提交,这些提交为应用程序添加了一个可工作的后端。这允许您发出请求以获取数据,您将在本章开始这样做。
在配套代码存储库中,从Chapter06/Start开始,npm run build命令将自动构建服务器。
然后,您可以通过使用npm run serve命令并浏览到http://localhost:3000或http://127.0.0.1:3000来启动应用程序。
如果您遇到问题
如果您无法运行应用程序,请查看存储库README.md文件的故障排除部分。
什么是测试替身?
单元测试中的单元指的是在测试期间我们关注的单个函数或组件。测试的行为阶段应该只涉及一个单元的一个动作。但单元不会孤立行动:函数调用其他函数,组件渲染子组件并调用从父组件传入的回调属性。可以将您的应用程序视为一个依赖关系的网络,测试替身帮助我们设计和测试这些依赖关系。
当我们编写测试时,我们会隔离被测试的单元。通常这意味着我们避免对任何协作对象进行操作。为什么?首先,这有助于我们朝着独立、专注的测试目标前进。其次,有时那些协作对象会有副作用,这会复杂化我们的测试。
以一个例子来说明,在 React 组件中,我们有时想避免渲染子组件,因为它们在挂载时会执行网络请求。
一个onSubmit函数,它被传递给CustomerForm和AppointmentForm两个组件。我们可以在测试中用测试替身替换它。正如我们将看到的,这有助于我们定义两者之间的关系。
在我们的系统中,测试替身最重要的使用地方是在与页面内容之外的任何外部事物交互的边缘:超文本传输协议(HTTP)请求、文件系统访问、套接字、本地存储等等。
测试替身被分为几种不同的类型:间谍、存根、模拟、哑元和伪造。我们通常只使用前两种,这也是本章我们将要集中讨论的内容。
学习避免伪造
伪造是指任何包含任何逻辑或控制结构的测试替身,例如条件语句或循环。其他类型的测试对象,如间谍和存根,完全由变量赋值和函数调用组成。
您会看到的一种伪造类型是内存中的存储库。您可以使用它来替代结构化查询语言(SQL)数据存储、消息代理和其他复杂的数据源。
伪造在测试两个单元之间的复杂协作时很有用。我们通常会先使用间谍和存根,然后在代码开始变得难以控制时重构为伪造。一个伪造可以覆盖一组测试,这比维护大量间谍和存根要简单。
我们避免使用伪造的原因如下:
-
任何逻辑都需要测试,这意味着我们必须为伪造编写测试,即使它们是测试代码的一部分。间谍和存根不需要测试。
-
通常,间谍和存根可以替代伪造。当我们使用伪造时,只有一小部分测试会更简单。
-
伪造增加了测试的脆弱性,因为它们在测试之间是共享的,而其他测试替身则不是。
既然我们已经涵盖了测试替身的理论,让我们继续在代码中使用它们。
使用间谍提交表单
在本节中,您将手动创建一个可重用的间谍函数,并调整您的测试以使它们回到 AAA 顺序。
这里是一个提醒,说明了CustomerForm测试套件中的一个测试看起来是怎样的。由于它被测试生成器包裹,所以有点复杂,但你现在可以忽略这一点——重要的是测试内容:
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
expect.hasAssertions();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={(props) =>
expect(props[fieldName]).toEqual(value)
}
/>
);
click(submitButton());
});
这段代码有几个问题,如下所述:
-
测试的断言阶段——期望——似乎被包裹在行为阶段中。这使得测试难以阅读和理解。
-
调用
expect.hasAssertions很丑陋,它只在那里是因为我们的期望被作为onSubmit函数的一部分调用,而这个函数可能被调用也可能不被调用。
我们可以通过构建一个间谍来解决这两个问题。
什么是间谍?
间谍是一种测试双重角色,它记录了被调用的参数,以便稍后可以检查这些值。
解开 AAA
将期望放在传递给onSubmit函数的firstName值下面。然后我们针对存储的值编写期望。
让我们现在这样做,如下所示:
-
修改
test/CustomerForm.test.js中的saves existing value when submitted测试生成器函数,如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { let submitArg; const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submittedCustomer => ( submitArg = submittedCustomer )} /> ); click(submitButton()); expect(submitArg).toEqual(customer); });
submitArg变量在onSubmit处理程序中分配,然后在测试的最后一条语句中断言。这解决了第一个测试中的两个问题:我们的测试回到了 AAA 顺序,并且我们摆脱了丑陋的expect.hasAssertions()调用。
-
如果你现在运行测试,它们应该都是绿色的。然而,每次你以这种方式重构测试时,你应该通过展开生产代码并观察测试失败来验证你仍在测试正确的事情。为了检查我们的测试是否仍然有效,定位
src/CustomerForm.js中的这一行:<form id="customer" onSubmit={handleSubmit}>
完全移除onSubmit属性,如下所示:
<form id="customer">
-
运行
npm test。你会从不同的测试中得到多个测试失败。然而,我们只对这一个测试生成器感兴趣,所以将其声明更新为it.only而不是it,如下所示:it.only("saves existing value when submitted", () => { -
现在,你应该只有三个失败,每个字段使用此生成器函数一个,如下面的代码片段所示。这是一个好兆头;如果更少,我们就可能产生了假阳性:
FAIL test/CustomerForm.test.js ● CustomerForm › first name field › saves existing value when submitted expect(received).toEqual(expected) // deep equality Expected: {"firstName": "existingValue"} Received: undefined -
我们已经证明了测试是有效的,所以你可以继续将
it.only声明改回it,并重新插入你从CustomerForm.js中移除的onSubmit属性。
你在这个测试中编写的代码显示了间谍函数的本质:当间谍被调用时,我们设置一个变量,然后基于该变量的值编写期望。
但我们还没有一个真正的间谍函数。我们将在下一步创建它。
制作可重用的间谍函数
我们仍然在CustomerForm和AppointmentForm中都有其他使用expect.hasAssertions形式的测试。我们如何将这个测试中构建的内容重用于其他所有内容?我们可以创建一个通用的spy函数,这样我们就可以在需要间谍功能时使用它。
让我们首先定义一个可以替代任何单个参数函数的函数,例如我们传递给onSubmit表单属性的处理器,如下所示:
-
在
test/CustomerForm.test.js的顶部定义以下函数。注意fn定义的格式与我们在上一个测试中使用的onSubmit处理器相似:const singleArgumentSpy = () => { let receivedArgument; return { fn: arg => (receivedArgument = arg), receivedArgument: () => receivedArgument }; }; -
重新编写您的测试生成器以使用此函数。尽管您的测试应该仍然通过,但请记住通过撤销生产代码来观察测试失败。代码如下所示:
const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = singleArgumentSpy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy.receivedArgument()).toEqual( customer ); }); -
通过将
singleArgumentSpy替换为以下函数,使你的间谍函数能够适用于任何数量的参数的函数:const spy = () => { let receivedArguments; return { fn: (...args) => (receivedArguments = args), receivedArguments: () => receivedArguments, receivedArgument: n => receivedArguments[n] }; };
这使用参数解构来保存整个参数数组。我们可以使用receivedArguments来返回该数组,或者使用receivedArgument(n)来检索第n个参数。
-
更新您的测试代码以使用这个新函数,如下面的代码片段所示。您可以在
receivedArguments上添加一个额外的期望来检查toBeDefined。这是一种表示“我期望函数被调用”的方式:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = spy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect( submitSpy.receivedArguments() ).toBeDefined(); expect(submitSpy.receivedArgument(0)).toEqual( customer ); });
间谍(spy)实际上很简单:它只是用来跟踪何时被调用以及调用时的参数。
使用 matcher 简化间谍期望
让我们编写一个 matcher,将这些期望封装成一个单独的语句,如下所示:
expect(submitSpy).toBeCalledWith(value);
这比在 matcher 上使用toBeDefined()参数更具有描述性。它还封装了如果receivedArguments尚未设置,则它尚未被调用的概念。
抛弃代码
我们将spike这段代码——换句话说,我们不会编写测试。这是因为不久之后,我们将用 Jest 的内置间谍功能来替换它。由于我们并不打算长时间保留它,所以深入到“真实”实现中是没有意义的。
我们将首先替换第一个期望的功能,如下所示:
-
在
test/domMatchers.js的底部添加以下代码。它添加了新的 matcher,为我们的测试做好准备:expect.extend({ toBeCalled(received) { if (received.receivedArguments() === undefined) { return { pass: false, message: () => "Spy was not called.", }; } return { pass: true, message: () => "Spy was called.", }; }, }); -
更新测试以使用新的 matcher,如下所示,替换使用
toBeDefined的第一个期望:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = spy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy).toBeCalled(customer); expect(submitSpy.receivedArgument(0)).toEqual( customer ); }); -
通过在您的生产代码中注释掉对
onSubmit的调用并观察测试失败来验证新的 matcher 是否工作。然后,取消注释并尝试在.not.toBeCalled测试中的否定形式。 -
现在,我们可以开始处理第二个期望——检查函数参数的期望。将以下代码添加到您的新 matcher 中,并将名称从
toBeCalled更改为toBeCalledWith:expect.extend({ toBeCalledWith(received, ...expectedArguments) { if (received.receivedArguments() === undefined) { ... } const notMatch = !this.equals( received.receivedArguments(), expectedArguments ); if (notMatch) { return { pass: false, message: () => "Spy called with the wrong arguments: " + received.receivedArguments() + ".", }; } return ...; }, });
在 matcher 中使用 this.equals
this.equals方法是一种特殊类型的相等函数,可以在 matcher 中使用。它执行深度相等匹配,这意味着它会递归遍历散列和数组以查找差异。它还允许使用expect.anything()、expect.objectContaining()和expect.arrayContaining()特殊函数。
如果你正在测试驱动此匹配器并将其提取到自己的文件中,你将不会使用 this.equals。相反,你会从 @jest/expect-utils 包中导入 equals 函数。我们将在 第七章* 测试 useEffect 和组件模拟* 中这样做。
-
更新你的测试以将这两个期望合并为一个,如下所示:
const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { ... click(submitButton()); expect(submitSpy).toBeCalledWith(customer); }); -
通过在
CustomerForm测试套件中更改onSubmit调用来发送明显错误的数据(例如,onSubmit(1, 2, 3))来使它失败。然后,也尝试匹配器的否定形式。
这完成了我们的监视器实现,你已经看到了如何测试回调属性。接下来,我们将探讨如何监视更复杂的函数:global.fetch。
监视 fetch API
在本节中,我们将使用 Fetch API 将客户数据发送到我们的后端服务。我们已经有了一个在表单提交时被调用的 onSubmit 属性。我们将在这个过程中将 onSubmit 调用转换为 global.fetch 调用,并调整我们的现有测试。
在我们更新的组件中,当通过 fetch 函数将 POST HTTP 请求发送到 /customers 端点时,请求体将是我们客户的 JavaScript 对象表示法(JSON)对象。
包含在 GitHub 仓库中的服务器实现将返回一个包含额外字段的更新后的 customer 对象:客户 id 值。
如果 fetch 请求成功,我们将调用一个新的 onSave 回调属性,并带有 fetch 响应。如果请求不成功,则不会调用 onSave,我们将渲染一个错误消息。
你可以将 fetch 视为 onSubmit 的更高级形式:两者都是我们将用客户对象调用的函数。但是 fetch 需要一组特殊的参数来定义正在进行的 HTTP 请求。它还返回一个 Promise 对象,因此我们需要考虑这一点,并且请求体需要是一个字符串,而不是一个普通对象,因此我们需要确保在我们的组件和测试套件中将其转换。
最后的一个区别是:fetch 是一个全局函数,可以通过 global.fetch 访问。我们不需要将其作为属性传递。为了监视它,我们用我们的监视器替换了原始函数。
理解 Fetch API
以下代码示例显示了 fetch 函数期望如何被调用。如果你不熟悉这个函数,请参阅本章末尾的 进一步阅读 部分。
考虑到所有这些,我们可以规划我们的前进路线:我们首先将全局函数替换为我们自己的监视器,然后添加新的测试以确保我们正确调用它,最后我们将更新 onSubmit 测试以调整其现有行为。
替换全局函数为监视器
我们已经看到了如何通过简单地将监视器作为回调属性的值来监视回调属性。要监视全局函数,我们只需在测试运行之前覆盖其值,并在之后将其重置回原始函数。
由于global.fetch是组件的必需依赖——没有它将无法工作——因此,在测试套件的beforeEach块中设置一个默认的 spy 是有意义的,这样 spy 就可以在所有测试中预先设置。beforeEach块也是设置 stubs 默认返回值的好地方,我们将在本章稍后进行操作。
按照以下步骤为你的测试套件在global.fetch上设置默认 spy:
-
在
test/CustomerForm.test.js的外部describe块的顶部添加以下声明:describe("CustomerForm", () => { const originalFetch = global.fetch; let fetchSpy; ... })
originalFetch常量将在测试完成后恢复 spy 时使用。fetchSpy变量将用于存储我们的fetch对象,这样我们就可以针对它编写期望。
-
将
beforeEach块修改如下。这将为你的测试套件中的每个测试设置global.fetch作为 spy:beforeEach(() => { initializeReactContainer(); fetchSpy = spy(); global.fetch = fetchSpy.fn; }); -
在
beforeEach块下方,添加一个afterEach块来取消 mock,如下所示:afterEach(() => { global.fetch = originalFetch; });
使用原始值重置全局 spies
重置任何用 spies 替换的全局变量是很重要的。这是测试相互依赖的常见原因:由于一个“脏”的 spy,一个测试可能会因为另一个测试未能重置其 spies 而失败。
在这个特定情况下,Node.js 运行时环境实际上没有global.fetch函数,所以originalFetch常量最终会是undefined。然后,你可以争论说这是不必要的:在我们的afterEach块中,我们只需简单地从global中删除fetch属性即可。
在本章的后面,我们将修改设置全局 spies 的方法,当我们使用 Jest 内置的 spy 函数时。
在全局 spy 就位后,你就可以在测试中使用它了。
测试驱动 fetch 参数值
是时候将global.fetch添加到我们的组件中了。当global.fetch使用正确的参数被调用时。类似于我们测试onSubmit的方式,我们将这个测试拆分为针对每个字段的测试,指定每个字段都必须传递。
结果表明global.fetch需要传递一大堆参数。我们不会在一个单独的单元测试中测试它们,而是根据它们的含义将测试拆分。
我们首先检查请求的基本情况:这是一个对/customers端点的POST请求。按照以下步骤操作:
-
在你的
CustomerForm测试套件的底部添加以下新测试。注意onSubmit被赋予了一个空函数定义—() => {}—而不是 spy,因为我们对这个测试中的属性不感兴趣:it("sends request to POST /customers when submitting the form", () => { render( <CustomerForm original={blankCustomer} onSubmit={() => {}} /> ); click(submitButton()); expect(fetchSpy).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); }); -
使用
npm test运行测试,并验证你是否收到一个期望失败的消息,显示为以下代码片段:● CustomerForm › sends request to POST /customers when submitting the form Spy was not called. 163 | ); 164 | click(submitButton()); > 165 | expect(fetchSpy).toBeCalledWith( | ^ 166 | "/customers", 167 | expect.objectContaining({ 168 | method: "POST", -
为了使其通过,通过在
onSubmit调用之前添加对global.fetch的调用,修改CustomerForm的handleSubmit函数,如下所示代码片段:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", }); onSubmit(customer); };
并行实现
这是一个并行实现。我们保留“旧”实现——即对 onSubmit 的调用——以便其他测试继续通过。
-
在这个测试通过后,添加下一个测试。在这个测试中,我们测试了请求所需的全部管道,我们将其称为“配置”,但您可以将这视为将所有常量、不太相关的信息批量处理。这个测试还使用了两个新函数,
expect.anything和expect.objectContaining,如下面的代码片段所示:it("calls fetch with the right configuration", () => { render( <CustomerForm original={blankCustomer} onSubmit={() => {}} /> ); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json", }, }) ); });
使用 expect.anything 和 expect.objectContaining 测试属性子集
expect.anything 函数是一种很有用的说法:“在这个测试中,我不关心这个参数;我在别处已经测试过了。”这是保持测试相互独立的好方法。在这种情况下,我们之前的测试检查第一个参数是否设置为 /customers,因此我们不需要在这个测试中再次测试这一点。
expect.objectContaining 函数与 expect.arrayContaining 函数类似,它允许我们测试完整参数值的一个子集。
-
运行该测试并观察测试失败。您可以在以下代码片段中看到,我们的匹配器在打印消息方面做得并不出色:第二个实际参数被打印为
[object Object]。现在我们先忽略这个问题,因为在本章的后面部分,我们将转向使用 Jest 的内置匹配器:● CustomerForm › calls fetch with the right configuration when submitting the form Spy was called with the wrong arguments: /customers,[object Object]. -
要使测试通过,只需将剩余的属性插入到您的
global.fetch调用中:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); onSubmit(customer); };
这为我们的 global.fetch 调用设置了管道,每个常量参数都已定义并放在适当的位置。接下来,我们将添加动态参数:请求体。
使用并行实现重写现有测试
您已经通过使用新测试开始构建并行实现。现在,是时候重写现有测试了。我们将移除旧实现(在这个例子中是 onSubmit)并用新实现(global.fetch)替换它。
完成这一步后,所有测试都将指向 global.fetch,因此我们可以更新我们的实现,从 handleSubmit 函数中移除 onSubmit 调用。
我们有两个测试需要更新:一个是检查提交现有值的测试,另一个是检查提交新值的测试。由于它们被封装在测试生成函数中,这使得测试变得复杂。这意味着当我们修改它们时,我们应该预期所有生成的测试——每个字段一个——作为一个组都会失败。这不是理想的情况,但即使只是一个普通的测试,我们遵循的过程也会是相同的。
让我们从本章中已经练习过的测试开始,提交现有值。请按照以下步骤操作:
-
返回到
itSubmitsExistingValue测试生成函数,并在底部插入一个新的期望值。暂时保留现有的期望值。运行测试并确保生成的测试失败。代码如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; const submitSpy = spy(); render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy).toBeCalledWith(customer); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(customer), }) ); }); -
为了使它通过,更新你的
CustomerForm组件中的handleSubmit函数,如下所示代码片段。在此更改之后,你的测试应该会通过:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(original), }); onSubmit(customer); }; -
对
onSubmit属性的最终测试引用是itSubmitsNewValue测试生成器。此测试仍然使用旧的expect.hasAssertions样式;我们稍后会删除它。现在,只需在测试底部添加一个新的期望,如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { ... expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ ...blankCustomer, [fieldName]: value, }), }) ); }); -
运行测试并验证此测试失败,失败信息为
Spy was called with the wrong arguments: /customers,[object Object]。 -
为了使它通过,你需要在
handleSubmit函数中将original更改为customer,如下所示:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(customer), }); onSubmit(customer); }; -
你对
fetch的调用现在已完成,因此你可以删除原始实现。首先从itSubmitsExistingValue测试生成器中移除onSubmit属性和submitSpy变量。新版本如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; render(<CustomerForm original={customer} />); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(customer), }) ); }); -
对于
itSubmitsNewValue也做同样的操作——你还可以删除hasAssertions调用。新版本如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { render(<CustomerForm original={blankCustomer} />); change(field(fieldName), value); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ ...blankCustomer, [fieldName]: value, }), }) ); }); -
从
handleSubmit方法中移除对onSubmit的调用。 -
从
CustomerForm组件定义中移除onSubmit属性。 -
最后,从
prevents the default action...测试中移除onSubmit属性。 -
使用
npm test验证所有测试是否通过。
你现在已经看到了如何通过重构测试来继续并行实现。一旦所有测试都已完成重构,你可以删除原始实现。
我们的测试又变得相当冗长。让我们通过一点清理来完成这一部分。
使用辅助函数改进间谍期望
当我们为我们的间谍编写期望时,我们不仅限于使用toBeCalledWith匹配器。我们可以提取参数并给它们命名,然后使用标准的 Jest 匹配器来处理它们。这样,我们可以避免使用expect.anything和expect.objectContaining的所有仪式。
让我们现在就做。按照以下步骤进行:
-
在
CustomerForm顶部添加一个新的辅助函数bodyOfLastFetchRequest,如下所示:const bodyOfLastFetchRequest = () => JSON.parse(fetchSpy.receivedArgument(1).body); -
更新你的
itSubmitsExistingValue测试生成器,使用这个新助手来简化其期望。注意这里使用了toMatchObject,它取代了之前版本此测试中的expect.objectContaining:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; render(<CustomerForm original={customer} />); click(submitButton()); expect(bodyOfLastFetchRequest()).toMatchObject( customer ); }); -
由于你已修改了测试,你应该验证它仍然测试正确的事情:将其标记为
it.only,然后从global.fetch调用中删除body属性。检查测试失败,然后撤销更改,使测试重新通过。 -
如此重复
itSubmitsNewValue测试生成器的操作,如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { render(<CustomerForm original={blankCustomer} />); change(field(fieldName), value); click(submitButton()); expect(bodyOfLastFetchRequest()).toMatchObject({ [fieldName]: value, }); });
这些测试现在看起来非常聪明!
这是一次复杂的变更:我们用对global.fetch的调用替换了onSubmit属性。我们通过在beforeEach块中引入一个全局间谍并在重构测试的同时编写并行实现来完成这项工作。
在本章的下一部分,我们将扩展我们对间谍的了解,将它们变成存根。
模拟获取响应
就像许多 HTTP 请求一样,我们的 POST /customers 端点返回数据:它将返回客户对象以及后端为我们选择的新生成的标识符。我们的应用程序将通过获取新 ID 并将其发送回父组件来使用它(尽管我们不会在 第八章,构建应用程序组件)之前构建这个父组件)。
要做到这一点,我们将创建一个新的 CustomerForm 属性,onSave,它将使用 fetch 调用的结果被调用。
但是等等——我们不是刚刚移除了一个 onSubmit 属性吗?是的,但这不是同一回事。原始的 onSubmit 属性接收用户提交的表单值。这个 onSave 属性将接收在成功保存后从服务器返回的客户对象。
要为这个新的 onSave 属性编写测试,我们需要为 global.fetch 提供一个模拟值,这本质上意味着,“这是调用 POST /customers 端点时 global.fetch 的返回值。”
什么是模拟?
模拟是一个测试双胞胎,当它被调用时总是返回相同的值。你在构造模拟时决定这个值是什么。
在本节中,我们将升级我们手工制作的间谍函数,使其也能模拟函数返回值。然后,我们将使用它来测试 CustomerForm 中新 onSave 属性的添加。最后,我们将使用它来在服务器由于某种原因未能保存新的客户对象时向用户显示错误。
升级间谍为模拟
模拟与间谍不同,因为它对跟踪被模拟函数的调用历史不感兴趣——它只关心返回单个值。
然而,结果证明,我们现有的使用间谍的测试也需要模拟值。这是因为一旦我们在生产代码中使用返回值,间谍必须返回一些东西;否则,测试会失败。所以,所有间谍最终也变成了模拟。
由于我们已经有了一个 spy 函数,我们可以“升级”它,使其也有模拟值的能力。以下是我们可以这样做的方法:
-
在
test/CustomerForm.test.js中,将spy函数更改为在顶部包含以下新的变量声明。这个变量将存储值,以便我们的函数返回:let returnValue; -
将
fn定义更改为以下所示:fn: (...args) => { receivedArguments = args; return returnValue; }, -
将这个新函数添加到你的间谍对象中,该函数设置
returnValue变量:stubReturnValue: value => returnValue = value
就这么简单:你的函数现在既是间谍也是模拟。让我们在我们的测试中使用它。
对获取响应采取行动
到目前为止,handleSubmit 函数会导致发起一个 fetch 请求,但它对响应没有任何操作。特别是,它不会等待响应;fetch API 是异步的,并返回一个承诺。一旦这个承诺解决,我们就可以对返回的数据做些事情。
我们将要编写的下一个测试将指定我们的组件应该对解决的数据做什么。
act的异步形式
当我们在 React 回调中处理承诺时,我们需要使用act的异步形式。它看起来是这样的:
await act(async () => performAsyncAction());
performAsyncAction函数不一定需要返回一个承诺;act将在返回之前等待浏览器async任务队列完成。
动作可能是一个按钮点击、表单提交或任何类型的输入字段事件。它也可能是具有执行某些异步副作用(如加载数据)的useEffect钩子的组件渲染。
向现有组件添加异步任务
现在,我们将使用act的异步形式来测试fetch承诺是否被等待。不幸的是,将async/await引入我们的handleSubmit函数将需要我们更新所有提交测试以使用act的异步形式。
如同往常,我们从测试开始。按照以下步骤进行:
-
在
test/CustomerForm.test.js中定义一个测试辅助函数,该函数构建一个Response对象类型,以模拟fetchAPI 返回的内容。这意味着它返回一个具有ok属性值为true的Promise对象,以及一个json函数,该函数返回另一个Promise,当解决时返回我们传递的 JSON。你可以在你的spy函数下面定义这个,如下所示:const fetchResponseOk = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
fetch返回值
ok属性在 HTTP 响应状态码在2xx范围内时返回true。任何其他类型的响应,如404或500,都会导致ok为false。
-
在
test/reactTestExtensions.js中添加以下代码,位于click定义下方:export const clickAndWait = async (element) => act(async () => click(element)); -
现在,将新的辅助函数导入到
test/CustomerForm.test.js中,如下所示:import { ..., clickAndWait, } from "./reactTestExtensions"; -
将下一个测试添加到
CustomerForm测试套件中,该测试检查当用户提交表单时是否调用onSave属性函数,并返回客户对象。这个测试的最佳位置是在calls fetch with correct configuration测试之下。以下代码片段展示了代码示例:it("notifies onSave when form is submitted", async () => { const customer = { id: 123 }; fetchSpy.stubReturnValue(fetchResponseOk(customer)); const saveSpy = spy(); render( <CustomerForm original={customer} onSave={saveSpy.fn} /> ); await clickAndWait(submitButton()); expect(saveSpy).toBeCalledWith(customer); }); -
要使这个测试通过,首先在
src/CustomerForm.js中为CustomerForm定义一个新的onSave属性,如下所示:export const CustomerForm = ({ original, onSave }) => { ... }; -
在
handleSubmit的末尾添加以下代码。现在,该函数被声明为async,并使用await来展开global.fetch返回的承诺:const handleSubmit = async (event) => { event.preventDefault(); const result = await global.fetch(...); const customerWithId = await result.json(); onSave(customerWithId); }; -
如果你运行测试,你会注意到尽管你的最新测试通过了,但之前的测试失败了,并且有一大堆未处理的承诺异常。实际上,任何提交表单的操作都会失败,因为它们使用了在
beforeEach块中初始化的fetchSpy变量,而这不是一个存根——它只是一个普通的间谍。现在通过在beforeEach中给间谍一个返回值来修复这个问题。在这种情况下,我们不需要给它一个客户;一个空对象就足够了,以下代码片段展示了这一点:beforeEach(() => { ... fetchSpy.stubReturnValue(fetchResponseOk({})); });
beforeEach块中的占位值
当模拟全局函数,如global.fetch时,始终在beforeEach块中设置一个默认的虚拟值,然后在需要特定模拟值的单个测试中覆盖它。
-
再次运行测试。此时你可能会看到一些奇怪的行为;我看到我的最近测试据说运行了六次,并且失败了!发生的事情是,我们之前的测试现在正在触发一大堆承诺,即使在测试结束时这些异步任务仍在继续运行。这些异步任务导致 Jest 错误地报告失败。为了解决这个问题,我们需要更新所有测试以使用
await clickAndWait。此外,测试需要标记为async。现在为每个调用click的测试执行此操作。这里有一个示例:it("sends HTTP request to POST /customers when submitting data", async () => { render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); ... }); -
删除
click导入,留下clickAndWait。 -
还有一个测试存在这个问题,那就是提交表单的测试:
在提交表单时阻止默认操作。这个测试调用了我们的submit辅助函数。我们也需要在act中包裹它。让我们在我们的测试扩展文件中创建一个submitAndWait辅助函数。将以下函数添加到submit下面,在test/reactTestExtensions.js中:export const submitAndWait = async (formElement) => act(async () => submit(formElement)); -
在你的
import语句中添加submitAndWait,在clickAndWait下面,如下所示:import { ..., submitAndWait, } from "./reactTestExtensions"; -
现在,你可以更新测试以使用新的辅助函数,如下所示:
it("prevents the default action when submitting the form", async () => { render(<CustomerForm original={blankCustomer} />); const event = await submitAndWait(form()); expect(event.defaultPrevented).toBe(true); }); -
如果你再次运行测试,我们仍然有测试失败(尽管幸运的是,
async任务被正确地考虑在内,事情看起来更有序)。你会看到现在有一大堆失败,说onSave 不是一个函数。为了解决这个问题,我们需要确保为每个提交表单的测试指定onSave属性。一个空的无操作函数就可以。这里有一个示例。现在将这个属性添加到每个提交表单的测试中。在这个更改之后,你的测试应该会通过,没有任何警告:it("calls fetch with correct configuration", async () => { render( <CustomerForm original={blankCustomer} onSave={() => {}} /> ); ... });
在需要时引入 testProps 对象
引入这个onSave无操作函数会导致噪音,这并不利于我们测试的可读性。这是一个引入testProps对象的绝佳机会,正如在第五章中所述,添加复杂表单交互。
-
添加另一个测试以确保在
fetch响应有错误状态(换句话说,当ok属性设置为false)时,我们不调用onSave。首先定义另一个辅助函数fetchResponseError,在fetchResponseOk下面,如下代码片段所示。这个不需要体,因为我们目前对它不感兴趣:const fetchResponseError = () => Promise.resolve({ ok: false }); -
在下一个
CustomerForm测试中使用这个新函数,如下所示:it("does not notify onSave if the POST request returns an error", async () => { fetchSpy.stubReturnValue(fetchResponseError()); const saveSpy = spy(); render( <CustomerForm original={blankCustomer} onSave={saveSpy.fn} /> ); await clickAndWait(submitButton()); expect(saveSpy).not.toBeCalledWith(); });
取反 toBeCalledWith
这个期望并不是我们真正想要的:如果我们仍然调用onSave但传递了错误的参数——例如,如果我们写了onSave(null),这个期望就会通过。我们真正想要的是.not.toBeCalled(),这将导致onSave以任何形式被调用时失败。但我们还没有构建这个匹配器。在本章的后面,我们将通过移动到 Jest 的内置 spy 函数来修复这个期望。
-
要使这通过,将
onSave调用移动到handleSubmit中的新条件中,如下所示:const handleSubmit = async (event) => { ... const result = ...; if (result.ok) { const customerWithId = await result.json(); onSave(customerWithId); } };
正如你所看到的,将组件从同步行为移动到异步行为真的会扰乱我们的测试套件。上面概述的步骤是这种情况所需工作的典型步骤。
异步组件操作可能导致 Jest 测试失败报告不准确
如果你看到测试失败而感到惊讶,并且无法解释为什么它失败了,请仔细检查测试套件中的所有测试,以确保在需要时使用了act的异步形式。Jest 在测试以异步任务完成时不会警告你,并且由于你的测试使用的是共享的 DOM 文档,这些异步任务将影响后续测试的结果。
这些就是处理测试中异步行为的基本方法。现在,让我们对我们的实现添加一些细节。
向用户显示错误
如果 fetch 返回的ok值为false,则向用户显示错误。这会在 HTTP 状态码返回在4xx或5xx范围内时发生,尽管对于我们的测试,我们不需要担心具体的状态码。遵循以下步骤:
-
将以下测试添加到
test/CustomerForm.test.js。这个测试检查页面上是否显示了错误区域。它依赖于 ARIA 角色alert,这是屏幕阅读器的特殊标识符,表示该区域可能改变以包含重要信息:it("renders an alert space", async () => { render(<CustomerForm original={blankCustomer} />); expect(element("[role=alert]")).not.toBeNull(); }); -
要使这通过,首先,定义一个新的
Error组件,如下所示。这可以放在src/CustomerForm.js中,正好在CustomerForm组件本身之上:const Error = () => ( <p role="alert" /> ); -
然后,在
CustomerForm的 JSX 中添加该组件的一个实例,正好在form元素顶部,如下所示:<form> <Error /> ... </form> -
回到
test/CustomerForm.test.js,添加下一个测试,该测试检查警告中的错误信息,如下所示:it("renders error message when fetch call fails", async () => { fetchSpy.mockReturnValue(fetchResponseError()); render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); expect(element("[role=alert]")).toContainText( "error occurred" ); }); -
要使这通过,我们只需要在
Error组件中硬编码字符串。我们将使用另一个测试来三角定位以到达真正的实现,如下所示:const Error = () => ( <p role="alert"> An error occurred during save. </p> ); -
将最后的测试添加到
test/CustomerForm.test.js中,如下所示:it("initially hano text in the alert space", async () => { render(<CustomerForm original={blankCustomer} />); expect(element("[role=alert]")).not.toContainText( "error occurred" ); }); -
要使这通过,在
CustomerForm定义的顶部引入一个新的error状态变量,如下所示:const [error, setError] = useState(false); -
修改
handleSubmit函数,如下所示:const handleSubmit = async (event) => { ... if (result.ok) { ... } else { setError(true); } } -
在组件的 JSX 中,更新
Error实例以包括新的hasErrorprop 并将其设置为error状态,如下所示:<form> <Error hasError={error} /> ... </form> -
剩下的只是用新的 prop 完成
Error组件,如下所示:const Error = ({ hasError }) => ( <p role="alert"> {hasError ? "An error occurred during save." : ""} </p> );
我们的CustomerForm实现到此结束。现在是时候对我们的测试进行一点清理了。
在嵌套的 describe 上下文中对 stub 场景进行分组
一种常见的做法是使用嵌套的describe块来设置存根值作为一组测试的场景。我们刚刚编写了四个测试,这些测试处理了POST /customers端点返回错误的场景。其中两个是嵌套describe上下文的良好候选。
然后,我们可以将存根值拉入一个beforeEach块中。让我们从describe块开始。按照以下步骤进行:
-
看看您最后写的四个测试。其中两个是关于警报空间的,与错误情况无关。保留这两个测试,并将另外两个移动到一个新的
describe块中,命名为when POST requests return an error,如下所示:it("renders an alert space", ...) it("initially has no text in the alert space", ...) describe("when POST request returns an error", () => { it("does not notify onSave if the POST request returns an error", ...) it("renders error message when fetch call fails", ...) }); -
注意,两个测试描述是如何重复的,它们以不同的方式说同样的话,就像
describe块一样?从两个测试描述中删除if/when语句,如下所示:describe("when POST request returns an error", () => { it("does not notify onSave", ...) it("renders error message ", ...) }); -
这两个测试具有相同的
global.fetch存根。将这个存根拉入一个新的beforeEach块中,如下所示:describe("when POST request returns an error", () => { beforeEach(() => { fetchSpy.stubReturnValue(fetchResponseError()); }); ... }) -
最后,从两个测试中删除存根调用,只留下
beforeEach块中的存根调用。
您现在已经看到了如何使用嵌套的describe块来描述特定的测试场景,这涵盖了所有基本的存根技术。在下一节中,我们将通过介绍 Jest 的自有存根和存根函数来继续我们的清理工作,这些函数比我们自己构建的稍微简单一些。
迁移到 Jest 的内置测试双倍支持
到目前为止,在本章中,您已经构建了自己的手工制作的存根函数,支持存根值和具有自己的匹配器。这样做的目的是为了教您如何使用测试双倍,并展示您将在组件测试中使用的基本存根和存根模式。
然而,我们的存根函数和toBeCalledWith匹配器还远远不够完善。与其在我们手工制作的版本上投入更多时间,现在切换到 Jest 的自有函数似乎更有意义。这些函数基本上与我们的spy函数以相同的方式工作,但有一些细微的差别。
本节首先概述了 Jest 的测试双倍功能。然后,我们将CustomerForm测试套件从我们手工制作的存根函数迁移出去。最后,我们将通过提取更多测试辅助工具进行一些清理。
使用 Jest 进行存根和存根
下面是 Jest 测试双倍支持的概述:
-
要创建一个新的存根函数,请调用
jest.fn()。例如,您可能编写const fetchSpy = jest.fn()。 -
要覆盖现有属性,请调用
jest.spyOn(object, property)。例如,您可能编写jest.spyOn(global, "fetch")。 -
要设置返回值,请调用
spy.mockReturnValue()。您也可以直接将此值传递给jest.fn()调用。 -
您可以通过链式调用
spy.mockReturnValueOnce()来设置多个返回值。 -
当您的函数返回承诺时,您可以使用
spy.mockResolvedValue()和spy.mockRejectedValue()。 -
要检查您的存根是否被调用,请使用
expect(spy).toBeCalled()。 -
要检查传递给您的间谍的参数,您可以使用
expect(spy).toBeCalledWith(arguments)。或者,如果您的间谍被多次调用,并且您想检查它最后一次被调用的情况,您可以使用expect(spy).toHaveLastBeenCalledWith(arguments)。 -
调用
spy.mockReset()将从间谍中移除所有模拟实现、返回值和现有的调用历史。 -
调用
spy.mockRestore()将移除模拟并恢复原始实现。 -
在您的
package.json文件的 Jest 配置部分,您可以将restoreMocks设置为true,这样在每次测试之后,使用jest.spyOn创建的所有间谍都将自动恢复。 -
当使用
toBeCalledWith时,您可以将expect.anything()作为参数值传递,表示您不关心该参数的值是什么。 -
您可以使用
expect.objectMatching(object)来检查一个参数是否具有您传递的对象的所有属性,而不是与该对象完全相等。 -
当您的间谍被多次调用时,您可以使用
spy.mock.calls[n]来检查特定调用传递的参数,其中n是调用编号(例如,calls[0]将返回第一次被调用的参数)。 -
如果您需要对特定参数执行复杂的匹配,您可以使用
spy.mock.calls[0][n],其中n是参数编号。 -
您可以使用
jest.mock()函数来模拟和间谍化整个模块,我们将在下一章中探讨这一点。
Jest API 提供了更多功能,但这些是核心特性,应该覆盖您的大多数测试驱动用例。
将测试套件迁移到使用 Jest 的测试双工支持
让我们将 CustomerForm 测试从我们手工制作的间谍函数中转换出来。我们将从 fetchSpy 变量开始。
我们将使用 jest.spyOn 来完成这项工作。它本质上创建了一个使用 jest.fn() 的间谍,并将其分配给 global.fetch 变量。jest.spyOn 函数会跟踪所有被间谍监视的对象,以便在没有我们干预的情况下自动恢复它们,使用 restoreMock 配置属性。
它还有一个特性,阻止我们间谍化任何不是函数的属性。这会影响我们,因为 Node.js 没有默认的 global.fetch 实现。我们将在下一组步骤中看到如何解决这个问题。
值得指出的是,jest.fn() 函数的一个非常出色的特性。返回的间谍对象既充当函数本身,也充当模拟对象。它是通过将一个特殊的 mock 属性附加到返回的函数上来实现这一点的。结果是,我们不再需要一个 fetchSpy 变量来存储我们的间谍对象。我们可以直接引用 global.fetch,正如我们即将看到的。
按照以下步骤操作:
-
更新
beforeEach块,如下所示。这使用mockResolvedValue来设置一个被 promise 包装的返回值(与mockReturnedValue相反,后者只是返回一个值,不涉及任何 promise):beforeEach(() => { initializeReactContainer(); jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk({})); }); -
CustomerForm测试套件中有两行遵循此模式:fetchSpy.stubResolvedValue(...);
将它们替换为以下代码:
global.fetch.mockResolvedValue(...);
-
有两个期望检查
fetchSpy。将expect(fetchSpy)替换为expect(global.fetch)。移除fetchSpy变量可以提供更好的可读性和对正在发生的事情的理解。以下是一个期望的例子:expect(global.fetch).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); -
bodyOflastFetchRequest函数需要更新以使用 Jest 间谍对象的mock属性。更新如下所示:const bodyOfLastFetchRequest = () => { const allCalls = global.fetch.mock.calls; const lastCall = allCalls[allCalls.length - 1]; return JSON.parse(lastCall[1].body); }; -
打开
package.json并添加restoreMocks属性,这确保在每个测试之后将global.fetch间谍重置到其原始设置。代码如下所示:"jest": { ..., "restoreMocks": true } -
这应该就是你的
global.fetch间谍的全部内容。你可以删除afterEach块、fetchSpy变量声明和originalFetch常量定义。 -
让我们继续到
saveSpy。回到你的CustomerForm测试套件中,找到表单提交时通知 onSave测试。按照以下代码片段更新它。我们正在用jest.fn()替换spy()的使用。注意我们不再需要将onSave属性设置为saveSpy.fn,而是直接设置为saveSpy:it("notifies onSave when form is submitted", async () => { const customer = { id: 123 }; global.fetch.mockResolvedValue( fetchResponseOk(customer) ); const saveSpy = jest.fn(); render( <CustomerForm original={blankCustomer} onSave={saveSpy} /> ); await clickAndWait(submitButton()); expect(saveSpy).toBeCalledWith(customer); }); -
为
如果 POST 请求返回错误则不通知 onSave测试重复此操作。 -
在测试套件的顶部删除你的
spy函数定义。 -
在
test/domMatchers.js中删除你的toBeCalledWith匹配器。 -
我们现在几乎接近一个可工作的测试套件。尝试运行你的测试——你会看到以下错误:
Cannot spy the fetch property because it is not a function; undefined given instead -
为了解决这个问题,我们需要让 Jest 认为
global.fetch确实是一个函数。最简单的方法是在测试套件启动时设置一个虚拟实现。创建一个test/globals.js文件,并在其中添加以下定义:global.fetch = () => Promise.resolve({}); -
现在,回到
package.json中,将此文件添加到setupFilesAfterEnv属性中,如下所示:"setupFilesAfterEnv": [ "./test/domMatchers.js", "./test/globals.js" ], -
使用
npm test运行所有测试。它们应该会通过。 -
最后还需要做一件清理工作。找到以下期望:
expect(saveSpy).not.toBeCalledWith();
如本章前面所述,这个期望是不正确的,我们之所以使用它,仅仅是因为我们手工制作的匹配器并没有完全支持这个用例。我们想要的期望是在任何形式下调用onSave时失败。现在我们正在使用 Jest 自己的匹配器,我们可以更优雅地解决这个问题。用以下代码替换这个期望:
expect(saveSpy).not.toBeCalled();
你的CustomerForm测试套件现在已经完全迁移。我们将以提取一些额外的辅助函数来结束本章。
提取 fetch 测试功能
CustomerForm不是唯一会调用fetch的组件:其中一个练习是更新AppointmentForm以将预约提交到服务器。将我们使用的通用代码抽取出来作为一个单独的模块是有意义的。按照以下步骤进行:
-
创建一个名为
test/spyHelpers.js的文件,并添加以下函数定义,它与测试套件中的函数相同,但这次标记为导出:export const bodyOfLastFetchRequest = () => { const allCalls = global.fetch.mock.calls; const lastCall = allCalls[allCalls.length - 1]; return JSON.parse(lastCall[1].body); }; -
创建一个名为
test/builders/fetch.js的文件,并将以下两个函数添加到其中:export const fetchResponseOk = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body), }); export const fetchResponseError = () => Promise.resolve({ ok: false }); -
从
test/CustomerForm.test.js内删除那些定义,并用以下代码片段中的import语句替换它们。在此更改后,运行您的测试并检查它们是否仍然通过:import { bodyOfLastFetchRequest } from "./spyHelpers"; import { fetchResponseOk, fetchResponseError, } from "./builders/fetch"; -
最后,我们可以通过移除此处显示的
Promise.resolve调用来简化fetchResponseOk和fetchResponseError。这是因为 Jest 的mockResolvedValue函数将自动将值包装在一个承诺中:export const fetchResponseOk = (body) => ({ ok: true, json: () => Promise.resolve(body), }); export const fetchResponseError = () => ({ ok: false, }); -
确保您已运行所有测试,并且在继续之前状态为绿色。
现在,您已经准备好在 AppointmentForm 测试套件中重用这些函数。
摘要
我们刚刚探讨了测试替身及其如何用于验证与协作对象的交互,例如组件属性(onSave)和浏览器 API 函数(global.fetch)。我们详细研究了间谍和存根,这是您将使用的两种主要类型的替身。您还看到了如何使用并排实现作为一项技术,在您从一个实现切换到另一个实现时,保持测试失败在可控范围内。
尽管本章涵盖了您在处理测试替身时将使用的核心模式,但我们还有一个主要模式尚未介绍,那就是如何监视和模拟 React 组件。这就是我们将在下一章中探讨的内容。
练习
尝试以下练习:
-
向
CustomerForm测试套件添加一个测试,指定当表单在所有验证错误纠正后第二次提交时,错误状态会被清除。 -
更新
AppointmentForm测试套件以使用jest.fn()和jest.spyOn()。 -
扩展
AppointmentForm以使其通过向/appointments发送POST请求来提交预约。/appointments端点返回一个没有主体的201 Created响应,因此您不需要在响应对象上调用json或向onSave发送任何参数。
进一步阅读
更多信息,请参考以下资源:
- 一个速查表,显示了您在测试 React 代码库时需要的所有 Jest 模拟构造函数
reacttdd.com/mocking-cheatsheet
- 不同类型测试替身的好介绍
martinfowler.com/articles/mocksArentStubs.xhtml
- Fetch API 使用简介
第七章:测试 useEffect 和模拟组件
在上一章中,你看到了如何使用测试替身来验证用户操作(如点击提交按钮)时发生的网络请求。我们还可以使用它们来验证组件挂载时的副作用,例如当我们从服务器获取组件需要的数据时。此外,测试替身可以用来验证子组件的渲染。这两种用例通常与 容器组件 一起发生,容器组件负责简单地加载数据并将其传递给另一个组件进行显示。
在本章中,我们将构建一个新的组件,AppointmentsDayViewLoader,它从服务器加载当天的预约并将其传递给我们在 第二章 中实现的 AppointmentsDayView 组件,渲染列表和详情视图。通过这样做,用户可以查看今天发生的预约列表。
在本章中,我们将涵盖以下主题:
-
模拟子组件
-
使用
useEffect在挂载时获取数据 -
jest.mock调用的变体
这些可能是你在测试驱动 React 组件时遇到的最困难的任务。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter07
模拟子组件
在本节中,我们将使用 jest.mock 测试辅助函数来用模拟实现替换子组件。然后,我们将编写期望来检查我们是否向子组件传递了正确的属性,并且它是否正确地渲染在屏幕上。
但首先,让我们详细了解一下模拟组件是如何工作的。
如何模拟组件,以及为什么?
本章我们将构建的组件具有以下形状:
export const AppointmentsDayViewLoader = ({ today }) => {
const [appointments, setAppointments] = useState([]);
useEffect(() => {
// fetch data from the server
const result = await global.fetch(...);
// populate the appointments array:
setAppointments(await result.json());
}, [today]);
return (
<AppointmentsDayView appointments={appointments} />
);
};
其目的是显示给定日期的所有当前预约。然后,这些信息作为 today 属性传递给组件。组件的职责是从服务器获取数据,然后将其传递给之前构建并已测试过的 AppointmentsDayView 组件。
考虑我们可能需要的测试。首先,我们想要一个测试来证明 AppointmentsDayView 在没有显示任何预约的情况下加载。然后,我们想要一些测试来验证我们的 global.fetch 调用是否成功调用,并且返回的数据被传递到 AppointmentsDayView。
我们如何测试 AppointmentsDayView 是否以正确数据调用?我们可以在 AppointmentsDayView 的测试套件中重复一些已经编写的测试 – 例如,通过测试是否显示了一个预约列表,以及显示的相关预约数据。
然而,这样我们就会在我们的测试套件中引入重复。如果我们修改AppointmentsDayView的工作方式,我们将有两个地方需要更新测试。
另一个选择是使用间谍对象模拟组件。为此,我们可以使用jest.mock函数,与间谍一起使用。这将是这样看起来:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(() => (
<div id="AppointmentsDayView" />
)),
}));
函数的第一个参数是要模拟的文件路径。它必须与传递给import语句的路径匹配。这个函数正在模拟整个模块:
import { MyComponent } from "some/file/path";
jest.mock("/some/file/path", ...);
describe("something that uses MyComponent", () => {
});
在前面的代码中,Jest 将这个调用提升到文件顶部,并挂钩到导入逻辑,以便当import语句运行时,你的模拟会被返回。
无论何时在测试套件或被测试的组件中引用AppointmentsDayView,你都会得到这个模拟值而不是真实的组件。而不是渲染我们的日视图,我们会得到一个具有AppointmentsDayView的id值的单个div。
第二个参数是AppointmentsDayView。
因为模拟定义被提升到文件顶部,所以你无法在这个函数中引用任何变量:它们在你运行函数之前还没有被定义。然而,你可以编写 JSX,就像我们在这里所做的那样!
组件模拟设置的复杂性
这段代码非常晦涩难懂,我知道。幸运的是,你通常只需要写一次。当我需要在一个测试套件中引入一个新的模拟时,我经常发现自己是在复制粘贴模拟。我会查找我在其他测试套件中写的上一个模拟,并将其复制过来,更改相关细节。
所以,现在有一个大问题:你为什么要这样做?
首先,使用模拟可以通过鼓励具有独立表面的多个测试套件来改进测试组织。如果父组件和其子组件都是非平凡组件,那么为这些组件提供两个单独的测试套件可以帮助减少测试套件的复杂性。
父组件的测试套件将只包含少量测试,以证明子组件已被渲染并传递了预期的属性值。
通过在父组件的测试套件中模拟子组件,你实际上是在说,“我现在想忽略这个子组件,但我保证我会在其他地方测试其功能!”
另一个原因是,你可能已经对子组件进行了测试。这就是我们发现自己所处的场景:我们已经有AppointmentsDayView的测试,所以除非我们想要重复,否则在它被使用的地方模拟组件是有意义的。
这个原因的扩展是使用库组件。因为它们是由别人构建的,所以你有理由相信它们已经过测试并且能正确工作。而且由于它们是库组件,它们可能已经执行了一些相当复杂的操作,所以在你的测试中渲染它们可能会产生意外的副作用。
可能你有一个库组件,它可以构建各种复杂的 HTML 小部件,而你不想让测试代码知道这一点。相反,你可以将其视为黑盒。在这种情况下,最好是验证传递给组件的属性值,再次相信该组件按预期工作。
库组件通常具有复杂的组件 API,允许以多种方式配置组件。模拟组件允许你编写合同测试,确保你正确设置了属性。我们将在第十一章 测试驱动 React Router中看到这一点,当我们模拟 React Router 的Link组件时。
模拟组件的最后一个原因是它们在挂载时可能有副作用,例如执行网络请求以拉取数据。通过模拟组件,你的测试套件不需要考虑这些副作用。我们将在第八章 构建应用程序组件中这样做。
说了这么多,让我们开始构建我们的新组件。
测试初始组件属性
我们将首先为新的组件构建一个测试套件:
-
创建一个新文件,
test/AppointmentsDayViewLoader.js,并添加以下所有导入。我们不仅导入正在测试的组件(AppointmentsDayViewLoader),还导入我们将要模拟的子组件(AppointmentsDayView):import React from "react"; import { initializeReactContainer, render, element, } from "./reactTestExtensions"; import { AppointmentsDayViewLoader } from "../src/AppointmentsDayViewLoader"; import { AppointmentsDayView } from "../src/AppointmentsDayView"; -
在导入下面添加模拟设置:
jest.mock("../src/AppointmentsDayView", () => ({ AppointmentsDayView: jest.fn(() => ( <div id="AppointmentsDayView" /> )), })); -
从这里显示的第一个测试开始。这检查我们刚刚模拟的组件是否被渲染。模拟渲染一个具有
id值为AppointmentsDayView的div元素。测试使用element辅助函数查找id值,并检查它不为空:describe("AppointmentsDayViewLoader", () => { beforeEach(() => { initializeReactContainer(); }); it("renders an AppointmentsDayView", () => { await render(<AppointmentsDayViewLoader />); expect( element("#AppointmentsDayView") ).not.toBeNull(); }); });
使用 ID 属性
如果你熟悉用于识别组件的data-testid。如果你想使用这些模拟技术与 React Testing Library 一起使用,那么你可以使用data-testid而不是id属性,然后使用queryByTestId函数查找你的元素。
虽然通常建议不要在测试套件中选择元素时依赖data-testid,但这并不适用于模拟组件。你需要 ID 来区分它们,因为你可能会遇到多个由同一父组件渲染的模拟组件。给每个组件分配 ID 是找到这些 DOM 存在性测试的最简单方法。记住,模拟永远不会超出你的单元测试环境,所以使用 ID 没有害处。
更多关于使用 React Testing Library 的模拟策略的讨论,请访问reacttdd.com/mocking-with-react-testing-library。
-
让这个测试通过。创建一个新文件,
src/AppointmentsDayViewLoader.js,并继续填写实现,如下所示。它只是渲染组件,这正是测试所要求的:import React from "react"; import { AppointmentsDayView } from "./AppointmentsDayView"; export const AppointmentsDayViewLoader = () => ( <AppointmentsDayView /> ); -
下次测试的时间到了。我们将检查发送给函数的 props 的初始值。
AppointmentsDayView是我们期望的结果。我们将通过使用toBeCalledWith匹配器来实现这一点,我们已经使用过它了。注意expect.anything()的第二个参数值:这是必需的,因为 React 在渲染组件函数时传递第二个参数。你永远不会需要在你的代码中关心这个参数——这是 React 实现的一个内部细节——因此我们可以安全地忽略它。我们将使用expect.anything来断言我们不在乎那个参数是什么:
it("initially passes empty array of appointments to AppointmentsDayView", () => {
await render(<AppointmentsDayViewLoader />);
expect(AppointmentsDayView).toBeCalledWith(
{ appointments: [] },
expect.anything()
);
});
验证 props 及其在 DOM 中的存在
在这两个测试中,测试传递给模拟的 props 以及模拟值是否渲染在 DOM 中非常重要,正如我们所做的。在第八章 构建应用程序组件中,我们将看到一个案例,其中我们想要检查在用户操作后模拟组件是否已卸载。
-
通过更新你的组件定义来通过测试,如下所示:
export const AppointmentsDayViewLoader = () => ( <AppointmentsDayView appointments={[]} /> );
你刚刚使用了你的第一个模拟组件!你已经看到了如何创建模拟,以及验证其使用的两种类型的测试。接下来,我们将添加一个useEffect钩子,在组件挂载时加载数据,并通过appointments prop 传递它。
使用useEffect在挂载时获取数据
我们将加载的预约数据来自一个接受开始和结束日期的端点。这些值将结果过滤到特定的时间范围:
GET /appointments/<from>-<to>
我们的新组件接收一个today prop,它是一个包含当前时间值的Date对象。我们将从today prop 计算from和to日期,并构建一个 URL 传递给global.fetch。
要达到这个目标,首先,我们将简要介绍一些关于测试useEffect钩子的理论。然后,我们将实现一个新的renderAndWait函数,因为我们将在组件挂载时调用一个 promise。最后,我们将使用这个函数在我们的新测试中,构建完整的useEffect实现。
理解useEffect钩子
useEffect钩子是 React 执行副作用的方式。想法是,你提供一个函数,该函数将在钩子的任何依赖项更改时运行。这个依赖项列表是useEffect调用的第二个参数。
让我们再次看看我们的示例:
export const AppointmentsDayViewLoader = ({ today }) => {
useEffect(() => {
// ... code runs here
}, [today]);
// ... render something
}
当today prop 改变时,钩子代码将运行。这包括当组件首次挂载时。当我们进行测试驱动时,我们将从一个空的依赖项列表开始,然后使用一个特定的测试来强制在组件使用新的today prop 值重新挂载时刷新。
你传递给useEffect的函数应该返回另一个函数。这个函数执行清理操作:它在值改变时被调用,尤其是在钩子函数再次被调用之前,这使你能够取消任何正在运行的任务。
我们将在 第十五章 添加动画 中详细探讨这个返回函数。然而,现在你应该知道这会影响我们调用 Promise 的方式。我们不能这样做:
useEffect(async () => { ... }, []);
将外部函数定义为 async 意味着它返回一个 Promise,而不是一个函数。我们必须这样做:
useEffect(() => {
const fetchAppointments = async () => {
const result = await global.fetch(...);
setAppointments(await result.json());
};
fetchAppointments();
}, [today]);
当运行测试时,如果你在 useEffect Hook 内直接调用 global.fetch,你会收到 React 的警告。它会提醒你 useEffect Hook 不应该返回一个 Promise。
在 useEffect Hook 函数中使用设置器
React 保证像 setAppointments 这样的设置器保持静态。这意味着它们不需要出现在 useEffect 依赖列表中。
要开始我们的实现,我们需要确保我们的测试为运行 Promise 的 render 调用做好准备。
添加 renderAndWait 辅助函数
就像我们对 clickAndWait 和 submitAndWait 所做的那样,现在我们需要 renderAndWait。这将渲染组件,然后等待我们的 useEffect Hook 运行,包括任何 Promise 任务。
为了清晰起见,这个函数的必要性不是因为 useEffect Hook 本身——一个普通的同步 act 调用就能确保它运行——而是因为 useEffect 运行的 Promise:
-
在
test/reactTestExtensions.js中,在render定义下方添加以下函数:export const renderAndWait = (component) => act(async () => ( ReactDOM.createRoot(container).render(component) ) ); -
更新测试套件中的导入,使其引用这个新函数:
import { initializeReactContainer, renderAndWait, element, } from "./reactTestExtensions"; -
然后,更新第一个测试:
it("renders an AppointmentsDayView", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect( element("#AppointmentsDayView") ).not.toBeNull(); }); -
添加第二个测试,检查在服务器返回任何数据之前,我们是否向
AppointmentsDayView发送了一个空的预约数组:it("initially passes empty array of appointments to AppointmentsDayView", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect(AppointmentsDayView).toBeCalledWith( { appointments: [] }, expect.anything() ); });
在继续之前,请确保这些测试通过。
添加 useEffect Hook
我们即将引入一个带有对 global.fetch 调用的 useEffect Hook。我们首先将使用 jest.spyOn 模拟这个调用。然后,我们继续进行测试:
-
在测试套件的顶部添加以下新的导入:
import { todayAt } from "./builders/time"; import { fetchResponseOk } from "./builders/fetch"; -
在
describe块的顶部定义一组示例预约:describe("AppointmentsDayViewLoader", () => { const appointments = [ { startsAt: todayAt(9) }, { startsAt: todayAt(10) }, ]; ... }); -
要设置
global.fetch以返回此示例数组,修改测试套件的beforeEach块,如下所示:beforeEach(() => { initializeReactContainer(); jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk(appointments)); }); -
是时候进行我们的测试了。我们断言当组件挂载时,我们应该看到带有正确参数的
global.fetch调用。我们的测试计算了正确的参数值——应该是从今晚午夜到明天午夜:it("fetches data when component is mounted", async () => { const from = todayAt(0); const to = todayAt(23, 59, 59, 999); await renderAndWait( <AppointmentsDayViewLoader today={today} /> ); expect(global.fetch).toBeCalledWith( `/appointments/${from}-${to}`, { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, } ); }); -
要使这个测试通过,首先,我们需要在组件文件中引入一个 useEffect Hook:
import React, { useEffect } from "react"; -
现在,我们可以更新组件以进行调用,如下所示。尽管代码已经很多了,但请注意我们还没有使用
return返回值:没有存储任何状态,AppointmentsDayView仍然将appointments属性设置为空数组。我们稍后会填充它:export const AppointmentsDayViewLoader = ( { today } ) => { useEffect(() => { const from = today.setHours(0, 0, 0, 0); const to = today.setHours(23, 59, 59, 999); const fetchAppointments = async () => { await global.fetch( `/appointments/${from}-${to}`, { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, } ); }; fetchAppointments(); }, []); return <AppointmentsDayView appointments={[]} />; }; -
在继续下一个测试之前,让我们为
today属性设置一个默认值,这样任何调用者都不需要指定这个值:AppointmentsDayViewLoader.defaultProps = { today: new Date(), }; -
下一个测试将确保我们使用
global.fetch调用的返回值。注意我们如何使用toHaveBeenLastCalledWith匹配器。这很重要,因为组件的第一次渲染将是一个空数组。包含数据的是第二次调用:it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect( AppointmentsDayView ).toHaveBeenLastCalledWith( { appointments }, expect.anything() ); }); -
要使其通过,首先,更新你的组件的
import以引入useState函数:import React, { useEffect, useState } from "react"; -
现在,更新你的组件定义,如下所示:
export const AppointmentsDayViewLoader = ( { today } ) => { const [ appointments, setAppointments ] = useState([]); useEffect(() => { ... const fetchAppointments = async () => { const result = await global.fetch( ... ); setAppointments(await result.json()); }; fetchAppointments(); }, []); return ( <AppointmentsDayView appointments={appointments} /> ); };
这完成了基本的useEffect实现——我们的组件现在正在加载数据。然而,我们必须用useEffect实现来解决最后一个问题。
测试useEffect依赖列表
useEffect调用的第二个参数是一个依赖列表,它定义了应该导致效果重新评估的变量。在我们的例子中,today属性是重要的。如果组件以新的today值重新渲染,那么我们应该从服务器拉取新的预约。
我们将编写一个渲染组件两次的测试。这种测试在任何使用useEffect钩子的时候都非常重要。为了支持这一点,我们需要调整我们的渲染函数,确保它们只创建一个根:
-
在
test/reactTestExtensions.js中,添加一个名为reactRoot的新顶级变量,并更新initializeReactContainer以设置此变量:export let container; let reactRoot; export const initializeReactContainer = () => { container = document.createElement("div"); document.body.replaceChildren(container); reactRoot = ReactDOM.createRoot(container); }; -
现在,更新
render和renderAndWait的定义,使它们使用这个reactRoot变量。在做出这个更改后,你将能够在单个测试中重新挂载组件:export const render = (component) => act(() => reactRoot.render(component)); export const renderAndWait = (component) => act(async () => reactRoot.render(component)); -
在你的测试套件中,更新
import以包含today、tomorrow和tomorrowAt。我们将在下一个测试中使用这些:import { today, todayAt, tomorrow, tomorrowAt } from "./builders/time"; -
现在,添加测试。这会渲染组件两次,
today属性有两个不同的值。然后,它检查global.fetch是否被调用两次:it("re-requests appointment when today prop changes", async () => { const from = tomorrowAt(0); const to = tomorrowAt(23, 59, 59, 999); await renderAndWait( <AppointmentsDayViewLoader today={today} /> ); await renderAndWait( <AppointmentsDayViewLoader today={tomorrow} /> ); expect(global.fetch).toHaveBeenLastCalledWith( `/appointments/${from}-${to}`, expect.anything() ); }); -
如果你现在运行测试,你会看到
global.fetch只被调用了一次:AppointmentsDayViewLoader ' re-requests appointment when today prop changes expect( jest.fn() ).toHaveBeenLastCalledWith(...expected) Expected: "/appointments/1643932800000-1644019199999", Anything Received: "/appointments/1643846400000-1643932799999", {"credentials": "same-origin", "headers": {"Content-Type": "application/json"}, "method": "GET"} -
使其通过只需一个单词的更改。找到
useEffect调用的第二个参数,并将其从空数组更改为如下所示:useEffect(() => { ... }, [today]);
这就是本组件实现的全部内容。在下一节中,我们将使用一个新的匹配器清理我们的测试代码。
为组件模拟构建匹配器
在本节中,我们将介绍一个新的匹配器toBeRenderedWithProps,它简化了我们对模拟间谍对象的期望。
回想一下,我们的期望看起来是这样的:
expect(AppointmentsDayView).toBeCalledWith(
{ appointments },
expect.anything()
);
想象一下,如果你在一个有这种测试的团队中工作。新加入的人能理解第二个参数expect.anything()在做什么吗?如果你一段时间不回来,忘记了组件模拟的工作方式,你会理解这个吗?
让我们将它包装成一个匹配器,允许我们隐藏第二个属性。
我们需要两个匹配器来覆盖常见的用例。第一个,toBeRenderedWithProps,是我们将在本章中解决的问题。第二个,toBeFirstRenderedWithProps,留作你的练习。
匹配器 toBeRenderedWithProps 将在组件当前使用给定属性渲染时通过。这个函数将与使用 toHaveBeenLastCalledWith 匹配器等效。
这个匹配器的关键部分是当它从 mock.calls 数组中提取最后一个元素时:
const mockedCall =
mockedComponent.mock.calls[
mockedComponent.mock.calls.length – 1
];
mock.calls 数组
回想一下,每个使用 jest.spyOn 或 jest.fn 创建的模拟函数都将有一个 mock.calls 属性,它是一个包含所有调用的数组。这一点在 第六章 中有介绍,探索测试替身。
第二个匹配器是 toBeFirstRenderedWithProps。这对于任何检查子属性初始值并且在任何 useEffect 钩子运行之前的测试都很有用。我们不会选择 mock.calls 数组的最后一个元素,而是直接选择第一个:
const mockedCall = mockedComponent.mock.calls[0];
让我们从 toBeRenderedWithProps 开始:
-
在
test/matchers/toBeRenderedWithProps.test.js创建一个新的匹配器测试文件。添加以下导入:import React from "react"; import { toBeRenderedWithProps, } from "./toBeRenderedWithProps"; import { initializeReactContainer, render, } from "../reactTestExtensions"; -
添加以下测试设置。由于我们的测试将在一个间谍函数上操作,我们可以在
beforeEach块中设置它,如下所示:describe("toBeRenderedWithProps", () => { let Component; beforeEach(() => { initializeReactContainer(); Component = jest.fn(() => <div />); }); }); -
如同往常,我们的第一个测试是检查
pass返回true。注意我们必须在调用匹配器之前渲染组件:it("returns pass is true when mock has been rendered", () => { render(<Component />); const result = toBeRenderedWithProps(Component, {}); expect(result.pass).toBe(true); }); -
为了使这个测试通过,创建一个新的匹配器文件
test/matchers/toBeRenderedWithProps.js,并添加以下实现:export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => ({ pass: true }); -
是时候进行三角测量了。对于下一个测试,让我们检查在调用组件之前没有渲染它时,
pass是否为false:it("returns pass is false when the mock has not been rendered", () => { const result = toBeRenderedWithProps(Component, {}); expect(result.pass).toBe(false); }); -
为了让测试通过,我们只需检查模拟至少被调用了一次:
export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => ({ pass: mockedComponent.mock.calls.length > 0, }); -
接下来,我们需要检查如果属性不匹配,
pass是否为false。我们无法编写相反的测试——即如果属性匹配,则pass为true——因为根据我们当前的实现,这个测试已经通过了:it("returns pass is false when the properties do not match", () => { render(<Component a="b" />); const result = toBeRenderedWithProps( Component, { c: "d", } ); expect(result.pass).toBe(false); }); -
对于组件代码,我们将使用
expect-utils包内的equals函数,这个包已经作为 Jest 的一部分安装。这个函数测试深度相等,同时也允许你使用expect辅助函数,如expect.anything和expect.objectContaining:import { equals } from "@jest/expect-utils"; export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => { const mockedCall = mockedComponent.mock.calls[0]; const actualProps = mockedCall ? mockedCall[0] : null; const pass = equals(actualProps, expectedProps); return { pass }; }; -
对于我们的最终测试,我们想要一个例子来展示这个匹配器可以在模拟的最后渲染上匹配期望:
it("returns pass is true when the properties of the last render match", () => { render(<Component a="b" />); render(<Component c="d" />); const result = toBeRenderedWithProps( Component, { c: "d" } ); expect(result.pass).toBe(true); }); -
为了使这个测试通过,我们需要更新实现,使其选择
mock.calls数组的最后一个元素,而不是第一个:export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => { const mockedCall = mockedComponent.mock.calls[ mockedComponent.mock.calls.length – 1 ]; ... }; -
我们在这里留下我们的实现。完成消息属性的测试留作你的练习,但它们的顺序与 第三章 中显示的测试相同,重构测试套件。现在,转到
test/domMatchers.js并注册你的新匹配器:import { toBeRenderedWithProps, } from "./matchers/toBeRenderedWithProps"; expect.extend({ ..., toBeRenderedWithProps, }); -
最后,回到你的测试套件中,更新检查
appointments属性的测试。它应该看起来像这样;现在expect.anything参数值已经移除,它看起来更简洁了:it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect(AppointmentsDayView).toBeRenderedWithProps({ appointments, }); });
通过这样,你已经学会了如何为组件模拟构建一个匹配器,这减少了我们最初使用内置的toBeCalledWith匹配器时所拥有的冗余。
这个测试套件中的另一个测试需要一个第二个匹配器,toBeFirstRenderedWithProps。这个实现的细节留给你作为练习。
在下一节中,我们将探讨组件模拟可以变得更加复杂的各种方式。
jest.mock 调用的变体
在我们完成本章之前,让我们看看一些你可能最终会使用的jest.mock调用的变体。
需要记住的关键点是尽可能保持你的模拟简单。如果你开始觉得你的模拟需要变得更加复杂,你应该将其视为一个信号,表明你的组件过载了,并且应该以某种方式拆分。
话虽如此,有些情况下你必须使用基本组件模拟的不同形式。
移除间谍函数
首先,你可以通过不使用jest.fn来简化你的jest.mock调用:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: () => (
<div id="AppointmentsDayView" />
),
}));
使用这种形式,你已经设置了一个存根返回值,但你将无法监视任何属性。如果,例如,你有多个文件正在测试同一个组件,但只有其中一些验证了模拟组件的属性,这有时是有用的。它也可以与第三方组件一起使用。
渲染模拟组件的子组件
有时候,你可能想要渲染孙组件,跳过子组件(它们的父组件)。这种情况经常发生,例如,当第三方组件渲染一个复杂且难以测试的 UI 时:例如,它可能通过阴影 DOM 加载元素。在这种情况下,你可以通过你的模拟传递children:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ children }) => (
<div id="AppointmentsDayView">{children}</div>
)),
}));
我们将在第十一章中看到这个例子,测试驱动 React Router。
检查渲染组件的多个实例
有时候,你可能想要模拟一个被多次渲染到文档中的组件。你如何区分它们?如果它们有一个唯一的 ID 属性(如key),你可以在id字段中使用它:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ key }) => (
<div id={`AppointmentsDayView${key}`} />
)),
}));
小心行事!
模拟组件的最大问题之一是模拟定义可能会失控。但是模拟设置很复杂,可能会非常令人困惑。因此,你应该避免编写除了最简单的模拟之外的内容。
幸运的是,大多数时候,组件模拟的普通形式就足够了。这些变体偶尔是有用的,但应该避免使用。
我们将在第十一章中看到这个变体的实际应用,测试驱动 React Router。
模块模拟的替代方案
模拟整个模块相当直接。你设置的模拟必须用于同一测试模块中的所有测试:你不能混合使用测试,一些使用模拟,一些不使用。如果你想要使用jest.mock来做这件事,你必须创建两个测试套件。一个会有模拟,另一个则不会。
您还遇到了模拟处于模块级别的问题。您不能只是模拟模块的一部分。Jest 有允许您引用原始实现的函数,称为requireActual。对我来说,这涉及到进入过于复杂的测试替身的风险区域,所以我避免使用它——我遇到了一个需要它的用例。
然而,使用jest.mock有替代方案。一个是浅渲染,它使用一个特殊的渲染器,只渲染单个父组件,忽略所有非标准 HTML 元素的子组件。从某种意义上说,这甚至更加直接,因为所有您的组件最终都会被模拟。
对于CommonJS模块,您也可以通过简单地给它们赋新值来覆盖模块内的特定导出!这为您在测试级别设置模拟提供了一个更细粒度的方法。然而,这在ECMAScript中不受支持,因此为了最大程度地发挥能力,您可能希望避免这种方法。
为了了解这些替代方法的示例以及何时可能需要使用它们,请参阅reacttdd.com/alternatives-to-module-mocks。
摘要
本章介绍了最复杂的模拟形式:使用jest.mock设置组件模拟。
由于模拟是一项复杂的艺术,因此最好坚持使用一组已建立的模式,我在本章中展示了这些模式。您还可以参考第十一章,测试驱动 React Router中的代码,以了解本章中描述的一些变体。
您还学习了如何在编写另一个匹配器之前测试驱动useEffect钩子。
现在,您应该对使用组件模拟测试子组件感到自信,包括通过useEffect动作将这些组件加载数据。
在下一章中,我们将通过从模拟组件中提取callback属性并在测试中调用它们来进一步扩展这项技术。
练习
以下是一些供您尝试的练习:
-
完成
toBeRenderedWithProps匹配器上的消息属性测试。 -
添加
toBeFirstRenderedWithProps匹配器,并更新您的测试套件以使用此匹配器。由于此匹配器与toBeRenderedWithProps非常相似,您可以将它添加到包含toBeRenderedWithProps匹配器的同一模块文件中。您还可以尝试将任何共享代码提取到其自己的函数中,这两个匹配器都可以使用。 -
添加一个
toBeRendered匹配器,该匹配器检查一个组件是否已渲染,而不检查其属性。 -
完成您编写的匹配器,以便在传递的参数不是 Jest 模拟时抛出异常。
-
创建一个新的组件,
AppointmentFormLoader,当组件挂载时调用GET /availableTimeSlots端点。它应该渲染一个AppointmentForm组件,并将其appointments属性设置为从服务器返回的数据。
进一步阅读
要了解如何在不依赖 jest.mock 的情况下模拟组件,请查看reacttdd.com/alternatives-to-module-mocks。
第八章:构建应用程序组件
您迄今为止构建的组件都是独立构建的:它们不能很好地结合在一起,用户在加载应用程序时没有遵循的工作流程。到目前为止,我们通过在src/index.js文件中替换组件来手动测试我们的组件。
在本章中,我们将通过创建一个根应用程序组件App,将所有这些组件整合到一个功能系统中,该组件将依次显示这些组件。
您现在已经看到了几乎所有的测试驱动开发(TDD)技术,这些技术对于测试驱动 React 应用程序都是必需的。本章将介绍最后一个技术:测试回调属性。
本章将涵盖以下主题:
-
制定计划
-
使用状态来控制活动视图
-
测试驱动回调属性
-
利用回调值
到本章结束时,您将学会如何使用模拟来测试应用程序的根组件,并且您将拥有一个将本书第一部分中您所工作的所有组件连接在一起的工作应用程序。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter08
制定计划
在我们深入到App组件的代码之前,让我们先进行一点前期设计,以便我们知道我们要构建什么。
以下图显示了您所构建的所有组件以及App如何将它们连接起来:

图 8.1 – 组件层次结构
这就是它的工作方式:
-
当用户首次加载应用程序时,他们将使用
AppointmentsDayView组件看到今天的预约列表,该组件的预约数据将由其容器AppointmentsDayViewLoader组件填充。 -
在屏幕顶部,用户将看到一个标签为
AppointmentsDayView的按钮消失,并出现CustomerForm。 -
当表单填写完毕并点击提交按钮时,用户将看到
AppointmentForm,并可以为该客户添加一个新的预约。 -
一旦他们添加了预约,他们将被带回到
AppointmentsDayView。
第一步如下截图所示。在这里,您可以看到右上角的新按钮。App组件将渲染此按钮,然后协调此工作流程:

图 8.2 – 显示在右上角的新按钮的应用程序
这是一个非常简单的流程,仅支持单一用例:同时添加新客户和预约。在本书的后面部分,我们将添加对为现有客户创建预约的支持。
到此为止,我们已经准备好构建新的App组件。
使用状态来控制活动视图
在本节中,我们将以通常的方式开始构建一个新的App组件。首先,我们将显示AppointmentsDayViewLoader组件。因为这个子组件在挂载时进行网络请求,所以我们将模拟它。然后,我们将在页面的顶部添加一个按钮,位于menu元素内。当这个按钮被点击时,我们将用CustomerForm组件替换AppointmentsDayViewLoader组件。
我们将引入一个名为view的状态变量,它定义了当前显示哪个组件。最初,它将设置为dayView。当按钮被点击时,它将更改为addCustomer。
JSX 构建将最初使用三元运算符在这两个视图之间切换。稍后,我们将添加一个名为addAppointment的第三个值。当我们这样做时,我们将“升级”我们的三元表达式为switch语句。
要开始,请按照以下步骤操作:
-
创建一个新文件
test/App.test.js,为新App组件添加以下导入:import React from "react"; import { initializeReactContainer, render, } from "./reactTestExtensions"; import { App } from "../src/App"; -
接下来,导入
AppointmentsDayViewLoader并模拟其实现:import { AppointmentsDayViewLoader } from "../src/AppointmentsDayViewLoader"; jest.mock("../src/AppointmentsDayViewLoader", () => ({ AppointmentsDayViewLoader: jest.fn(() => ( <div id="AppointmentsDayViewLoader" /> )), })); -
现在,让我们添加我们的第一个测试,该测试检查
AppointmentsDayViewLoader是否已渲染:describe("App", () => { beforeEach(() => { initializeReactContainer(); }); it("initially shows the AppointmentDayViewLoader", () => { render(<App />); expect(AppointmentsDayViewLoader).toBeRendered(); }); }); -
通过向新文件
src/App.js中添加以下代码来使该测试通过:import React from "react"; import ReactDOM from "react-dom"; import { AppointmentsDayViewLoader } from "./AppointmentsDayViewLoader"; export const App = () => ( <AppointmentsDayViewLoader /> ); -
对于第二个测试,我们将在页面的顶部添加一个菜单。为此,我们需要元素匹配器,所以将其添加到测试套件的导入中:
import { initializeReactContainer, render, element, } from "./reactTestExtensions"; -
添加第二个测试:
it("has a menu bar", () => { render(<App />); expect(element("menu")).not.toBeNull(); }); -
要使该测试通过,将
App组件更改为包括位于加载组件之上的menu元素:export const App = () => ( <> <menu /> <AppointmentsDayViewLoader /> </> ) -
接下来,我们希望在菜单中显示一个按钮,点击后可以切换到
CustomerForm。添加以下测试,它断言按钮出现在页面上,使用 CSS 选择器找到渲染的按钮元素。这使用了:first-of-type伪类来确保我们找到第一个按钮(在本书的后面部分,我们将向菜单中添加第二个按钮):it("has a button to initiate add customer and appointment action", () => { render(<App />); const firstButton = element( "menu > li > button:first-of-type" ); expect(firstButton).toContainText( "Add customer and appointment" ); }); -
要使该测试通过,将
App组件中的菜单更改为以下内容:<menu> <li> <button type="button"> Add customer and appointment </button> <li> </menu> -
对于下一个测试,我们必须检查点击按钮是否渲染
CustomerForm。我们必须模拟此组件。为此,我们需要已导入到测试套件中的组件。将以下行添加到test/App.test.js中:import { CustomerForm } from "../src/CustomerForm"; -
在此代码下方,添加以下模拟定义,这是我们的标准模拟定义:
jest.mock("../src/CustomerForm", () => ({ CustomerForm: jest.fn(() => ( <div id="CustomerForm" /> )), }));
为什么要模拟一个在挂载时没有影响的组件?
此组件已经有一个测试套件,这样我们就可以使用测试替身并验证正确的属性,以避免重新测试我们在其他地方已经测试过的功能。例如,CustomerForm测试套件有一个测试来检查提交按钮是否调用onSave属性并传递保存的客户对象。因此,而不是扩展App的测试范围以包括该提交功能,我们可以模拟该组件并直接调用onSave。我们将在下一节中这样做。
-
要点击按钮,我们需要我们的点击助手。现在将其引入:
import { initializeReactContainer, render, element, click, } from "./reactTestExtensions"; -
现在,添加测试。这引入了一个辅助函数
beginAddingCustomerAndAppointment,它找到按钮并点击它。我们现在将其提取出来,因为我们将在大多数剩余的测试中使用它:const beginAddingCustomerAndAppointment = () => click(element("menu > li > button:first-of-type")); it("displays the CustomerForm when button is clicked", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(element("#CustomerForm")).not.toBeNull(); }); -
使其通过需要添加一个组件状态来跟踪我们是否点击了按钮。在
src/App.js中,导入我们需要的两个钩子useState和useCallback,以及导入CustomerForm:import React, { useState, useCallback } from "react"; import { CustomerForm } from "./CustomerForm"; -
在
App组件中,定义新的视图状态变量并将其初始化为dayView字符串,我们将用它来表示AppointmentsDayViewLoader:const [view, setView] = useState("dayView"); -
在那下面,添加一个新的回调函数
transitionToAddCustomer,我们将在下一步将其附加到按钮的onClick处理程序。这个回调更新视图状态变量,使其指向第二页,我们将称之为addCustomer:const transitionToAddCustomer = useCallback( () => setView("addCustomer"), [] ); -
将其连接到按钮的
onClick属性:<button type="button" onClick={transitionToAddCustomer}> Add customer and appointment </button> -
现在,我们只剩下修改我们的 JSX,以确保当
view状态变量设置为addCustomer时,CustomerForm组件会被渲染。注意,测试并没有强迫我们隐藏AppointmentsDayViewLoader。这一点将在后续的测试中体现。目前,我们只需要最简单的代码来使测试通过。按照以下所示更新你的 JSX:return ( <> <menu> ... </menu> {view === "addCustomer" ? <CustomerForm /> : null} </> );
测试新组件的存在
严格来说,这不是使测试通过的最简单方法。我们可以通过总是渲染一个 CustomerForm 组件来使测试通过,无论 view 的值如何。然后,我们需要通过第二个测试来三角定位,以证明组件最初并没有被渲染。我跳过这一步以节省篇幅,但如果你愿意,可以添加它。
-
我们需要确保向
CustomerForm传递一个original属性。在这个工作流程中,我们正在创建一个新的客户,以便我们可以给它一个空白客户对象,就像我们在CustomerForm测试套件中使用的那样。在下面添加以下测试。我们将在下一步定义blankCustomer:it("passes a blank original customer object to CustomerForm", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(CustomerForm).toBeRenderedWithProps( expect.objectContaining({ original: blankCustomer }) ); }); -
创建一个新的文件,
test/builders/customer.js,并添加blankCustomer的定义:export const blankCustomer = { firstName: "", lastName: "", phoneNumber: "", }; -
将这个新定义导入到你的
App测试套件中:import { blankCustomer } from "./builders/customer";
值构建函数与函数构建函数
我们将 blankCustomer 定义为一个常量值,而不是一个函数。我们可以这样做,因为我们编写的所有代码都将变量视为不可变对象。如果不是这样,我们可能更喜欢使用一个函数 blankCustomer(),它在每次被调用时都会生成新的值。这样,我们可以确保一个测试不会意外地修改后续测试的设置。
-
让我们使那个测试通过。首先,在
src/App.js的顶部定义blankCustomer:const blankCustomer = { firstName: "", lastName: "", phoneNumber: "", };
在生产代码和测试代码中使用构建函数
现在,你的生产代码和测试代码中都有相同的 blankCustomer 定义。这种重复通常是可行的,特别是考虑到这个对象如此简单。但对于非平凡的构建函数,你应该考虑先进行测试驱动实现,然后在测试套件中充分利用它。
-
然后,只需通过将其设置为
CustomerForm的original属性来引用该值。进行此更改后,您的测试应该会通过:{view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : null} -
接下来,添加以下测试以在添加客户时隐藏
AppointmentsDayViewLoader:it("hides the AppointmentsDayViewLoader when button is clicked", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect( element("#AppointmentsDayViewLoader") ).toBeNull(); }); -
为了使测试通过,我们需要将
AppointmentsDayViewLoader移动到三元表达式中,以替换 null:{ view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : ( <AppointmentsDayViewLoader /> )} -
让我们也将按钮栏隐藏:
it("hides the button bar when CustomerForm is being displayed", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(element("menu")).toBeNull(); }); -
为了解决这个问题,我们需要将三元表达式从 JSX 中完全提取出来,如下面的代码所示。这很混乱,但我们在下一节中会改进其实现:
return view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : ( <> <menu> ... </menu> <AppointmentsDayViewLoader /> </> );
这样,您已经实现了工作流程中的第一步——即将屏幕从 AppointmentsDayViewLoader 组件更改为 CustomerForm 组件。您通过将 view 状态变量从 dayView 更改为 addCustomer 来完成此操作。对于下一步,我们将使用 CustomerForm 的 onSave 属性来提醒我们何时将 view 更新为 addAppointment。
测试驱动回调属性
在本节中,我们将介绍一个新的扩展函数 propsOf,它深入模拟的子组件并返回传递给它的属性。我们将使用它来获取 onSave 回调属性值,并在测试中调用它,模拟如果真实的 CustomerForm 已被提交会发生的情况。
值得重新审视我们为什么想这样做。直接深入组件并调用属性似乎很复杂。然而,替代方案更复杂,也更脆弱。
我们接下来要编写的测试是断言在 CustomerForm 提交并保存新客户后,AppointmentFormLoader 组件被显示:
it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => {
// ...
});
现在,假设我们想在没有模拟的 CustomerForm 的情况下测试这个功能。我们需要填写真实的 CustomerForm 表单字段并点击提交按钮。这可能看起来很合理,但我们会增加 App 测试套件的表面积,包括 CustomerForm 组件。任何对 CustomerForm 组件的更改不仅需要更新 CustomerForm 测试,现在还需要更新 App 测试。这正是我们将在 第九章 中看到的场景,表单验证,当我们更新 CustomerForm 以包括字段验证时。
通过模拟子组件,我们可以减少表面积并降低子组件更改时破坏测试的可能性。
模拟组件需要小心处理
即使是模拟组件,我们的父组件测试套件仍然可能受到子组件更改的影响。这可能发生在属性的含义发生变化时。例如,如果我们更新了 CustomerForm 上的 onSave 属性以返回不同的值,我们需要更新 App 测试以反映这一点。
这是我们需要做的事情。首先,我们必须在我们的扩展模块中定义一个propsOf函数。然后,我们必须编写模拟提交CustomerForm组件并将用户转移到AppointmentFormLoader组件的测试。我们将通过为视图状态变量引入一个新的addAppointment值来实现这一点。按照以下步骤操作:
-
在
test/reactTestExtensions.js中,添加以下propsOf的定义。它查找对模拟组件的最后调用,并返回其属性:export const propsOf = (mockComponent) => { const lastCall = mockComponent.mock.calls[ mockComponent.mock.calls.length – 1 ]; return lastCall[0]; }; -
在
test/App.test.js中,更新扩展导入,使其包括propsOf:import { initializeReactContainer, render, element, click, propsOf, } from "./reactTestExtensions"; -
你还需要从 React 的测试工具中导入
act函数。我们的测试将包装对回调属性的调用,以确保在调用返回之前运行任何设置器:import { act } from "react-dom/test-utils"; -
还有一个导入需要添加——
AppointmentFormLoader的导入:import { AppointmentFormLoader } from "../src/AppointmentFormLoader"; -
在那下面,使用标准的组件模拟定义定义它的模拟:
jest.mock("../src/AppointmentFormLoader", () => ({ AppointmentFormLoader: jest.fn(() => ( <div id="AppointmentFormLoader" /> )), })); -
我们几乎准备好进行测试了。不过,首先让我们定义一个辅助函数
saveCustomer。这是代码中调用属性的关键部分。注意,这设置了默认客户对象exampleCustomer。我们将使用这个默认值来避免在每个测试中指定客户,因为那里的值并不重要:const exampleCustomer = { id: 123 }; const saveCustomer = (customer = exampleCustomer) => act(() => propsOf(CustomerForm).onSave(customer));
在测试套件中使用 act
这是我们第一次自愿在我们的测试套件中留下对 act 的引用。在其他所有用例中,我们设法在我们的扩展模块中隐藏了对act的调用。不幸的是,这在这里是不可能的——至少,按照我们编写propsOf的方式是不可能的。另一种方法是将一个名为invokeProp的扩展函数写出来,它接受属性的名称并为我们调用它:
invokeProp(CustomerForm, "onSave", customer);
这种方法的缺点是,你现在已经将onSave从对象属性降级为字符串。所以,我们现在将忽略这种方法,并忍受在我们的测试套件中使用act。
-
让我们编写我们的测试。我们想要断言,一旦
CustomerForm被提交,AppointmentsFormLoader就会显示一次:it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); expect( element("#AppointmentFormLoader") ).not.toBeNull(); }); -
使这个通过将涉及向视图状态变量
addAppointment添加一个新值。有了这个第三个值,三元表达式就不再适合用途,因为它只能处理视图的两个可能值。所以,在我们继续使这个通过之前,让我们重构那个三元表达式,使其使用switch语句。跳过你刚刚编写的测试,使用it.skip。 -
用以下代码替换组件的返回语句:
switch (view) { case "addCustomer": return ( <CustomerForm original={blankCustomer} /> ); default: return ( <> <menu> <li> <button type="button" onClick={transitionToAddCustomer}> Add customer and appointment </button> </li> </menu> <AppointmentsDayViewLoader /> </> ); } -
一旦你验证了你的测试仍然通过,将你最新的测试从
it.skip改回it。 -
当
CustomerForm的onSave属性被调用时,组件应该更新视图到addAppointment。让我们用一个新的回调处理程序来实现这一点。在transitionToAddCustomer定义下面添加以下代码:const transitionToAddAppointment = useCallback( () => { setView("addAppointment") }, []); -
修改
CustomerForm渲染表达式,使其接受这个作为属性:<CustomerForm original={blankCustomer} onSave={transitionToAddAppointment} /> -
通过添加以下
case语句将新的addAppointment值连接起来。在做出这个更改后,你的测试应该会通过:case "addAppointment": return ( <AppointmentFormLoader /> ); -
对于下一个测试,我们需要为
original属性传递一个值,这次是传递给AppointmentFormLoader。注意expect.objectContaining的双重使用。这是必要的,因为我们的预约不会是一个简单的空白预约对象。这次,预约将传递一个客户 ID。这个客户 ID 是我们刚刚添加的客户 ID - 我们将在下一个测试中为它编写测试:it("passes a blank original appointment object to CustomerForm", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); expect(AppointmentFormLoader).toBeRenderedWithProps( expect.objectContaining({ original: expect.objectContaining(blankAppointment), }) ); }); -
我们需要一个构建函数,就像
blankCustomer一样。创建一个新的文件,test/builders/appointment.js,并添加以下定义:export const blankAppointment = { service: "", stylist: "", startsAt: null, }; -
更新测试代码以导入它:
import { blankAppointment } from "./builders/appointment"; -
然后,在
src/App.js中创建相同的内容:const blankAppointment = { service: "", stylist: "", startsAt: null, }; -
最后,你可以通过设置
original属性来使测试通过,如下所示:<AppointmentFormLoader original={blankAppointment} />
我们几乎完成了 AppointmentFormLoader 的显示,但还不完全:我们仍然需要从 onSave 回调中接收客户 ID,并通过 original 属性值传递给它,这样 AppointmentForm 就知道我们正在为哪个客户创建预约。
利用回调值
在本节中,我们将介绍一个新的状态变量 customer,它将在 CustomerForm 接收到 onSave 回调时设置。之后,我们将在我们的工作流程中进行最后的转换,从 addAppointment 返回到 dayView。
按照以下步骤操作:
-
这次,我们将检查新的客户 ID 是否传递给了
AppointmentFormLoader。记得在上一节中我们如何给saveCustomer提供一个客户参数?我们将在本测试中使用它:it("passes the customer to the AppointmentForm", async () => { const customer = { id: 123 }; render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(customer); expect(AppointmentFormLoader).toBeRenderedWithProps( expect.objectContaining({ original: expect.objectContaining({ customer: customer.id, }), }) ); }); -
为了实现这一点,我们需要为客户添加一个状态变量。在
App组件的顶部添加以下内容:const [customer, setCustomer] = useState(); -
当我们在 第六章 中构建
CustomerForm的onSave属性时,探索测试替身,我们传递了更新后的客户对象。更新transitiontoAddAppointment处理程序,使其接受此参数值并使用setCustomer设置器保存它:const transitionToAddAppointment = useCallback( (customer) => { setCustomer(customer); setView("addAppointment") }, []); -
通过创建一个新的
original对象值,将客户 ID 合并到blankAppointment中,将其传递给AppointmentFormLoader:case "addAppointment": return ( <AppointmentFormLoader original={{ ...blankAppointment, customer: customer.id, }} /> ); -
是时候对这个组件进行最后的测试了。我们通过断言一旦预约保存,视图就会更新回
dayView来完成用户工作流程:const saveAppointment = () => act(() => propsOf(AppointmentFormLoader).onSave()); it("renders AppointmentDayViewLoader after AppointmentForm is submitted", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); saveAppointment(); expect(AppointmentsDayViewLoader).toBeRendered(); }); -
定义一个新的函数来将状态重置回
dayView:const transitionToDayView = useCallback( () => setView("dayView"), [] ); -
将此函数传递给
AppointmentsFormLoader以确保在预约保存时调用它。在此之后,你的测试应该完成并通过:case "addAppointment": return ( <AppointmentFormLoader original={{ ...blankAppointment, customer: customer.id, }} onSave={transitionToDayView} /> );
我们完成了!
现在,剩下的就是更新 src/index.js 以渲染 App 组件。然后,你可以手动测试以检查你的成果:
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM
.createRoot(document.getElementById("root"))
.render(<App />);
要运行应用程序,请使用npm run serve命令。有关更多信息,请参阅第六章**,探索测试替身部分中的技术要求部分,或查阅存储库中的README.md文件。
摘要
本章介绍了你将要学习的最终 TDD 技术——模拟组件回调属性。你学习了如何使用propsOf扩展获取组件回调的引用,以及如何使用状态变量来管理工作流程不同部分之间的转换。
你会注意到App中的所有子组件都被模拟了。这种情况通常发生在顶级组件中,其中每个子组件都是一个相对复杂、自包含的单元。
在本书的下一部分,我们将把所学的一切应用到更复杂的场景中。我们将首先将字段验证引入到我们的CustomerForm组件中。
练习
以下是一些供你尝试的练习:
-
更新你的
CustomerForm和AppointmentForm测试,以使用你创建的新构建器。 -
向
AppointmentForm添加一个测试,确保在表单提交时提交客户 ID。
第二部分 – 构建应用程序功能
这一部分基于你在第一部分中学到的基本技术,通过将它们应用于你在工作中会遇到的真实世界问题来应用它们,并介绍了许多 React 开发者使用的库:React Router、Redux 和 Relay(GraphQL)。目标是向你展示 TDD 工作流程甚至可以用于大型应用程序。
本部分包括以下章节:
-
第九章,表单验证
-
第十章,过滤和搜索数据
-
第十一章,测试驱动 React Router
-
第十二章,测试驱动 Redux
-
第十三章,测试驱动 GraphQL
第九章:表单验证
对于许多程序员来说,TDD 在涉及他们在培训环境中学习的玩具程序时是有意义的。但当面对现实世界程序的复杂性时,他们发现很难将这些点连接起来。本书的这一部分的目的就是让你将学到的技术应用到现实世界的应用中。
本章将带我们进行一次关于表单验证的自我放纵之旅。通常,使用 React,你会寻找一个现成的表单库来处理验证。但在这个章节中,我们将亲手制作自己的验证逻辑,作为一个例子,说明如何用 TDD 克服现实世界的复杂性。
当处理像 React 这样的框架时,你将发现一个重要的架构原则:抓住每一个机会将逻辑从框架控制的组件中移出,放入普通的 JavaScript 对象中。
在本章中,我们将涵盖以下主题:
-
执行客户端验证
-
处理服务器错误
-
指示表单提交状态
到本章结束时,你将看到如何使用测试将验证引入你的 React 表单中。
技术要求
本章的代码文件可以在这里找到:
执行客户端验证
在本节中,我们将更新CustomerForm和AppointmentForm组件,以便它们向用户提醒他们输入文本中可能存在的问题。例如,如果他们在电话号码字段中输入非数字字符,应用程序将显示错误。
我们将监听每个字段的 DOM 的blur事件,以获取当前字段值并对其运行验证规则。
任何验证错误都将存储为字符串,例如First name is required,在validationErrors状态变量中。每个字段在这个对象中都有一个键。未定义的值(或值的缺失)表示没有验证错误,而字符串值表示一个错误。以下是一个示例:
{
firstName: "First name is required",
lastName: undefined,
phoneNumber: "Phone number must contain only numbers, spaces, and any of the following: + - ( ) ."
}
这个错误在浏览器中显示如下:

图 9.1 – 显示给用户的验证错误
为了支持操作键盘焦点的测试,我们需要一个新函数来模拟当用户完成字段值时引发的focus和blur事件。我们将把这个函数命名为withFocus。它将测试提供的操作(例如更改字段值)与focus/blur事件包装起来。
本节将首先检查CustomerForm的姓名字段是否提供。然后,我们将使这个验证通用化,使其适用于表单中的所有三个字段。之后,我们将确保在按下提交按钮时也运行验证。最后,我们将把构建的所有逻辑提取到一个单独的模块中。
验证必填字段
我们页面上的三个字段——firstName、lastName 和 phoneNumber——都是必填字段。如果任何字段没有提供值,用户应该看到一个消息告诉他们这一点。为此,每个字段都将有一个警告消息区域,实现为一个具有 ARIA 角色 alert 的 span:
让我们先添加 firstName 字段的警告,然后通过在用户移除焦点时验证字段来使其生效:
-
将以下新测试添加到
CustomerForm测试套件的底部。它应该在一个名为validation的新嵌套describe块中。此测试检查是否已渲染一个警告空间。注意 CSS 选择器:这有点像是一个技巧。我们主要感兴趣的是找到一个匹配[role=alert]的元素。然而,我们还在firstNameErrorID 上进行了限定,因为我们最终会有多个警告空间——每个字段一个:describe("validation", () => { it("renders an alert space for first name validation errors", () => { render(<CustomerForm original={blankCustomer} />); expect( element("#firstNameError[role=alert]") ).not.toBeNull(); }); }); -
要使那个通过,请转到
src/CustomerForm.js并在firstName输入字段下方添加以下span定义:<input type="text" name="firstName" id="firstName" value={customer.firstName} onChange={handleChange} /> <span id="firstNameError" role="alert" /> -
接下来,我们想要检查该字段是否有一个指向错误警告的
aria-describedby字段。这有助于屏幕阅读器理解页面内容。在测试套件的底部添加以下新测试:it("sets alert as the accessible description for the first name field", async () => { render(<CustomerForm original={blankCustomer} />); expect( field( "firstName" ).getAttribute("aria-describedby") ).toEqual("firstNameError"); }); -
要使这个通过,请将
aria-describedby属性添加到firstName字段定义中:<input type="text" name="firstName" id="firstName" value={customer.firstName} onChange={handleChange} aria-describedby="firstNameError" /> -
我们将要编写的下一个测试将使用失焦 DOM 事件来触发验证。为此测试,我们将首先构建一个新的测试扩展,
withFocus,它调用focus事件以确保目标元素有焦点,然后运行一个动作——例如在聚焦的字段中输入文本——最后调用blur事件。在test/reactTestExtensions.js中,为withFocus函数添加以下定义:export const withFocus = (target, fn) => act(() => { target.focus(); fn(); target.blur(); });
聚焦和失焦序列
初始调用 focus 是必要的,因为如果元素没有聚焦,JSDOM 会认为 blur 没有关系。
-
在
test/CustomerForm.test.js中,导入新的withFocus函数:import { ..., withFocus, } from "./reactTestExtensions"; -
在测试套件的底部(仍然在
validation嵌套describe块中)添加以下新测试。它检查如果用户输入一个空白名称值,他们会看到一个消息告诉他们需要一个值:it("displays error after blur when first name field is blank", () => { render(<CustomerForm original={blankCustomer} />); withFocus(field("firstName"), () => change(field("firstName"), " "); ) expect( element("#firstNameError[role=alert]") ).toContainText("First name is required"); }); -
要使这个通过,我们需要硬编码消息:
<span id="firstNameError" role="alert"> First name is required </span> -
让我们通过替换硬编码来定位。以下测试断言警告消息最初是空的。注意使用
toEqual而不是not.toContainText:这是前瞻性规划。当我们来到下一节中泛化此函数时,警告文本可以是任何内容:it("initially has no text in the first name field alert space", async () => { render(<CustomerForm original={blankCustomer} />); expect( element("#firstNameError[role=alert]").textContent ).toEqual(""); });
空文本内容的匹配器
虽然这本书没有涵盖,但这将是构建一个新的匹配器,如 toHaveNoText 或 not.toContainAnyText 的好机会。
-
为了使这个测试通过,我们将在
CustomerForm中添加运行验证规则的支持。首先,在src/CustomerForm.js的顶部添加以下内联函数定义,位于导入语句下方但CustomerForm组件定义上方。这是我们第一个验证规则,required,如果提供的值是空的,则返回一个错误字符串,否则返回undefined:const required = value => !value || value.trim() === "" ? "First name is required" : undefined; -
在
CustomerForm组件中,定义一个validationErrors状态变量,初始设置为空对象:const [ validationErrors, setValidationErrors ] = useState({}); -
在
CustomerForm中创建一个处理函数,当用户从名字字段切换焦点时可以使用。它运行我们在第一步中定义的required验证,然后将响应保存到validationErrors状态对象中:const handleBlur = ({ target }) => { const result = required(target.value); setValidationErrors({ ...validationErrors, firstName: result }); }; -
接下来,定义一个函数,JSX 将使用它来选择要显示的消息,命名为
hasFirstNameError:const hasFirstNameError = () => validationErrors.firstName !== undefined; -
剩下的只是修改我们的 JSX,使其调用验证逻辑,然后显示验证错误。使用以下代码设置现有输入字段
firstName的onBlur处理程序,并在其后渲染错误文本。在此更改后,你的测试应该通过:<input type="text" name="firstName" ... onBlur={handleBlur} /> <span id="firstNameError" role="alert"> {hasFirstNameError() ? validationErrors["firstName"] : ""} </span>
现在你已经完成了一个用于验证名字字段的完整、可工作的系统。
为多个字段泛化验证
接下来,我们将添加对姓氏和电话号码字段的必要验证。
由于我们现在处于绿色状态,我们可以在编写下一个测试之前重构现有的代码。我们将更新 JSX 以及 hasFirstNameError 和 handleBlur 函数,以便它们适用于表单上的所有字段。
这将是一个系统重构的练习:将重构分解成小步骤。在每个步骤之后,我们都在努力使测试保持绿色:
-
首先,我们将提取一个包含 JSX 片段的函数,用于渲染错误。在
CustomerForm中的 JSX 返回值上方添加一个名为renderFirstNameError的新函数,内容如下:const renderFirstNameError = () => ( <span id="firstNameError" role="alert"> {hasFirstNameError() ? validationErrors["firstName"] : ""} <span> ); -
现在,你可以在 JSX 中使用它来替换
span警报。你的测试在每个步骤中都应该通过:<input type="text" name="firstName" ... /> {renderFirstNameError()} -
接下来,我们将向此函数引入一个参数,该参数将引用我们显示错误字段的 ID。调整你刚刚添加的行以引入新参数:
<input type="text" name="firstName" ... /> {renderFirstNameError("firstName")}
总是保持绿色测试 – JavaScript 与 TypeScript
这一节是以一种方式编写的,你的测试应该在每一步都通过。在上一个步骤中,我们向 renderFirstNameError 函数传递了一个函数无法接受的参数。在 JavaScript 中,这是完全正常的。在 TypeScript 中,当你尝试构建源代码时,你会得到一个类型错误。
-
将该参数引入
renderFirstNameError函数中,如下所示,用fieldName变量替换firstName字符串。在此更改后,你的测试应该仍然通过:const renderFirstNameError = (fieldName) => ( <span id={`${fieldName}Error`} role="alert"> {hasFirstNameError() ? validationErrors[fieldName] : ""} <span> ); -
通过添加参数值重复相同的步骤为
hasFirstNameError函数:const renderFirstNameError = (fieldName) => ( <span id={`${fieldName}Error`} role="alert"> {hasFirstNameError(fieldName) ? validationErrors[fieldName] : ""} <span> ); -
将
fieldName参数添加到hasFirstNameError中,并修改函数体,使其使用参数代替firstName错误属性:const hasFirstNameError = fieldName => validationErrors[fieldName] !== undefined; -
现在,将
renderFirstNameError重命名为renderError,
将hasFirstNameError重命名为hasError。
在你的 IDE 中的重构支持
你的 IDE 可能内置了重命名支持。如果有的话,你应该使用它。自动重构工具可以减少人为错误的风险。
-
让我们处理
handleBlur。我们已经在传递target参数,我们可以使用target.name来键入一个映射,然后告诉我们为每个字段运行哪个验证器:const handleBlur = ({ target }) => { const validators = { firstName: required }; const result = validatorstarget.name; setValidationErrors({ ...validationErrors, [target.name]: result }); };
如你所见,函数的前半部分(validators的定义)现在是静态数据,它定义了firstName的验证应该如何发生。这个对象将在以后扩展,包括lastName和phoneNumber字段。后半部分是通用的,将适用于任何传入的输入字段,只要存在该字段的验证器。
-
required验证器硬编码了第一个名字的描述。让我们将整个消息作为一个变量提取出来。我们可以创建一个高阶函数,它返回一个使用此消息的验证函数。修改required,使其看起来如下:const required = description => value => !value || value.trim() === "" ? description : undefined; -
最后,更新验证器,使其调用这个新的必需函数:
const validators = { firstName: required("First name is required") };
到目前为止,你的测试应该通过,你应该有一个完全通用的解决方案。现在,让我们通过将我们的四个验证测试转换为测试生成器函数来通用化测试:
-
在
validations嵌套的describe块顶部定义一个新的errorFor辅助函数。这将在测试生成器中使用:const errorFor = (fieldName) => element(`#${fieldName}Error[role=alert]`); -
在本节中找到你编写的第一个测试(
渲染一个警告空间...)。按照这里所示,通过将其包裹在一个函数定义中来修改它,该函数定义接受一个fieldName参数。在测试描述和期望中使用该参数,替换firstName的使用,并利用新的errorFor辅助函数来查找适当的字段:const itRendersAlertForFieldValidation = (fieldName) => { it(`renders an alert space for ${fieldName} validation errors`, async () => { render(<CustomerForm original={blankCustomer} />); expect(errorFor(fieldName)).not.toBeNull(); }); }; -
由于你现在丢失了第一个名字的测试,使用新的测试生成器将其添加回来,就在它下面:
itRendersAlertForFieldValidation("firstName"); -
对第二个测试重复相同的步骤:将其包裹在一个函数定义中,引入一个
fieldName参数,并在测试描述和期望中将firstName替换为fieldName:const itSetsAlertAsAccessibleDescriptionForField = ( fieldName ) => { it(`sets alert as the accessible description for the ${fieldName} field`, async () => { render(<CustomerForm original={blankCustomer} />); expect( field(fieldName).getAttribute( "aria-describedby" ) ).toEqual(`${fieldName}Error`); }); }; -
然后,重新引入
firstName字段的测试用例:itSetsAlertAsAccessibleDescriptionForField( "firstName" ); -
接下来,是时候处理最复杂的测试——
在失去焦点后显示错误...测试。前两个测试生成器只使用了一个参数,fieldName。这个需要一个额外的两个参数,value和description,分别在行为阶段和断言阶段使用:const itInvalidatesFieldWithValue = ( fieldName, value, description ) => { it(`displays error after blur when ${fieldName} field is '${value}'`, () => { render(<CustomerForm original={blankCustomer} />); withFocus(field(fieldName), () => change(field(fieldName), value) ); expect( errorFor(fieldName) ).toContainText(description); }); }; -
在那个测试生成器定义下方,重新引入
first name字段的测试用例:itInvalidatesFieldWithValue( "firstName", " ", "First name is required" ); -
最后,对第四个测试重复相同的步骤:
const itInitiallyHasNoTextInTheAlertSpace = (fieldName) => { it(`initially has no text in the ${fieldName} field alert space`, async () => { render(<CustomerForm original={blankCustomer} />); expect( errorFor(fieldName).textContent ).toEqual(""); }); }; -
然后,重新引入
firstName测试用例:itInitiallyHasNoTextInTheAlertSpace("firstName"); -
在所有这些努力之后,现在是时候使用新的测试生成器来构建
lastName字段的验证了。在你的测试套件底部添加以下单行:itRendersAlertForFieldValidation("lastName"); -
为了使其通过,只需将代码添加到
CustomerFormJSX 中,在lastName字段下方渲染另一个警告:<label htmlFor="lastName">Last name</label> <input type="text" name="lastName" id="lastName" value={customer.lastName} onChange={handleChange} /> {renderError("lastName")} -
接下来,我们必须创建
aria-describedby属性的测试。itSetsAlertAsAccessibleDescriptionForField( "lastName" ); -
为了使其通过,将此属性添加到
lastName输入元素中:<input type="text" name="lastName" ... aria-describedby="lastNameError" /> -
接下来,添加对必需验证规则的测试。
itInvalidatesFieldWithValue( "lastName", " ", "Last name is required" ); -
在我们已经做了这么多艰苦工作的基础上,使这个测试通过现在变得非常简单。将
lastName条目添加到validators对象中,如下所示:const validators = { firstName: required("First name is required"), lastName: required("Last name is required"), }; -
为了完整性,我们需要为
lastName字段添加第四个和最后一个测试。由于我们依赖于我们刚刚泛化的机制,这个测试已经通过了。然而,鉴于它是一行代码,即使不是必需的,也值得指定:itInitiallyHasNoTextInTheAlertSpace("lastName"); -
重复步骤 10到16以对
phone number字段进行测试。
谁需要测试生成器函数?
测试生成器函数可能看起来很复杂。你可能更喜欢在测试中保留重复,或者找到从测试中提取公共功能的其他方法。
测试生成器方法有一个缺点:你将无法在单个测试上使用it.only或it.skip。
这样,我们就涵盖了所需的字段验证。现在,让我们为phoneNumber字段添加不同类型的验证。我们希望确保电话号码只包含数字和一些特殊字符:括号、破折号、空格和加号。
为了做到这一点,我们将引入一个可以进行所需电话号码匹配的match验证器和一个组合验证的list验证器。
让我们添加第二个验证。
-
添加以下新测试:
itInvalidatesFieldWithValue( "phoneNumber", "invalid", "Only numbers, spaces and these symbols are allowed: ( ) + -" ); -
在
src/CustomerForm.js的顶部添加以下定义。这期望一个正则表达式re,然后可以将其与以下内容匹配:const match = (re, description) => value => !value.match(re) ? description : undefined;
学习正则表达式
正则表达式是匹配字符串格式的灵活机制。如果你对它们感兴趣,并想了解更多关于它们以及如何测试驱动它们的信息,请查看reacttdd.com/testing-regular-expressions。
-
现在,让我们来处理
list验证函数。这是一段相当密集的代码,它返回一个短路验证器。它会运行它所提供的每个验证器,直到找到一个返回字符串的验证器,然后返回那个字符串。将此代码添加到match定义的下方:const list = (...validators) => value => validators.reduce( (result, validator) => result || validator(value), undefined ); -
用以下验证替换
handleBlur函数中现有的phoneNumber验证,该验证使用了所有三个验证器函数:const validators = { ... phoneNumber: list( required("Phone number is required"), match( /^[0-9+()\- ]*$/, "Only numbers, spaces and these symbols are allowed: ( ) + -" ) ) }; -
你的测试现在应该通过了。然而,如果你回顾我们刚才编写的测试,它并没有提到允许的字符集:它只是说
invalid不是一个有效的电话号码。为了证明使用真实正则表达式,我们需要一个反向测试来检查任何字符组合都有效。你可以添加这个测试;它应该已经通过了:it("accepts standard phone number characters when validating", () => { render(<CustomerForm original={blankCustomer} />); withFocus(field("phoneNumber"), () => change(field("phoneNumber"), "0123456789+()- ") ); expect(errorFor("phoneNumber")).not.toContainText( "Only numbers" ); });
这是一个有效的测试吗?
这个测试通过而不需要任何修改。这违反了我们只编写失败的测试的规则。
我们陷入这种局面是因为我们在之前的测试中做得太多:我们只需要证明invalid字符串不是一个有效的电话号码。但相反,我们提前实现了完整的正则表达式。
如果我们“正确”地进行了三角测量,从一个虚拟的正则表达式开始,我们最终会到达现在的地方,但我们做了一大堆额外的中间工作,这些工作最终都被删除了。
在某些场景中,例如处理正则表达式时,我发现短路过程是可以接受的,因为它可以节省我一些工作。
通过这样,你已经学会了如何使用 TDD 来泛化验证。
提交表单
当我们提交表单时会发生什么?对于我们的应用程序,如果用户在表单完成之前点击提交按钮,提交过程应该被取消,并且所有字段应该一次性显示它们的验证错误。
我们可以通过两个测试来完成这个任务:一个测试用来检查在存在错误时表单不会被提交,另一个测试用来检查所有字段都显示错误。
在我们这样做之前,我们需要更新我们现有的提交表单的测试,因为它们都假设表单已经被正确填写。首先,我们需要确保我们传递有效的客户数据,这些数据可以在每个测试中被覆盖。
让我们开始编写CustomerForm测试套件:
-
我们需要一个新构建器来帮助表示
validCustomer记录。我们将更新我们现有的许多测试以使用这个新值。在test/builders/customer.js中,定义以下对象:export const validCustomer = { firstName: "first", lastName: "last", phoneNumber: "123456789" }; -
在
test/CustomerForm.test.js中,更新包含blankCustomer的导入,同时引入新的validCustomer:import { blankCustomer, validCustomer, } from "./builders/customer"; -
从顶部开始,修改每个模拟提交事件的测试。每个都应该使用这个新的
validCustomer对象进行挂载。在继续之前,在做出这些更改后运行你的测试,并确保它们仍然通过:render(<CustomerForm original={validCustomer} />); -
添加一个新的表单提交测试。这个测试可以和其它提交测试放在一起,而不是在验证块中:
it("does not submit the form when there are validation errors", async () => { render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); expect(global.fetch).not.toBeCalled(); }); -
为了使这个测试通过,首先,在
CustomerForm组件内部定义以下validateMany函数。它的任务是同时验证多个字段。它接受一个参数,fields,这是我们关心的字段值的对象:const validateMany = fields => Object.entries(fields).reduce( (result, [name, value]) => ({ ...result, [name]: validatorsname }), {} ); -
validateMany函数引用了validators常量,但这个常量目前是在handleBlur函数中定义的。将这个定义拉上来,使其存在于组件作用域的顶部,现在handleBlur和validateMany都可以访问它。 -
我们需要一个新函数来检查所有字段的错误。这就是
anyErrors;现在添加它,如下所示。如果存在任何错误,它返回true,否则返回false:const anyErrors = errors => Object.values(errors).some(error => ( error !== undefined ) ); -
现在,我们可以在
handleSubmit函数中使用validateMany和anyErrors,如下所示。我们将用条件包装大多数现有函数。添加此代码后,你的测试应该通过:const handleSubmit = async e { e.preventDefault(); const validationResult = validateMany(customer); if (!anyErrors(validationResult)) { ... existing code ... } } -
让我们继续下一个测试。我们需要几个新的导入,
textOf和elements,这样我们就可以在所有三个警报空间中编写一个期望。现在添加这些:import { ..., textOf, elements, } from "./reactTestExtensions"; -
接下来,在测试套件的底部添加以下测试。我们想要检查屏幕上是否出现任何错误:
it("renders validation errors after submission fails", async () => { render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); expect( textOf(elements("[role=alert]")) ).not.toEqual(""); });
在多个元素上使用警报角色
本章使用多个警报空间,每个表单字段一个。然而,当多个警报角色同时显示警报时,屏幕阅读器表现不佳——例如,如果点击提交按钮导致我们的三个字段都出现验证错误。
另一种方法是对 UI 进行重构,使其在检测到任何错误时具有一个额外的元素来承担警报角色;之后,它应该从各个字段错误描述中移除警报角色。
-
这个很容易通过;我们只需要在
anyErrors返回false时,用validationResult调用setValidationErrors即可:if (!anyErrors(validationResult)) { ... } else { setValidationErrors(validationResult); }
你现在已经看到了如何在表单提交时运行所有字段验证。
将非 React 功能提取到新模块中
一个有用的设计指南是尽快走出“框架领域”。你希望处理的是纯 JavaScript 对象。这对于 React 组件来说尤其如此:尽可能多地提取逻辑到独立的模块中。
这有几个不同的原因。首先,测试组件比测试纯对象更难。其次,React 框架的变化比 JavaScript 语言本身更频繁。如果我们的代码库首先是一个 React 代码库,那么保持我们的代码库与最新的 React 趋势保持一致是一项大规模的任务。如果我们能保持 React 在一边,从长远来看,我们的生活将会更简单。因此,当有选择时,我们总是更喜欢编写纯 JavaScript。
我们的验证代码是这方面的绝佳例子。我们有一些完全不考虑 React 的函数:
-
验证器:
required、match和list -
hasError和anyErrors -
validateMany -
handleBlur中的部分代码,它类似于validateMany的单入口等效
让我们将所有这些内容提取到一个名为 formValidation 的单独命名空间中:
-
创建一个名为
src/formValidation.js的新文件。 -
将
required、match和list的函数定义从CustomerForm的顶部移动过来。确保你删除了旧的定义! -
在新模块的每个定义前添加单词
export。 -
在
CustomerForm的顶部添加以下导入,然后检查你的测试是否仍然通过:import { required, match, list, } from "./formValidation"; -
在
src/CustomerForm.js中,修改renderError以使其将state中的错误传递到hasError:const renderError = fieldName => { if (hasError(validationErrors, fieldName)) { ... } } -
更新
hasError,使其包含新的validationErrors参数,并使用该参数而不是state:const hasError = (validationErrors, fieldName) => validationErrors[fieldName] !== undefined; -
更新
validateMany,使其将验证器列表作为其第一个参数传递,而不是使用state:const validateMany = (validators, fields) => Object.entries(fields).reduce( (result, [name, value]) => ({ ...result, [name]: validatorsname }), {} ); -
更新
handleBlur,使其使用validateMany:const handleBlur = ({ target }) => { const result = validateMany(validators, { [target.name] : target.value }); setValidationErrors({ ...validationErrors, ...result }); } -
更新
handleSubmit,使其将validators传递给validateMany:const validationResult = validateMany( validators, customer ); -
将
hasError、validateMany和anyErrors移动到src/formValidation.js中,确保您从CustomerForm组件中删除这些函数。 -
在每个定义前添加单词
export。 -
更新导入,以便引入这些函数:
import { required, match, list, hasError, validateMany, anyErrors, } from "./formValidation";
虽然这足以将代码从 React 领域提取出来,但我们才刚刚开始。这个 API 还有很多改进的空间。这里有几个不同的方法可以采取。本章的练习包含了一些关于如何做到这一点的建议。
使用测试替身进行验证函数
你可能会想,这些函数现在需要它们自己的单元测试吗?我应该更新CustomerForm中的测试,以便使用测试替身代替这些函数吗?
在这种情况下,我可能会为formValidation编写几个测试,以便清楚地说明每个函数应该如何使用。这并不是测试驱动开发,因为你已经有了代码,但你仍然可以通过像平时一样编写测试来模拟这种体验。
当从像这样的组件中提取功能时,通常有更新原始组件以简化并可能移动测试的必要。在这种情况下,我不会费心去做。测试足够高级,无论代码内部如何组织,都是有意义的。
本节介绍了如何编写表单的验证逻辑。你现在应该对如何使用 TDD 来实现诸如字段验证等复杂要求有很好的了解。接下来,我们将把服务器端错误集成到相同的流程中。
处理服务器错误
如果客户数据验证失败,/customers端点可能会返回422 Unprocessable Entity错误。例如,如果电话号码已经在系统中存在,这可能会发生。如果发生这种情况,我们不想调用onSave回调,而是向用户显示错误,并给他们机会进行更正。
响应体将包含与为验证框架构建的数据非常相似的错误数据。以下是一个将接收到的 JSON 示例:
{
"errors": {
"phoneNumber": "Phone number already exists in the system"
}
}
我们将更新我们的代码,以显示这些错误,就像我们的客户端错误出现时一样。由于我们已处理CustomerForm的错误,因此我们还需要调整我们的测试,以及现有的CustomerForm代码。
到目前为止,我们的代码使用了从global.fetch返回的ok属性。该属性在 HTTP 状态码为200时返回true,否则返回false。现在,我们需要更具体一些。对于状态码为422的情况,我们想要显示新的错误,而对于其他任何情况(如500错误),我们想要回退到现有的行为。
让我们添加对这些附加状态码的支持:
-
如下更新
test/builders/fetch.js中的fetchResponseError方法:const fetchResponseError = ( status = 500, body = {} ) => ({ ok: false, status, json: () => Promise.resolve(body), }); -
在
test/CustomerForm.test.js中为422错误编写一个测试。我已经将这个测试放在文件顶部,靠近其他操作 HTTP 响应的测试:it("renders field validation errors from server", async () => { const errors = { phoneNumber: "Phone number already exists in the system" }; global.fetch.mockResolvedValue( fetchResponseError(422, { errors }) ); render(<CustomerForm original={validCustomer} />); await clickAndWait(submitButton()); expect(errorFor("phoneNumber")).toContainText( errors.phoneNumber ); }); -
要实现这个跳转,请向
handleSubmit中的嵌套条件语句添加一个新的分支,该分支处理 fetch 请求的响应:if (result.ok) { setError(false); const customerWithId = await result.json(); onSave(customerWithId); } else if (result.status === 422) { const response = await result.json(); setValidationErrors(response.errors); } else { setError(true); }
你的测试现在应该通过了。
本节向您展示了如何将服务器端错误集成到您已经拥有的相同的客户端验证逻辑中。为了完成,我们将添加一些装饰。
指示表单提交状态
如果我们能向用户指示他们的表单数据正在发送到我们的应用程序服务器,那就太好了。这本书的 GitHub 仓库包含了一个旋转器图形和一些我们可以使用的 CSS。我们的 React 组件需要做的只是显示一个具有submittingIndicator类名的span元素。
在我们编写测试之前,让我们看看生产代码将如何工作。我们将引入一个新的布尔状态变量submitting,用于在状态之间切换。在我们执行 fetch 请求之前,它将被切换为true,一旦请求完成,它将被切换为false。以下是我们将如何修改handleSubmit:
...
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await global.fetch(...);
setSubmitting(false);
...
}
...
如果提交被设置为true,那么我们将渲染旋转器图形。否则,我们将不渲染任何内容。
在 promise 完成前测试状态
测试 React 组件最棘手的一个方面是测试任务期间发生的事情。这正是我们现在需要做的:我们想要检查在表单提交期间显示提交指示器。然而,指示器一旦 promise 完成就会消失,这意味着我们不能使用我们迄今为止使用的标准clickAndWait函数,因为它会在指示器消失之后的点返回!
记住clickAndWait使用的是act测试辅助函数的异步形式。这是问题的关键。为了解决这个问题,我们需要一个同步形式的我们的函数click,以便在任务队列完成之前返回——换句话说,在global.fetch调用返回任何结果之前。
然而,为了停止 React 的警告警报响起,我们仍然需要在我们的测试中包含异步的act形式。React 知道提交处理程序返回一个 promise,并且它期望我们通过调用act等待其执行。我们需要在检查提交的切换值之后做这件事,而不是之前。
现在我们来构建这个测试:
-
将
act作为导入添加到test/CustomerForm.test.js:import { act } from "react-dom/test-utils"; -
重新添加
click函数导入:import { ..., click, clickAndWait, } from "./reactTestExtensions"; -
在
CustomerForm测试套件的底部创建一个新的嵌套describe块,位于现有的表单提交测试下方。这个测试在同步的click中提交调用本身,如前所述。然后,我们必须将期望包裹在一个抑制 React 任何警告或错误的异步act调用中:describe("submitting indicator", () => { it("displays when form is submitting", async () => { render( <CustomerForm original={validCustomer} onSave={() => {}} /> ); click(submitButton()); await act(async () => { expect( element("span.submittingIndicator") ).not.toBeNull(); }); }); }); -
为了使这个通过,我们只需要在 JSX 中显示那个
span。将其放置在提交按钮之后,如下所示:return ( <form id="customer" onSubmit={handleSubmit}> ... <input type="submit" value="Add" /> <span className="submittingIndicator" /> </form> ); -
现在,我们需要进行三角测量,以确保指示器仅在表单提交后才显示,而不是在提交之前:
it("initially does not display the submitting indicator", () => { render(<CustomerForm original={validCustomer} />); expect(element(".submittingIndicator")).toBeNull(); }); -
我们可以通过使用一个名为
submitting的标志来使这个通过。当指示器禁用时,它应该设置为false,当它启用时,设置为true。将以下状态变量添加到CustomerForm组件的顶部:const [submitting, setSubmitting] = useState(false); -
将提交的
span指示器更改为以下内容:{submitting ? ( <span className="submittingIndicator" /> ) : null} -
新的测试现在将通过,但原始测试将失败。我们不得不在调用
fetch之前将submittingIndicator切换到true。在handleSubmit中,在调用fetch之前添加此行。添加此代码后,你的测试应该通过:if (!anyErrors(validationResult)) { setSubmitting(true); const result = await global.fetch(/* ... */); ... } -
添加这个最后的测试,该测试检查指示器在收到响应后消失。这个测试与我们的第一个提交指示器测试非常相似:
it("hides after submission", async () => { render( <CustomerForm original={validCustomer} onSave={() => {}} /> ); await clickAndWait(submitButton()); expect(element(".submittingIndicator")).toBeNull(); }); -
这次,我们需要在 fetch 之后添加一个
setSubmitting调用:if (!anyErrors(validationResult)) { setSubmitting(true); const result = await global.fetch(/* ... */); setSubmitting(false); ... }
那就是全部了;你的所有测试都应该通过。
重构长方法
在此之后,我们的 handleSubmit 函数变得很长——在我的实现中我数了 23 行。这对我来说太长了!
将 handleSubmit 重构为更小的方法是一个留给你的练习;请参阅 练习 部分以获取更多详细信息。但这里有一些关于如何系统地进行的提示:
-
将代码块提取到方法中;在这种情况下,这意味着
if语句的内容。例如,如果没有验证错误,你可以调用doSave方法进行提交。 -
在 fetch 调用之前寻找
true,然后是调用之后的false。这可以有不同的实现方式。
现在,让我们总结这一章。
概述
本章向你展示了如何将 TDD 应用于不仅仅是玩具示例之外。虽然你可能永远不会想自己实现表单验证,但你可以看到如何使用你在本书第一部分学到的相同方法来驱动复杂的代码。
首先,你学习了如何在适当的时候验证字段值:当字段失去焦点和表单提交时。你还看到了如何将服务器端错误集成到其中,以及如何显示指示器来告知用户数据正在保存过程中。
本章还介绍了如何将逻辑从你的 React 组件移动到它们自己的模块中。
在下一章中,我们将向我们的系统添加一个新功能:一个时尚的搜索界面。
练习
以下是一些供您完成的练习:
-
添加一个功能,当用户更正错误时清除任何验证错误。使用
onChange处理器来完成此操作,而不是onBlur,因为我们希望让用户在更正错误后立即知道。 -
添加一个功能,一旦表单提交,就禁用提交按钮。
-
为
formValidation模块中的每个函数编写测试。 -
handleSubmit函数很长。提取一个doSave函数,用于提取if语句的主体部分。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
- 通过示例解释的正则表达式指南
reacttdd.com/testing-regular-expressions
- 关于
aria-describedby等 ARIA 注解的更多信息
developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Annotations
第十章:过滤和搜索数据
在本章中,我们将继续将我们已学到的技术应用到另一个更复杂的使用案例中。
在我们学习本章内容的过程中,我们将学习如何通过测试调整组件的设计,以显示设计中的不足。测试驱动开发在测试变得复杂时,真正有助于突出设计问题。幸运的是,我们已编写的测试给了我们信心改变方向并完全重新设计。每次更改时,我们只需运行npm test,并在几秒钟内验证我们的新实现。
在当前的流程中,用户首先添加一个新客户,然后立即为该客户预订一个预约。现在,我们将在此基础上扩展,允许他们在添加预约之前选择一个现有客户。
我们希望用户能够快速搜索客户。这个沙龙可能有数百甚至数千名注册客户。因此,我们将构建一个CustomerSearch搜索组件,允许我们的用户通过姓名搜索客户并浏览返回的结果。
在本章中,你将了解以下主题:
-
显示从端点获取的表格数据
-
在大型数据集中分页
-
数据过滤
-
使用渲染属性执行操作
以下截图显示了新组件的外观:

图 10.1 – 新的 CustomerSearch 组件
到本章结束时,你将使用到目前为止所学到的所有技术构建一个相对复杂的组件。
技术要求
本章的代码文件可以在以下位置找到:
显示从端点获取的表格数据
在本节中,我们将设置表格的基本形式,并在组件挂载时从服务器检索初始数据集。
服务器对/customers的GET请求。有一个searchTerm参数,它接受用户正在搜索的字符串。还有一个after参数,用于检索下一页的结果。响应是一个客户数组,如下所示:
[{ id: 123, firstName: "Ashley"}, ... ]
发送不带参数的请求到/customers将返回我们客户的头 10 个,按姓氏字母顺序排列。
这为我们提供了一个良好的起点。当组件挂载时,我们将执行这个基本搜索并在表格中显示结果。
跳过起点
如果你正在使用 GitHub 仓库进行跟随,请注意,本章从已经实现且已连接到App组件的裸骨CustomerSearch组件开始。该组件通过点击顶部菜单中的搜索预约按钮显示。
让我们从对新的CustomerSearch组件的第一个测试开始。按照以下步骤操作:
-
打开
test/CustomerSearch.test.js并添加第一个测试。它检查是否渲染了我们想要看到的四个标题的表格。代码如下所示:it("renders a table with four headings", async () => { await renderAndWait(<CustomerSearch />); const headings = elements("table th"); expect(textOf(headings)).toEqual([ "First name", "Last name", "Phone number", "Actions", ]); }); -
该测试应该很容易通过,以下是在
src/CustomerSearch.js中对CustomerSearch的以下定义:export const CustomerSearch = () => ( <table> <thead> <tr> <th>First name</th> <th>Last name</th> <th>Phone number</th> <th>Actions</th> </tr> </thead> </table> ); -
为了显示数据,组件需要执行一个
GET请求。编写出这个下一个测试,它指定了该行为:it("fetches all customer data when component mounts", async () => { await renderAndWait(<CustomerSearch />); expect(global.fetch).toBeCalledWith("/customers", { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); }); -
为了使这一步通过,向组件添加一个执行搜索的
useEffect钩子。我们需要使用之前看到的相同的useEffect仪式,使用内联函数以确保我们不返回值,并将空数组传递给依赖项列表,这确保了效果仅在组件首次挂载时运行。代码如下所示:export const CustomerSearch = () => { useEffect(() => { const fetchData = async () => await global.fetch("/customers", { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); fetchData(); }, []); return ( ... ) }; -
现在,是时候编写根据返回的数据发生的事情的代码了。我们将从确定单行数据的显示开始。在文件顶部,在
describe块上方添加oneCustomer的定义,如下所示:const oneCustomer = [ { id: 1, firstName: "A", lastName: "B", phoneNumber: "1" }, ]; -
在下一个测试中,使用该定义,如下所示,该测试验证组件显示单个客户行的所有客户数据:
it("renders all customer data in a table row", async () => { global.fetch.mockResolvedValue( fetchResponseOk(oneCustomer) ); await renderAndWait(<CustomerSearch />); const columns = elements("table > tbody > tr > td"); expect(columns[0]).toContainText("A"); expect(columns[1]).toContainText("B"); expect(columns[2]).toContainText("1"); }); -
为了使这一步通过,我们需要使用组件状态将数据从
useEffect钩子传递到下一个渲染周期。创建一个新的状态变量customers,其初始值为空数组([]),如下所示:const [customers, setCustomers] = useState([]); -
将搜索结果保存到
customers中,通过修改useEffect的定义,如下所示:const fetchData = async () => { const result = await global.fetch(...); setCustomers(await result.json()); }; -
我们准备好显示数据了。我们将使用一个新的
CustomerRow组件来显示单个客户信息的一行。在CustomerSearch定义上方添加其实现。注意这里最后一列是空的;它将包含执行特定客户记录上各种操作的按钮。我们将在稍后的单独测试中填充该功能:const CustomerRow = ({ customer }) => ( <tr> <td>{customer.firstName}</td> <td>{customer.lastName}</td> <td>{customer.phoneNumber}</td> <td /> </tr> ); -
剩下的就是在这个
CustomerSearch中利用这个新组件。添加以下tbody元素,如果存在,则渲染第一个客户的CustomerRow。添加此代码后,你的测试现在应该通过了:return ( <table> <thead> ... </thead> <tbody> {customers[0] ? ( <CustomerRow customer={customers[0]} /> ) : null} </tbody> </table> ); -
对于本节最后的测试,让我们添加一个测试来显示这适用于多个客户。为此,我们需要一个新的结果集:
twoCustomers。这可以放在文件顶部,在oneCustomer之后,如下所示:const twoCustomers = [ { id: 1, firstName: "A", lastName: "B", phoneNumber: "1" }, { id: 2, firstName: "C", lastName: "D", phoneNumber: "2" } ]; -
然后,添加一个测试,利用这个功能并检查是否渲染了两行,如下所示:
it("renders multiple customer rows", async () => { global.fetch.mockResolvedValue( fetchResponseOk(twoCustomers) ); await renderAndWait(<CustomerSearch />); const rows = elements("table tbody tr"); expect(rows[1].childNodes[0]).toContainText("C"); }); -
使这一步通过只需要一行代码;将 JSX 更改为映射每个客户,而不是仅提取第一个客户:
<tbody> {customers.map(customer => ( <CustomerRow customer={customer} key={customer.id} /> ) )} </tbody>
这为我们构建本章剩余功能提供了一个很好的基础。
在下一节中,我们将介绍在多个搜索结果页面之间切换的能力。
在大型数据集中分页
默认情况下,我们的端点返回 10 条记录。为了获取下一组 10 条记录,我们可以通过使用表示已看到最后一个客户标识符的after参数来分页结果集。服务器将跳过结果,直到找到该 ID,然后从下一个客户开始返回结果。
我们将在下一个搜索请求中添加after参数。
为了支持每次用户点击Previous时可以弹出的after ID。
添加一个按钮以跳转到下一页
让我们从buttonWithLabel辅助函数开始,该函数将匹配具有该标签的按钮。按照以下步骤操作:
-
在
test/reactTestExtensions.js文件底部添加以下新的辅助函数:export const buttonWithLabel = (label) => elements("button").find( ({ textContent }) => textContent === label ); -
在
test/CustomerSearch.test.js中,更新导入语句以包括此新辅助函数,如下所示:import { ..., buttonWithLabel, } from "./reactTestExtensions"; -
编写以下测试,这将使我们能够在页面上获得一个Next按钮:
it("has a next button", async () => { await renderAndWait(<CustomerSearch />); expect(buttonWithLabel("Next")).not.toBeNull(); }); -
创建一个
SearchButtons组件,渲染menu元素,就像我们在App中做的那样。我们将在后续测试中扩展此菜单栏,添加更多按钮。代码如下所示:const SearchButtons = () => ( <menu> <li> <button>Next</button> </li> </menu> ); -
现在,在
CustomerSearch中表格上方渲染它,如下所示:return ( <> <SearchButtons /> <table> ... </table> </> ); -
当按钮被点击时,我们希望获取已显示的最后客户 ID 并将其发送回服务器。为了使我们的测试中这个选择明显,我们将使用一个新的返回值
tenCustomers,该值模仿从服务器 API 返回的默认记录数。将此tenCustomers定义放置在文件顶部,靠近你的其他客户定义,如下所示:const tenCustomers = Array.from("0123456789", id => ({ id }) );
充分利用 Array.from
此定义使用了一个“巧妙”版本的Array.from函数,它将字符串的每个字符作为输入创建一个对象。我们最终得到 10 个对象,每个对象都有一个从0到9的范围的id属性。
-
下一个测试检查当带有最后看到客户 ID 的
GET请求。根据我们之前的tenCustomers定义,这是 ID 为9的客户。注意以下代码片段中toHaveBeenLastCalledWith的必要性,因为这将是对global.fetch的第二次调用:it("requests next page of data when next button is clicked", async () => { global.fetch.mockResolvedValue( fetchResponseOk(tenCustomers) ); await renderAndWait(<CustomerSearch />); await clickAndWait(buttonWithLabel("Next")); expect(global.fetch).toHaveBeenLastCalledWith( "/customers?after=9", expect.anything() ); });
避免不必要的字段以突出重要含义
tenCustomers值只是每个客户的部分定义:只包含id属性。这不是懒加载:这是故意的。因为获取最后一个 ID 的逻辑不明显,所以突出id属性作为此流程的关键特性很重要。我们不会担心其他字段,因为我们的先前测试检查了它们的正确使用。
-
为了使这个通过,定义一个处理
fetch请求的处理程序。它通过以下代码片段中所示的方式计算after请求参数,即从customers状态变量中获取最后一个客户:const handleNext = useCallback(() => { const after = customers[customers.length - 1].id; const url = `/customers?after=${after}`; global.fetch(url, { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" } }); }, [customers]); -
给
SearchButtons一个handleNext属性,并将其设置为按钮上的onClick处理程序,如下所示:const SearchButtons = ({ handleNext }) => ( <menu> <li> <button onClick={handleNext}>Next</button> </li> </menu> ); -
将处理程序连接到
SearchButtons,如下所示。此更改后,你的测试应该可以通过:<SearchButtons handleNext={handleNext} /> -
继续添加以下测试。它使用一系列
mockResolvedValueOnce后跟mockResolvedValue来设置两个fetch响应。第二个响应只包含一条记录。测试断言在按下下一步按钮后显示此记录:it("displays next page of data when next button is clicked", async () => { const nextCustomer = [{ id: "next", firstName: "Next" }]; global.fetch .mockResolvedValueOnce( fetchResponseOk(tenCustomers) ) .mockResolvedValue(fetchResponseOk(nextCustomer)); await renderAndWait(<CustomerSearch />); await clickAndWait(buttonWithLabel("Next")); expect(elements("tbody tr")).toHaveLength(1); expect(elements("td")[0]).toContainText("Next"); }); -
为了使这个通过,修改
handleNext以将其响应保存到customers状态变量中,如下所示:const handleNext = useCallback(async () => { ... const result = await global.fetch(...); setCustomers(await result.json()); }, [customers]);
对于我们的下一步按钮来说,这就结束了。在我们继续到上一页按钮之前,我们需要纠正一个设计问题。
调整设计
看这里handleNext和fetchData函数之间的相似之处。它们几乎相同;它们唯一的不同之处在于fetch调用的第一个参数。handleNext函数有一个after参数;fetchData没有参数:
const handleNext = useCallback(async () => {
const after = customers[customers.length - 1].id;
const url = `/customers?after=${after}`;
const result = await global.fetch(url, ...);
setCustomers(await result.json());
}, [customers]);
const fetchData = async () => {
const result = await global.fetch(`/customers`, ...);
setCustomers(await result.json());
};
我们将添加useEffect钩子的能力,使其在状态变化时重新运行。
我们将引入一个新的状态变量queryString,handleNext将更新它,useEffect将监听它。
现在就来做这件事。按照以下步骤进行:
-
现在将这个新变量添加到
CustomerSearch组件的顶部,如下代码片段所示。它的初始值是空字符串,这很重要:const [queryString, setQueryString] = useState(""); -
将
handleNext替换为以下函数:const handleNext = useCallback(() => { const after = customers[customers.length - 1].id; const newQueryString = `?after=${after}`; setQueryString(newQueryString); }, [customers]); -
使用以下定义更新
useEffect,将queryString附加到统一资源定位符(URL)。此时,你的测试应该仍然通过:useEffect(() => { const fetchData = async () => { const result = await global.fetch( `/customers${queryString}`, ... ); setCustomers(await result.json()); }; fetchData(); }, [queryString]);
对于下一步按钮来说,你已经看到了如何为复杂的 API 编排逻辑编写优雅的测试,而且我们也已经重构了我们的生产代码以使其变得优雅。
添加一个按钮以跳转到上一页
让我们继续到上一页按钮:
-
编写以下测试:
it("has a previous button", async () => { await renderAndWait(<CustomerSearch />); expect(buttonWithLabel("Previous")).not.toBeNull(); }); -
通过修改
SearchButtons以包括以下按钮,在下一步按钮之前,使其通过:<menu> <li> <button>Previous</button> </li> ... </menu> -
下一个测试挂载组件,点击下一步,然后点击上一页。它期望对端点的另一个调用已被执行,但这次与初始页面相同——换句话说,没有查询字符串。代码如下所示:
it("moves back to first page when previous button is clicked", async () => { global.fetch.mockResolvedValue( fetchResponseOk(tenCustomers) ); await renderAndWait(<CustomerSearch />); await clickAndWait(buttonWithLabel("Next")); await clickAndWait(buttonWithLabel("Previous")); expect(global.fetch).toHaveBeenLastCalledWith( "/customers", expect.anything() ); }); -
为了使这个通过,首先定义一个
handlePrevious函数,如下所示:const handlePrevious = useCallback( () => setQueryString(""), [] ); -
修改
SearchButtons以接受一个新的handlePrevious属性,并将该属性设置为新按钮的onClick处理程序,如下所示:const SearchButtons = ( { handleNext, handlePrevious } ) => ( <menu> <li> <button onClick={handlePrevious} > Previous </button> </li> ... </menu> ); -
将处理程序连接到
SearchButtons,如下所示。在此之后,你的测试应该通过:<SearchButtons handleNext={handleNext} handlePrevious={handlePrevious} /> -
下一个测试需要我们进行一些思考。它模拟在
tenCustomers定义之后立即点击anotherTenCustomers,如下所示:const anotherTenCustomers = Array.from("ABCDEFGHIJ", id => ({ id })); -
现在,添加下一个测试,该测试检查在导航到另外两个页面后,上一页按钮仍然有效:
it("moves back one page when clicking previous after multiple clicks of the next button", async () => { global.fetch .mockResolvedValueOnce( fetchResponseOk(tenCustomers) ) .mockResolvedValue( fetchResponseOk(anotherTenCustomers) ); await renderAndWait(<CustomerSearch />); await clickAndWait(buttonWithLabel("Next")); await clickAndWait(buttonWithLabel("Next")); await clickAndWait(buttonWithLabel("Previous")); expect(global.fetch).toHaveBeenLastCalledWith( "/customers?after=9", expect.anything() ); }); -
我们将通过维护传递给端点的查询字符串的记录来使这个通过。对于这个特定的测试,我们只需要知道上一个查询字符串是什么。添加一个新的状态变量来记录它,如下所示:
const [ previousQueryString, setPreviousQueryString ] = useState("");
强制设计问题
你可能认为这是一个过于复杂的设计。现在我们先这样进行:我们将在另一个测试中再次简化它。
-
将
handleNext修改为保存之前的查询字符串,确保在调用setQueryString之前完成。将queryString包含在传递给useCallback第二个参数的数组中,以便每次queryString的值改变时,这个回调都会被重新生成。代码如下所示:const handleNext = useCallback(queryString => { ... setPreviousQueryString(queryString); setQueryString(newQueryString); }, [customers, queryString]); -
现在,
handlePrevious可以使用这个值作为传递给fetchData的查询字符串,如下所示。此时,你的测试应该已经通过:const handlePrevious = useCallback(async () => setQueryString(previousQueryString) , [previousQueryString]);
这就是基本的 上一页 按钮实现。然而,当我们想要后退两页或更多页时会发生什么?我们当前的设计只有两个额外的页面深度。如果我们想支持任意数量的页面怎么办?
使用测试强制设计更改
我们可以使用测试来强制设计问题。TDD 的过程帮助我们确保我们总是花时间思考最简单的解决方案,以解决所有测试。因此,如果我们添加一个强调当前设计局限性的测试,那么这个测试就成为了我们停止、思考和重新实现的触发器。
在这种情况下,我们可以使用之前查询字符串的堆栈来记住页面的历史。我们将用单个状态变量 queryStrings 替换我们的两个状态变量 queryString 和 previousQueryString,queryStrings 是所有之前查询字符串的堆栈。
让我们开始测试。按照以下步骤进行:
-
添加以下测试,它断言 上一页 按钮可以多次点击:
it("moves back multiple pages", async () => { global.fetch .mockResolvedValue(fetchResponseOk(tenCustomers)); await renderAndWait(<CustomerSearch />); await clickAndWait(buttonWithLabel("Next")); await clickAndWait(buttonWithLabel("Next")); await clickAndWait(buttonWithLabel("Previous")); await clickAndWait(buttonWithLabel("Previous")); expect(global.fetch).toHaveBeenLastCalledWith( "/customers", expect.anything() ); }); -
为了通过这个测试,首先添加一个新的
queryStrings状态变量,删除queryString和previousQueryStrings,如下所示:const [queryStrings, setQueryStrings] = useState([]); -
按照以下方式修改
fetchData。如果queryStrings数组中有条目,它将queryString设置为最后一个条目,然后该值传递给fetch调用。如果没有条目在数组中,那么queryString将是一个空字符串:useEffect(() => { const fetchData = async () => { const queryString = queryStrings[queryStrings.length - 1] || ""; const result = await global.fetch( `/customers${queryString}`, ... ); setCustomers(await result.json()); }; fetchData(); }, [queryStrings]); -
按照以下方式修改
handleNext。现在它将当前的查询字符串 附加 到之前的查询字符串堆栈上:const handleNext = useCallback(() => { const after = customers[customers.length - 1].id; const newQueryString = `?after=${after}`; setQueryStrings([...queryStrings, newQueryString]); }, [customers, queryStrings]); -
按照以下方式修改
handlePrevious。最后一个值是从查询字符串堆栈中 弹出 的:const handlePrevious = useCallback(() => { setQueryStrings(queryStrings.slice(0, -1)); } [queryStrings]);
现在,你已经有了相对完整的 下一页 和 上一页 按钮的实现。你也看到了测试如何帮助你遇到问题时改变你的设计。
接下来,我们将继续构建与 /customers HTTP 端点的 searchTerm 参数的集成。
数据过滤
在本节中,我们将添加一个文本框,用户可以使用它来过滤名称。用户在搜索字段中输入的每个字符都会导致向服务器发出一个新的 fetch 请求。该请求将包含由搜索框提供的新的搜索术语。
/customers 端点支持一个名为 searchTerm 的参数,它使用这些术语过滤搜索结果,如下面的代码片段所示:
GET /customers?searchTerm=Dan
[
{
firstName: "Daniel",
...
}
...
]
让我们先添加一个文本字段,用户可以在其中输入搜索词,如下所示:
-
将以下测试添加到
CustomerSearch测试套件中,在最后一个测试下方。它只是检查一个新的字段:it("renders a text field for a search term", async () => { await renderAndWait(<CustomerSearch />); expect(element("input")).not.toBeNull(); }); -
在
CustomerSearch中更新您的 JSX,将输入元素添加到组件的顶部,如下所示:return ( <> <input /> ... </> ); -
接下来,我们想检查该字段的
placeholder属性是否已设置。我们可以通过运行以下代码来完成此操作:it("sets the placeholder text on the search term field", async () => { await renderAndWait(<CustomerSearch />); expect( element("input").getAttribute("placeholder") ).toEqual("Enter filter text"); }); -
为了使其通过,将占位符添加到您的 JSX 中的输入元素,如下所示:
<input placeholder="Enter filter text" /> -
我们想将其连接到 DOM 更改事件:每次值更改时,我们将进行一个
async的 fetch 请求。为此,我们需要一个新的辅助函数。在test/reactTestExtensions.js中,在change定义下方添加以下changeAndWait定义。这允许我们在 DOM 更改事件发生时运行效果:export const changeAndWait = async (target, value) => act(async () => change(target, value)); -
在
test/CustomerSearch.test.js的顶部导入新的辅助函数,如下所示:import { ..., changeAndWait, } from "./reactTestExtensions"; -
每当在搜索框中输入新字符时,我们应该使用文本框中输入的任何文本执行新的搜索。添加以下测试:
it("performs search when search term is changed", async () => { await renderAndWait(<CustomerSearch />); await changeAndWait(element("input"), "name"); expect(global.fetch).toHaveBeenLastCalledWith( "/customers?searchTerm=name", expect.anything() ); }); -
定义一个新的
searchTerm变量,如下所示:const [searchTerm, setSearchTerm] = useState(""); -
添加一个新的处理程序,
handleSearchTextChanged,如下所示。它将搜索词存储在状态中,因为我们将在在不同页面之间移动时需要将其拉回:const handleSearchTextChanged = ( { target: { value } } ) => setSearchTerm(value); -
将其连接到输入元素,如下所示:
<input value={searchTerm} onChange={handleSearchTextChanged} placeholder="Enter filter text" /> -
现在,我们可以在
fetchData中使用searchTerm变量从服务器获取更新后的客户集,如下所示:const fetchData = async () => { let queryString = ""; if (searchTerm !== "") { queryString = `?searchTerm=${searchTerm}`; } else if (queryStrings.length > 0) { queryString = queryStrings[queryStrings.length - 1]; } ... }; -
最后,我们需要通过将
searchTerm添加到依赖列表中来修改useEffect,如下所示。之后,测试应该通过:useEffect(() => { ... }, [queryStrings, searchTerm]); -
我们需要确保点击下一个按钮将保持我们的搜索词。目前,它不会。我们可以使用以下测试来修复这个问题:
it("includes search term when moving to next page", async () => { global.fetch.mockResolvedValue( fetchResponseOk(tenCustomers) ); await renderAndWait(<CustomerSearch />); await changeAndWait(element("input"), "name"); await clickAndWait(buttonWithLabel("Next")); expect(global.fetch).toHaveBeenLastCalledWith( "/customers?after=9&searchTerm=name", expect.anything() ); }); -
为了使其通过,让我们通过向
if语句添加一个附加项将行为强制进入fetchData,如下所示:const fetchData = async () => { let queryString; if (queryStrings.length > 0 && searchTerm !== "") { queryString = queryStrings[queryStrings.length - 1] + `&searchTerm=${searchTerm}`; } else if (searchTerm !== '') { queryString = `?searchTerm=${searchTerm}`; } else if (queryStrings.length > 0) { queryString = queryStrings[queryStrings.length - 1]; } ... };
我们已经通过了这个测试...但这太乱了!任何包含这么多可变部分的if语句(变量、运算符、条件等)都是一个信号,表明设计并不像它本可以做到的那样好。让我们来修复它。
简化组件设计的重构
问题在于queryString数据结构和它的历史对应物,queryStrings状态变量。构建是复杂的。
我们是否只存储原始数据呢——最后表格行中的客户 ID?然后,我们可以在实际中立即构建queryString数据结构,因为实际上queryString只是fetch请求的输入。保留原始数据似乎会更简单。
让我们规划一下我们的重构。在以下每个阶段,我们的测试都应该通过,这让我们有信心我们仍然走在正确的道路上:
-
首先,将查询字符串构建逻辑从
handleNext移动到fetchData中,在这个过程中将存储在queryStrings中的值从查询字符串更改为客户 ID。 -
然后,使用您的编辑器的搜索和替换功能更改那些变量的名称。
-
最后,简化
fetchData中的逻辑。
这听起来不难,对吧?让我们开始,如下所示:
-
在组件顶部,将
queryStrings变量替换为这个新变量:const [lastRowIds, setLastRowIds] = useState([]); -
使用你编辑器的搜索和替换功能将所有
queryStrings的出现更改为lastRowIds。 -
同样,将调用
setQueryStrings改为调用setLastRowIds。在这个阶段,你的测试应该仍然通过。 -
从
handleNext中删除以下行:const newQueryString = `?after=${after}`; -
在下一行中,将调用
fetchData改为传入after而不是现在已删除的newQueryString,如下所示:const handleNext = useCallback(() => { const after = customers[customers.length - 1].id; setLastRowIds([...lastRowIds, after]); }, [customers, lastRowIds]); -
在同一个函数中,将
after重命名为currentLastRowId。在这个阶段,你的测试应该仍然通过。 -
现在是时候简化
fetchData内部的逻辑了。创建一个searchParams函数,它将根据after和searchTerm的值为我们生成搜索参数。这可以在组件外部定义。代码如下所示:const searchParams = (after, searchTerm) => { let pairs = []; if (after) { pairs.push(`after=${after}`); } if (searchTerm) { pairs.push(`searchTerm=${searchTerm}`); } if (pairs.length > 0) { return `?${pairs.join("&")}`; } return ""; }; -
最后,更新
fetchData以使用这个新函数替换现有的查询字符串逻辑,如下所示。在这个阶段,你的测试应该通过,实现大大简化且易于理解:const fetchData = async () => { const after = lastRowIds[lastRowIds.length - 1]; const queryString = searchParams(after, searchTerm); const response = await global.fetch(...); };
你现在已经构建了一个功能性的搜索组件。你引入了一个新的辅助函数changeAndWait,并提取了一个searchParams函数,该函数可以在其他地方重用。
接下来,我们将向CustomerSearch组件添加一个最终机制。
使用渲染属性执行操作
表的每一行将包含一个AppointmentForm组件,为该客户创建一个预约。
我们将通过使用CustomerSearch来显示这些操作。父组件——在我们的例子中是App——使用这个来将其自己的渲染逻辑插入到子组件中。App将传递一个函数,该函数在App本身中显示一个按钮,导致视图转换。
如果子组件应该不知道它正在操作的环境,例如App提供的流程,那么渲染属性是有用的。
不必要的复杂代码警告!
你即将看到的实现可能比必要的更复杂。还有其他解决这个问题的方法:你可以简单地让CustomerSearch直接渲染AppointmentFormLoader,或者你可以允许CustomerSearch渲染按钮,然后调用一个回调,例如onSelect(customer)。
渲染属性可能对库作者更有用,而不是对任何应用程序作者,因为库组件无法考虑到它们运行的上下文。
我们需要的渲染属性测试技术比我们迄今为止看到的任何技术都要复杂,你可以把这当作“更好的”解决方案的另一个迹象。
首先,我们将向CustomerSearch添加renderCustomerActions属性,并在新的表格单元格中渲染它。按照以下步骤操作:
-
在
test/CustomerSearch.test.js中编写以下测试:it("displays provided action buttons for each customer", async () => { const actionSpy = jest.fn(() => "actions"); global.fetch.mockResolvedValue( fetchResponseOk(oneCustomer) ); await renderAndWait( <CustomerSearch renderCustomerActions={actionSpy} /> ); const rows = elements("table tbody td"); expect(rows[rows.length - 1]) .toContainText("actions"); }); -
设置一个默认的
renderCustomerActionsprop,这样我们的现有测试在开始使用新 prop 时不会开始失败,如下所示。这应该在src/CustomerSearch.js的底部进行:CustomerSearch.defaultProps = { renderCustomerActions: () => {} }; -
在
CustomerSearch组件的顶部行解构那个 prop,如下所示:export const CustomerSearch = ( { renderCustomerActions } ) => { ... }; -
将它传递给
CustomerRow,如下所示:<CustomerRow customer={customer} key={customer.id} renderCustomerActions={renderCustomerActions} /> -
在
CustomerRow中,更新第四个td单元格以调用这个新 prop,如下所示:const CustomerRow = ( { customer, renderCustomerActions } ) => ( <tr> <td>{customer.firstName}</td> <td>{customer.lastName}</td> <td>{customer.phoneNumber}</td> <td>{renderCustomerActions()}</td> </tr> ); -
对于下一个测试,我们想要检查这个渲染 prop 是否接收了适用于该行的特定客户记录。我们可以这样做到:
it("passes customer to the renderCustomerActions prop", async () => { const actionSpy = jest.fn(() => "actions"); global.fetch.mockResolvedValue( fetchResponseOk(oneCustomer) ); await renderAndWait( <CustomerSearch renderCustomerActions={actionSpy} /> ); expect(actionSpy).toBeCalledWith(oneCustomer[0]); }); -
要使这个通过,你所要做的就是更新你刚才写的 JSX 调用,包括客户作为参数,如下所示:
<td>{renderCustomerActions(customer)}</td>
这就是调用CustomerSearch组件内的渲染 prop 的全部内容。难点在于测试驱动App组件中渲染 prop 的实现本身。
在额外的渲染上下文中测试渲染 prop
回想一下,App组件有一个view状态变量,它决定了用户当前在屏幕上查看哪个组件。如果他们在搜索客户,那么view将被设置为searchCustomers。
按压CustomerSearch组件应该将view设置为addAppointment,导致用户的屏幕隐藏CustomerSearch组件并显示AppointmentForm组件。
我们还需要将App组件的customer状态变量设置为用户在CustomerSearch组件中刚刚选择的客户。
所有这些都将由App传递给customer的渲染 prop 来完成。
最大的问题是:我们如何测试驱动这个渲染 prop 的实现?
我们可以采取几种不同的方法来做这件事:
-
你可以在
App组件内部渲染实际的CustomerSearch组件,导航到客户,并点击App。如果你有一个模块级别的CustomerSearch模拟,你需要为这些测试创建一个新的测试套件,这增加了维护开销。 -
你可以修改
CustomerSearch模拟以具有触发渲染 prop 的机制。这涉及到使模拟定义比标准形式更复杂。这对我来说是一个直接的红旗,原因如第七章中所述,测试 useEffect 和模拟组件。这个解决方案被放在了后面。 -
你可以从
CustomerSearch组件中提取渲染 prop,渲染它,然后找到创建预约按钮并点击它。这是我们将继续采用的方法。
如果我们使用我们的render和renderAndWait函数来渲染这个额外的 prop,它将替换已渲染的App组件。然后我们点击按钮,我们会观察到没有任何事情发生,因为App已经消失了。
我们需要的是一个第二级 React 根,可以用来仅渲染那额外的 DOM 片段。我们的测试可以简单地假装它是CustomerSearch组件。
要做到这一点,我们需要一个新的渲染组件,我们将称之为renderAdditional。现在让我们添加它,然后编写以下测试:
-
在
test/reactTestExtensions.js中,在renderAndWait定义下方添加以下函数定义:export const renderAdditional = (component) => { const container = document.createElement("div"); act(() => ReactDOM.createRoot(container).render(component) ); return container; }; -
在
test/App.test.js中,更新import语句以引入这个新扩展,如下所示:import { ..., renderAdditional, } from "./reactTestExtensions"; -
定位到
search customers嵌套的describe块,并添加一个searchFor辅助函数,该函数调用提供的客户的渲染属性,如下所示:const searchFor = (customer) => propsOf(CustomerSearch) .renderCustomerActions(customer); -
现在,添加测试。这会渲染属性并检查是否已渲染了一个按钮,如下面的代码片段所示:
it("passes a button to the CustomerSearch named Create appointment", async () => { render(<App />); navigateToSearchCustomers(); const buttonContainer = renderAdditional(searchFor()); expect( buttonContainer.firstChild ).toBeElementWithTag("button"); expect( buttonContainer.firstChild ).toContainText("Create appointment"); }); -
在
src/App.js中,在返回的 JSX 上方添加以下函数:const searchActions = () => ( <button>Create appointment</button> ); -
在
CustomerSearch上设置属性,如下所示。在此更改后,你的测试应该通过:case "searchCustomers": return ( <CustomerSearch renderCustomerActions={searchActions} /> ); -
在
test/CustomerSearch.test.js中,添加以下测试。这使用相同的辅助函数,但这次点击按钮并验证是否显示了带有正确客户 ID 的AppointmentFormLoader:it("clicking appointment button shows the appointment form for that customer", async () => { const customer = { id: 123 }; render(<App />); navigateToSearchCustomers(); const buttonContainer = renderAdditional( searchFor(customer) ); click(buttonContainer.firstChild); expect( element("#AppointmentFormLoader") ).not.toBeNull(); expect( propsOf(AppointmentFormLoader).original ).toMatchObject({ customer: 123 }); }); -
为了使它通过,更新
src/App.js中的searchActions以使用CustomerSearch传递给它的客户参数,如下所示:const searchActions = (customer) => ( <button onClick={ () => transitionToAddAppointment(customer) }> Create appointment </button> );
就这么多了:你现在已经使用了renderAdditional来触发你的渲染属性并检查它是否按预期工作。
当与期望你传递渲染属性的三方库一起工作时,这个技术非常有用。
这样就完成了这个功能;如果你想要看到它全部的效果,请手动测试。
摘要
本章探讨了构建一个组件,其中用户界面和 API 之间存在一些复杂的用户交互。你已经创建了一个新的表格组件,并将其集成到现有的应用程序工作流程中。
你看到了如何通过使用测试作为安全机制来对组件的实现进行重大更改。
你还看到了如何使用额外的渲染根来测试渲染属性——我希望你不需要经常使用这个技术!
在下一章中,我们将使用测试将 React Router 集成到我们的应用程序中。我们将继续使用CustomerSearch组件,通过添加使用浏览器地址栏指定搜索条件的能力。这将为我们引入 Redux 和 GraphQL 打下良好的基础。
练习
-
如果用户在第一页上,禁用上一页按钮;如果当前列表显示的记录少于 10 条,禁用下一页按钮。
-
将
searchParams函数提取到一个单独的模块中,该模块可以处理任意数量的参数,并使用encodeURIComponentJavaScript 函数来确保值被正确编码。 -
/customers端点支持一个limit参数,允许你指定返回的最大记录数。为用户提供一个机制,以便在每个页面上更改限制。
第十一章:测试驱动 React Router
React Router 是一个流行的组件库,它与浏览器的自身导航系统集成。它操作浏览器的地址栏,使得你的 UI 变化看起来像是页面转换。对于用户来说,他们似乎在导航到不同的页面。实际上,他们仍然停留在同一个页面上,避免了昂贵的页面重新加载。
在本章中,我们将重构我们的示例预约系统以使用 React Router。与本书的其余部分不同,本章不是一个逐步指南。这是因为重构过程相当长且费力。相反,我们将依次查看每个主要更改。
本章涵盖了以下内容:
-
从测试优先的角度设计 React Router 应用程序
-
在路由器内测试组件
-
测试路由链接
-
测试编程式导航
到本章结束时,你将学会所有测试驱动 React Router 集成所必需的技术。
技术要求
本章的代码文件可以在以下位置找到:
从测试优先的角度设计 React Router 应用程序
本节是对 React Router 生态系统所有主要部分的概述,以防你不熟悉它。它还包含了如何测试依赖于 React Router 的系统的指导。
所有 React Router 组件的列表
你将使用 React Router 库中的以下内容:
-
一个
路由器组件。你通常会有一个这样的组件,并且有很多不同的类型。基本的是BrowserRouter,但如果你需要在外部路由器中操作历史记录,你无疑会升级到HistoryRouter,因为你正在编写测试。在 第十二章 测试驱动 Redux 中,你也会看到这是在 Redux 动作中引起页面转换所必需的。 -
一个
路由组件。这类似于我们现有App组件中的switch语句。它有一个路由子组件列表,并且一次只会选择其中一个子组件来显示。 -
一组带有
路由父组件的路由组件。每个路由都有一个路径属性,例如/addCustomer,路由器组件会使用它来与窗口的当前位置进行比较。匹配的路由就是显示的那个。 -
一个或多个
链接组件。这些显示得像正常的 HTML 超链接,但它们的行为不同;React Router 会阻止浏览器接收这些导航事件,并将它们发送回路由组件,这意味着不会发生页面转换。 -
useNavigate钩子。这用于在 React 侧效应或事件处理程序中执行页面转换。 -
useLocation和useSearchParams钩子。这些用于在组件中获取当前窗口位置的某些部分。
当窗口位置改变时拆分测试
您可以从这个列表中看到,React Router 的核心功能是操作窗口位置并根据该位置修改您应用程序的行为。
关于这一点的一种思考方式是,我们将利用窗口位置作为应用程序状态的一种形式,这种状态对所有我们的组件都是可访问的。重要的是,这种状态在 Web 请求之间持续存在,因为用户可以保存或收藏链接以供以后使用。
这的结果是我们现在必须将一些单元测试分开。以之前用于切换页面上显示的主要组件的创建预约按钮为例。在 React Router 到位后,这个按钮将变成一个链接。之前,我们有一个名为以下内容的单个单元测试:
displays the AppointmentFormLoader after the CustomerForm is submitted
但现在,我们将它拆分为两个测试:
navigates to /addAppointment after the CustomerForm is submitted
renders AppointmentFormRoute at /addAppointment
您可以看到,第一个测试在窗口位置改变时停止。第二个测试在浏览器导航到相同位置时开始。
进行这项更改很重要,因为 React Router 不仅仅是重构,它还在添加一个新功能:URL 现在可以作为您应用程序的入口点。
也就是说,在本质上,这是在将 React Router 引入您的项目之前您需要知道的最重要的事情。
为我们新的路由进行前期设计
在开始重构之前,让我们看看我们将要引入的路由:
-
默认路由
/将保持为我们的AppointmentsDayViewLoader,以及导航按钮。这被提取出来作为一个名为MainScreen的新组件。 -
一个用于添加新客户的路由,位于
/addCustomer。 -
一个用于为特定客户添加新预约的路由,位于
/addAppointment?customer=<id>。 -
一个用于在
/searchCustomers搜索客户的路由。它可以接收一组查询字符串值:searchTerm、limit和previousRowIds。例如,查询字符串可能如下所示:?searchTerm=An&limit=20&previousRowIds=123,456
接下来,我们将查看如何测试Router组件及其Route子组件。
在路由器内测试组件
在本节中,我们将探讨如何使用主要的Router、Routes和Route组件。
本章没有演练内容
如章节介绍中所述,本章不遵循通常的演练方法。这里显示的示例是从我们 Appointments 代码库的完成重构中提取的,您可以在 GitHub 仓库的Chapter11/Complete目录中找到。
路由器组件及其测试等效组件
这是一个顶级组件,它连接到您浏览器的位置机制。我们通常不进行测试驱动,因为 JSDOM 不处理页面转换,也没有对window.location API 的完全支持。
相反,我们将其放在src/index.js文件中:
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
这是因为如果你尝试在Router组件的子组件之外使用任何其他 React Router 组件,它将会崩溃。对于我们的测试也是如此:我们的组件需要在路由器内部渲染。因此,我们引入了一个新的渲染辅助函数,称为renderWithRouter。
这个定义在test/reactTestExtensions.js中:
import { createMemoryHistory } from "history";
import {
unstable_HistoryRouter as HistoryRouter
} from "react-router-dom";
export let history;
export const renderWithRouter = (
component,
{ location } = { location: "" }
) => {
history = createMemoryHistory({
initialEntries: [location]
});
act(() =>
reactRoot.render(
<HistoryRouter history={history}>
{component}
</HistoryRouter>
)
);
};
MemoryRouter 与 HistoryRouter
React Router 文档建议你使用MemoryRouter,这通常足够好。使用HistoryRouter允许你控制传入的历史实例,这意味着你可以在测试中操作它。
更多信息,请参阅reacttdd.com/memory-router-vs-history-router。
如果你想在测试中操作窗口位置,那么导出history变量本身是很重要的。这种情况的一个特例是,如果你想在挂载组件之前设置窗口位置;在这种情况下,你可以简单地将一个location属性传递给renderWithRouter函数。你将在下一节中看到它是如何工作的。
使用Routes组件替换 switch 语句
现在,让我们看看如何使用Routes组件根据窗口位置切换组件。这个组件通常位于应用程序组件层次结构的顶部,在我们的例子中,它确实是App中的第一个组件。
Routes组件与原始应用程序中存在的switch语句类似。switch语句使用状态变量来确定应该显示哪个组件。Routes组件依赖于父Router来提供窗口位置作为上下文。
这是App组件中原始的switch语句的样子:
const [view, setView] = useState("dayView");
...
switch (view) {
case "addCustomer":
return (
<CustomerForm ... />
);
case "searchCustomers":
return (
<CustomerSearch ... />
);
case "addAppointment":
return (
<AppointmentFormLoader ... />
);
default:
return ...
}
它的Router替代品看起来像这样:
<Routes>
<Route
path="/addCustomer"
element={<CustomerForm ... />}
/>
<Route
path="/addAppointment"
element={<AppointmentFormRoute ... />}
/>
<Route
path="/searchCustomers"
element={<CustomerSearchRoute ... />}
/>
<Route path="/" element={<MainScreen />} />
</Routes>
view状态变量不再需要。注意我们有几个带有Route后缀的新组件。这些组件是小的包装器,在将它们传递给原始组件之前,从窗口位置中提取客户 ID 和其他参数。我们很快就会看到它们。
但首先,这些新路由的测试看起来是怎样的?
对于默认路由,测试很简单,是对之前存在的测试的更新:
it("initially shows the AppointmentDayViewLoader", () => {
renderWithRouter(<App />);
expect(AppointmentsDayViewLoader).toBeRendered();
});
it("has a menu bar", () => {
renderWithRouter(<App />);
expect(element("menu")).not.toBeNull();
});
唯一的区别是我们使用renderWithRouter辅助函数,而不是render。
其他路由类似,只是它们使用location属性设置初始窗口位置,并且它们的断言基于模拟组件:
it("renders CustomerForm at the /addCustomer endpoint", () => {
renderWithRouter(<App />, {
location: "/addCustomer"
});
expect(CustomerForm).toBeRendered();
});
it("renders AppointmentFormRoute at /addAppointment", () => {
renderWithRouter(<App />, {
location: "/addAppointment?customer=123",
});
expect(AppointmentFormRoute).toBeRendered();
});
it("renders CustomerSearchRoute at /searchCustomers", () => {
renderWithRouter(<App />, {
location: "/searchCustomers"
});
expect(CustomerSearchRoute).toBeRendered();
});
使用中间组件来转换 URL 状态
让我们更仔细地看看AppointmentFormRoute和CustomerSearchRoute。这些组件在做什么?
这是AppointmentFormRoute的定义:
import React from "react";
import { useSearchParams } from "react-router-dom";
import {
AppointmentFormLoader
} from "./AppointmentFormLoader";
const blankAppointment = {
service: "",
stylist: "",
startsAt: null,
};
export const AppointmentFormRoute = (props) => {
const [params, _] = useSearchParams();
return (
<AppointmentFormLoader
{...props}
original={{
...blankAppointment,
customer: params.get("customer"),
}}
/>
);
};
这个组件是一个中间组件,位于/addAppointment的Route组件实例和AppointmentFormLoader组件实例之间。
本可以直接在AppointmentFormLoader内部引用useSearchParams函数,但通过使用这个中间类,我们可以避免修改该组件,并保持两个职责的分离。
每个组件只负责一个任务有助于理解。这也意味着,如果我们以后希望移除 React Router,AppointmentFormLoader就不需要被修改。
对于这个组件有一些有趣的测试。第一个是检查解析customer搜索参数:
it("adds the customer id into the original appointment object", () => {
renderWithRouter(<AppointmentFormRoute />, {
location: "?customer=123",
});
expect(AppointmentFormLoader).toBeRenderedWithProps({
original: expect.objectContaining({
customer: "123",
}),
});
});
发送到renderWithRouter的location属性只是一个标准的查询字符串:?customer=123。我们本可以在这里输入一个完整的 URL,但通过仅关注 URL 的查询字符串部分,测试会更清晰。
第二个测试是对剩余的 props:
it("passes all other props through to AppointmentForm", () => {
const props = { a: "123", b: "456" };
renderWithRouter(<AppointmentFormRoute {...props} />);
expect(AppointmentFormLoader).toBeRenderedWithProps(
expect.objectContaining({
a: "123",
b: "456",
})
);
});
这个测试很重要,因为Route元素传递了一个onSave属性,它是为AppointmentFormLoader准备的:
<Route
path="/addAppointment"
element={
<AppointmentFormRoute onSave={transitionToDayView} />
}
/>
我们将在稍后的测试导航部分看看transitionToDayView函数做了什么。
现在让我们看看CustomerSearchRoute。这稍微复杂一些,因为它使用名为convertParams的函数解析了一些查询字符串参数:
const convertParams = () => {
const [params] = useSearchParams();
const obj = {};
if (params.has("searchTerm")) {
obj.searchTerm = params.get("searchTerm");
}
if (params.has("limit")) {
obj.limit = parseInt(params.get("limit"), 10);
}
if (params.has("lastRowIds")) {
obj.lastRowIds = params
.get("lastRowIds")
.split(",")
.filter((id) => id !== "");
}
return obj;
};
这个函数替换了现有CustomerSearch组件中使用的三个状态变量。由于所有查询字符串参数都是字符串,每个值都需要解析成正确的格式。然后这些值作为 props 传递给CustomerSearch:
import React from "react";
import {
useNavigate,
useSearchParams,
} from "react-router-dom";
import {
CustomerSearch
} from "./CustomerSearch/CustomerSearch";
const convertParams = ...; // as above
export const CustomerSearchRoute = (props) => (
<CustomerSearch
{...props}
navigate={useNavigate()}
{...convertParams()}
/>
);
这个参数解析功能原本可以放入CustomerSearch中,但将这个逻辑放在一个单独的组件中有助于提高可读性。
这个例子还展示了useNavigate的使用,它被传递给CustomerSearch。将这个钩子函数的返回值作为 prop 传递意味着我们可以使用标准的 Jest spy 函数测试navigate的值,从而避免在路由中渲染测试组件。
这个组件的测试很简单。让我们看看一个例子:
it("parses lastRowIds from query string", () => {
const location =
"?lastRowIds=" + encodeURIComponent("1,2,3");
renderWithRouter(<CustomerSearchRoute />, { location });
expect(CustomerSearch).toBeRenderedWithProps(
expect.objectContaining({
lastRowIds: ["1", "2", "3"],
})
);
});
你现在已经了解了如何与三个组件一起工作:Router、Routes和Route。接下来是Link组件。
测试路由链接
在本节中,你将学习如何使用和测试Link组件。这个组件是 React Router 对谦逊的 HTML 锚点(或a)标签的版本。
我们使用了两种形式的Link组件。第一种使用to属性作为字符串,例如,/addCustomer:
<Link to="/addCustomer" role="button">
Add customer and appointment
</Link>
第二个设置to属性为一个具有search属性的object:
<Link
to={{
search: objectToQueryString(queryParams),
}}
>
{children}
</Link>
这个对象形式也接受一个pathname属性,但我们可以避免设置它,因为对于我们的用例,路径保持不变。
我们将探讨两种不同的测试链接的方法:标准方法(通过检查超链接),以及稍微痛苦一些的模拟方法。
检查页面中的超链接
这是src/App.js中的MainScreen组件,它显示了导航链接和预约日视图:
export const MainScreen = () => (
<>
<menu>
<li>
<Link to="/addCustomer" role="button">
Add customer and appointment
</Link>
</li>
<li>
<Link to="/searchCustomers" role="button">
Search customers
</Link>
</li>
</menu>
<AppointmentsDayViewLoader />
</>
);
提取组件
MainScreen组件已被从App中提取出来。相同的代码之前位于switch语句的默认情况下。
Link组件生成一个标准的 HTML 锚标签。这意味着我们创建了一个辅助工具,通过查找具有匹配href属性的锚标签来找到特定的链接。这位于test/reactTestExtensions.js中:
export const linkFor = (href) =>
elements("a").find(
(el) => el.getAttribute("href") === href
);
然后,你可以用来测试链接的存在及其标题:
it("renders a link to the /addCustomer route", async () => {
renderWithRouter(<App />);
expect(linkFor("/addCustomer")).toBeDefined();
});
it("captions the /addCustomer link as 'Add customer and appointment'", async () => {
renderWithRouter(<App />);
expect(linkFor("/addCustomer")).toContainText(
"Add customer and appointment"
);
});
测试这个问题的另一种方法是通过点击链接并检查其是否正常工作,如下面的测试所示。然而,正如本章开头提到的,这个测试是不必要的,因为你已经测试了测试的两个“部分”:链接是否显示,以及导航到 URL 是否渲染了正确的组件:
it("displays the CustomerSearch when link is clicked", async () => {
renderWithRouter(<App />);
click(linkFor("/searchCustomers"));
expect(CustomerSearchRoute).toBeRendered();
});
这涵盖了测试Link组件的主要方法。另一种测试链接的方法是模拟Link组件,我们将在下一节中介绍。
模拟Link组件
这种方法比简单地测试 HTML 超链接要复杂一些。然而,这意味着你可以避免在Router组件内渲染你的测试组件。
src/CustomerSearch/RouterButton.js文件包含这个组件:
import React from "react";
import {
objectToQueryString
} from "../objectToQueryString";
import { Link } from "react-router-dom";
export const RouterButton = ({
queryParams,
children,
disabled,
}) => (
<Link
className={disabled ? "disabled" : ""}
role="button"
to={{
search: objectToQueryString(queryParams),
}}
>
{children}
</Link>
);
要使用普通的render测试而不是renderWithRouter,我们需要模拟Link组件。以下是test/CustomerSearch/RouterButton.test.js中的样子:
import { Link } from "react-router-dom";
import {
RouterButton
} from "../../src/CustomerSearch/RouterButton";
jest.mock("react-router-dom", () => ({
Link: jest.fn(({ children }) => (
<div id="Link">{children}</div>
)),
}));
现在,你可以在测试中愉快地使用这个模拟:
it("renders a Link", () => {
render(<RouterButton queryParams={queryParams} />);
expect(Link).toBeRenderedWithProps({
className: "",
role: "button",
to: {
search: "?a=123&b=234",
},
});
});
有一个最后的要点需要考虑。有时候,你有一个单个的模拟组件在同一页面上有多个渲染实例,这种情况在Link实例中经常发生。
在我们的案例中,这是SearchButtons组件,它包含一个RouterButton和ToggleRouterButton组件的列表:
<menu>
...
<li>
<RouterButton
id="previous-page"
queryParams={previousPageParams()}
disabled={!hasPrevious}
>
Previous
</RouterButton>
</li>
<li>
<RouterButton
id="next-page"
queryParams={nextPageParams()}
disabled={!hasNext}
>
Next
</RouterButton>
</li>
</menu>
当涉及到测试这些链接时,最简单的方法是使用renderWithRouter来渲染SearchButtons组件,然后检查渲染的 HTML 超链接。
然而,如果你已经决定模拟,那么你需要一种方法来轻松地找到你渲染的元素。
首先,你需要指定模拟包括一个id属性:
jest.mock("../../src/CustomerSearch/RouterButton", () => ({
RouterButton: jest.fn(({ id, children }) => (
<div id={id}>{children}</div>
)),
}));
然后,你可以使用一个新的测试扩展propsMatching来找到特定的实例。以下是来自test/reactTestExtensions.js的定义:
export const propsMatching = (mockComponent, matching) => {
const [k, v] = Object.entries(matching)[0];
const call = mockComponent.mock.calls.find(
([props]) => props[k] === v
);
return call?.[0];
};
然后,你可以编写测试来利用这一点,如下面的代码所示。但请记住,可能更容易不模拟这个组件,而直接使用renderWithRouter,然后直接检查 HTML 超链接:
const previousPageButtonProps = () =>
propsMatching(RouterButton, { id: "previous-page" });
it("renders", () => {
render(<SearchButtons {...testProps} />);
expect(previousPageButtonProps()).toMatchObject({
disabled: false,
});
expect(element("#previous-page")).toContainText(
"Previous"
);
});
这就是测试Link组件的所有内容。在下一节中,我们将探讨测试 React Router 的最后一个方面:程序化导航。
测试程序化导航
有时候,你可能想要程序化地触发位置变化——换句话说,不等待用户点击链接。
有两种方法可以做到这一点:一种使用useNavigate钩子,另一种使用传递给顶级路由器的history实例。
组件内外的导航
在本章中,我们将仅探讨第一种方法,即使用钩子。稍后,在第十二章“测试驱动 Redux”中,我们将使用第二种方法来更改 Redux 动作中的位置。
当你能够在 React 组件内部进行导航时,useNavigate钩子是适当的方法。
在预约应用程序中,这发生在两个地方。第一个是在客户被添加后,我们希望将用户移动到/addAppointment路由。第二个是在表单填写完成并且预约被创建后——然后我们希望将他们移回默认路由。
由于这些非常相似,我们只需查看第一个。
这是/addCustomer路由定义在src/App.js中的样子:
<Route
path="/addCustomer"
element={
<CustomerForm
original={blankCustomer}
onSave={transitionToAddAppointment}
/>
}
/>
注意到onSave属性;这是在客户表单提交完成后被调用的回调。以下是该回调定义,以及与useNavigate钩子相关的部分:
import {
...,
useNavigate,
} from "react-router-dom";
export const App = () => {
const navigate = useNavigate();
const transitionToAddAppointment = (customer) =>
navigate(`/addAppointment?customer=${customer.id}`);
...
};
当涉及到测试这一点时,显然,我们不能仅仅依赖于Link组件的存在,因为并没有。相反,我们必须调用onSave回调:
import {
...,
history,
} from "./reactTestExtensions";
...
it("navigates to /addAppointment after the CustomerForm is submitted", () => {
renderWithRouter(<App />);
click(linkFor("/addCustomer"));
const onSave = propsOf(CustomerForm).onSave;
act(() => onSave(customer));
expect(history.location.pathname).toEqual(
"/addAppointment"
);
});
预期是要测试历史记录是否正确更新。这个历史记录是来自test/reactTestExtensions.js的导出常量,它在我们在在路由器中测试组件部分定义的renderWithRouter函数中被设置。
这有一个变体。你不仅可以使用history导入,还可以简单地使用window.location实例:
expect(
window.location.pathname
).toEqual("/addAppointment");
你现在已经学会了如何测试程序化的 React Router 导航。
在下一章“测试驱动 Redux”中,我们将看到如何使用这个相同的历史实例从 Redux sagas 中。
摘要
本章向您展示了如何以可测试的方式使用 React Router。你已经学会了如何测试驱动Router、Routes、Route和Link组件。你已经看到了如何使用 React Router 的useSearchParams和useNavigate钩子。
最重要的是,你已经看到,由于路由为你的应用程序提供了额外的入口级别,你必须将现有的导航测试分成两部分:一部分测试链接是否存在(或被跟随),另一部分检查如果你访问该 URL,是否显示正确的组件。
现在我们已经成功集成了一个库,下一个库应该不会太棘手,对吧?在下一章中,我们将把本章学到的所有技能应用到另一个库的集成中:Redux。
练习
在本章中,没有进行详细说明,因为重构过程相当复杂,会占用相当的时间和空间。
利用这个机会尝试自我重构。使用系统化重构方法将 React Router 的更改分解成许多小步骤。在每一步中,你仍然应该有可工作的软件。
你可以在reacttdd.com/refactoring-to-react-router找到如何处理此类重构的指南。
进一步阅读
官方 React Router 文档可以在以下链接找到:
reacttraining.com/react-router/
第十二章:测试驱动 Redux
Redux是一个可预测的状态容器。对于初学者来说,这些词意义不大。幸运的是,TDD 可以帮助我们理解如何思考和实现 Redux 应用架构。本章中的测试将帮助你了解 Redux 如何集成到任何应用中。
Redux 的主要优势是能够在异步浏览器环境中以提供数据一致性的方式在组件之间共享状态。其重大缺点是必须在应用中引入大量管道和复杂性。
这里是龙
对于许多应用来说,Redux 的复杂性超过了其好处。仅仅因为本章存在于这本书中,并不意味着你应该急忙去使用 Redux。实际上,我希望本书中的代码示例足以作为警告,提醒你将要引入的复杂性。
在本章中,我们将构建一个 reducer 和一个 saga 来管理CustomerForm组件的提交。
我们将使用一个名为expect-redux的测试库来测试 Redux 交互。这个库允许我们编写与redux-saga库无关的测试。独立于库是确保测试不脆弱且对变化有弹性的好方法:你可以用redux-thunk替换redux-saga,测试仍然会工作。
本章涵盖了以下主题:
-
为 reducer 和 saga 进行前期设计
-
测试驱动 reducer
-
测试驱动 saga
-
将组件状态切换为 Redux 状态
到本章结束时,你将看到测试 Redux 所需的所有技术。
技术要求
本章的代码文件可以在以下位置找到:
为 reducer 和 saga 进行前期设计
在本节中,我们将像往常一样制定一个粗略的计划,说明我们将要构建什么。
让我们先看看实际的技术变化是什么,并讨论我们为什么要这样做。
我们将把提交客户的逻辑——CustomerForm中的doSave函数——从 React 组件移到 Redux 中。我们将使用 Redux reducer 来管理操作的状态:它是否正在提交、已完成或发生了验证错误。我们将使用 Redux saga 来执行异步操作。
为什么选择 Redux?
考虑到当前的应用功能集,实际上没有理由使用 Redux。然而,想象一下,在未来,我们希望支持以下功能:
-
在添加新客户后,
AppointmentForm组件会在提交之前显示客户信息,而无需从服务器重新获取数据 -
在从
CustomerSearch组件中找到客户并选择创建预约后,相同的客户信息将显示在预约屏幕上,无需重新获取数据
在这个未来的场景中,可能有必要有一个共享的 Redux 状态来存储客户数据。
我说“可能”,因为还有其他可能更简单的解决方案:组件上下文,或者可能某种类型的 HTTP 响应缓存。谁知道解决方案会是什么样子?没有具体要求很难说。
总结一下:在本章中,我们将使用 Redux 来存储客户数据。它没有比我们当前方法更多的实际好处,实际上,它有所有额外管道的缺点。然而,鉴于本书的教育目的,让我们继续前进。
设计存储状态和动作
Redux 存储只是一个具有一些访问限制的数据对象。这是我们希望它看起来的样子。该对象编码了CustomerForm已经使用关于保存客户数据的fetch请求的所有信息:
{
customer: {
status: SUBMITTING | SUCCESSFUL | FAILED | ...
// only present if the customer was saved successfully
customer: { id: 123, firstName: "Ashley" ... },
// only present if there are validation errors
validationErrors: { phoneNumber: "..." },
// only present if there was another type of error
error: true | false
}
}
Redux 通过命名动作来改变这个状态。我们将有以下动作:
-
ADD_CUSTOMER_REQUEST, 当用户按下提交客户按钮时调用。这触发了 saga,然后触发剩余的操作 -
ADD_CUSTOMER_SUBMITTING,当 saga 开始其工作时 -
ADD_CUSTOMER_SUCCESSFUL,当服务器保存客户并返回一个新的客户 ID。使用这个动作,我们还将保存新的客户信息到 reducer 中,以便以后使用 -
ADD_CUSTOMER_VALIDATION_FAILED,如果提供的客户数据无效 -
ADD_CUSTOMER_FAILED,如果服务器由于其他原因无法保存数据
作为参考,以下是现有代码,我们将从CustomerForm中提取这些代码。它都在一个名为doSave的函数中,尽管它相当长:
const doSave = async () => {
setSubmitting(true);
const result = await global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(customer),
});
setSubmitting(false);
if (result.ok) {
setError(false);
const customerWithId = await result.json();
onSave(customerWithId);
} else if (result.status === 422) {
const response = await result.json();
setValidationErrors(response.errors);
} else {
setError(true);
}
};
我们将用 saga 和 reducer 的组合替换所有这些代码。我们将在下一节中开始从 reducer 开始。
测试驱动 reducer
在本节中,我们将测试驱动一个新的 reducer 函数,然后提取一些重复的代码。
一个 reducer 是一个简单的函数,它接受一个动作和当前存储状态作为输入,并返回一个新的状态对象作为输出。现在让我们按照以下方式构建它:
-
创建一个名为
test/reducers/customer.test.js的新文件(在新的目录中)。添加以下第一个测试,该测试检查如果 reducer 被一个未知动作调用,我们的 reducer 应该返回一个默认状态给我们的对象。这是 Redux reducer 的标准行为,所以你应该始终从一个这样的测试开始:import { reducer } from "../../src/reducers/customer"; describe("customer reducer", () => { it("returns a default state for an undefined existing state", () => { expect(reducer(undefined, {})).toEqual({ customer: {}, status: undefined, validationErrors: {}, error: false }); }); }); -
创建一个
src/reducers/customer.js文件,如下所示,并让这个测试通过:const defaultState = { customer: {}, status: undefined, validationErrors: {}, error: false }; export const reducer = (state = defaultState, action) => { return state; }; -
对于下一个测试,添加对
ADD_CUSTOMER_SUBMITTING动作的支持,如下所示。这个测试检查当接收到这个动作时,状态值更新为SUBMITTING:describe("ADD_CUSTOMER_SUBMITTING action", () => { const action = { type: "ADD_CUSTOMER_SUBMITTING" }; it("sets status to SUBMITTING", () => { expect(reducer(undefined, action)).toMatchObject({ status: "SUBMITTING" }); }); }); -
通过用以下代码替换 reducer 的主体来实现这个过渡。我们可以直接使用
switch语句(而不是使用if语句),因为我们确定我们将填充其他动作类型:switch(action.type) { case "ADD_CUSTOMER_SUBMITTING": return { status: "SUBMITTING" }; default: return state; } -
在
ADD_CUSTOMER_SUBMITTINGdescribe块中添加第二个测试,如下所示。这个测试指定了 reducer 动作的预期行为:我们不关心的任何状态(在这个例子中是status)保持不变:it("maintains existing state", () => { expect(reducer({ a: 123 }, action)).toMatchObject({ a: 123 }); }); -
通过修改 reducer 来实现这个过渡,如下所示:
export const reducer = (state = defaultState, action) => { switch (action.type) { case "ADD_CUSTOMER_SUBMITTING": return { ...state, status: "SUBMITTING" }; default: return state; } }; -
我们需要处理
ADD_CUSTOMER_SUCCESSFUL动作。从下面显示的两个测试开始。我通过一次编写两个测试来作弊,但这没关系,因为我知道它们是ADD_CUSTOMER_SUBMITTING测试的近似复制品:describe("ADD_CUSTOMER_SUCCESSFUL action", () => { const customer = { id: 123 }; const action = { type: "ADD_CUSTOMER_SUCCESSFUL", customer }; it("sets status to SUCCESSFUL", () => { expect(reducer(undefined, action)).toMatchObject({ status: "SUCCESSFUL" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
要实现这个过渡,请向您的 reducer 添加一个最后的
case语句,如下所示:case "ADD_CUSTOMER_SUCCESSFUL": return { ...state, status: "SUCCESSFUL" }; -
添加下一个测试,如下所示。动作提供了一个带有分配 ID 的新
customer对象,我们应该将其保存在 reducer 中以供以后使用:it("sets customer to provided customer", () => { expect(reducer(undefined, action)).toMatchObject({ customer }); }); -
通过添加
customer属性来实现这个过渡,如下所示:case "ADD_CUSTOMER_SUCCESSFUL": return { ...state, status: "SUCCESSFUL", customer: action.customer }; -
添加下一个
describe块,用于ADD_CUSTOMER_FAILED,如下所示:describe("ADD_CUSTOMER_FAILED action", () => { const action = { type: "ADD_CUSTOMER_FAILED" }; it("sets status to FAILED", () => { expect(reducer(undefined, action)).toMatchObject({ status: "FAILED" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
通过向
switchreducer 添加一个新的case语句来实现这两个测试的通过,如下所示:case "ADD_CUSTOMER_FAILED": return { ...state, status: "FAILED" }; -
我们还没有完成
ADD_CUSTOMER_FAILED。在这种情况下,我们还想将error设置为true。回想一下,我们在CustomerForm组件中使用了error状态变量来标记何时发生了未解释的错误。我们需要在这里复制它。向describe块添加以下第三个测试:it("sets error to true", () => { expect(reducer(undefined, action)).toMatchObject({ error: true }); }); -
通过修改
case语句来实现这个过渡,如下所示:case "ADD_CUSTOMER_FAILED": return { ...state, status: "FAILED", error: true }; -
为
ADD_CUSTOMER_VALIDATION_FAILED动作添加测试,该动作发生在字段验证失败的情况下。代码如下所示:describe("ADD_CUSTOMER_VALIDATION_FAILED action", () => { const validationErrors = { field: "error text" }; const action = { type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors }; it("sets status to VALIDATION_FAILED", () => { expect(reducer(undefined, action)).toMatchObject({ status: "VALIDATION_FAILED" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
通过在 reducer 中添加另一个
case语句来实现这些测试的通过,如下所示:case "ADD_CUSTOMER_VALIDATION_FAILED": return { ...state, status: "VALIDATION_FAILED" }; -
这个动作也需要第三个测试。这次,动作可以包含有关验证错误的错误信息,如下面的代码片段所示:
it("sets validation errors to provided errors", () => { expect(reducer(undefined, action)).toMatchObject({ validationErrors }); }); -
按照下面的更改实现这个过渡:
case "ADD_CUSTOMER_VALIDATION_FAILED": return { ...state, status: "VALIDATION_FAILED", validationErrors: action.validationErrors };
这样就完成了 reducer,但在我们将其用于 saga 之前,我们不妨稍微简化一下这些测试?
提取 reducer 动作的生成函数
大多数 reducer 将遵循相同的模式:每个动作都将设置一些新数据以确保现有状态不会丢失。
让我们编写几个测试生成函数来帮我们完成这个任务,以帮助我们简化测试。按照以下步骤进行:
-
创建一个新文件,
test/reducerGenerators.js,并向其中添加以下函数:export const itMaintainsExistingState = (reducer, action) => { it("maintains existing state", () => { const existing = { a: 123 }; expect( reducer(existing, action) ).toMatchObject(existing); }); }; -
将以下
import语句添加到src/reducers/customer.test.js的顶部:import { itMaintainsExistingState } from "../reducerGenerators"; -
修改您的测试以使用此函数,删除每个
describe块中的测试,并用以下单行替换它:itMaintainsExistingState(reducer, action); -
在
test/reducerGenerators.js中,定义以下函数:export const itSetsStatus = (reducer, action, value) => { it(`sets status to ${value}`, () => { expect(reducer(undefined, action)).toMatchObject({ status: value }); }); }; -
修改现有的
import语句以引入新函数,如下所示:import { itMaintainsExistingState, itSetsStatus } from "../reducerGenerators"; -
修改你的测试以使用此函数,就像你之前做的那样。确保运行你的测试以证明它们可以工作!你的测试现在应该会短得多。以下是一个
describe块的示例,用于ADD_CUSTOMER_SUCCESSFUL:describe("ADD_CUSTOMER_SUBMITTING action", () => { const action = { type: "ADD_CUSTOMER_SUBMITTING" }; itMaintainsExistingState(reducer, action); itSetsStatus(reducer, action, "SUBMITTING"); });
这就完成了 reducer。在我们继续 saga 之前,让我们将其与应用程序连接起来。我们根本不会使用它,但现在建立基础设施是好的。
设置存储和入口点
除了我们编写的 reducer 之外,我们还需要定义一个名为 configureStore 的函数,然后在我们应用程序启动时调用它。按照以下步骤进行:
-
创建一个名为
src/store.js的新文件,并包含以下内容。目前不需要测试这个文件,因为它有点像src/index.js:连接一切的基础设施。然而,我们将在下一节测试 saga 时使用它:import { createStore, combineReducers } from "redux"; import { reducer as customerReducer } from "./reducers/customer"; export const configureStore = (storeEnhancers = []) => createStore( combineReducers({ customer: customerReducer }), storeEnhancers ); -
在
src/index.js中,在文件顶部添加以下两个import语句:import { Provider } from "react-redux"; import { configureStore } from "./store"; -
然后,像下面这样将现有的 JSX 包装在
Provider组件中。这就是我们的所有组件如何获得访问 Redux 存储的权限:ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStore()}> <BrowserRouter> <App /> </BrowserRouter> </Provider> );
这样一来,我们就准备好编写复杂的部分了:saga。
测试驱动 saga
Saga 是一段特殊的代码,它使用 JavaScript 生成器函数来管理对 Redux 存储的异步操作。因为它非常复杂,我们实际上不会测试 saga 本身;相反,我们将向存储派发一个动作并观察结果。
在我们开始 saga 测试之前,我们需要一个名为 renderWithStore 的新测试辅助函数。
添加 renderWithStore 测试扩展
按照以下步骤进行:
-
在
test/reactTestExtensions.js的顶部,添加以下新的import语句:import { Provider } from "react-redux"; import { storeSpy } from "expect-redux"; import { configureStore } from "../src/store";
expect-redux 包
为了做到这一点,我们将使用 NPM 中的 expect-redux 包,它已经包含在 package.json 文件中供你使用——确保在开始之前运行 npm install。
-
添加一个新的变量
store,并在initializeReactContainer中初始化它,如下代码片段所示。这使用了来自expect-redux的storeSpy,我们将在测试中用它来检查对存储的调用:export let store; export const initializeReactContainer = () => { store = configureStore([storeSpy]); container = document.createElement("div"); document.body.replaceChildren(container); reactRoot = ReactDOM.createRoot(container); }; -
在
renderWithRouter函数下方添加你的新渲染函数,如下代码片段所示:export const renderWithStore = (component) => act(() => reactRoot.render( <Provider store={store}>{component}</Provider> ) ); -
最后,添加
dispatchStore,当我们在组件中开始派发动作时将需要它,如下所示:export const dispatchToStore = (action) => act(() => store.dispatch(action));
现在你已经拥有了开始测试连接到 Redux 存储的 sagas 和组件所需的所有辅助工具。所有这些都已就绪,让我们开始 saga 测试。
使用 expect-redux 编写期望
我们编写的 saga 将响应从 CustomerForm 组件派发的 ADD_CUSTOMER_REQUEST 动作。saga 的功能与本章开头 设计存储状态和动作 部分中列出的 doSave 函数相同。区别在于我们需要使用 saga 的 put、call 等函数调用。
让我们从编写一个名为 addCustomer 的生成器函数开始。按照以下步骤进行:
-
创建一个新文件(在新的目录中),命名为
test/sagas/customer.test.js,并添加以下代码来设置我们的describe块。我们初始化一个store变量,我们的 sagas 和测试期望都将使用它。这是我们在initializeReactContainer测试辅助程序中之前使用的代码的重复——我们在这里不能使用它,因为我们不是在编写组件:import { storeSpy, expectRedux } from "expect-redux"; import { configureStore } from "../../src/store"; describe("addCustomer", () => { let store; beforeEach(() => { store = configureStore([ storeSpy ]); }); }); -
在
beforeEach块下面,添加以下辅助函数,它为我们提供了一种构建动作的更优雅的方式——你将在下一个测试中看到:const addCustomerRequest = (customer) => ({ type: "ADD_CUSTOMER_REQUEST", customer, }); -
现在是第一个测试。我们的 saga 应该首先做什么?它必须更新我们的存储状态,以反映表单正在提交。这样,
CustomerForm组件就可以立即向用户显示提交指示器。我们使用expect-redux的期望来确保我们派发了正确的动作,如下所示:it("sets current status to submitting", () => { store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_SUBMITTING" }); });
从测试中返回承诺
这个测试返回一个承诺。这是一个我们可以使用的快捷方式,而不是将我们的测试函数标记为 async 并使用 await 来设置期望。Jest 知道如果测试函数返回一个承诺,就需要等待。
-
让我们从 saga 实现开始。创建一个名为
src/sagas/customer.js的新文件,并添加以下内容。注意function*语法,它表示一个生成器函数,以及使用put向存储发射另一个动作:import { put } from "redux-saga/effects"; export function* addCustomer() { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); }
生成器函数语法
我们在整个书中一直在使用的箭头函数语法不适用于生成器函数,因此我们需要回退到使用 function 关键字。
-
在那个测试通过之前,我们需要使用
addCustomersaga 更新存储。从导入语句开始,将src/store.js更改为以下内容:import { createStore, applyMiddleware, compose, combineReducers } from "redux"; import createSagaMiddleware from "redux-saga"; import { takeLatest } from "redux-saga/effects"; import { addCustomer } from "./sagas/customer"; import { reducer as customerReducer } from "./sagas/customer"; -
在那些导入下面,添加以下
rootSaga定义:function* rootSaga() { yield takeLatest( "ADD_CUSTOMER_REQUEST", addCustomer ); } -
现在,更新
configureStore以包括 saga 中间件和“运行”rootSaga,如下所示。在此更改之后,你的测试应该可以通过:export const configureStore = (storeEnhancers = []) => { const sagaMiddleware = createSagaMiddleware(); const store = createStore( combineReducers({ customer: customerReducer }), compose( applyMiddleware(sagaMiddleware), ...storeEnhancers ) ); sagaMiddleware.run(rootSaga); return store; };
这完成了 saga 的第一个测试,并放置了所有必要的管道。你还看到了如何使用 put。接下来,让我们介绍 call。
使用 sagas 进行异步请求
在 saga 中,call 允许我们执行异步请求。现在让我们介绍这一点。按照以下步骤进行:
-
添加以下测试,以检查对
fetch的调用:it("sends HTTP request to POST /customers", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); }); -
为了使这个功能正常工作,我们需要在
global.fetch上定义一个间谍。将beforeEach块更改为如下,包括新的客户常量:beforeEach(() => { jest.spyOn(global, "fetch"); store = configureStore([ storeSpy ]); }); -
在
src/sagas/customer.js中,更新 saga 导入以包括call函数,如下所示:import { put, call } from "redux-saga/effects"; -
现在,创建一个名为
fetch的函数,并在 saga 中使用call来调用它,如下所示。之后,你的测试应该可以通过:const fetch = (url, data) => global.fetch(url, { method: "POST", }); export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); yield call(fetch, "/customers", customer); } -
好吧——现在,让我们添加一个测试来添加我们的
fetch请求的配置,如下所示:it("calls fetch with correct configuration", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json" }, }) ); }); -
为了让它通过,请将以下行添加到
fetch定义中:const fetch = (url, data) => global.fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" } }); -
现在,让我们测试一下我们是否正在发送正确的客户数据。以下是我们可以这样做的方法:
it("calls fetch with customer as request body", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(inputCustomer), }) ); }); -
为了实现这一点,完成
fetch定义,如下所示:const fetch = (url, data) => global.fetch(url, { body: JSON.stringify(data), method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" } }); -
对于下一个测试,我们希望在
fetch调用成功返回时分派一个ADD_CUSTOMER_SUCCESSFUL事件。它使用一个名为customer的常量,我们将在下一步定义。以下是我们需要执行的代码:it("dispatches ADD_CUSTOMER_SUCCESSFUL on success", () => { store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_SUCCESSFUL", customer }); }); -
在我们之前设置
fetch间谍之前,我们没有设置返回值。因此,现在创建一个customer常量,并设置fetch间谍以返回它,如下所示:const customer = { id: 123 }; beforeEach(() => { jest .spyOn(global, "fetch") .mockReturnValue(fetchResponseOk(customer)); store = configureStore([ storeSpy ]); }); -
按照如下方式导入
fetchResponseOk。在此之后,你将能够运行你的测试:import { fetchResponseOk } from "../builders/fetch"; -
通过处理
call函数的结果,使测试通过,如下所示:export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call(fetch, "/customers", customer); const customerWithId = yield call([result, "json"]); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } -
如果
fetch调用不成功,可能是由于网络故障,该怎么办?添加一个测试,如下所示:it("dispatches ADD_CUSTOMER_FAILED on non-specific error", () => { global.fetch.mockReturnValue(fetchResponseError()); store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_FAILED" }); }); -
该测试使用了
fetchResponseError;现在像这样导入它:import { fetchResponseOk, fetchResponseError } from "../builders/fetch"; -
通过将现有代码包裹在一个带有
else子句的if语句中,使测试通过,如下所示:export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call( fetch, "/customers", customer ); if(result.ok) { const customerWithId = yield call( [result, "json"] ); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } else { yield put({ type: "ADD_CUSTOMER_FAILED" }); } } -
最后,添加一个针对更具体类型的失败的测试——验证失败,如下所示:
it("dispatches ADD_CUSTOMER_VALIDATION_FAILED if validation errors were returned", () => { const errors = { field: "field", description: "error text" }; global.fetch.mockReturnValue( fetchResponseError(422, { errors }) ); store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: errors }); }); -
使用以下代码使测试通过:
export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call(fetch, "/customers", customer); if(result.ok) { const customerWithId = yield call( [result, "json"] ); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } else if (result.status === 422) { const response = yield call([result, "json"]); yield put({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: response.errors }); } else { yield put({ type: "ADD_CUSTOMER_FAILED" }); } }
现在 saga 已经完成。将此函数与我们要替换的 CustomerForm 中的函数进行比较:doSave。结构是相同的。这是一个好的迹象,表明我们准备好从 CustomerForm 中移除 doSave。
在下一节中,我们将更新 CustomerForm 以使用我们新的 Redux 存储。
将组件状态切换为 Redux 状态
现在 saga 和 reducer 已经完成并准备好在 CustomerForm React 组件中使用。在本节中,我们将替换 doSave 的使用,然后作为最后的润色,我们将把我们的 React Router 导航推入 saga,从 App 中移除 onSave 回调。
通过分派 Redux 动作提交 React 表单
在本章的开头,我们探讨了这次更改的目的基本上是将 CustomerForm 的 doSave 函数移植到 Redux 动作中。
使用我们新的 Redux 设置,我们使用组件状态来显示提交指示器并显示任何验证错误。这些信息现在存储在 Redux 存储中,而不是组件状态中。因此,除了分派一个替换 doSave 的动作外,组件还需要从存储中读取状态。组件状态变量可以被删除。
这对我们的测试也有影响。由于 saga 测试失败模式,我们的 CustomerForm 组件测试只需要处理 Redux 存储的各种状态,我们将使用我们的 dispatchToStore 扩展来操作这些状态。
我们将首先使我们的组件具有 Redux 意识,如下所示:
-
将以下
import语句添加到test/CustomerForm.test.js的顶部:import { expectRedux } from "expect-redux"; -
更新测试扩展的
import语句,将render替换为renderWithStore,并添加两个新的导入,如下所示:import { initializeReactContainer, renderWithStore, dispatchToStore, store, ... } from "./reactTestExtensions"; -
将所有对
render的调用替换为renderWithStore。如果你正在进行搜索和替换操作,请注意:单词render出现在一些测试描述中,你应该保持它们不变。 -
让我们重写一个单独的测试:描述为
sends HTTP request to POST /customers when submitting data的那个测试。将该测试更改为以下内容:it("dispatches ADD_CUSTOMER_REQUEST when submitting data", async () => { renderWithStore( <CustomerForm {...validCustomer} /> ); await clickAndWait(submitButton()); return expectRedux(store) .toDispatchAnAction() .matching({ type: 'ADD_CUSTOMER_REQUEST', customer: validCustomer }); }); -
为了使这个通过,我们将使用并排实现来确保我们的其他测试继续通过。在
handleSubmit中添加以下代码片段中突出显示的行。这调用了一个我们很快就会定义的新addCustomerRequest属性:const handleSubmit = async (event) => { event.preventDefault(); const validationResult = validateMany( validators, customer ); if (!anyErrors(validationResult)) { await doSave(); dispatch(addCustomerRequest(customer)); } else { setValidationErrors(validationResult); } }; -
这使用了
useDispatch钩子。现在按照以下方式导入它:import { useDispatch } from "react-redux"; -
然后,将此行添加到
CustomerForm组件的顶部:const dispatch = useDispatch(); -
为了使测试通过,剩下的只是
addCustomerRequest的定义,你可以在import语句和CustomerForm组件定义之间添加它,如下所示:const addCustomerRequest = (customer) => ({ type: "ADD_CUSTOMER_REQUEST", customer, });
到目前为止,你的组件现在是 Redux 感知的,并且正在向 Redux 派遣正确的动作。剩余的工作是修改组件以处理来自 Redux 的验证错误,而不是组件状态。
在组件中使用存储状态
现在,是时候引入useSelector钩子来从存储中提取状态了。我们将从ADD_CUSTOMER_FAILED通用错误动作开始。回想一下,当 reducer 接收到这个动作时,它会将error存储状态值更新为true。按照以下步骤操作:
-
找到名为
renders error message when fetch call fails的测试,并用下面的实现替换它。它模拟了一个ADD_CUSTOMER_FAILED动作,以确保所有 Redux 连接都是正确的。别忘了从测试函数中移除async关键字:it("renders error message when error prop is true", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_FAILED" }); expect(element("[role=alert]")).toContainText( "error occurred" ); }); -
在
src/CustomerForm.js的顶部添加一个useSelector钩子的import语句,如下所示:import { useDispatch, useSelector } from "react-redux"; -
在
CustomerForm组件的顶部调用useSelector钩子,如下面的代码片段所示。它从 Redux 存储的customer部分提取出error状态值:const { error, } = useSelector(({ customer }) => customer); -
删除任何调用
setError的行。在doSave中有两个出现。 -
现在,你可以删除在
CustomerForm组件顶部使用useState钩子定义的error/setError变量对。由于error被声明了两次,你的测试将无法运行,直到你这样做。在这个阶段,你的测试应该通过。 -
下一个测试,
clears error message when fetch call succeeds,可以被删除。根据现状,reducer 实际上并没有做这件事;完成它是练习部分的一个练习。 -
找到
does not submit the form when there are validation errors测试,并按以下方式更新它。它应该已经通过:it("does not submit the form when there are validation errors", async () => { renderWithStore( <CustomerForm original={blankCustomer} /> ); await clickAndWait(submitButton()); return expectRedux(store) .toNotDispatchAnAction(100) .ofType("ADD_CUSTOMER_REQUEST"); });
toNotDispatchAnAction匹配器
这个匹配器应该始终与超时一起使用,例如在这种情况下使用 100 毫秒。这是因为,在异步环境中,事件可能只是发生得较慢,而不是根本不发生。
-
找到下一个测试,
renders field validation errors from server。用以下代码替换它,记得从函数定义中删除async关键字:it("renders field validation errors from server", () => { const errors = { phoneNumber: "Phone number already exists in the system" }; renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: errors }); expect( errorFor(phoneNumber) ).toContainText(errors.phoneNumber); }); -
要使这个通过,我们需要从 Redux 客户存储中提取
validationErrors。这里有一些复杂性:组件已经有一个validationErrors状态变量,它涵盖了服务器和客户端验证错误。我们无法完全替换它,因为它除了处理服务器错误外,还处理客户端错误。
因此,让我们将服务器返回的属性重命名,如下所示:
const {
error,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
设计问题
这突显了我们原始代码中的设计问题。validationErrors状态变量有两个用途,它们被混淆了。我们在这里的更改将分离这些用途。
-
我们还没有完成这个测试。更新
renderError函数以渲染validationErrors(客户端验证)和serverValidationErrors(服务器端验证)的错误,如下所示:const renderError = fieldName => { const allValidationErrors = { ...validationErrors, ...serverValidationErrors }; return ( <span id={`${fieldname}error`} role="alert"> {hasError(allValidationErrors, fieldName) ? allValidationErrors[fieldname] : ""} </span> ); }; -
我们接下来需要查看的测试是提交指示器的测试。我们将更新这些测试以响应存储操作而不是表单提交。这是第一个:
it("displays indicator when form is submitting", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_SUBMITTING" }); expect( element(".submittingIndicator") ).not.toBeNull(); }); -
要使这个通过,需要在
useSelector调用中添加status,如下所示:const { error, status, validationErrors: serverValidationErrors, } = useSelector(({ customer }) => customer); -
删除在此组件内部任何调用
setSubmitting的地方。 -
删除
submitting状态变量,并用以下代码行替换它。现在测试应该通过了:const submitting = status === "SUBMITTING"; -
然后,更新名为
hides indicator when form has submitted的测试,如下所示。这个测试不需要对生产代码进行任何更改:it("hides indicator when form has submitted", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_SUCCESSFUL" }); expect(element(".submittingIndicator")).toBeNull(); }); -
最后,找到
disable the submit button when submitting测试,并以与步骤 12相同的方式进行修改。
测试更改到此结束,doSave几乎完全冗余。然而,对onSave的调用仍然需要迁移到 Redux saga 中,我们将在下一节中这样做。
在 Redux saga 中导航路由历史
回想一下,是App组件渲染CustomerForm,并且App通过将一个函数传递给CustomerForm的onSave属性来导致页面导航。当客户信息已提交时,用户将被移动到/addAppointment路由。
但是,现在表单提交发生在 Redux saga 中,我们如何调用onSave属性?答案是,我们不能。相反,我们可以将页面导航移动到 saga 本身,并完全删除onSave属性。
要做到这一点,我们必须更新src/index.js以使用HistoryRouter而不是BrowserRouter。这允许你传递自己的历史单例对象,然后你可以显式地构造它并通过 saga 访问它。按照以下步骤进行:
-
创建一个名为
src/history.js的新文件,并将以下内容添加到其中。这与我们在test/reactTestExtensions.js中已经做过的非常相似:import { createBrowserHistory } from "history"; export const appHistory = createBrowserHistory(); -
更新
src/index.js,如下所示:import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"; import { appHistory } from "./history"; import { configureStore } from "./store"; import { App } from "./App"; ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStore()}> <HistoryRouter history={appHistory}> <App /> </HistoryRouter> </Provider> ); -
现在,我们可以在 saga 中使用
appHistory。打开test/sagas/customer.js,并将以下import语句添加到文件顶部:import { appHistory } from "../../src/history"; -
然后,添加以下两个测试以定义导航应该如何发生:
it("navigates to /addAppointment on success", () => { store.dispatch(addCustomerRequest()); expect(appHistory.location.pathname).toEqual( "/addAppointment" ); }); it("includes the customer id in the query string when navigating to /addAppointment", () => { store.dispatch(addCustomerRequest()); expect( appHistory.location.search ).toEqual("?customer=123"); }); -
要使这些测试通过,首先打开
src/sagas/customer.js并添加以下import语句:import { appHistory } from "../history"; -
然后,更新
addCustomer生成器函数,在成功添加客户后进行导航,如下所示:export function* addCustomer({ customer }) { ... yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId, }); appHistory.push( `/addAppointment?customer=${customerWithId.id}` ); } -
现在,剩下要做的就是从
App和CustomerForm中删除现有的onSave配管。打开test/App.test.js并删除以下三个测试:-
使用正确的配置调用 fetch -
在 CustomerForm 提交后导航到/addAppointment -
在 CustomerForm 提交后将保存的客户传递给 AppointmentFormLoader
-
-
您还可以删除在标记为
when POST 请求返回错误的嵌套describe块中设置global.fetch的beforeEach块。 -
在
src/App.js中,删除transitionToAddAppointment的定义,并将/addCustomer路由的onSave属性更改为无,如下代码片段所示。此时,您的App测试应该已经通过:<Route path="/addCustomer" element={<CustomerForm original={blankCustomer} />} /> -
现在,我们可以从
CustomerForm中删除onSave属性。首先,从CustomerForm测试套件中删除以下不再必要的测试:-
当表单提交时通知 onSave -
如果 POST 请求返回错误则不通知 onSave
-
-
从
CustomerForm组件中删除onSave属性。 -
最后,从
handleSubmit中移除对doSave的调用。此函数不再等待任何内容,因此您可以从函数定义中安全地移除async。此时,所有您的测试应该都通过了。
您现在已经看到了如何将 Redux 存储集成到您的 React 组件中,以及如何在 Redux 桥接中控制 React Router 导航。
如果一切顺利,您的应用程序现在应该已经运行,由 Redux 管理工作流程。
摘要
这是对 Redux 及如何使用 TDD 重构您的应用程序进行的一次快速浏览。
如本章引言中所警告的,Redux 是一个复杂的库,它将大量的额外配管引入到您的应用程序中。幸运的是,测试方法很简单。
在下一章中,我们将添加另一个库:Relay,GraphQL 客户端。
练习
- 修改客户还原器以确保在
ADD_CUSTOMER_SUCCESSFUL动作发生时将error重置为false。
进一步阅读
更多信息,请参阅以下资源:
- MDN 关于生成器函数的文档:
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*
expect-redux包的主页:
github.com/rradczewski/expect-redux
第十三章:测试驱动 GraphQL
GraphQL 为获取数据提供了 HTTP 请求的替代方案。它为数据请求提供了一系列额外的功能。
与 Redux 类似,GraphQL 系统可能看起来很复杂,但 TDD 有助于提供理解和学习的途径。
在本章中,我们将使用 CustomerHistory 组件来显示单个客户的详细信息及其预约历史。
这是一个基础的 GraphQL 实现,展示了测试驱动技术的核心。如果您使用的是其他 GraphQL 库而不是 Relay,本章中我们将探讨的技术也适用。
这是新 CustomerHistory 组件的外观:

图 13.1 – 新的 CustomerHistory 组件
本章涵盖了以下主题:
-
在开始之前编译模式
-
测试驱动 Relay 环境
-
从组件内部获取 GraphQL 数据
到本章结束时,您将探索测试驱动方法在 GraphQL 中的应用。
技术要求
本章的代码文件可以在此处找到:
在开始之前编译模式
本章的代码示例已经包含了一些新增内容:
-
react-relay、relay-compiler和babel-plugin-relay包。 -
Babel 配置以确保您的构建过程理解新的 GraphQL 语法。
-
在
relay.config.json文件中的 Relay 配置。主要的配置项是模式的存储位置。 -
文件
src/schema.graphql中的 GraphQL 模式。 -
一个位于
POST/graphql的服务器端点,用于处理传入的 GraphQL 请求。
本书不涉及这些内容的每个细节,但您在开始之前需要编译模式,可以通过输入以下命令来完成:
npx relay-compiler
npm run build 命令也已修改,以便在您忘记的情况下为您运行此命令。一旦所有内容都编译完成,您就可以开始编写测试了。
测试 Relay 环境
有几种不同的方法可以将 Relay 集成到 React 应用程序中。本书中我们将使用 fetchQuery 函数,该函数与我们已经用于标准 HTTP 请求的 global.fetch 函数类似。
然而,Relay 的 fetchQuery 函数的设置比 global.fetch 复杂得多。
fetchQuery 函数的一个参数是 环境,在本节中,我们将了解这是什么以及如何构建它。
为什么我们需要构建一个环境?
继电器环境是一个扩展点,可以添加各种功能。数据缓存就是一个例子。如果你对此感兴趣,请查看本章末尾的进一步阅读部分。
我们将构建一个名为buildEnvironment的函数,然后是另一个名为getEnvironment的函数,它提供这个环境的单例实例,这样初始化只需要进行一次。这两个函数都返回一个类型为Environment的对象。
Environment构造函数所需的参数之一是一个名为performFetch的函数。不出所料,这个函数实际上是获取数据的部分——在我们的例子中,是从POST /graphql服务器端点获取数据。
在一个单独的测试中,我们将检查performFetch是否传递给了新的Environment对象。我们需要将performFetch视为其自身的单元,因为我们不会测试结果环境的操作行为,而只是测试其构建。
构建 performFetch 函数
让我们首先创建自己的performFetch函数:
-
创建一个新文件,
test/relayEnvironment.test.js,并添加以下设置。这以通常的方式设置我们的global.fetch间谍。这里有两个新的常量,text和variables,我们将很快使用:import { fetchResponseOk, fetchResponseError } from "./builders/fetch"; import { performFetch } from "../src/relayEnvironment"; describe("performFetch", () => { let response = { data: { id: 123 } }; const text = "test"; const variables = { a: 123 }; beforeEach(() => { jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk(response)); }); }); -
然后,添加第一个测试,检查我们是否发出了适当的 HTTP 请求。
performFetch函数调用包含两个参数,它们包含text(封装在对象中)和variables。这模仿了继电器环境将如何为每个请求调用performFetch函数:it("sends HTTP request to POST /graphql", () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ method: "POST", }) ); }); -
创建一个新文件,
src/relayEnvironment.js,并使用以下代码使测试通过:export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", }); -
添加我们 HTTP 请求舞蹈的第二个测试,确保我们传递了正确的请求配置:
it("calls fetch with the correct configuration", () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json" }, }) ); }); -
通过添加这里突出显示的两行代码来使它通过:
export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); -
然后,添加我们 HTTP 请求舞蹈的第三个和最后一个测试。这个测试检查我们是否传递了正确的请求数据——所需的
text查询和包含在内的variables参数:it("calls fetch with query and variables as request body", async () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ body: JSON.stringify({ query: text, variables, }), }) ); }); -
通过定义
fetch请求的body属性来使它通过,如下所示:export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: operation.text, variables }) });
理解操作、文本和变量
operation参数的text属性是定义查询的静态数据,而variables参数将是与这个特定请求相关的部分。
本章中我们编写的测试并不包括检查这个继电器管道代码的行为。在编写这种不涉及行为的单元测试时,重要的是要注意,将需要某种类型的端到端测试。这将确保你的单元测试具有正确的规范。
-
下一个测试检查我们从函数返回正确的数据。继电器期望我们的
performFetch函数返回一个承诺,该承诺将解决或拒绝。在这种情况下,我们将其解决为fetch响应:it("returns the request data", async () => { const result = await performFetch( { text }, variables ); expect(result).toEqual(response); }); -
使其通过:
export const performFetch = (operation, variables) => global .fetch("/graphql", ...) .then(result => result.json()); -
现在,我们需要处理错误情况。如果发生了 HTTP 错误,我们需要让 promise 拒绝。我们使用我们之前没有见过的
expect函数的新形式;它接受一个 promise 并期望它拒绝:it("rejects when the request fails", () => { global.fetch.mockResolvedValue( fetchResponseError(500) ); return expect( performFetch({ text }, variables) ).rejects.toEqual(new Error(500)); }); -
在我们的生产代码中,我们将测试 fetch 响应的
ok属性是否为false,如果是,则拒绝 promise。添加以下函数:const verifyStatusOk = result => { if (!result.ok) { return Promise.reject(new Error(500)); } else { return result; } }; -
在你的 promise 链中调用该函数。之后,我们的
performFetch函数就完成了:export const performFetch = (operation, variables) => global .fetch("/graphql", ...) .then(verifyStatusOk) .then(result => result.json());
现在,你已经学会了如何指定和测试 Environment 构造函数所需的 performFetch 函数。现在,我们准备进行这个构造。
测试 Environment 对象的构造
我们将构建一个名为 buildEnvironment 的函数,它接受构建 Environment 对象所需的所有各种部分。之所以有这么多部分,是因为它们都是扩展点,使得配置 Relay 连接成为可能。
这些部分是我们的 performFetch 函数和一些直接来自 relay-runtime 包的 Relay 类型。我们将使用 jest.mock 一次性模拟所有这些。
让我们开始吧:
-
在相同的测试文件
test/relayEnvironment.test.js中,更新你的导入以包含新的函数:import { performFetch, buildEnvironment } from "../src/relayEnvironment"; -
现在,是时候从
relay-runtime包中导入所有我们需要的相关部分并模拟它们了。在文件顶部添加以下内容:import { Environment, Network, Store, RecordSource } from "relay-runtime"; jest.mock("relay-runtime"); -
对于我们的第一个测试,我们需要测试
Environment构造函数是否被调用:describe("buildEnvironment", () => { const environment = { a: 123 }; beforeEach(() => { Environment.mockImplementation(() => environment); }); it("returns environment", () => { expect(buildEnvironment()).toEqual(environment); }); }); -
首先,在
src/relayEnvironment.js的生产代码中添加所有导入:import { Environment, Network, RecordSource, Store } from "relay-runtime"; -
通过在文件底部添加以下代码来使测试通过:
export const buildEnvironment = () => new Environment(); -
第二个测试确保我们向
Environment函数传递了正确的参数。它的第一个参数是调用Network.create的结果,第二个参数是构造Store对象的结果。测试需要模拟这些并检查返回值:describe("buildEnvironment", () => { const environment = { a: 123 }; const network = { b: 234 }; const store = { c: 345 }; beforeEach(() => { Environment.mockImplementation(() => environment); Network.create.mockReturnValue(network); Store.mockImplementation(() => store); }); it("returns environment", () => { expect(buildEnvironment()).toEqual(environment); }); it("calls Environment with network and store", () => { expect(Environment).toBeCalledWith({ network, store }); }); });
模拟构造函数
注意我们模拟构造函数和函数调用的差异。为了模拟一个新的 Store 和一个新的 Environment,我们需要使用 mockImplementation(fn)。为了模拟 Network.create,我们需要使用 mockReturnValue(returnValue)。
-
通过更新函数以将这些参数传递给
Environment构造函数来使测试通过:export const buildEnvironment = () => new Environment({ network: Network.create(), store: new Store() }); -
接下来,我们需要确保
Network.create获取到我们的performFetch函数的引用:it("calls Network.create with performFetch", () => { expect(Network.create).toBeCalledWith(performFetch); }); -
通过将
performFetch传递给Network.create函数来实现这个通过:export const buildEnvironment = () => new Environment({ network: Network.create(performFetch), store: new Store() }); -
Store构造函数需要一个RecordSource对象。在你的测试设置中添加一个新的模拟实现RecordSource:describe("buildEnvironment", () => { ... const recordSource = { d: 456 }; beforeEach(() => { ... RecordSource.mockImplementation( () => recordSource ); }); ... }); -
添加以下测试以指定我们想要的行为:
it("calls Store with RecordSource", () => { expect(Store).toBeCalledWith(recordSource); }); -
通过构造一个新的
RecordSource对象来实现这个通过:export const buildEnvironment = () => new Environment({ network: Network.create(performFetch), store: new Store(new RecordSource()) });
就这样,buildEnvironment 就完成了!在这个阶段,你将拥有一个有效的 Environment 对象。
测试 Environment 单例实例
因为创建 Environment 需要大量的配置工作,所以通常我们会创建一次,然后在整个应用程序中使用这个值。
使用 RelayEnvironmentProvider 的替代方法
使用这里显示的单例实例的替代方法之一是使用 React Context。Relay 提供的 RelayEnvironmentProvider 组件可以帮助你做到这一点。有关更多信息,请参阅本章末尾的 进一步阅读 部分。
让我们构建 getEnvironment 函数:
-
在
test/relayEnvironment.test.js的顶部导入新的函数:import { performFetch, buildEnvironment, getEnvironment } from "../src/relayEnvironment"; -
在文件的底部,添加一个包含一个测试的第三个
describe块,针对这个函数:describe("getEnvironment", () => { it("constructs the object only once", () => { getEnvironment(); getEnvironment(); expect(Environment.mock.calls.length).toEqual(1); }); }); -
在
src/relayEnvironment.js中,通过引入一个顶层变量来存储getEnvironment的结果(如果尚未调用)来实现这一点:let environment = null; export const getEnvironment = () => environment || (environment = buildEnvironment());
环境模板代码就到这里。我们现在有一个闪亮的 getEnvironment 函数,我们可以在我们的 React 组件中使用它。
在下一节中,我们将开始构建 CustomerHistory 组件。
在组件内部获取 GraphQL 数据
现在我们有了 Relay 环境,我们可以开始构建我们的功能。回想一下介绍中提到的,我们正在构建一个新的 CustomerHistory 组件,用于显示客户详情和客户的预约列表。返回此信息的 GraphQL 查询已经存在于我们的服务器中,所以我们只需要以正确的方式调用它。查询看起来像这样:
customer(id: $id) {
id
firstName
lastName
phoneNumber
appointments {
startsAt
stylist
service
notes
}
}
这表示我们为指定的客户 ID(由 $id 参数指定)获取一个客户记录,以及他们的预约列表。
当组件挂载时,我们的组件将执行此查询。我们将直接测试 fetchQuery 的调用:
-
创建一个新的文件,
test/CustomerHistory.test.js,并添加以下设置。我们将把这个设置分成几个部分,因为它很长!首先是我们导入,以及再次调用模拟relay-runtime,这样我们就可以模拟fetchQuery:import React from "react"; import { act } from "react-dom/test-utils"; import { initializeReactContainer, render, renderAndWait, container, element, elements, textOf, } from "./reactTestExtensions"; import { fetchQuery } from "relay-runtime"; import { CustomerHistory, query } from "../src/CustomerHistory"; import { getEnvironment } from "../src/relayEnvironment"; jest.mock("relay-runtime"); jest.mock("../src/relayEnvironment"); -
现在,让我们定义一些示例数据:
const date = new Date("February 16, 2019"); const appointments = [ { startsAt: date.setHours(9, 0, 0, 0), stylist: "Jo", service: "Cut", notes: "Note one" }, { startsAt: date.setHours(10, 0, 0, 0), stylist: "Stevie", service: "Cut & color", notes: "Note two" } ]; const customer = { firstName: "Ashley", lastName: "Jones", phoneNumber: "123", appointments }; -
接下来,让我们确保
beforeEach正确设置。这个占位符使用特殊的sendCustomer模拟,来模仿fetchQuery请求的返回值:describe("CustomerHistory", () => { let unsubscribeSpy = jest.fn(); const sendCustomer = ({ next }) => { act(() => next({ customer })); return { unsubscribe: unsubscribeSpy }; }; beforeEach(() => { initializeReactContainer(); fetchQuery.mockReturnValue( { subscribe: sendCustomer } ); }); });
fetchQuery 的返回值
这个函数有一个相对复杂的用法模式。对 fetchQuery 的调用返回一个具有 subscribe 和 unsubscribe 函数属性的对象。我们使用具有 next 回调属性的对象调用 subscribe。该回调由 Relay 的 fetchQuery 在查询返回结果集时调用。我们可以使用该回调来设置组件状态。最后,unsubscribe 函数从 useEffect 块返回,以便在组件卸载或相关属性更改时调用。
-
最后,添加测试,检查我们是否以预期的方式调用
fetchQuery:it("calls fetchQuery", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(fetchQuery).toBeCalledWith( getEnvironment(), query, { id: 123 } ); }); -
让我们确保这一点。创建一个新的文件,
src/CustomerHistory.js,并从导入和导出的query定义开始:import React, { useEffect } from "react"; import { fetchQuery, graphql } from "relay-runtime"; import { getEnvironment } from "./relayEnvironment"; export const query = graphql` query CustomerHistoryQuery($id: ID!) { customer(id: $id) { id firstName lastName phoneNumber appointments { startsAt stylist service notes } } } `; -
添加该组件,以及一个
useEffect钩子:export const CustomerHistory = ({ id }) => { useEffect(() => { fetchQuery(getEnvironment(), query, { id }); }, [id]); return null; }; -
如果你现在运行测试,你可能会看到错误,如下所示:
Cannot find module './__generated__/CustomerHistoryQuery.graphql' from 'src/CustomerHistory.js'
为了修复这个问题,运行以下命令来编译你的 GraphQL 查询:
npx relay-compiler
-
接下来,我们可以添加一个测试来显示当我们提取一些数据时会发生什么:
it("unsubscribes when id changes", async () => { await renderAndWait(<CustomerHistory id={123} />); await renderAndWait(<CustomerHistory id={234} />); expect(unsubscribeSpy).toBeCalled(); }); -
为了使测试通过,更新
useEffect块以返回unsubscribe函数属性:useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ); return subscription.unsubscribe; }, [id]); -
然后,更新你的组件以渲染这些数据,包括客户数据:
it("renders the first name and last name together in a h2", async () => { await renderAndWait(<CustomerHistory id={123} />); await new Promise(setTimeout); expect(element("h2")).toContainText("Ashley Jones"); }); -
然后,更新你的组件以包括一个新的状态变量
customer。这是通过在我们的下一个回调定义中调用setCustomer来设置的:export const CustomerHistory = ({ id }) => { const [customer, setCustomer] = useState(null); useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ).subscribe({ next: ({ customer }) => setCustomer(customer), }); return subscription.unsubscribe; }, [id]); -
通过扩展你的 JSX 来渲染客户数据,使测试通过:
const { firstName, lastName } = customer; return ( <> <h2> {firstName} {lastName} </h2> </> ); -
现在,添加一个测试来渲染客户的电话号码:
it("renders the phone number", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(document.body).toContainText("123"); }); -
通过以下更改使测试通过:
const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> </> ); -
现在,让我们开始渲染预约信息:
it("renders a Booked appointments heading", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(element("h3")).not.toBeNull(); expect(element("h3")).toContainText( "Booked appointments" ); }); -
这很容易修复;添加一个
h3元素,如下所示:const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> <h3>Booked appointments</h3> </> ); -
接下来,我们将为每个可用的预约渲染一个表格:
it("renders a table with four column headings", async () => { await renderAndWait(<CustomerHistory id={123} />); const headings = elements( "table > thead > tr > th" ); expect(textOf(headings)).toEqual([ "When", "Stylist", "Service", "Notes", ]); }); -
添加以下表格:
const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> <h3>Booked appointments</h3> <table> <thead> <tr> <th>When</th> <th>Stylist</th> <th>Service</th> <th>Notes</th> </tr> </thead> </table> </> ); -
对于下一组测试,我们将使用一个
columnValues辅助函数,它将找到一个渲染的表格元素并提取列中的所有值。我们可以使用这个来测试我们的代码显示的是一系列预约的数据,而不仅仅是单个数据:const columnValues = (columnNumber) => elements("tbody > tr").map( (tr) => tr.childNodes[columnNumber] ); it("renders the start time of each appointment in the correct format", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(0))).toEqual([ "Sat Feb 16 2019 09:00", "Sat Feb 16 2019 10:00", ]); }); -
在
thead下方添加一个新的tbody元素。这引用了一个我们还没有构建的新AppointmentRow组件,但我们将在这个下一步中完成它:<table> <thead> ... </thead> <tbody> {customer.appointments.map((appointment, i) => ( <AppointmentRow appointment={appointment} key={i} /> ))} </tbody> </table> -
现在,让我们定义
AppointmentRow。在CustomerHistory定义之上添加此代码。之后,你的测试应该通过:const toTimeString = (startsAt) => new Date(Number(startsAt)) .toString() .substring(0, 21); const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> </tr> ); -
让我们添加其他列,从样式列开始:
it("renders the stylist", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(1))).toEqual([ "Jo", "Stevie" ]); }); -
将它作为
AppointmentRow的下一个列添加:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> </tr> ); -
接下来是
service字段:it("renders the service", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(2))).toEqual([ "Cut", "Cut & color", ]); }); -
再次,这仅仅涉及在
AppointmentRow中添加一个额外的td元素:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> <td>{appointment.service}</td> </tr> ); -
最后,为了渲染信息,我们还将显示
notes字段。it("renders notes", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(3))).toEqual([ "Note one", "Note two", ]); }); -
完成如这里所示的
AppointmentRow组件:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> <td>{appointment.service}</td> <td>{appointment.notes}</td> </tr> ); -
我们几乎完成了。让我们在刚刚完成的测试下方显示一个
describe块。它使用一个不执行任何操作的noSend模拟;没有调用next。这可以用来模拟数据仍在加载的情况:describe("submitting", () => { const noSend = () => unsubscribeSpy; beforeEach(() => { fetchQuery.mockReturnValue({ subscribe: noSend }); }); it("displays a loading message", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(element("[role=alert]")).toContainText( "Loading" ); }); }); -
为了使测试通过,在 JSX 之前引入一个条件:
export const CustomerHistory = ({ id }) => { const [customer, setCustomer] = useState(null); useEffect(() => { ... }, [id]); if (!customer) { return <p role="alert">Loading</p>; } ... }; -
最后,让我们处理在获取数据时出现错误的情况。这使用了另一个模拟的
errorSend,它调用错误回调。它就像next回调一样,可以用来设置状态,我们将在下一步中看到:describe("when there is an error fetching data", () => { const errorSend = ({ error }) => { act(() => error()); return { unsubscribe: unsubscribeSpy }; }; beforeEach(() => { fetchQuery.mockReturnValue( { subscribe: errorSend } ); }); it("displays an error message", async () => { await renderAndWait(<CustomerHistory />); expect(element("[role=alert]")).toContainText( "Sorry, an error occurred while pulling data from the server." ); }); }); -
为了使测试通过,你需要引入一个新的
status状态变量。最初,它具有loading值。当成功时,它变为loaded,当发生错误时,它变为failed。对于failed状态,我们渲染指定的错误消息:const [customer, setCustomer] = useState(null); const [status, setStatus] = useState("loading"); useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ).subscribe({ next: ({ customer }) => { setCustomer(customer); setStatus("loaded"); }, error: (_) => setStatus("failed"), }) return subscription.unsubscribe; }, [id]); if (status === "loading") { return <p role="alert">Loading</p>; } if (status === "failed") { return ( <p role="alert"> Sorry, an error occurred while pulling data from the server. </p> ); } const { firstName, lastName, phoneNumber } = customer; ...
这就完成了新的 CustomerHistory 组件。你现在已经学会了如何在你的应用程序中测试驱动 Relay 的 fetchQuery 函数的使用,并且这个组件现在可以与 App 集成了。这被留作练习。
摘要
本章探讨了如何使用 Relay 测试驱动集成 GraphQL 端点。你看到了如何测试驱动构建 Relay 环境,以及如何构建使用fetchQuery API 的组件。
在第三部分,交互性中,我们将开始在一个新的代码库中工作,这将使我们能够探索涉及撤销/重做、动画和 WebSocket 操作更复杂的用例。
在第十四章, 构建 Logo 解释器中,我们将首先编写新的 Redux 中间件来处理撤销/重做行为。
练习
通过以下步骤将CustomerHistory组件集成到你的应用程序的其余部分:
-
在
/viewHistory?customer=<customer id>处添加一个新的路由,显示CustomerHistory组件,使用一个新的中间CustomerHistoryRoute组件。 -
在
CustomerSearch屏幕上的搜索操作中添加一个新的Link,标题为查看历史记录,当按下时,将导航到新路由。
进一步阅读
RelayEnvironmentProvider组件:
relay.dev/docs/api-reference/relay-environment-provider/
第三部分 – 交互性
本部分介绍了一个新的代码库,使我们能够探索更多复杂场景,在这些场景中可以应用 TDD。你将深入探究 Redux 中间件、动画和 WebSockets。目标是展示如何使用 TDD 工作流程来处理复杂任务。
本部分包括以下章节:
-
第十四章, 构建 Logo 解释器
-
第十五章, 添加动画
-
第十六章, 与 WebSockets 协作
第十四章:构建 Logo 解释器
Logo 是在 20 世纪 60 年代创建的一个编程环境。在许多十年里,它是教授孩子们如何编码的一种流行方式——我对高中时编写 Logo 程序的记忆犹新。其核心,它是一种通过命令式指令构建图形的方法。
在本书的这一部分,我们将构建一个名为Spec Logo的应用程序。起点是一个已经可以工作的解释器和基本的 UI。在接下来的章节中,我们将向这个代码库添加更多功能。
本章提供了第二次测试 Redux 的机会。它涵盖了以下主题:
-
研究 Spec Logo 用户界面
-
在 Redux 中撤销和重做用户操作
-
通过 Redux 中间件将数据保存到本地存储
-
更改键盘焦点
到本章结束时,你将学会如何测试驱动复杂的 Redux reducer 和中间件。
技术要求
本章的代码文件可以在以下位置找到:
研究 Spec Logo 用户界面
界面有两个面板:左侧面板是绘图面板,这是 Logo 脚本输出出现的地方。右侧是一个提示框,用户可以在此编辑指令:

图 14.1:Spec Logo 界面
看一下截图。你可以看到以下内容:
-
左上角的脚本名称。这是一个用户可以点击以更改当前脚本名称的文本字段。
-
显示区域,它显示脚本输出在页面左侧。你可以看到这里绘制了一个形状,这是在提示框中输入的 Logo 语句的结果。
-
屏幕中间的海龟。这是一个标记绘图命令起点的绿色三角形。海龟有一个x和y位置,起始位置为0,0,这是屏幕的中间。可见的绘图大小为 600x600,海龟可以在这个区域内移动。海龟还有一个角度,初始为零,指向正右方。
-
右下角的提示框,标记为>符号。这是你输入语句的地方,可以是多行的。按下Enter键将当前提示文本发送到解释器。如果它是一个完整的语句,它将被执行,并且提示框将被清除,以便输入下一个语句。
-
上方提示框中的语句历史。它列出了所有之前执行过的语句。每个语句都有一个编号,这样你可以回溯到相应的语句。
-
右上角的菜单栏,包含撤销、重做和重置按钮。我们将在本章中构建这个菜单栏。
尽管我们本章不会编写任何 Logo 代码,但花些时间在解释器上玩耍并制作自己的绘图是值得的。以下是一份您可以使用的指令列表:

值得一看的是代码库。src/parser.js 文件和 src/language 目录包含 Logo 解释器。测试目录中也有相应的测试文件。我们不会修改这些文件,但您可能对查看此功能是如何被测试的感兴趣。
在 src/reducers/script.js 中有一个单独的 Redux reducer。它的 defaultState 定义巧妙地封装了表示 Logo 程序执行所需的一切。几乎所有的 React 组件都以某种方式使用这个状态。
在本章中,我们将向该目录添加两个额外的 reducer:一个用于撤销/重做,另一个用于提示焦点。我们还将对三个 React 组件进行修改:MenuButtons、Prompt 和 ScriptName。
让我们从构建一个新的 reducer 开始,命名为 withUndoRedo。
在 Redux 中撤销和重做用户操作
在本节中,我们将在页面顶部添加撤销和重做按钮,允许用户撤销和重做他们之前运行的语句。它们的工作方式如下:
-
初始时,两个按钮都将被禁用。
-
一旦用户执行了一个语句,撤销按钮将变为可用。
-
当用户点击撤销按钮时,最后一个语句将被撤销。
-
在这一点上,重做按钮变为可用,用户可以选择重做最后一个语句。
-
可以按顺序撤销和重做多个操作。
-
如果用户在重做可用时执行新的操作,重做序列将被清除,重做按钮再次不可用。
除了添加按钮元素外,这里的工作涉及构建一个新的 reducer,名为 withUndoRedo,它将装饰脚本 reducer。这个 reducer 将返回与脚本 reducer 相同的状态,但有两个额外的属性:canUndo 和 canRedo。此外,reducer 在其中存储 past 和 future 数组,记录过去和未来的状态。这些将不会被返回给用户,只是存储,如果用户选择撤销或重做,将替换当前状态。
构建 reducer
这个 reducer 将是一个高阶函数,当与现有的 reducer 一起调用时,返回一个新的 reducer,该 reducer 返回我们期望的状态。在我们的生产代码中,我们将用以下 store 代码替换它:
combineReducers({
script: scriptReducer
})
我们将用这个装饰过的 reducer 来替换它,这个 reducer 完全相同的 reducer,并用我们将在本节中构建的 withUndoRedo reducer 包装:
combineReducers({
script: withUndoRedo(scriptReducer)
})
为了测试这个,我们需要使用一个间谍来代替脚本 reducer,我们将称之为 decoratedReducerSpy。
设置初始状态
让我们从构建 reducer 本身开始,然后再添加按钮来练习新功能:
-
创建一个名为
test/reducers/withUndoRedo.test.js的新文件,并添加以下设置和测试,该测试指定了当我们向 reducer 传递一个未定义的状态时应该发生什么。这相当于我们开始测试其他 reducer 的方式,但在这个情况下,我们将调用传递给装饰 reducer。测试将一个undefined状态传递给 reducer,这是初始化 reducer 所需机制:import { withUndoRedo } from "../../src/reducers/withUndoRedo"; describe("withUndoRedo", () => { let decoratedReducerSpy; let reducer; beforeEach(() => { decoratedReducerSpy = jest.fn(); reducer = withUndoRedo(decoratedReducerSpy); }); describe("when initializing state", () => { it("calls the decorated reducer with undefined state and an action", () => { const action = { type: "UNKNOWN" }; reducer(undefined, action); expect(decoratedReducerSpy).toBeCalledWith( undefined, action); }); }); }); -
创建一个名为
src/reducers/withUndoRedo.js的新文件,并使用以下代码使测试通过:export const withUndoRedo = (reducer) => { return (state, action) => { reducer(state, action); }; }; -
按照以下所示将下一个测试添加到
describe块中。这使用了我们在 第六章 中首次遇到的toMatchObject匹配器,探索测试替身:it("returns a value of what the inner reducer returns", () => { decoratedReducerSpy.mockReturnValue({ a: 123 }); expect(reducer(undefined)).toMatchObject( { a : 123 } ); }); -
通过添加
return关键字来使测试通过:export const withUndoRedo = (reducer) => { return (state, action) => { return reducer(state, action); }; } -
初始时,
canUndo和canRedo都应该是false,因为没有可以移动到的前一个或未来状态。让我们将这两个测试作为一对添加,仍然在同一describe块中:it("cannot undo", () => { expect(reducer(undefined)).toMatchObject({ canUndo: false }); }); it("cannot redo", () => { expect(reducer(undefined)).toMatchObject({ canRedo: false }); }); -
为了使这些测试通过,我们需要创建一个新的对象,并添加以下属性:
export const withUndoRedo = (reducer) => { return (state, action) => { return { canUndo: false, canRedo: false, ...reducer(state, action) }; }; } -
让我们继续到 reducer 的核心部分。在执行一个动作之后,我们希望能够执行
present和future常量来表示那些状态:describe("performing an action", () => { const innerAction = { type: "INNER" }; const present = { a: 123 }; const future = { b: 234 }; beforeEach(() => { decoratedReducerSpy.mockReturnValue(future); }); it("can undo after a new present has been provided", () => { const result = reducer( { canUndo: false, present }, innerAction ); expect(result.canUndo).toBeTruthy(); }); }); -
使用以下代码使测试通过。由于我们不再处理未定义的状态,这是我们需要将现有代码包裹在条件块中的时刻:
export const withUndoRedo = (reducer) => { return (state, action) => { if (state === undefined) return { canUndo: false, canRedo: false, ...reducer(state, action) }; return { canUndo: true }; }; }; -
接下来,我们确保再次调用 reducer,因为对于这个新块,它不会发生。编写以下测试:
it("forwards action to the inner reducer", () => { reducer(present, innerAction); expect(decoratedReducerSpy).toBeCalledWith( present, innerAction ); }); -
为了使测试通过,只需在
return值之前调用 reducer:if (state === undefined) ... reducer(state, action); return { canUndo: true }; -
下一个测试显示这个对象还需要返回新的状态:
it("returns the result of the inner reducer", () => { const result = reducer(present, innerAction); expect(result).toMatchObject(future); }); -
通过将 reducer 值保存在名为
newPresent的变量中,并将其作为返回对象的一部分返回来使测试通过:const newPresent = reducer(state, action); return { ...newPresent, canUndo: true }; -
脚本 reducer 持有一个名为
nextInstructionId的特殊值。我们可以使用这个值来确定脚本指令是否被处理,或者是否发生了错误。当一条语句有效时,它将被执行,nextInstructionId将递增。但是当一条语句无法被处理时,nextInstructionId保持不变。我们可以使用这个事实来避免在语句包含错误时保存历史记录。为此,修改present和future常量以包含此参数,并添加新的测试,如下所示:const present = { a: 123, nextInstructionId: 0 }; const future = { b: 234, nextInstructionId: 1 }; ... it("returns the previous state if nextInstructionId does not increment", () => { decoratedReducerSpy.mockReturnValue({ nextInstructionId: 0 }); const result = reducer(present, innerAction); expect(result).toBe(present); }); -
通过将我们的新
return块包裹在条件语句中,并在条件不满足时返回旧状态来使测试通过:const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { return { ...newPresent, canUndo: true }; } return state;
这涵盖了执行任何动作的所有功能,除了撤销和重做。下一节将介绍撤销。
处理撤销动作
我们将创建一个新的 Redux 动作,类型为 UNDO,这将导致我们将当前状态推入一个新的数组 past 中:
-
对于这个测试,我们可以重用
present和innerAction属性,所以现在将它们推送到外部的describe块中。同时,定义一个新的undoActionRedux 动作。我们将在第一个测试中使用它:describe("withUndoRedo", () => { const undoAction = { type: "UNDO" }; const innerAction = { type: "INNER" }; const present = { a: 123, nextInstructionId: 0 }; const future = { b: 234, nextInstructionId: 1 }; ... }); -
添加一个新的嵌套
describe块,包含以下测试和设置。beforeEach块设置了一个场景,其中我们已经执行了一个将存储先前状态的行动。然后我们就可以在测试中撤销它:describe("undo", () => { let newState; beforeEach(() => { decoratedReducerSpy.mockReturnValue(future); newState = reducer(present, innerAction); }); it("sets present to the latest past entry", () => { const updated = reducer(newState, undoAction); expect(updated).toMatchObject(present); }); });
在beforeEach块内执行操作
注意beforeEach设置中对reducer函数的调用。这个函数是我们要测试的函数,因此可以认为它是reducer测试设置的一部分,因为所有这些测试都依赖于至少执行了一个可以撤销的操作。这样,我们可以将这个reducer调用视为断言阶段的一部分。
-
通过以下方式修改函数以使测试通过。我们使用一个
past变量来存储先前状态。如果我们收到一个UNDO操作,我们返回该值。我们还使用switch语句,因为我们稍后会添加一个REDO的情况:export const withUndoRedo = (reducer) => { let past; return (state, action) => { if (state === undefined) ... switch(action.type) { case "UNDO": return past; default: const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = state; return { ...newPresent, canUndo: true }; } return state; } }; }; -
接下来,让我们调整它,以便我们可以撤销任意深度的操作。添加下一个测试:
it("can undo multiple levels", () => { const futureFuture = { c: 345, nextInstructionId: 3 }; decoratedReducerSpy.mockReturnValue(futureFuture); newState = reducer(newState, innerAction); const updated = reducer( reducer(newState, undoAction), undoAction ); expect(updated).toMatchObject(present); }); -
对于这一点,我们需要将
past升级为一个数组:export const withUndoRedo = (reducer) => { let past = []; return (state, action) => { if (state === undefined) ... switch(action.type) { case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); return lastEntry; default: const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = [ ...past, state ]; return { ...newPresent, canUndo: true }; } return state; } }; }; -
我们还需要进行一个最后的测试。我们需要检查在撤销之后,我们也可以重做:
it("sets canRedo to true after undoing", () => { const updated = reducer(newState, undoAction); expect(updated.canRedo).toBeTruthy(); }); -
为了使这个测试通过,返回一个由
lastEntry和新的canRedo属性组成的新对象:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); return { ...lastEntry, canRedo: true };
这就是UNDO操作的全部内容。接下来,让我们添加REDO操作。
处理重做操作
重做与撤销非常相似,只是顺序相反:
-
首先,在顶级
describe块中添加一个 Redux 操作类型REDO的新定义:describe("withUndoRedo", () => { const undoAction = { type: "UNDO" }; const redoAction = { type: "REDO" }; ... }); -
在撤销
describe块下方,添加以下重做describe块和第一个测试。注意间谍的设置;这里的调用是mockReturnValueOnce,而不是mockReturnValue。测试需要确保它从存储的redo状态中获取其值:describe("redo", () => { let newState; beforeEach(() => { decoratedReducerSpy.mockReturnValueOnce(future); newState = reducer(present, innerAction); newState = reducer(newState, undoAction); }); it("sets the present to the latest future entry", () => { const updated = reducer(newState, redoAction); expect(updated).toMatchObject(future); }); }); -
为了使这个测试通过,在你的生产代码中,声明一个
future变量,紧挨着past的声明:let past = [], future; -
在
UNDO操作中设置此值:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); future = state; -
现在它已经保存,我们可以处理
REDO操作。在UNDO子句和default子句之间添加以下case语句:case "UNDO": ... case "REDO": return future; default: ... -
下一个测试是针对多级重做的。这比
undo块中的相同情况稍微复杂一些——我们需要修改beforeEach块,使其回退两次。首先,从撤销测试中提取futureFuture值并将其带入外部作用域,紧挨着其他值,位于future下方:const future = { b: 234, nextInstructionId: 1 }; const futureFuture = { c: 345, nextInstructionId: 3 }; -
现在,更新
beforeEach以向前移动两步然后后退两步:beforeEach(() => { decoratedReducerSpy.mockReturnValueOnce(future); decoratedReducerSpy.mockReturnValueOnce( futureFuture ); newState = reducer(present, innerAction); newState = reducer(newState, innerAction); newState = reducer(newState, undoAction); newState = reducer(newState, undoAction); }); -
最后,添加以下测试:
it("can redo multiple levels", () => { const updated = reducer( reducer(newState, redoAction), redoAction ); expect(updated).toMatchObject(futureFuture); }); -
为了使这个测试通过,首先初始化
future变量为一个空数组:let past = [], future = []; -
更新
UNDO子句以将当前值推入其中:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); future = [ ...future, state ]; -
更新
REDO子句以提取我们刚刚推入的值。在此更改之后,测试应该通过:case "REDO": const nextEntry = future[future.length - 1]; future = future.slice(0, -1); return nextEntry; -
对于我们的基础实现,我们需要编写一个最后的测试,该测试检查重做操作后跟一个撤销操作能否带我们回到原始状态:
it("returns to previous state when followed by an undo", () => { const updated = reducer( reducer(newState, redoAction), undoAction ); expect(updated).toMatchObject(present); }); -
通过设置
REDO情况中的past属性来使测试通过:case "REDO": const nextEntry = future[future.length - 1]; past = [ ...past, state ]; future = future.slice(0, -1); return nextEntry; -
这完成了我们的 reducer。然而,我们的实现存在内存泄漏!当我们生成新状态时,我们从未清除
future数组。如果用户反复点击future但变得不可访问,这是由于最新状态中的canRedo为false。
为了测试这个场景,你可以模拟序列并检查你期望返回undefined。这个测试并不很好,因为我们实际上不应该在canRedo返回false时发送REDO动作,但我们的测试最终就是这样做的:
it("return undefined when attempting a do, undo, do, redo sequence", () => {
decoratedReducerSpy.mockReturnValue(future);
let newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, redoAction);
expect(newState).not.toBeDefined();
});
-
为了完成这个操作,只需在设置新状态时清除
future,如下所示:if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = [ ...past, state ]; future = []; return { ...newPresent, canUndo: true }; } -
我们现在已经完成了 reducer。为了完成这个任务,将其连接到我们的 Redux 存储。打开
src/store.js并做出以下更改:import { withUndoRedo } from "./reducers/withUndoRedo"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(scriptReducer) }), initialState, compose(...storeEnhancers) ); };
您的所有测试都应该通过,并且应用程序仍然可以运行。
然而,撤销和重做功能仍然不可访问。为此,我们需要在菜单栏中添加一些按钮。
构建按钮
这个谜题的最后一部分是添加按钮来触发新的行为,通过在菜单栏中添加撤销和重做按钮:
-
打开
test/MenuButtons.test.js并在文件的底部添加以下describe块,嵌套在MenuButtonsdescribe块内部。它使用了一些已经通过renderWithStore文件和按钮定义的辅助函数:describe("undo button", () => { it("renders", () => { renderWithStore(<MenuButtons />); expect(buttonWithLabel("Undo")).not.toBeNull(); }); }); -
通过修改
src/MenuButtons.js文件中的MenuButtons实现来执行这个操作,如下所示:export const MenuButtons = () => { ... return ( <> <button>Undo</button> <button onClick={() => dispatch(reset())} disabled={!canReset} > Reset </button> </> ); }; -
添加下一个测试,该测试检查按钮最初是禁用的:
it("is disabled if there is no history", () => { renderWithStore(<MenuButtons />); expect( buttonWithLabel("Undo").hasAttribute("disabled") ).toBeTruthy(); }); -
通过添加硬编码的
disabled属性来执行这个操作,如下所示:<button disabled={true}>Undo</button> -
现在,我们添加代码,这将需要我们连接到 Redux:
it("is enabled if an action occurs", () => { renderWithStore(<MenuButtons />); dispatchToStore({ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" }); expect( buttonWithLabel("Undo").hasAttribute("disabled") ).toBeFalsy(); }); -
修改
MenuButtons以从存储中提取canUndo。它已经使用script状态来处理重置按钮的行为,因此在这种情况下,我们只需要进一步解构它:export const MenuButtons = () => { const { canUndo, nextInstructionId } = useSelector(({ script }) => script); ... const canReset = nextInstructionId !== 0; return ( <> <button disabled={!canUndo}>Undo</button> <button onClick={() => dispatch(reset())} disabled={!canReset} > Reset </button> </> ); } ); -
当点击
UNDO动作时的最终测试:it("dispatches an action of UNDO when clicked", () => { renderWithStore(<MenuButtons />); dispatchToStore({ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" }); click(buttonWithLabel("Undo")); return expectRedux(store) .toDispatchAnAction() .matching({ type: "UNDO" }); }); -
通过添加下面突出显示的行来完成这个操作。我们添加了新的
undo动作辅助函数,然后使用它来调用dispatch:const reset = () => ({ type: "RESET" }); const undo = () => ({ type: "UNDO" }); export const MenuButtons = () => { ... return ( <> <button onClick={() => dispatch(undo())} disabled={!canUndo} > Undo </button> ... </> ); }; -
从步骤 2到步骤 8重复
canRedo属性从脚本状态。
需要做的最后一个更改。撤销和重做功能现在已完成。
接下来,我们将从构建 Redux reducer 转移到构建 Redux 中间件。
通过 Redux 中间件将数据保存到本地存储
在本节中,我们将更新我们的应用程序,将当前状态保存到本地存储,这是一个由用户的网络浏览器管理的持久数据存储。我们将通过 Redux 中间件来实现这一点。
每当在LocalStorage API 中执行一个语句时。当用户下次打开应用程序时,这些令牌将被读取并通过解析器重新播放。
parseTokens函数
提醒一下,解析器(在src/parser.js中)有一个parseTokens函数。这是我们将在中间件内部调用的函数,在本节中,我们将构建测试来断言我们已调用此函数。
我们将为这个任务编写一个新的 Redux 中间件。该中间件将提取脚本状态中的两个部分:name 和 parsedTokens。
在我们开始之前,让我们回顾一下浏览器的 LocalStorage API:
-
window.localStorage.getItem(key)返回本地存储中一个项的值。存储的值是一个字符串,因此如果它是一个序列化对象,那么我们需要调用JSON.parse来反序列化它。如果给定键没有值,函数返回null。 -
window.localStorage.setItem(key, value)设置一个项的值。该值被序列化为字符串,因此我们需要确保在传递之前对任何对象调用JSON.stringify。
构建中间件
让我们测试驱动我们的中间件:
-
创建
src/middleware和test/middleware目录,然后打开test/middleware/localStorage.test.js文件。为了开始,定义两个间谍函数,getItemSpy和setItemSpy,它们将组成新的对象。我们必须使用Object.defineProperty来设置这些间谍函数,因为window.localStorage属性是只写的:import { save } from "../../src/middleware/localStorage"; describe("localStorage", () => { const data = { a: 123 }; let getItemSpy = jest.fn(); let setItemSpy = jest.fn(); beforeEach(() => { Object.defineProperty(window, "localStorage", { value: { getItem: getItemSpy, setItem: setItemSpy }}); }); }); -
让我们为中间件编写第一个测试。这个测试简单地断言中间件做了所有中间件都应该做的事情,即调用
next(action)。Redux 中间件函数具有复杂的语义,是返回函数的函数,但我们的测试将轻松处理这一点:describe("save middleware", () => { const name = "script name"; const parsedTokens = ["forward 10"]; const state = { script: { name, parsedTokens } }; const action = { type: "ANYTHING" }; const store = { getState: () => state }; let next; beforeEach(() => { next = jest.fn(); }); const callMiddleware = () => save(store)(next)(action); it("calls next with the action", () => { callMiddleware(); expect(next).toBeCalledWith(action); }); }); -
为了使其通过,创建
src/middleware/localStorage.js文件并添加以下定义:export const save = store => next => action => { next(action); }; -
下一个测试检查我们是否返回该值:
it("returns the result of next action", () => { next.mockReturnValue({ a : 123 }); expect(callMiddleware()).toEqual({ a: 123 }); }); -
更新
save函数以返回该值:export const save = store => next => action => { return next(action); }; -
现在,检查我们是否将字符串化的值添加到本地存储中:
it("saves the current state of the store in localStorage", () => { callMiddleware(); expect(setItemSpy).toBeCalledWith("name", name); expect(setItemSpy).toBeCalledWith( "parsedTokens", JSON.stringify(parsedTokens) ); }); -
为了使其通过,完成
save中间件的实现:export const save = store => next => action => { const result = next(action); const { script: { name, parsedTokens } } = store.getState(); localStorage.setItem("name", name); localStorage.setItem( "parsedTokens", JSON.stringify(parsedTokens) ); return result; }; -
让我们继续到
load函数,它不是中间件,但将其放在同一文件中并无害。创建一个新的describe块,包含以下测试,并确保更新import:import { load, save } from "../../src/middleware/localStorage"; ... describe("load", () => { describe("with saved data", () => { beforeEach(() => { getItemSpy.mockReturnValueOnce("script name"); getItemSpy.mockReturnValueOnce( JSON.stringify([ { a: 123 } ]) ); }); it("retrieves state from localStorage", () => { load(); expect(getItemSpy).toBeCalledWith("name"); expect(getItemSpy).toHaveBeenLastCalledWith( "parsedTokens" ); }); }); }); -
通过在
save的定义下方添加load函数来使该操作通过,在生成代码中定义一个新的函数:export const load = () => { localStorage.getItem("name"); localStorage.getItem("parsedTokens"); }; -
现在要将这些数据发送到解析器。为此,我们需要一个
parserSpy间谍函数,我们使用它来监视解析器的parseTokens函数:describe("load", () => { let parserSpy; describe("with saved data", () => { beforeEach(() => { parserSpy = jest.fn(); parser.parseTokens = parserSpy; ... }); it("calls to parsedTokens to retrieve data", () => { load(); expect(parserSpy).toBeCalledWith( [ { a: 123 } ], parser.emptyState ); }); }); }); -
添加以下生成代码以使其通过:
import * as parser from "../parser"; export const load = () => { localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); parser.parseTokens(parsedTokens, parser.emptyState); }; -
下一个测试确保数据以正确的格式返回:
it("returns re-parsed draw commands", () => { parserSpy.mockReturnValue({ drawCommands: [] }); expect( load().script ).toHaveProperty("drawCommands", []); }); -
通过返回一个包含解析响应的对象来使其通过:
export const load = () => { localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); return { script: parser.parseTokens( parsedTokens, parser.emptyState ) }; }; -
接下来,让我们将名称添加到该数据结构中:
it("returns name", () => { expect(load().script).toHaveProperty( "name", "script name" ); }); -
为了使其通过,首先,我们需要保存从本地存储返回的名称,然后将其插入到
present对象中:export const load = () => { const name = localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); return { script: { ...parser.parseTokens( parsedTokens, parser.initialState ), name } }; }; -
最后,我们需要处理尚未保存任何状态的情况。在这种情况下,
LocalStorageAPI 返回null,但我们希望返回undefined,这将触发 Redux 使用默认状态。将此测试添加到外部的describe块中,这样它就不会拾取额外的getItemSpy模拟值:it("returns undefined if there is no state saved", () => { getItemSpy.mockReturnValue(null); expect(load()).not.toBeDefined(); }); -
通过将
return语句包裹在if语句中来使其通过:if (parsedTokens && parsedTokens !== null) { return { ... }; } -
打开
src/store.js并修改它以包含新的中间件。我正在定义一个新的函数configureStoreWithLocalStorage,这样我们的测试就可以继续使用configureStore而不与本地存储交互:... import { save, load } from "./middleware/localStorage"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(scriptReducer) }), initialState, compose( ...[ applyMiddleware(save), ...storeEnhancers ] ) ); }; export const configureStoreWithLocalStorage = () => configureStore(undefined, load()); -
打开
src/index.js并将对configureStore的调用替换为对configureStoreWithLocalStorage的调用。你还需要更新import以使用这个新函数:import { configureStoreWithLocalStorage } from "./store"; ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStoreWithLocalStorage()}> <App /> </Provider> );
就这样。如果你愿意的话,这是一个运行手动测试和尝试应用的好时机。打开浏览器窗口,输入几个命令,然后试试看!
如果你不知道要运行手动测试的命令,可以使用以下命令:
forward 100
right 90
to drawSquare
repeat 4 [ forward 100 right 90 ]
end
drawSquare
这些命令在解释器和显示中的大多数功能都会得到锻炼。当你在第十五章“添加动画”中进行手动测试时,它们会很有用。
你已经学会了如何测试驱动 Redux 中间件。对于本章的最后一部分,我们将编写另一个 reducer,这次是一个帮助我们操作浏览器键盘焦点的 reducer。
改变键盘焦点
我们应用程序的用户,大多数时候,会在屏幕右下角的提示框中输入。为了帮助他们,当应用启动时,我们将键盘焦点移动到提示框。当另一个元素——例如名称文本框或菜单按钮——被使用并完成其工作后,我们也应该这样做。然后,焦点应该回到提示框,准备接收下一个指令。
React 不支持设置焦点,因此我们需要在我们的组件上使用一个React ref,然后将其放入 DOM API 中。
我们将通过 Redux reducer 来实现这一点。它将有两个动作:PROMPT_FOCUS_REQUEST和PROMPT_HAS_FOCUSED。我们应用程序中的任何 React 组件都可以发出第一个动作。Prompt组件将监听它,并在它聚焦后发出第二个动作。
编写 reducer
我们将像往常一样,从 reducer 开始:
-
创建一个名为
test/reducers/environment.test.js的新文件,并添加以下describe块。这涵盖了 reducer 在接收到undefined时需要返回默认状态的基本情况:import { environmentReducer as reducer } from "../../src/reducers/environment"; describe("environmentReducer", () => { it("returns default state when existing state is undefined", () => { expect(reducer(undefined, {})).toEqual({ promptFocusRequest: false }); }); }); -
使用以下代码使测试通过,在一个名为
src/reducers/environment.js的文件中。由于我们之前已经构建过 reducer,我们知道这次的目标在哪里:const defaultState = { promptFocusRequest: false }; export const environmentReducer = ( state = defaultState, action) => { return state; }; -
添加下一个测试,该测试检查我们是否设置了
promptFocusRequest值:it("sets promptFocusRequest to true when receiving a PROMPT_FOCUS_REQUEST action", () => { expect( reducer( { promptFocusRequest: false}, { type: "PROMPT_FOCUS_REQUEST" } ) ).toEqual({ promptFocusRequest: true }); }); -
通过添加一个
switch语句使其通过,如下所示:export const environmentReducer = ( state = defaultState, action ) => { switch (action.type) { case "PROMPT_FOCUS_REQUEST": return { promptFocusRequest: true }; } return state; }; -
为这个 reducer 添加最后的测试:
it("sets promptFocusRequest to false when receiving a PROMPT_HAS_FOCUSED action", () => { expect( reducer( { promptFocusRequest: true}, { type: "PROMPT_HAS_FOCUSED" } ) ).toEqual({ promptFocusRequest: false }); }); -
最后,通过添加另一个
case语句使其通过:export const environmentReducer = (...) => { switch (action.type) { ..., case "PROMPT_HAS_FOCUSED": return { promptFocusRequest: false }; } ... } -
在我们可以在测试中使用新的 reducer 之前,我们需要将其添加到存储中。打开
src/store.js并按以下方式修改:... import { environmentReducer } from "./reducers/environment"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(logoReducer), environment: environmentReducer }), ... ); };
这为我们提供了一个新的 reducer,它已经连接到 Redux 存储。现在,让我们利用它。
焦点提示
让我们继续到这部分最困难的部分:聚焦实际的提示。为此,我们需要引入一个 React ref:
-
打开
test/Prompt.test.js并在Promptdescribe块底部添加以下describe块。测试使用document.activeElement属性,它是当前具有焦点的元素。它还使用renderInTableWithStore函数,它与您已经看到的renderWithStore辅助函数相同,只是组件首先被包裹在一个表格中:describe("prompt focus", () => { it("sets focus when component first renders", () => { renderInTableWithStore(<Prompt />); expect( document.activeElement ).toEqual(textArea()); }); }); -
让我们通过这个。我们使用
useRef钩子定义一个新的引用,并添加一个useEffect钩子以在组件挂载时聚焦。确保从 React 常量中提取新的常量,该常量位于文件顶部:import React, { useEffect, useRef, useState } from "react"; export const Prompt = () => { ... const inputRef = useRef(); useEffect(() => { inputRef.current.focus(); }, [inputRef]); return ( ... <textarea ref={inputRef} /> ... ); }; -
对于下一个测试,我们将向 Redux 存储发送一个动作。由于这个测试套件还没有发送动作的测试,我们需要添加所有管道。首先,将
dispatchToStore函数导入到测试套件中:import { ..., dispatchToStore, } from "./reactTestExtensions"; -
现在,我们需要一个新的辅助函数来清除焦点。因为焦点将在组件挂载时立即设置,我们需要再次取消设置,以便我们可以验证我们的焦点请求的行为。一旦我们有了这个辅助函数,我们就可以添加下一个测试:
const jsdomClearFocus = () => { const node = document.createElement("input"); document.body.appendChild(node); node.focus(); node.remove(); } it("calls focus on the underlying DOM element if promptFocusRequest is true", async () => { renderInTableWithStore(<Prompt />); jsdomClearFocus(); dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" }); expect(document.activeElement).toEqual(textArea()); }); -
为了通过这个测试,首先,创建一个新的
useSelector调用来从存储中提取promptFocusRequest值:export const Prompt = () => { const nextInstructionId = ... const promptFocusRequest = useSelector( ({ environment: { promptFocusRequest } }) => promptFocusRequest ); ... }; -
然后,添加一个当
promptFocusRequest发生变化时运行的新效果。这使用引用来调用 DOM 的focus方法在 HTML 元素上:useEffect(() => { inputRef.current.focus(); }, [promptFocusRequest]); -
对于下一个测试,当焦点发生时发送一个动作:
it("dispatches an action notifying that the prompt has focused", () => { renderWithStore(<Prompt />); dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" }); return expectRedux(store) .toDispatchAnAction() .matching({ type: "PROMPT_HAS_FOCUSED" }); }); -
为了通过这个测试,首先添加一个新的动作辅助函数,我们可以在
Prompt组件中调用它:const submitEditLine = ... const promptHasFocused = () => ( { type: "PROMPT_HAS_FOCUSED" } ); -
最后,在
useEffect钩子中调用promptHasFocused:useEffect(() => { inputRef.current.focus(); dispatch(promptHasFocused()); }, [promptFocusRequest]);
最后一个代码片段有一个小问题。发送的 PROMPT_HAS_FOCUSED 动作会将 promptFocusRequest 设置回 false。这会导致 useEffect 钩子再次运行,组件重新渲染。这显然不是预期的,也不是必要的。然而,由于它对用户没有可识别的影响,我们可以暂时跳过修复它。
这完成了 Prompt 组件,现在每当 promptFocusRequest 变量值发生变化时,它都会夺取焦点。
在其他组件中请求焦点
剩下的只是当需要时调用请求动作。我们将为 ScriptName 做这件事,但你也可以为菜单栏中的按钮做这件事:
-
打开
test/ScriptName.test.js,找到名为when the user hits Enter的describe块,并添加以下测试:it("dispatches a prompt focus request", () => { return expectRedux(store) .toDispatchAnAction() .matching({ type: "PROMPT_FOCUS_REQUEST" }); }); -
在
src/ScriptName.js中,修改组件以定义一个名为promptFocusRequest的动作辅助器:const submitScriptName = ... const promptFocusRequest = () => ({ type: "PROMPT_FOCUS_REQUEST", }); -
在编辑完成处理程序中调用它:
const completeEditingScriptName = () => { if (editingScriptName) { toggleEditingScriptName(); dispatch(submitScriptName(updatedScriptName)); dispatch(promptFocusRequest()); } };
就这样!如果你现在构建并运行,你会看到焦点是如何自动赋予 prompt 文本框的,如果你编辑脚本名称(通过点击它,输入一些内容,然后按 Enter),你会看到焦点返回到提示。
摘要
你现在应该对测试驱动复杂的 Redux 红 ucer 和中间件有一个很好的理解。
首先,我们通过 Redux 装饰器 reducer 添加了撤销/重做功能。然后,我们构建了 Redux 中间件,通过浏览器的LocalStorage API 保存和加载现有状态。最后,我们探讨了如何测试驱动改变浏览器的焦点。
在下一章中,我们将探讨如何测试驱动更复杂的动画。
进一步阅读
关于 Logo 编程语言的维基百科条目:
en.wikipedia.org/wiki/Logo_(programming_language)
第十五章:添加动画
动画与其他任何功能一样,也适合测试驱动开发。在本章中,我们将根据用户输入的命令动画化 Logo 乌龟的移动。
Spec Logo 中有两种类型的动画:
-
首先,当乌龟向前移动时。例如,当用户输入
forward 100作为指令时,乌龟应该以固定速度沿 100 个单位移动。在移动过程中,它将在后面画一条线。 -
其次,当乌龟旋转时。例如,如果用户输入
rotate 90,那么乌龟应该缓慢旋转,直到它完成四分之一转弯。
本章的大部分内容是关于测试驱动window.requestAnimationFrame函数。这是浏览器 API,允许我们在屏幕上动画化视觉元素,例如乌龟的位置或线的长度。这个函数的机制在本章的第三部分使用 requestAnimationFrame 进行动画中解释。
手动测试的重要性
在编写动画代码时,自然想要直观地检查我们正在构建的内容。自动测试是不够的。手动测试也很重要,因为动画不是大多数程序员每天都会做的事情。当某事是新的时,通常最好进行大量的手动测试来验证行为,除了你的自动测试之外。
事实上,在准备本章时,我进行了大量的手动测试。这里展示的试验了几种不同的方法。有很多次我打开浏览器输入forward 100或right 90来直观地验证发生了什么。
本章涵盖了以下主题:
-
设计动画
-
构建动画线条组件
-
使用
requestAnimationFrame进行动画 -
使用
cancelAnimationFrame取消动画 -
变化动画行为
我们将要编写的代码与本书中其他部分的代码相比相对复杂,因此我们需要先做一些前期设计。
到本章结束时,你将深入理解如何测试驱动更复杂的浏览器 API 之一。
技术要求
本章的代码文件可以在这里找到:
设计动画
在阅读本节时,你可能希望打开src/Drawing.js并阅读现有代码,以了解它在做什么。
当前的Drawing组件显示了在此点绘图的外观的静态快照。它渲染一组可缩放矢量图形(SVG)线条来表示乌龟到达此点的路径,以及一个三角形来表示乌龟。
组件使用了两个子组件:
-
Turtle组件只显示一次,并在指定位置绘制一个 SVG 三角形 -
StaticLines组件是一组在屏幕上绘制的 SVG 线条,用于表示绘制的命令
我们将添加一个新的 AnimatedLine 组件,表示正在动画化的当前线条。当线条完成动画后,它们将移动到 StaticLines 集合中。
我们需要做一些工作来将这个静态视图转换为动画表示。
目前,该组件接受一个 turtle 属性和一个 drawCommands 属性。turtle 属性是乌龟的当前位置,前提是所有绘图命令都已经绘制完成。
在我们新的动画绘图中,我们仍然将 drawCommands 视为一组要执行的命令。但不是依赖于 turtle 属性来告诉我们乌龟的位置,我们将乌龟的 当前 位置存储为组件状态。我们将逐条指令通过 drawCommands 数组,每次一个指令,并在动画过程中更新乌龟组件状态。一旦所有指令都完成,乌龟组件状态将匹配最初为 turtle 属性设置的值。
乌龟始终从 0,0 坐标以 0 角度开始。
我们需要跟踪哪些命令已经被动画化。我们将创建另一个组件状态变量 animatingCommandIndex,以表示当前正在动画化的数组项的索引。
我们从索引 0 开始动画。一旦该命令被动画化,我们就将索引增加 1,移动到下一个命令,并对其动画化。这个过程会重复进行,直到我们达到数组的末尾。
这种设计意味着用户可以在动画运行时在提示符中输入新的 drawCommands。组件将确保在离开的点重新绘制带有动画的图形。
最后,有两种类型的绘图命令:drawLine 和 rotate。以下是一些将在 drawCommands 数组中出现的命令示例:
{
drawCommand: "drawLine",
id: 123,
x1: 100,
y1: 100,
x2: 200,
y2: 100
}
{
drawCommand: "rotate",
id: 234,
previousAngle: 0,
newAngle: 90
}
每种类型的动画都需要不同的处理方式。例如,当乌龟旋转时,AnimatedLine 组件将被隐藏。
大概就是这样。我们将遵循以下方法:
-
从构建
AnimatedLine组件开始 -
在
Drawing中创建一个useEffect钩子,调用window.requestAnimationFrame函数来动画化drawLine命令 -
当添加新指令时取消
drawLine命令的动画 -
添加乌龟旋转的动画
让我们从 AnimatedLine 组件开始。
构建一个动画线条组件
在本节中,我们将创建一个新的 AnimatedLine 组件。
此组件本身不包含动画逻辑,而是从动画线条的起点绘制到当前乌龟位置的一条线。因此,它需要两个属性:commandToAnimate,这将是之前显示的 drawLine 命令结构之一,以及包含位置的 turtle 属性。
让我们开始:
-
创建一个新的文件,
test/AnimatedLine.test.js,并使用以下导入和describe块设置初始化它。注意包括horizontalLine的样本指令定义:import React from "react"; import ReactDOM from "react-dom"; import { initializeReactContainer, render, element, } from "./reactTestExtensions"; import { AnimatedLine } from "../src/AnimatedLine"; import { horizontalLine } from "./sampleInstructions"; const turtle = { x: 10, y: 10, angle: 10 }; describe("AnimatedLine", () => { beforeEach(() => { initializeReactContainer(); }); const renderSvg = (component) => render(<svg>{component}</svg>); const line = () => element("line"); }); -
现在添加第一个测试,用于检查线的起始位置:
it("draws a line starting at the x1,y1 co-ordinate of the command being drawn", () => { renderSvg( <AnimatedLine commandToAnimate={horizontalLine} turtle={turtle} /> ); expect(line()).not.toBeNull(); expect(line().getAttribute("x1")).toEqual( horizontalLine.x1.toString() ); expect(line().getAttribute("y1")).toEqual( horizontalLine.y1.toString() ); }); -
创建一个新的文件,
src/AnimatedLine.js,并通过以下实现使测试通过:import React from "react"; export const AnimatedLine = ({ commandToAnimate: { x1, y1 } }) => ( <line x1={x1} y1={y1} /> ); -
接下来是下一个测试。在这个测试中,我们明确设置海龟值,以便清楚地看到预期值来自何处:
it("draws a line ending at the current position of the turtle", () => { renderSvg( <AnimatedLine commandToAnimate={horizontalLine} turtle={{ x: 10, y: 20 }} /> ); expect(line().getAttribute("x2")).toEqual("10"); expect(line().getAttribute("y2")).toEqual("20"); }); -
为了使其通过,我们只需要在线元素上设置
x2和y2属性,从海龟那里拉取这些值:export const AnimatedLine = ({ commandToAnimate: { x1, y1 }, turtle: { x, y } }) => ( <line x1={x1} y1={y1} x2={x} y2={y} /> ); -
然后我们需要两个测试来设置
strokeWidth和stroke属性:it("sets a stroke width of 2", () => { renderSvg( <AnimatedLine commandToAnimate={horizontalLine} turtle={turtle} /> ); expect( line().getAttribute("stroke-width") ).toEqual("2"); }); it("sets a stroke color of black", () => { renderSvg( <AnimatedLine commandToAnimate={horizontalLine} turtle={turtle} /> ); expect( line().getAttribute("stroke") ).toEqual("black"); }); -
通过添加这两个属性来完成组件:
export const AnimatedLine = ({ commandToAnimate: { x1, y1 }, turtle: { x, y } }) => ( <line x1={x1} y1={y1} x2={x} y2={y} strokeWidth="2" stroke="black" /> );
这就完成了 AnimatedLine 组件。
接下来,是时候将其添加到 Drawing 中了,通过将 commandToAnimate 属性设置为当前正在动画化的线条,并使用 requestAnimationFrame 来改变 turtle 属性的位置。
使用 requestAnimationFrame 进行动画
在本节中,你将结合使用 useEffect 钩子和 window.requestAnimationFrame 来调整 AnimatedLine 和 Turtle 的位置。
window.requestAnimationFrame 函数用于动画视觉属性。例如,你可以用它在一个给定的时间段内,比如 2 秒内,将一条线的长度从 0 单位增加到 200 单位。
为了使这工作,你提供一个回调,该回调将在下一个重绘间隔运行。当调用时,该回调提供了当前的动画时间:
const myCallback = time => {
// animating code here
};
window.requestAnimationFrame(myCallback);
如果你已知动画的开始时间,你可以计算出已过的动画时间,并使用这个时间来计算动画属性的当前值。
浏览器可以以非常高的刷新率调用你的回调,例如每秒 60 次。因为这些非常小的时间间隔,你的更改看起来像是一个平滑的动画。
注意,浏览器只为每个请求的帧调用一次你的回调。这意味着你有责任重复调用 requestAnimationFrame 函数,直到动画时间达到你定义的结束时间,如下例所示。浏览器负责仅在屏幕需要重绘时调用你的回调:
let startTime;
let endTimeMs = 2000;
const myCallback = time => {
if (startTime === undefined) startTime = time;
const elapsed = time - startTime;
// ... modify visual state here ...
if (elapsed < endTimeMs) {
window.requestAnimationFrame(myCallback);
}
};
// kick off the first animation frame
window.requestAnimationFrame(myCallback);
随着我们进入本节,你会看到如何使用这个来修改组件状态(例如 AnimatedLine 的位置),这会导致你的组件重新渲染。
让我们从 Redux 存储中移除现有的海龟值开始——我们不再使用这个值,而是依赖于从 drawCommands 数组中计算出的海龟位置:
-
打开
test/Drawing.test.js并找到名为passes the turtle x, y and angle as props to Turtle的测试。用以下内容替换它:it("initially places the turtle at 0,0 with angle 0", () => { renderWithStore(<Drawing />); expect(Turtle).toBeRenderedWithProps({ x: 0, y: 0, angle: 0 }); }); -
现在,在
src/Drawing.js文件中,你可以通过替换useSelector调用,移除从 Redux 存储中提取的海龟值:const { drawCommands } = useSelector( ({ script }) => script ); -
我们将用一个新的状态变量替换现有的乌龟值。当我们开始移动乌龟的位置时,这将非常有用。首先,将
useState导入到src/Drawing.js中:import React, { useState } from "react"; -
然后,在
useSelector调用下方添加另一个useState调用。在此更改之后,你的测试应该可以通过:const [turtle, setTurtle] = useState({ x: 0, y: 0, angle: 0 }); -
在
test/Drawing.test.js中,在describe块的beforeEach中模拟requestAnimationFrame函数:beforeEach(() => { ... jest .spyOn(window, "requestAnimationFrame"); }); -
将以下新的
describe块和测试添加到现有describe块的底部,在现有describe块内部(因此它是嵌套的)。它定义了一个初始状态horizontalLineDrawn,它只有一条线——这条线在sampleInstructions文件中定义。测试表明我们期望在组件挂载时调用requestAnimationFrame:describe("movement animation", () => { const horizontalLineDrawn = { script: { drawCommands: [horizontalLine], turtle: { x: 0, y: 0, angle: 0 }, }, }; it("invokes requestAnimationFrame when the timeout fires", () => { renderWithStore(<Drawing />, horizontalLineDrawn); expect(window.requestAnimationFrame).toBeCalled(); }); }); -
要使这个测试通过,打开
src/Drawing.js并首先导入useEffect钩子:import React, { useState, useEffect } from "react"; -
然后,将新的
useEffect钩子添加到Drawing组件中。在return语句 JSX 上方添加以下三行:export const Drawing = () => { ... useEffect(() => { requestAnimationFrame(); }, []); return ... }; -
由于我们现在处于
useEffect的领域,任何导致组件状态更新的操作都必须在act块内发生。这包括任何触发的动画帧,我们即将触发一些。因此,回到test/Drawing.test.js,现在添加act导入:import { act } from "react-dom/test-utils"; -
我们还需要导入
AnimatedLine,因为在下一个测试中,我们将断言我们渲染了它。添加以下导入,以及其间谍设置,如下所示:import { AnimatedLine } from "../src/AnimatedLine"; jest.mock("../src/AnimatedLine", () => ({ AnimatedLine: jest.fn( () => <div id="AnimatedLine" /> ), })); -
requestAnimationFrame的调用需要一个handler函数作为参数。然后浏览器将在下一个动画帧期间调用此函数。对于下一个测试,我们将检查当计时器第一次触发时,乌龟是否位于第一条线的起点。我们需要定义一个新的辅助函数来完成这个任务,即triggerRequestAnimationFrame。在浏览器环境中,这个调用会自动发生,但在我们的测试中,我们扮演浏览器的角色,并在代码中触发它。正是这个调用必须被act函数调用包裹,因为我们的处理程序将导致组件状态改变:const triggerRequestAnimationFrame = time => { act(() => { const mock = window.requestAnimationFrame.mock const lastCallFirstArg = mock.calls[mock.calls.length - 1][0] lastCallFirstArg(time); }); }; -
现在,我们准备好编写动画周期的测试了。第一个是一个简单的测试:在时间零时,乌龟位置被放置在线的起点。如果你检查
test/sampleInstructions.js中的定义,你会看到horizontalLine从位置100,100开始:it("renders an AnimatedLine with turtle at the start position when the animation has run for 0s", () => { renderWithStore(<Drawing />, horizontalLineDrawn); triggerRequestAnimationFrame(0); expect(AnimatedLine).toBeRenderedWithProps({ commandToAnimate: horizontalLine, turtle: { x: 100, y: 100, angle: 0 } }); });
使用乌龟位置进行动画
记住,AnimatedLine组件从drawLine指令的起始位置绘制到当前乌龟位置。然后,这个乌龟位置被动画化,这产生了AnimatedLine实例长度增长直到找到drawLine指令的终点位置的效果:
-
使这个测试通过将是一个小小的“大爆炸”。首先,按照所示扩展
useEffect。我们定义了两个变量,commandToAnimate和isDrawingLine,我们使用它们来确定是否应该进行动画。isDrawingLine测试是必要的,因为一些现有的测试根本不会向组件发送任何绘图命令,在这种情况下commandToAnimate将是null。另一个测试将一个未知类型的命令传递到组件中,如果我们尝试从中提取x1和y1,它也会崩溃。这就是为什么需要调用isDrawLineCommand的原因——这是一个已经在文件顶部定义好的函数:const commandToAnimate = drawCommands[0]; const isDrawingLine = commandToAnimate && isDrawLineCommand(commandToAnimate); useEffect(() => { const handleDrawLineFrame = time => { setTurtle(turtle => ({ ...turtle, x: commandToAnimate.x1, y: commandToAnimate.y1, })); }; if (isDrawingLine) { requestAnimationFrame(handleDrawLineFrame); } }, [commandToAnimate, isDrawingLine]);
使用函数式更新设置器
这段代码使用了 setTurtle 的 函数式更新 变体,它接受一个函数而不是一个值。当新的状态值依赖于旧值时,会使用这种形式的设置器。使用这种形式的设置器意味着乌龟不需要在 useEffect 的依赖列表中,并且不会导致 useEffect 钩子重置自己。
-
到目前为止,我们还没有渲染
AnimatedLine,这正是我们的测试所期望的。现在让我们修复这个问题。首先,添加导入:import { AnimatedLine } from "./AnimatedLine"; -
在
StaticLines的 JSX 下方插入此代码。此时,你的测试应该可以通过:<AnimatedLine commandToAnimate={commandToAnimate} turtle={turtle} /> -
我们需要进一步的测试来确保在没有动画线条时不会渲染
AnimatedLine。按照所示添加下一个测试,但不要将其添加到movement animation块中;相反,将其放入父上下文中:it("does not render AnimatedLine when not moving", () => { renderWithStore(<Drawing />, { script: { drawCommands: [] } }); expect(AnimatedLine).not.toBeRendered(); }); -
通过将
AnimatedLine组件用三元运算符包裹来实现这个过渡。如果isDrawingLine为假,我们简单地返回null:{isDrawingLine ? ( <AnimatedLine commandToAnimate={commandToAnimate} turtle={turtle} /> : null} -
我们已经处理了 第一个 动画帧应该做什么;现在让我们编写 下一个 动画帧的代码。在下面的测试中,有 两个 调用
triggerRequestAnimationFrame。第一个用于表示动画已经开始;第二个允许我们移动。我们需要第一个调用(时间索引为0)来标记动画开始的时间:it("renders an AnimatedLine with turtle at a position based on a speed of 5px per ms", () => { renderWithStore(<Drawing />, horizontalLineDrawn); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(250); expect(AnimatedLine).toBeRenderedWithProps({ commandToAnimate: horizontalLine, turtle: { x: 150, y: 100, angle: 0 } }); });
使用动画持续时间来计算移动的距离
当浏览器调用 handleDrawLineFrame 函数时,会传递一个时间参数。这是动画的当前持续时间。乌龟以恒定的速度移动,因此知道持续时间可以让我们计算出乌龟的位置。
-
为了实现这个过渡,首先,我们需要定义几个函数。滚动到
src/Drawing.js的顶部,直到你看到isDrawLineCommand的定义,然后在那里添加这两个新的定义。distance和movementSpeed函数用于计算动画的持续时间:const distance = ({ x1, y1, x2, y2 }) => Math.sqrt( (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1) ); const movementSpeed = 5; -
现在我们可以计算动画的持续时间;按照所示修改
useEffect:useEffect(() => { let duration; const handleDrawLineFrame = time => { setTurtle(...); }; if (isDrawingLine) { duration = movementSpeed * distance(commandToAnimate); requestAnimationFrame(handleDrawLineFrame); } }, [commandToAnimate, isDrawingLine]); -
通过将
duration声明为useEffect块中的第一行,该变量在requestAnimationFrame处理程序的作用域内,以便读取它来计算距离。为此,我们取经过的时间并将其除以总持续时间:useEffect(() => { let duration; const handleDrawLineFrame = time => { const { x1, x2, y1, y2 } = commandToAnimate; setTurtle(turtle => ({ ...turtle, x: x1 + ((x2 - x1) * (time / duration)), y: y1 + ((y2 - y1) * (time / duration)), })); }; if (isDrawingLine) { ... } }, [commandToAnimate, isDrawingLine]); -
我们取得了很大的进展!在之前的测试中,我们假设起始时间是
0,但实际上,浏览器可以给我们任何时间作为起始时间(它给出的时间被称为时间原点)。因此,让我们确保我们的计算对于非零起始时间也是有效的。添加以下测试:it("calculates move distance with a non-zero animation start time", () => { const startTime = 12345; renderWithStore(<Drawing />, horizontalLineDrawn); triggerRequestAnimationFrame(startTime); triggerRequestAnimationFrame(startTime + 250); expect(AnimatedLine).toBeRenderedWithProps({ commandToAnimate: horizontalLine, turtle: { x: 150, y: 100, angle: 0 } }); }); -
通过引入
start和elapsed时间,实现这个过渡,如下所示:useEffect(() => { let start, duration; const handleDrawLineFrame = time => { if (start === undefined) start = time; const elapsed = time - start; const { x1, x2, y1, y2 } = commandToAnimate; setTurtle(turtle => ({ ...turtle, x: x1 + ((x2 - x1) * (elapsed / duration)), y: y1 + ((y2 - y1) * (elapsed / duration)), })); }; if (isDrawingLine) { ... } }, [commandToAnimate, isDrawingLine]); -
我们需要确保我们的组件在达到持续时间之前重复调用
requestAnimationFrame。到那时,线条应该已经完全绘制。在这个测试中,我们触发了三个动画帧,并期望requestAnimationFrame被调用了三次:it("invokes requestAnimationFrame repeatedly until the duration is reached", () => { renderWithStore(<Drawing />, horizontalLineDrawn); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(250); triggerRequestAnimationFrame(500); expect( window.requestAnimationFrame.mock.calls ).toHaveLength(3); }); -
为了实现这个过渡,我们需要确保
handleDrawLineFrame在运行时触发另一个requestAnimationFrame。然而,我们只应该在持续时间到达之前这样做。通过以下条件将setTurtle和requestAnimationFrame调用包装起来,以实现这个过渡:const handleDrawLineFrame = (time) => { if (start === undefined) start = time; if (time < start + duration) { const elapsed = time - start; const { x1, x2, y1, y2 } = commandToAnimate; setTurtle(...); requestAnimationFrame(handleDrawLineFrame); } }; -
对于下一个测试,我们将检查当一条线“完成”绘制后,如果还有下一条线,我们将继续绘制下一条(如果没有,则停止)。在刚刚实现的
describe块下方添加一个新的describe块,并添加以下测试。第二个时间戳500是在horizontalLine绘制所需的时间之后,因此AnimatedLine应该显示verticalLine:describe("after animation", () => { it("animates the next command", () => { renderWithStore(<Drawing />, { script: { drawCommands: [horizontalLine, verticalLine] } }); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(500); expect(AnimatedLine).toBeRenderedWithProps( expect.objectContaining({ commandToAnimate: verticalLine, }) ); }); }); -
为了实现这个过渡,我们需要引入一个指向当前正在动画化的命令的指针。这个指针将从
0索引开始,每次动画完成后都会递增。在组件顶部添加以下新的状态变量:const [ animatingCommandIndex, setAnimatingCommandIndex ] = useState(0); -
将
commandToAnimate常量更新为使用这个新变量:const commandToAnimate = drawCommands[animatingCommandIndex]; -
在
handleDrawLineFrame中的条件语句中添加一个else子句来增加值:if (time < start + duration) { ... } else { setAnimatingCommandIndex( animatingCommandIndex => animatingCommandIndex + 1 ); } -
对于最后的测试,我们想要确保只有之前已经动画化的命令被发送到
StaticLines。当前正在动画化的线条将由AnimatedLine渲染,而尚未动画化的线条根本不应被渲染:it("places line in StaticLines", () => { renderWithStore(<Drawing />, { script: { drawCommands: [horizontalLine, verticalLine] } }); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(500); expect(StaticLines).toBeRenderedWithProps({ lineCommands: [horizontalLine] }); }); -
为了实现这个过渡,将
lineCommands更新为只包含drawCommands中直到当前animatingCommandIndex值的部分:const lineCommands = drawCommands .slice(0, animatingCommandIndex) .filter(isDrawLineCommand); -
虽然最新的测试现在会通过,但现有的测试
sends only line commands to StaticLines现在会失败。由于我们最新的测试覆盖了基本相同的功能,你现在可以安全地删除那个测试了。
如果你运行应用程序,你现在将能够看到线条在屏幕上被动画化。
在下一节中,我们将确保当用户同时输入多个命令时,动画表现良好。
使用cancelAnimationFrame取消动画
我们编写的 useEffect 钩子在其依赖列表中有 commandToAnimate 和 isDrawingLine。这意味着当这两个值中的任何一个更新时,useEffect 钩子将被销毁并重新启动。但还有其他情况下我们想要取消动画。其中一种情况是当用户重置他们的屏幕时。
如果用户点击 重置 按钮时,命令正在动画化,我们不想让当前的动画帧继续。我们想要清理它。
现在让我们为这个功能添加一个测试:
-
在
test/Drawing.test.js的底部添加以下测试:it("calls cancelAnimationFrame on reset", () => { renderWithStore(<Drawing />, { script: { drawCommands: [horizontalLine] } }); renderWithStore(<Drawing />, { script: { drawCommands: [] } }); expect(window.cancelAnimationFrame).toBeCalledWith( cancelToken ); }); -
你还需要更改
beforeEach块,使requestAnimationFrame模拟返回一个虚拟的取消令牌,并为cancelAnimationFrame函数添加一个新的模拟:describe("Drawing", () => { const cancelToken = "cancelToken"; beforeEach(() => { ... jest .spyOn(window, "requestAnimationFrame") .mockReturnValue(cancelToken); jest.spyOn(window, "cancelAnimationFrame"); }); }); -
为了使测试通过,更新
useEffect钩子以存储requestAnimationFrame函数在调用时返回的cancelToken值。然后从useEffect钩子返回一个清理函数,该函数使用该令牌取消下一个请求的帧。这个函数将在 React 销毁钩子时被调用:useEffect(() => { let start, duration, cancelToken; const handleDrawLineFrame = time => { if (start === undefined) start = time; if (time < start + duration) { ... cancelToken = requestAnimationFrame( handleDrawLineFrame ); } else { ... } }; if (isDrawingLine) { duration = movementSpeed * distance(commandToAnimate); cancelToken = requestAnimationFrame( handleDrawLineFrame ); } return () => { cancelAnimationFrame(cancelToken); } }); -
最后,我们不想在没有设置
cancelToken的情况下运行这个清理。如果当前没有绘制线条,则不会设置令牌。我们可以通过以下测试来证明这一点,你应该现在添加它:it("does not call cancelAnimationFrame if no line animating", () => { jest.spyOn(window, "cancelAnimationFrame"); renderWithStore(<Drawing />, { script: { drawCommands: [] } }); renderWithStore(<React.Fragment />); expect( window.cancelAnimationFrame ).not.toHaveBeenCalled(); });
卸载组件
这个测试展示了如何在 React 中模拟组件的卸载,这仅仅是通过在测试组件的位置渲染 <React.Fragment /> 来实现的。当发生这种情况时,React 将卸载你的组件。
-
为了使测试通过,只需将返回的清理函数包裹在一个条件语句中:
return () => { if (cancelToken) { cancelAnimationFrame(cancelToken); } };
那就是我们为动画化 drawLine 命令需要做的所有事情。接下来是旋转海龟。
变化动画行为
我们现在可以看到线条和海龟正在很好地动画化。然而,我们仍然需要处理第二种类型的绘制命令:旋转。当海龟旋转到新的角度时,它将以恒定的速度移动。一个完整的旋转应该需要 1 秒来完成,我们可以用这个来计算旋转的持续时间。例如,四分之一旋转将需要 0.25 秒来完成。
在最后一节中,我们从一个测试开始,检查我们是否调用了 requestAnimationFrame。这次,这个测试不是必需的,因为我们已经通过绘制线条证明了相同的设计。我们可以直接进入更复杂的测试,使用之前相同的 triggerRequestAnimationFrame 辅助函数。
让我们更新 Drawing 以使海龟坐标动画化:
-
在
Drawing的describe块底部添加以下测试。在另一个嵌套的describe块中创建它,位于你刚刚编写的最后一个测试下面。这个测试遵循我们绘制线条测试的相同原则:我们触发两个动画帧,一个在0毫秒,一个在500毫秒,然后期望旋转发生。除了 角度 之外,还测试了 x 和 y 坐标;这是为了确保我们继续传递这些值:describe("rotation animation", () => { const rotationPerformed = { script: { drawCommands: [rotate90] }, }; it("rotates the turtle", () => { renderWithStore(<Drawing />, rotationPerformed); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(500); expect(Turtle).toBeRenderedWithProps({ x: 0, y: 0, angle: 90 }); }); }); -
移动到
src/Drawing.js,首先在isDrawLineCommand定义下方添加isRotateCommand的定义:const isRotateCommand = command => command.drawCommand === "rotate"; -
在
Drawing组件中,在isDrawingLine定义下方添加一个新的常量,isRotating:const isRotating = commandToAnimate && isRotateCommand(commandToAnimate); -
在
useEffect钩子中,在handleDrawLineFrame定义下方定义一个新的旋转处理器,handleRotationFrame。为了这个测试的目的,它不需要做太多,只需将角度设置为新的值:const handleRotationFrame = time => { setTurtle(turtle => ({ ...turtle, angle: commandToAnimate.newAngle })); }; -
我们可以利用这个来在旋转命令动画时调用
requestAnimationFrame。修改useEffect钩子的最后部分,使其看起来如下,确保你将isRotating添加到依赖列表中。更改后测试应该通过:useEffect(() => { ... if (isDrawingLine) { ... } else if (isRotating) { requestAnimationFrame(handleRotationFrame); } }, [commandToAnimate, isDrawingLine, isRotating]); -
让我们添加一个测试来获取持续时间并在我们的计算中使用它。这基本上与上一个测试相同,但具有不同的持续时间,因此预期的旋转也不同:
it("rotates part-way at a speed of 1s per 180 degrees", () => { renderWithStore(<Drawing />, rotationPerformed); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(250); expect(Turtle).toBeRenderedWithProps({ x: 0, y: 0, angle: 45 }); }); -
为了使这个通过,首先,我们需要定义
rotateSpeed。你可以在movementSpeed定义下方添加这个定义:const rotateSpeed = 1000 / 180; -
接下来,更新
useEffect处理器底部的条件,以计算rotate命令的持续时间:} else if (isRotating) { duration = rotateSpeed * Math.abs( commandToAnimate.newAngle - commandToAnimate.previousAngle ); requestAnimationFrame(handleRotationFrame); } -
更新
handleRotationFrame以使用持续时间来计算一个成比例的角度来移动:const handleRotationFrame = (time) => { const { previousAngle, newAngle } = commandToAnimate; setTurtle(turtle => ({ ...turtle, angle: previousAngle + (newAngle - previousAngle) * (time / duration) })); }; -
就像
handleDrawLineFrame一样,我们需要确保我们可以处理除0之外的其他起始时间。添加以下测试:it("calculates rotation with a non-zero animation start time", () => { const startTime = 12345; renderWithStore(<Drawing />, rotationPerformed); triggerRequestAnimationFrame(startTime); triggerRequestAnimationFrame(startTime + 250); expect(Turtle).toBeRenderedWithProps({ x: 0, y: 0, angle: 45 }); }); -
通过添加
start和elapsed变量来使那个通过。之后,测试应该通过。你会注意到handleDrawLineFrame和handleRotationFrame之间的相似性:const handleRotationFrame = (time) => { if (start === undefined) start = time; const elapsed = time - start; const { previousAngle, newAngle } = commandToAnimate; setTurtle(turtle => ({ ...turtle, angle: previousAngle + (newAngle - previousAngle) * (elapsed / duration) })); }; -
添加一个测试以确保我们反复调用
requestAnimationFrame。这个测试与用于drawLine处理器的测试相同,但现在我们传递的是rotate90命令。请确保测试属于嵌套上下文,这样你可以确保没有名称冲突:it("invokes requestAnimationFrame repeatedly until the duration is reached", () => { renderWithStore(<Drawing />, rotationPerformed); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(250); triggerRequestAnimationFrame(500); expect( window.requestAnimationFrame.mock.calls ).toHaveLength(3); }); -
为了使这个通过,我们需要做几件事情。首先,我们需要像修改
handleDrawLineFrame一样修改handleRotationFrame,通过添加一个条件,在持续时间到达后停止动画。其次,我们还需要填写条件的第二部分,以设置动画完成后乌龟的位置:const handleRotationFrame = (time) => { if (start === undefined) start = time; if (time < start + duration) { ... } else { setTurtle(turtle => ({ ...turtle, angle: commandToAnimate.newAngle })); } };
处理结束动画状态
这个else子句在drawLine处理器中不是必要的,因为一旦线条动画完成,它将被传递到StaticLines,渲染所有线条的全长。但这与旋转角度不同:它保持固定,直到下一次旋转。因此,我们需要确保它处于正确的最终值。
-
我们还有一个最后的测试。一旦动画完成,我们需要增加当前动画命令。与上一节中的相同测试一样,这个测试应该位于我们刚刚使用的
describe块之外,因为它有不同的测试设置:it("animates the next command once rotation is complete", async () => { renderWithStore(<Drawing />, { script: { drawCommands: [rotate90, horizontalLine] } }); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(500); triggerRequestAnimationFrame(0); triggerRequestAnimationFrame(250); expect(Turtle).toBeRenderedWithProps({ x: 150, y: 100, angle: 90 }); }); -
为了使那个通过,将
setNextCommandToAnimate的调用添加到else条件中:} else { setTurtle(turtle => ({ ...turtle, angle: commandToAnimate.newAngle })); setAnimatingCommandIndex( (animatingCommandToIndex) => animatingCommandToIndex + 1 ); }
就这样!如果你还没有这样做,运行应用尝试一下是值得的。
摘要
在本章中,我们探讨了如何测试requestAnimationFrame浏览器 API。这不是一个简单的过程,如果你希望完全覆盖,需要编写多个测试。
尽管如此,你已经看到为屏幕上的动画编写自动化测试是完全可能的。这样做的好处是,复杂的生产代码通过测试得到了完全的文档记录。
在下一章中,我们将探讨如何将 WebSocket 通信添加到 Spec Logo 中。
练习
-
更新
Drawing,以便当用户使用重置按钮清除屏幕时,重置海龟位置。 -
我们的测试有很多重复,因为重复调用
triggerRequestAnimationFrame。通过创建一个名为triggerAnimationSequence的包装函数来简化调用方式,该函数接受一个帧时间数组,并为这些时间中的每一个调用triggerRequestAnimationFrame。 -
加载现有脚本(例如,在启动时)将花费很长时间来动画化所有指令,粘贴代码片段也是如此。添加一个跳过动画按钮,可以用来跳过所有排队的动画。
-
确保在动画进行时撤销按钮能正确工作。
第十六章:与 WebSocket 一起工作
在本章中,我们将探讨如何在我们的 React 应用程序中测试驱动 WebSocket API。我们将使用它来构建一种教学机制,其中一个人可以共享他们的屏幕,其他人可以观看他们输入命令。
WebSocket API 并不简单。它使用了许多不同的回调,并要求以特定的顺序调用函数。为了使事情更复杂,我们将在 Redux saga 中这样做:这意味着我们需要做一些工作来将回调 API 转换为可以与生成器函数一起工作的 API。
因为这是最后一章介绍单元测试技术,所以它做了一些不同的处理。它不遵循严格的 TDD 过程。本章的起点是我们已经完成的功能框架。你需要完善这些功能,专注于学习 WebSocket 连接的测试驱动技术。
本章涵盖了以下主题:
-
设计 WebSocket 交互
-
测试 WebSocket 连接
-
使用 redux-saga 进行流式事件
-
更新应用程序
到本章结束时,你将学会 WebSocket API 是如何工作的,以及它的单元测试机制。
技术要求
本章的代码文件可以在以下位置找到:
设计 WebSocket 交互
在本节中,我们将首先描述共享工作流程,然后我们将查看支持此工作流程的新 UI 元素,最后我们将介绍你在本章中将进行的代码更改。
共享工作流程
一个共享会话由一个演示者和零个或多个观察者组成。这意味着应用程序可以处于两种模式之一:演示或观看。
当应用程序处于演示模式时,所有观看者都将收到你的 Spec Logo 指令的副本。所有指令都通过 WebSocket 发送到服务器。
当你的应用程序处于观看模式时,WebSocket 从服务器接收指令并立即将它们输出到你的屏幕上。
发送到和从服务器发送的消息是简单的 JSON 格式数据结构。
图 16.1显示了界面在演示模式下的外观。

图 16.1 – 演示模式下的 Spec Logo
那么,它是如何工作的?
-
演示者点击开始共享按钮。服务器收到以下消息:
{ type: "START_SHARING" } -
服务器随后响应会话的 ID:
{ status: "STARTED", id: 123 } -
这个 ID 用于构建一个 URL,该 URL 以观看模式打开应用程序,例如:
http://localhost:3000/index.xhtml?watching=123 -
URL 可以在任何地方共享和打开。当应用程序以这种模式打开时,应用程序立即向服务器打开一个 WebSocket 并发送此消息:
{ type: "START_WATCHING", id: 123 } -
可以有任意数量的连接的监视器。在初始连接时,演示者已经发送给服务器的任何命令都将被重新播放。这些命令是演示者发送给任何类型为
SUBMIT_EDIT_LINE的 Redux 动作的命令,并且像这样通过 WebSocket 发送到服务器:{ type: "NEW_ACTION", innerAction: { type: "SUBMIT_EDIT_LINE", text: "forward 10\n" } } -
当服务器接收到演示者的 WebSocket 动作时,它会立即将动作转发给每个订阅者:
{ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" } } -
服务器还将接收到的动作存储在历史记录中,因此新加入者可以重新播放这些动作。
-
当监视器完成时,他们只需关闭浏览器窗口,他们的 WebSocket 将关闭。
-
当演示者完成演示后,他们可以关闭浏览器窗口或点击停止共享按钮。这将关闭连接,服务器清除其内部状态。
新的 UI 元素
这就是您将在 UI 中找到的内容;所有这些都已经为您构建好了:
-
一个新的菜单按钮来切换共享的开关。它被命名为开始共享,但一旦开始共享,名称将切换到停止共享。
-
当 Spec Logo 处于共享模式时,菜单按钮栏中会出现一条新消息。它包含一个消息,告诉用户他们是正在演示还是观看。如果他们正在演示,它还包含一个他们可以复制并与他人分享的 URL。
-
您现在可以通过在 Spec Logo URL 的末尾添加搜索参数
?watching=<id>来以观看模式启动应用程序。
接下来,让我们看看您将要填充的 Redux saga 的框架。
分离 saga
在文件 src/middleware/sharingSagas.js 中存在一个新的 Redux 中间件。这个文件包含两部分。首先,有一个名为 duplicateForSharing 的中间件函数。这是一个过滤器,为我们提供了所有希望广播的动作:
export const duplicateForSharing =
store => next => action => {
if (action.type === "SUBMIT_EDIT_LINE") {
store.dispatch({
type: "SHARE_NEW_ACTION",
innerAction: action,
});
}
return next(action);
};
其次,还有根 saga 本身。它分为四个更小的函数,这些是我们将在本章中填充的函数,使用测试驱动的方法:
export function* sharingSaga() {
yield takeLatest("TRY_START_WATCHING", startWatching);
yield takeLatest("START_SHARING", startSharing);
yield takeLatest("STOP_SHARING", stopSharing);
yield takeLatest("SHARE_NEW_ACTION", shareNewAction);
}
在设计完成足够多的部分后,让我们开始实施。
测试驱动 WebSocket 连接
我们首先填充那个第一个函数,startSharing。当接收到 START_SHARING 动作时,将调用此函数。该动作是在用户点击开始共享按钮时触发的:
-
打开
test/middleware/sharingSagas.test.js文件,并在顶部添加以下导入:import { storeSpy, expectRedux } from "expect-redux"; import { act } from "react-dom/test-utils"; import { configureStore } from "../../src/store"; -
在文件底部,添加一个新的
describe块及其设置。我们将将其分为几个步骤:首先,设置 Redux 存储和 WebSocket 间谍。因为window.WebSocket是一个构造函数,我们使用mockImplementation来模拟它:describe("sharingSaga", () => { let store; let socketSpyFactory; beforeEach(() => { store = configureStore([storeSpy]); socketSpyFactory = spyOn(window, "WebSocket"); socketSpyFactory.mockImplementation(() => { return {}; }); }); });
理解 WebSocket API
WebSocket 构造函数返回一个具有send和close方法的对象,以及onopen、onmessage、onclose和onerror事件处理程序。在我们构建测试套件时,我们将实现这些中的大多数。如果你想了解更多关于 WebSocket API 的信息,请查看本章末尾的进一步阅读部分。
-
接下来,因为我们还关心窗口位置,所以我们还需要模拟
window.location对象。由于在 JSDOM 环境中这是一个只读对象,我们需要使用Object.defineProperty函数来覆盖它。这有点笨拙,所以你可能更喜欢将其提取到自己的函数中,并给它一个好名字。将以下内容添加到相同的beforeEach块中:beforeEach(() => { ... Object.defineProperty(window, "location", { writable: true, value: { protocol: "http:", host: "test:1234", pathname: "/index.xhtml", }, }); }); -
在嵌套的
describe块中添加第一个测试。这检查我们是否使用正确的 URL 建立 WebSocket 连接:describe("START_SHARING", () => { it("opens a websocket when starting to share", () => { store.dispatch({ type: "START_SHARING" }); expect(socketSpyFactory).toBeCalledWith( "ws://test:1234/share" ); }); }); -
通过在文件
src/middleware/sharingSagas.js中填充startSharing生成器函数来使测试通过(记住,已经为你创建了骨架)。这段代码构建了一个带有正确主机的新 URL:function* startSharing() { const { host } = window.location; new WebSocket(`ws://${host}/share`); } -
在测试套件中,修改 WebSocket 模拟实现以添加一个内部间谍,
sendSpy,当用户在 WebSocket 上调用send函数时会被调用。我们还需要存储创建的socketSpy函数的引用,以便我们可以调用用户附加到其事件处理程序(如onopen和onmessage)的回调。这将在我们编写下一个测试时变得有意义:let sendSpy; let socketSpy; beforeEach(() => { sendSpy = jest.fn(); socketSpyFactory = spyOn(window, "WebSocket"); socketSpyFactory.mockImplementation(() => { socketSpy = { send: sendSpy, }; return socketSpy; }); ... } -
当使用回调驱动的 API 进行测试驱动开发时,例如 WebSocket API,模拟每个回调的确切行为非常重要。我们将从
onopen回调开始。下一个测试将触发它,就像服务器发送消息一样。因为我们期望在接收到onopen时发生一系列异步操作,所以我们可以使用async act等待操作完成。因此,在下一个测试之前,定义以下函数,该函数触发onopen回调:const notifySocketOpened = async () => { await act(async () => { socketSpy.onopen(); }); };
使用 act 与非 React 代码
async act函数即使在处理 React 组件时也能帮助我们,因为它在返回之前会等待 promise 执行。
-
然后,我们可以在下一个测试中使用
notifySocketOpened函数,该函数检查当客户端接收到START_SHARING动作时,它会立即将其转发到服务器:it("dispatches a START_SHARING action to the socket", async () => { store.dispatch({ type: "START_SHARING" }); await notifySocketOpened(); expect(sendSpy).toBeCalledWith( JSON.stringify({ type: "START_SHARING" }) ); }); -
要使测试通过,首先将
startSharing函数中的现有代码提取到一个名为openWebsocket的新函数中。然后,添加代码来调用一个Promise对象,当在套接字上接收到onopen消息时,它会解析。这段代码相当困难——我们正在构建一个Promise对象,专门用于将基于回调的 API 转换为可以使用生成器yield关键字的东西:const openWebSocket = () => { const { host } = window.location; const socket = new WebSocket(`ws://${host}/share`); return new Promise(resolve => { socket.onopen = () => { resolve(socket) }; }); }; -
现在,你可以在
startSharing中使用那个openWebSocket函数。之后,你的测试应该会通过:function* startSharing() { const presenterSocket = yield openWebSocket(); presenterSocket.send( JSON.stringify({ type: "START_SHARING" }) ); } -
下一个测试将从服务器通过套接字向应用发送消息。为此,我们需要一个辅助函数来模拟发送消息并等待清空当前任务队列。将此辅助函数添加到
test/middleware/sharingSagas.test.js中,在notifySocketOpened下方:const sendSocketMessage = async message => { await act(async () => { socketSpy.onmessage({ data: JSON.stringify(message) }); }); }; -
添加下一个测试,使用你刚刚定义的函数:
it("dispatches an action of STARTED_SHARING with a URL containing the id that is returned from the server", async () => { store.dispatch({ type: "START_SHARING" }); await notifySocketOpened(); await sendSocketMessage({ type: "UNKNOWN", id: 123, }); return expectRedux(store) .toDispatchAnAction() .matching({ type: "STARTED_SHARING", url: "http://test:1234/index.xhtml?watching=123", }); }); -
为了使这个通过,我们将从套接字读取消息。一旦完成,我们可以将检索到的信息传递回 Redux 存储。首先在
src/middleware/sharingSagas.js顶部添加以下新函数:const receiveMessage = (socket) => new Promise(resolve => { socket.onmessage = evt => { resolve(evt.data) }; }); const buildUrl = (id) => { const { protocol, host, pathname } = window.location; return ( `${protocol}//${host}${pathname}?watching=${id}` ); }; -
现在,你可以使用这些函数来完成
startSharing的实现:function* startSharing() { const presenterSocket = yield openWebSocket(); presenterSocket.send( JSON.stringify({ type: "START_SHARING" }) ); const message = yield receiveMessage( presenterSocket ); const presenterSessionId = JSON.parse(message).id; yield put({ type: "STARTED_SHARING", url: buildUrl(presenterSessionId), }); }
开始共享的过程到此结束。现在让我们处理用户点击停止共享按钮时会发生什么:
-
在
describe块内部创建一个名为sharingSaga的辅助函数,如下所示。这个函数将系统状态更改为STARTED_SHARING:const startSharing = async () => { store.dispatch({ type: "START_SHARING" }); await notifySocketOpened(); await sendSocketMessage({ type: "UNKNOWN", id: 123, }); }; -
更新间谍以包括一个
closeSpy变量,我们以与sendSpy相同的方式设置它:let closeSpy; beforeEach(() => { sendSpy = jest.fn(); closeSpy = jest.fn(); socketSpyFactory = spyOn(window, "WebSocket"); socketSpyFactory.mockImplementation(() => { socketSpy = { send: sendSpy, close: closeSpy, }; return socketSpy; }); ... }); -
在新的嵌套上下文中添加第一个测试。它首先开始共享,然后分发
STOP_SHARING动作:describe("STOP_SHARING", () => { it("calls close on the open socket", async () => { await startSharing(); store.dispatch({ type: "STOP_SHARING" }); expect(closeSpy).toBeCalled(); }); }); -
为了使这个通过,我们需要填写
stopSharing生成器函数。首先,然而,我们需要获取在startSharing函数中创建的套接字。将这个变量提取到顶级命名空间中:let presenterSocket; function* startSharing() { presenterSocket = yield openWebSocket(); ... } -
然后,在
stopSharing函数中添加以下定义。然后你可以运行你的测试,一切应该通过;然而,如果你正在运行整个测试套件(使用npm test),你会看到几个控制台错误出现。这些错误来自MenuButtons测试套件中的一个测试——我们将在稍后的更新应用部分修复这个问题:function* stopSharing() { presenterSocket.close(); }
仅在一个测试套件中运行测试
为了避免看到控制台错误,请记住你可以选择仅使用命令npm test test/middleware/sharingSagas.test.js为此测试套件运行测试。
-
接下来进行下一个测试,我们想要更新 Redux 存储以包含新的
stopped状态。这将允许我们移除用户开始共享时出现的消息:it("dispatches an action of STOPPED_SHARING", async () => { await startSharing(); store.dispatch({ type: "STOP_SHARING" }); return expectRedux(store) .toDispatchAnAction() .matching({ type: "STOPPED_SHARING" }); }); -
这是一个简单的单行代码来使其通过:
function* stopSharing() { presenterSocket.close(); yield put({ type: "STOPPED_SHARING" }); }
接下来是向服务器广播从演示者发出的动作:
-
创建一个新的嵌套
describe块,包含以下测试:describe("SHARE_NEW_ACTION", () => { it("forwards the same action on to the socket", async () => { const innerAction = { a: 123 }; await startSharing(123); store.dispatch({ type: "SHARE_NEW_ACTION", innerAction, }); expect(sendSpy).toHaveBeenLastCalledWith( JSON.stringify({ type: "NEW_ACTION", innerAction, }) ); }); }); -
通过填写以下内容来使
shareNewAction函数通过:const shareNewAction = ({ innerAction }) => { presenterSocket.send( JSON.stringify({ type: "NEW_ACTION", innerAction, }) ); } -
添加下一个测试,该测试检查如果用户没有演示,则不会发送任何动作:
it("does not forward if the socket is not set yet", () => { store.dispatch({ type: "SHARE_NEW_ACTION" }); expect(sendSpy).not.toBeCalled(); });
在异步环境中使用 not.toBeCalled
这个测试有一个微妙的问题。尽管它将帮助你添加到软件的设计中,但它作为回归测试的实用性略低,因为它可能会导致假阳性。这个测试保证测试的开始和结束之间没有发生任何事情,但它对之后发生的事情没有任何保证。这就是异步环境的本质。
-
使这个测试通过只是简单地添加一段代码的判断条件:
function* shareNewAction({ innerAction } ) { if (presenterSocket) { presenterSocket.send( JSON.stringify({ type: "NEW_ACTION", innerAction, }) ); } } -
我们也不希望当用户停止共享时共享动作——所以让我们添加这个功能:
it("does not forward if the socket has been closed", async () => { await startSharing(); socketSpy.readyState = WebSocket.CLOSED; store.dispatch({ type: "SHARE_NEW_ACTION" }); expect(sendSpy.mock.calls).toHaveLength(1); });
WebSocket 规范
前一个测试中的常量WebSocket.CLOSED和以下代码中的常量WebSocket.OPEN在 WebSocket 规范中定义。
-
将测试文件顶部移动,并定义以下两个常量,在导入下面。这是因为当我们监视 WebSocket 构造函数时,我们会覆盖这些值。因此,我们需要将它们重新添加。首先保存真实值:
const WEB_SOCKET_OPEN = WebSocket.OPEN; const WEB_SOCKET_CLOSED = WebSocket.CLOSED; -
更新您的监视器,在
WebSocket被模拟后设置这些常量。当我们在这里时,让我们也将套接字的默认readyState设置为WebSocket.OPEN,这样其他测试就不会失败:socketSpyFactory = jest.spyOn(window, "WebSocket"); Object.defineProperty(socketSpyFactory, "OPEN", { value: WEB_SOCKET_OPEN }); Object.defineProperty(socketSpyFactory, "CLOSED", { value: WEB_SOCKET_CLOSED }); socketSpyFactory.mockImplementation(() => { socketSpy = { send: sendSpy, close: closeSpy, readyState: WebSocket.OPEN, }; return socketSpy; }); -
最后,回到生产代码中,通过检查
readyState是否为WebSocket.OPEN来使测试通过,这并不完全符合测试的指定,但足够好,可以使它通过:const shareNewAction = ({ innerAction }) => { if ( presenterSocket && presenterSocket.readyState === WebSocket.OPEN ) { presenterSocket.send( JSON.stringify({ type: "NEW_ACTION", innerAction, }) ); } }
这就是演示者的行为:我们已经通过测试驱动了onopen、onclose和onmessage回调。在实际应用中,您会希望对onerror回调执行相同的流程。
现在,让我们看看监视器的行为。
使用 redux-saga 进行事件流
在本节中,我们将重复很多相同的技巧。有两个新概念:首先,提取监视器 ID 的search参数,其次,使用eventChannel订阅onmessage回调。这用于从 WebSocket 持续地将消息流到 Redux 存储。
让我们从指定新的 URL 行为开始:
-
在
test/middleware/sharingSagas.test.js的底部写一个新的describe块,但仍然嵌套在主describe块中:describe("watching", () => { beforeEach(() => { Object.defineProperty(window, "location", { writable: true, value: { host: "test:1234", pathname: "/index.xhtml", search: "?watching=234" } }); }); it("opens a socket when the page loads", () => { store.dispatch({ type: "TRY_START_WATCHING" }); expect(socketSpyFactory).toBeCalledWith( "ws://test:1234/share" ); }); }); -
通过在您的生产代码中填写
startWatching函数来使其通过。您可以使用现有的openWebSocket函数:function* startWatching() { yield openWebSocket(); } -
在下一个测试中,我们将开始使用
search参数:it("does not open socket if the watching field is not set", () => { window.location.search = "?"; store.dispatch({ type: "TRY_START_WATCHING" }); expect(socketSpyFactory).not.toBeCalled(); }); -
通过使用
URLSearchParams对象提取search参数来使其通过:function* startWatching() { const sessionId = new URLSearchParams( window.location.search.substring(1) ).get("watching"); if (sessionId) { yield openWebSocket(); } } -
在我们编写下一个测试之前,添加以下辅助函数,该函数模拟真实 WebSocket 上将要发生的动作,确保
onopen被调用:const startWatching = async () => { await act(async () => { store.dispatch({ type: "TRY_START_WATCHING" }); socketSpy.onopen(); }); }; -
当一个新的观察会话开始时,我们需要重置用户的输出,使其为空:
it("dispatches a RESET action", async () => { await startWatching(); return expectRedux(store) .toDispatchAnAction() .matching({ type: "RESET" }); }); -
通过添加一个
put函数调用使其通过:function* startWatching() { const sessionId = new URLSearchParams( location.search.substring(1) ).get("watching"); if (sessionId) { yield openWebSocket(); yield put({ type: "RESET" }); } } -
接下来,我们需要向服务器发送一条消息,包括我们希望观察的会话 ID:
it("sends the session id to the socket with an action type of START_WATCHING", async () => { await startWatching(); expect(sendSpy).toBeCalledWith( JSON.stringify({ type: "START_WATCHING", id: "234", }) ); }); -
我们已经从上一节中设置了监视器,所以这是一个快速修复:
function* startWatching() { const sessionId = new URLSearchParams( window.location.search.substring(1) ).get("watching"); if (sessionId) { const watcherSocket = yield openWebSocket(); yield put({ type: "RESET" }); watcherSocket.send( JSON.stringify({ type: "START_WATCHING", id: sessionId, }) ); } } -
下一个测试告诉 Redux 存储我们已经开始观察。这样,React UI 就可以向用户显示一条消息,告诉他们他们已经连接:
it("dispatches a STARTED_WATCHING action", async () => { await startWatching(); return expectRedux(store) .toDispatchAnAction() .matching({ type: "STARTED_WATCHING" }); }); -
通过添加一个新的
put调用使其通过,如下所示:function* startWatching() { ... if (sessionId) { ... yield put({ type: "STARTED_WATCHING" }); } } -
现在是最大的一个。我们需要添加允许我们从服务器接收多条消息并读取它们的行为:
it("relays multiple actions from the websocket", async () => { const message1 = { type: "ABC" }; const message2 = { type: "BCD" }; const message3 = { type: "CDE" }; await startWatching(); await sendSocketMessage(message1); await sendSocketMessage(message2); await sendSocketMessage(message3); await expectRedux(store) .toDispatchAnAction() .matching(message1); await expectRedux(store) .toDispatchAnAction() .matching(message2); await expectRedux(store) .toDispatchAnAction() .matching(message3); socketSpy.onclose(); });
长测试
你可能会认为有一个只处理一条消息的小测试会很有帮助。然而,对于多条消息来说,这并不能帮助我们,因为我们需要为多条消息使用一个完全不同的实现,正如你将在下一步看到的那样。
-
我们将使用
eventChannel函数来完成这个任务。它的用法与之前将回调转换为可以使用yield等待的操作的Promise对象用法相似。在使用Promise对象时,我们在回调收到时调用resolve。在使用eventChannel时,当回调收到时,我们调用emitter(END)。这一点的意义将在下一步变得明显:import { eventChannel, END } from "redux-saga"; const webSocketListener = socket => eventChannel(emitter => { socket.onmessage = emitter; socket.onclose = () => emitter(END); return () => { socket.onmessage = undefined; socket.onclose = undefined; }; });
理解 eventChannel 函数
来自 redux-saga 的 eventChannel 函数是一个用于消费发生在 Redux 之外的事件流的机制。在上一个例子中,WebSocket 提供了事件流。当被调用时,eventChannel 会调用提供的函数来初始化通道,然后每次收到事件时都必须调用提供的 emmitter 函数。在我们的情况下,我们直接将消息传递给 emmitter 函数而不做任何修改。当 WebSocket 关闭时,我们传递特殊的 END 事件来通知 redux-saga 将不再接收更多事件,从而允许它关闭通道。
-
现在,你可以使用
websocketListener函数创建一个通道,我们可以通过循环反复从该通道获取事件。这个循环需要被try语句包围。当达到emitter(END)指令时,将调用finally块。创建一个新的生成器函数来完成这个任务,如下所示:function* watchUntilStopRequest(chan) { try { while (true) { let evt = yield take(chan); yield put(JSON.parse(evt.data)); } } finally { } }; -
通过在
startWatching中调用这两个函数来将webSocketListener函数和watchUntilStopRequest生成器函数链接起来。完成这一步后,你的测试应该通过:function* startWatching() { ... if (sessionId) { ... yield put({ type: "STARTED_WATCHING" }); const channel = yield call( webSocketListener, watcherSocket ); yield call(watchUntilStopRequest(channel); } } -
最后的测试是向 Redux 存储器发出警报,表明我们已经停止了监听,这样它就可以从 React UI 中删除出现的消息:
it("dispatches a STOPPED_WATCHING action when the connection is closed", async () => { await startWatching(); socketSpy.onclose(); return expectRedux(store) .toDispatchAnAction() .matching({ type: "STOPPED_WATCHING" }); }); -
通过在
watchUntilStopRequest中的finally块中添加这一行代码来实现这一点:try { ... } finally { yield put({ type: "STOPPED_WATCHING" }); }
你现在已经完成了整个故事:你的应用程序现在正在接收事件,你也看到了如何使用 eventChannel 函数来监听消息流。
剩下的工作就是将这个功能整合到我们的 React 组件中。
更新应用程序
我们已经完成了构建 sagas 的工作,但我们在应用程序的其余部分只需要做一些调整。
MenuButtons 组件已经功能完整,但我们需要更新测试以正确地测试中间件,有两种方式:首先,我们必须模拟 WebSocket 构造函数,其次,我们需要在应用程序启动时立即触发一个 TRY_START_WATCHING 动作:
-
打开
test/MenuButtons.test.js并首先导入act函数。我们需要这个函数来等待我们的 socket saga 动作:import { act } from "react-dom/test-utils"; -
接下来,找到名为
sharing button的describe块,并插入以下beforeEach块,它与你在 saga 测试中使用的模拟构造函数类似:describe("sharing button", () => { let socketSpyFactory; let socketSpy; beforeEach(() => { socketSpyFactory = jest.spyOn( window, "WebSocket" ); socketSpyFactory.mockImplementation(() => { socketSpy = { close: () => {}, send: () => {}, }; return socketSpy; }); }); }); -
接下来,在相同的
describe块中,添加以下notifySocketOpened实现方式。这与 saga 测试中的notifySocketOpened实现方式不同,因为它同时调用了onopen和onmessage,并附带一个示例消息。所有这些对于startSharingsaga 正确运行都是必要的:它模拟了 WebSocket 的打开,然后服务器发送第一条消息,这将导致发送STARTED_SHARING消息:const notifySocketOpened = async () => { const data = JSON.stringify({ id: 1 }); await act(async () => { socketSpy.onopen(); socketSpy.onmessage({ data }); }); }; -
我们现在可以使用这个来更新导致控制台错误的测试。这个测试的描述是
当点击停止共享时,触发 STOP_SHARING 动作。为了避免错误,我们必须调整几行。首先,我们发送一个START_SHARING消息,而不是STARTED_SHARING消息。然后,我们使用notifySocketOpened来模拟服务器对套接字打开的响应。这将触发 saga 发送STARTED_SHARING事件,导致MenuButtons改变为名为STOP_SHARING的事件被发送:it("dispatches an action of STOP_SHARING when stop sharing is clicked", async () => { renderWithStore(<MenuButtons />); dispatchToStore({ type: "START_SHARING" }); await notifySocketOpened(); click(buttonWithLabel("Stop sharing")); return expectRedux(store) .toDispatchAnAction() .matching({ type: "STOP_SHARING" }); }); -
测试通过后,更新
src/index.js以在应用首次加载时调用TRY_START_WATCHING动作:const store = configureStoreWithLocalStorage(); store.dispatch({ type: "TRY_START_WATCHING" }); ReactDOM .createRoot(document.getElementById("root")) .render( <Provider store={store}> <App /> </Provider);
你现在可以运行应用并尝试它。以下是一个你可以尝试的手动测试:
-
在浏览器窗口中打开一个会话并点击 开始共享。
-
右键单击出现的链接,并选择在新窗口中打开它。
-
将你的两个窗口移动到并排的位置。
-
在原始窗口中输入一些命令,例如
forward 100和right 90。你应该看到命令已更新。 -
现在,在原始窗口中点击 停止共享。你应该看到共享消息从两个屏幕上消失。
这就涵盖了测试驱动 WebSocket 的内容。
摘要
在本章中,我们介绍了如何针对 WebSocket API 进行测试。
你已经看到了如何模拟 WebSocket 构造函数,以及如何测试其 onopen、onclose 和 onmessage 回调。
你还看到了如何使用 Promise 对象将回调转换为可以在生成器函数中产生的对象,以及如何使用 eventChannel 将事件流发送到 Redux 存储。
在下一章中,我们将探讨如何使用 Cucumber 测试来推动共享功能的改进。
练习
你可以添加哪些测试来确保套接字错误能够优雅地处理?
进一步阅读
WebSocket 规范:
第四部分 – 使用 Cucumber 进行行为驱动开发
这一部分是关于使用 Cucumber 测试进行 行为驱动开发(BDD)。前三个部分侧重于在组件级别构建 Jest 单元测试,而这一部分则关注在 系统 级别编写测试——你也可以将这些视为端到端测试。目标是展示 TDD 工作流程如何应用于单元测试之外,并且可以被整个团队使用,而不仅仅是开发者。
最后,我们以讨论 TDD 如何在更广泛的测试领域中适用以及如何继续您的 TDD 之旅的建议来结束本书。
本部分包括以下章节:
-
第十七章, 编写您的第一个 Cucumber 测试
-
第十八章, 由 Cucumber 测试引导添加功能
-
第十九章, 在更广泛的测试领域中理解 TDD
第十七章:编写您的第一个 Cucumber 测试
测试驱动开发主要是一个面向开发者的过程。有时,客户和产品所有者也想看到自动化测试的结果。不幸的是,作为 TDD 基础的谦逊的单元测试太低级,对非开发者没有帮助。这就是行为驱动开发(BDD)理念出现的地方。
BDD 测试有一些特性,使它们与您迄今为止看到的单元测试有所不同:
-
它们是端到端测试,在整个系统中运行。
-
它们是用自然语言而不是代码编写的,既可被非编码者理解,也可被编码者理解。
-
他们避免提及内部机制,而是专注于系统的外部行为。
-
测试定义描述了自身(与单元测试不同,您需要编写一个与代码匹配的测试描述)。
-
语法设计是为了确保您的测试被编写为示例,并且作为行为的离散规范。
BDD 工具与 TDD 和单元测试的比较
您在这本书中迄今为止看到的 TDD 风格将测试(在大多数情况下)视为指定行为的示例。此外,我们的测试始终遵循安排-行动-断言(AAA)模式。然而,请注意,单元测试工具如 Jest 并不强迫您以这种方式编写测试。
这就是为什么存在 BDD 工具的原因之一:在您指定系统行为时,迫使您非常明确。
本章介绍了两个新的软件包:Cucumber 和 Puppeteer。
我们将使用 Cucumber 来构建我们的 BDD 测试。Cucumber 是一个存在于许多不同编程环境中的系统,包括 Node.js。它由一个测试运行器组成,该运行器运行包含在特性文件中的测试。特性是用一种称为Gherkin的普通英语编写的。当 Cucumber 运行您的测试时,它将这些特性文件转换为函数调用;这些函数调用是用 JavaScript 支持脚本编写的。
由于 Cucumber 有自己的测试运行器,因此它不使用 Jest。然而,我们将在一些测试中利用 Jest 的expect包。
Cucumber 不是编写系统测试的唯一方式
另一个流行的测试库是 Cypress,它可能更适合您和/或您的团队。Cypress 强调结果的视觉呈现。我倾向于避免使用它,因为它的 API 与行业标准测试模式非常不同,这增加了开发者需要具备的知识量。Cucumber 是跨平台的,测试看起来与您在这本书中看到的标准单元测试非常相似。
Puppeteer与 JSDOM 库执行类似的功能。然而,虽然 JSDOM 在 Node.js 环境中实现了一个假的 DOM API,Puppeteer 则使用真实的网络浏览器 Chromium。在这本书中,我们将以无头模式使用它,这意味着你不会在屏幕上看到应用程序的运行;但如果你愿意,你也可以关闭无头模式。Puppeteer 附带了许多附加功能,例如截图功能。
跨浏览器测试
如果你想测试你应用程序的跨浏览器支持,你可能更倾向于查看像 Selenium 这样的替代方案,这本书中没有涵盖 Selenium。然而,当为 Selenium 编写测试时,相同的测试原则同样适用。
本章涵盖了以下主题:
-
将 Cucumber 和 Puppeteer 集成到你的代码库中
-
编写你的第一个 Cucumber 测试
-
使用数据表进行设置
到本章结束时,你将很好地了解 Cucumber 测试是如何构建和运行的。
技术要求
本章的代码文件可以在以下位置找到:
将 Cucumber 和 Puppeteer 集成到你的代码库中
让我们向我们的项目添加必要的包:
-
首先,安装我们需要的包。除了 Cucumber 和 Puppeteer,我们还将引入
@babel/register,这将使我们能够在支持文件中使用 ES6 功能:$ npm install --save-dev @cucumber/cucumber puppeteer $ npm install --save-dev @babel/register -
接下来,创建一个名为
cucumber.json的新文件,并包含以下内容。这有两个设置;publishQuiet关闭了在运行测试时出现的许多噪音,而requireModule在运行测试之前连接@babel/register:{ "default": { "publishQuiet": true, "requireModule": [ "@babel/register" ] } } -
创建一个名为
features的新文件夹。这个文件夹应该与src和test处于同一级别。 -
在其中创建一个名为
features/support的文件夹。
你现在可以使用以下命令运行测试:
$ npx cucumber-js
你会看到如下输出:
0 scenarios
0 steps
0m00.000s
在本章和下一章中,缩小你正在运行的测试范围可能会有所帮助。你可以通过提供测试运行器的场景文件名和起始行号来运行单个场景:
$ npx cucumber-js features/drawing.feature:5
这就是使用 Cucumber 和 Puppeteer 设置的全部内容——现在是我们编写测试的时候了。
编写你的第一个 Cucumber 测试
在本节中,你将为我们已经构建的 Spec Logo 应用程序的一部分构建一个 Cucumber 功能文件。
关于 Gherkin 代码样本的警告
如果你正在阅读这本书的电子版,在复制粘贴功能定义时要小心。你可能会发现代码中插入了 Cucumber 无法识别的额外换行符。在运行测试之前,请检查你粘贴的代码片段,并删除任何不应该存在的换行符。
让我们开始吧!
- 在运行任何 Cucumber 测试之前,确保通过运行
npm run build来更新你的构建输出是很重要的。你的 Cucumber 规范将针对dist目录中构建的代码运行,而不是src目录中的源代码。
利用 package.json 脚本的优势
你也可以修改 package.json 脚本来在运行 Cucumber 规范之前调用构建,或者以监视模式运行 webpack。
-
创建一个名为
features/sharing.feature的新文件,并输入以下文本。一个功能有一个名称和简短描述,以及一系列按顺序列出的场景。我们现在只从一个场景开始:Feature: Sharing A user can choose to present their session to any number of other users, who observe what the presenter is doing via their own browser. Scenario: Observer joins a session Given the presenter navigated to the application page And the presenter clicked the button 'startSharing' When the observer navigates to the presenter's sharing link Then the observer should see a message saying 'You are now watching the session'
Gherkin 语法
Given, When, 和 Then 与你的 Jest 测试的 Arrange, Act, 和 Assert 阶段类似:given 所有这些条件都为真,when 我执行这个操作,then 我期望所有这些事情发生。
理想情况下,你每个场景中只有一个 When 子句。
你会注意到,我已经将 Given 子句写成过去时,When 子句写成现在时,而 Then 子句中有一个“should”。
-
在命令行中键入
npx cucumber-js来运行该功能。你会看到一条警告信息,如下面的代码块所示。Cucumber 在第一个Given...语句处停止处理,因为它找不到与之对应的 JavaScript 支持函数。在警告信息中,Cucumber 有助于为你提供了定义的起点:? Given the presenter navigated to the application page Undefined. Implement with the following snippet: Given('the presenter navigated to the application page', function () { // Write code here that turns the phrase above // into concrete actions return 'pending'; }); -
让我们按照它建议的去做。创建一个名为
features/support/sharing.steps.js的文件,并添加以下代码。它定义了一个步骤定义,该定义调用 Puppeteer 的 API 来启动一个新的浏览器,然后打开一个新页面,然后导航到提供的 URL。步骤定义描述与我们的测试场景中的 Given 子句相匹配。 -
async关键字的第二个参数。这是对 Cucumber 在其建议函数定义中告诉我们的内容的补充。我们需要async,因为 Puppeteer 的 API 调用都返回 promises,我们需要对它们进行await:import { Given, When, Then } from "@cucumber/cucumber"; import puppeteer from "puppeteer"; const port = process.env.PORT || 3000; const appPage = `http://localhost:${port}/index.xhtml`; Given( "the presenter navigated to the application page", async function () { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(appPage); } );
匿名函数,而非 lambda 表达式
你可能想知道为什么我们定义匿名函数(async function (...) { ... })而不是 lambda 表达式(async (...) => { ... })。这使我们能够利用匿名函数发生的隐式上下文绑定。如果我们使用 lambda 表达式,我们就需要在它们上调用 .bind(this)。
-
再次运行你的测试。Cucumber 现在指定了下一个需要工作的子句。对于这个子句,
并且演示者点击了按钮 'startSharing',我们需要访问我们在上一个步骤中刚刚创建的page对象。要做到这一点,我们需要访问所谓的World对象,它是当前场景中所有子句的上下文。我们必须现在构建它。创建features/support/world.js文件并添加以下内容。它定义了两个方法,setPage和getPage,允许我们在世界中保存多个页面。对于这个测试来说,能够保存多个页面是非常重要的,因为我们至少有两个页面——演示者页面和观察者页面:import { setWorldConstructor } from "@cucumber/cucumber"; class World { constructor() { this.pages = {}; } setPage(name, page) { this.pages[name] = page; } getPage(name) { return this.pages[name]; } }; setWorldConstructor(World); -
我们现在可以在我们的步骤定义中使用
setPage和getPage函数。我们的方法将是首先从第一个步骤定义——我们在步骤 3中编写的——调用setPage,然后使用getPage在后续步骤中检索它。现在修改第一个步骤定义,包括对setPage的调用,如下面的代码块所示:Given( "the presenter navigated to the application page", async function () { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(appPage); this.setPage("presenter", page); } ); -
接下来进行下一步,
演示者点击了按钮 'startSharing',我们将通过使用Page.clickPuppeteer 函数来查找一个 ID 为startSharing的按钮来解决此问题。就像上一个测试一样,我们使用buttonId参数,这样这个步骤定义就可以在未来场景中用于其他按钮:Given( "the presenter clicked the button {string}", async function (buttonId) { await this.getPage( "presenter" ).click(`button#${buttonId}`); } ); -
下一步,
观察者导航到演示者的分享链接,就像第一步那样,我们想要打开一个新的浏览器。不同的是,这是为观察者准备的,我们首先需要查找要遵循的路径。路径是通过演示者在开始搜索时显示的 URL 给出的。我们可以使用Page.$eval函数来查找:When( "the observer navigates to the presenter's sharing link", async function () { await this.getPage( "presenter" ).waitForSelector("a"); const link = await this.getPage( "presenter" ).$eval("a", a => a.getAttribute("href")); const url = new URL(link); const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); this.setPage("observer", page); } );
步骤定义重复
在我们的步骤定义之间正在积累一些重复。稍后,我们将把这个共性提取到它自己的函数中。
-
最后一个步骤定义再次使用
Page.$evalPuppeteer 函数,这次是为了找到一个 HTML 节点并将其转换为一个普通的 JavaScript 对象。然后我们使用expect函数以正常方式测试该对象。确保将列出的import语句放置在文件顶部:import expect from "expect"; ... Then( "the observer should see a message saying {string}", async function (message) { const pageText = await this.getPage( "observer" ).$eval("body", e => e.outerHTML); expect(pageText).toContain(message); } ); -
使用
npx cucumber-js运行你的测试。你的测试运行输出将如下所示。虽然我们的步骤定义是完整的,但似乎有些不对劲:1) Scenario: Observer joins a session ✖ Given the presenter navigated to the application page Error: net::ERR_CONNECTION_REFUSED at http://localhost:3000/index.xhtml -
虽然我们的应用已经加载,但我们仍然需要启动服务器来处理我们的请求。为此,将以下两个函数添加到
features/support/world.js中的World类,包括在文件顶部添加对应用的import语句。startServer函数相当于我们在server/src/server.js中启动服务器的方式。closeServer函数停止服务器,但在这样做之前,它会关闭所有 Puppeteer 浏览器实例。这样做很重要,因为当调用close方法时,服务器不会杀死任何活跃的 websocket 连接。我们需要确保它们首先关闭;否则,服务器将无法停止:
在同一项目中启动服务器
我们很幸运,所有代码都位于同一个项目中,因此可以在同一个进程中启动。如果你的代码库分布在多个项目中,你可能会发现自己需要处理多个进程。
import { app } from "../../server/src/app";
class World {
...
startServer() {
const port = process.env.PORT || 3000;
this.server = app.listen(port);
}
closeServer() {
Object.keys(this.pages).forEach(name =>
this.pages[name].browser().close()
);
this.server.close();
}
}
-
利用这些新函数通过
Before和After钩子。创建一个新文件features/support/hooks.js并添加以下代码:import { Before, After } from "@cucumber/cucumber"; Before(function() { this.startServer(); }); After(function() { this.closeServer(); }); -
运行
npx cucumber-js命令并观察输出。你的场景现在应该通过(如果没有通过,请再次检查你是否已经运行了npm run build):> npx cucumber-js ...... 1 scenario (1 passed) 4 steps (4 passed) 0m00.848s -
让我们回到代码中,整理一下重复的部分。我们将提取一个名为
browseToPageFor的函数,并将其放置在我们的World类中。打开features/support/world.js并在类底部添加以下方法:async browseToPageFor(role, url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); this.setPage(role, page); } -
此外,将 Puppeteer 的
import语句从features/support/sharing.steps.js移动到features/support/world.js:import puppeteer from "puppeteer"; -
最后,用
browseToPageFor重新编写两个导航步骤:Given( "the presenter navigated to the application page", async function () { await this.browseToPageFor("presenter", appPage); } ); When( "the observer navigates to the presenter's sharing link", async function () { await this.getPage( "presenter" ).waitForSelector("a"); const link = await this.getPage( "presenter" ).$eval("a", a => a.getAttribute("href")); const url = new URL(link); await this.browseToPageFor("observer", url); } );
在浏览器内和通过控制台日志进行观察
我们编写的测试以无头模式运行 Puppeteer,这意味着不会启动实际的 Chrome 浏览器窗口。如果你想看到这种情况发生,可以通过修改启动命令(记住在之前的步骤定义中有两个)来关闭无头模式,如下所示:
const browser = await puppeteer.launch(
{ headless: false }
);
如果你正在使用控制台日志来协助调试,你需要提供另一个参数来将控制台输出重定向到 stdout:
const browser = await puppeteer.launch(
{ dumpio: true }
);
你现在已经使用 Cucumber 和 Puppeteer 编写了一个 BDD 测试。接下来,让我们看看一个更高级的 Cucumber 场景。
使用数据表进行设置
在本节中,我们将探讨 Cucumber 的一个有用的节省时间特性:数据表。我们将编写第二个场景,就像之前的场景一样,将已经通过 Spec Logo 的现有实现:
-
创建一个名为
features/drawing.feature的新功能文件,内容如下。它包含一系列使用 Logo 函数绘制正方形的指令。使用小的边长10,以确保动画快速完成:Feature: Drawing A user can draw shapes by entering commands at the prompt. Scenario: Drawing functions Given the user navigated to the application page When the user enters the following instructions at the prompt: | to drawsquare | | repeat 4 [ forward 10 right 90 ] | | end | | drawsquare | Then these lines should have been drawn: | x1 | y1 | x2 | y2 | | 0 | 0 | 10 | 0 | | 10 | 0 | 10 | 10 | | 10 | 10 | 0 | 10 | | 0 | 10 | 0 | 0 | -
第一个短语与我们的上一个步骤定义做的是同样的事情,只是我们将
presenter重命名为user。在这种情况下,使用更通用的名称是有意义的,因为演示者的角色对这个测试不再相关。我们可以使用World函数browseToPageFor来完成这个第一步。在共享功能中,我们使用了这个函数与一个包含要导航到的 URL 的appPage常量。现在让我们将这个常量拉入World。在features/support/world.js文件中,在World类之上添加以下常量:const port = process.env.PORT || 3000; -
将以下方法添加到
World类中:appPage() { return `http://localhost:${port}/index.xhtml`; } -
在
features/support/sharing.steps.js文件中,删除port和appPage的定义,并更新第一个步骤定义,如下所示:Given( "the presenter navigated to the application page", async function () { await this.browseToPageFor( "presenter", this.appPage() ); } ); -
是时候为用户页面创建一个新的步骤定义了。打开
features/support/drawing.steps.js文件并添加以下代码:import { Given, When, Then } from "@cucumber/cucumber"; import expect from "expect"; Given("the user navigated to the application page", async function () { await this.browseToPageFor( "user", this.appPage() ); } ); -
那么,关于带有数据表的第二行,我们的步骤定义应该是什么样子呢?让我们问问 Cucumber。运行
npx cucumber-js命令并查看输出。它给出了我们定义的起点:1) Scenario: Drawing functions ✔ Before # features/support/sharing.steps.js:5 ✔ Given the user navigated to the application page ? When the user enters the following instructions at the prompt: | to drawsquare | | repeat 4 [ forward 10 right 90 ] | | end | | drawsquare | Undefined. Implement with the following snippet: When('the user enters the following instructions at the prompt:', function (dataTable) { // Write code here that turns the phrase above // into concrete actions return 'pending'; } ); -
现在将建议的代码添加到
features/supports/drawing.steps.js文件中。如果你现在运行npx cucumber-js,你会注意到 Cucumber 成功地注意到步骤定义是挂起的:When( "the user enters the following instructions at the prompt:", function (dataTable) { // Write code here that turns the phrase above //into concrete actions return "pending"; } ); -
dataTable变量是一个具有raw()函数的DataTable对象,该函数返回一个数组的数组。外层数组代表每一行,内层数组代表每一行的列。在下一步定义中,我们希望将每一行都插入到编辑提示中。每一行后面应该跟着一个按下 Enter 键的操作。现在就创建它:When( "the user enters the following instructions at the prompt:", async function (dataTable) { for (let instruction of dataTable.raw()) { await this.getPage("user").type( "textarea", `${instruction}\n` ); } } ); -
最后一步需要我们查找具有正确属性值的行元素,并将它们与我们的第二个数据表中的值进行比较。以下代码正是这样做的。现在复制这段代码并运行你的测试,以确保它工作并且测试能够通过。所有详细点的解释将会随后提供:
Then("these lines should have been drawn:", async function(dataTable) { await this.getPage("user").waitForTimeout(2000); const lines = await this.getPage("user").$$eval( "line", lines => lines.map(line => { return { x1: parseFloat(line.getAttribute("x1")), y1: parseFloat(line.getAttribute("y1")), x2: parseFloat(line.getAttribute("x2")), y2: parseFloat(line.getAttribute("y2")) }; }) ); for (let i = 0; i < lines.length; ++i) { expect(lines[i].x1).toBeCloseTo( parseInt(dataTable.hashes()[i].x1) ); expect(lines[i].y1).toBeCloseTo( parseInt(dataTable.hashes()[i].y1) ); expect(lines[i].x2).toBeCloseTo( parseInt(dataTable.hashes()[i].x2) ); expect(lines[i].y2).toBeCloseTo( parseInt(dataTable.hashes()[i].y2) ); } } });
最后一个测试包含了一些值得深入研究的复杂性:
-
我们使用了
Page.waitForTimeout来等待 2 秒,这给了系统完成动画的时间。包含这样的超时并不是一个好的做法,但暂时它是可行的。我们将在下一章中探讨使其更具体的方法。 -
Page.$$eval函数类似于Page.$eval,但在底层返回一个数组,并且调用document.querySelector而不是document.querySelectorAll。 -
重要的是,我们所有的属性转换逻辑——从 HTML 行元素和属性移动到“纯”整数
x1、y1等值——都应该在Page.$$eval的transform函数内完成。这是因为 Puppeteer 会在$$eval调用完成后回收任何 DOM 节点对象。 -
我们的行值需要用
parseFloat来解析,因为我们所编写的requestAnimationFrame逻辑与整数端点不完全对齐——它们有非常微小的分数差异。 -
这也意味着我们需要使用
toBeCloseToJest 匹配器而不是toBe,这是因为我们之前描述的分数值差异所必需的。 -
最后,我们在这里使用
DataTable的hashes()函数来提取一个对象数组,该数组具有数据表中每列的一个键,基于我们在功能定义中提供的标题行。例如,我们可以调用hashes()[0].x1来提取第一行x1列的值。
继续使用 npx cucumber-js 运行你的测试。一切都应该通过。
你现在已经很好地理解了如何使用 Cucumber 数据表来制作更具说服力的 BDD 测试。
摘要
Cucumber 测试(以及一般的 BDD 测试)与我们在这本书的其余部分所编写的单元测试类似。它们专注于指定行为的 示例。它们应该使用真实的数据和数字作为测试一般概念的手段,就像我们在本章的两个例子中所做的那样。
BDD 测试与单元测试的不同之处在于,它们是系统测试(具有更广泛的测试范围)并且使用自然语言编写。
就像单元测试一样,在编写 BDD 测试时,找到简化代码的方法很重要。首要规则是尝试编写通用的 World 类或其它模块。我们已经在本章中看到了如何做到这一点的例子。
在下一章中,我们将使用 BDD 测试来驱动 Spec Logo 中新功能的实现。
第十八章:由 Cucumber 测试引导的特性
在上一章中,我们学习了编写 Cucumber 测试的基本元素以及如何使用 Puppeteer 操作我们的 UI。但我们还没有探讨这些技术如何融入更广泛的开发生成过程。在本章中,我们将实现一个新的应用程序功能,但首先从 Cucumber 测试开始。这些将作为验收测试,我们的(虚构的)产品负责人可以使用它来确定软件是否按要求工作。
验收测试
验收测试是一个产品负责人或客户可以使用来决定是否接受交付的软件的测试。如果它通过,他们接受软件。如果它失败,开发者必须回去调整他们的工作。
我们可以使用术语验收测试驱动开发(ATDD)来指代一个整个团队都可以参与的测试工作流程。将其视为类似于 TDD,但它是在更广泛的团队层面上进行的,产品负责人和客户都参与其中。使用 Cucumber 编写 BDD 测试是将 ATDD 引入团队的一种方式——但不是唯一的方式。
在本章中,我们将使用我们的 BDD 风格的 Cucumber 测试作为我们的验收测试。
想象一下,我们的产品负责人已经看到了我们构建Spec Logo所做的出色工作。他们注意到共享屏幕功能很好,但还可以添加一些功能:它应该给演讲者提供在开始共享之前重置其状态的选择,如下所示:

图 18.1 – 新的共享对话框
产品负责人为我们提供了一些目前为红色以供实施的 Cucumber 测试——包括步骤定义和生成代码。
本章涵盖了以下主题:
-
为对话框添加 Cucumber 测试
-
通过测试驱动生产代码修复 Cucumber 测试
-
避免在测试代码中使用超时
到本章结束时,你将看到更多关于 Cucumber 测试的示例以及它们如何作为团队工作流程的一部分被使用。你还将了解到如何避免在代码中使用特定的超时设置。
技术要求
本章的代码文件可以在以下位置找到:
为对话框添加 Cucumber 测试
在本节中,我们将添加一个新的 Cucumber 测试,它目前不会通过。
让我们先看看这个新功能:
-
打开
features/sharing.feature文件,看看你被给出的第一个特性。阅读步骤并尝试理解我们的产品负责人在描述什么。测试覆盖了很多行为——与我们的单元测试不同。它讲述了一个完整的故事:Scenario: Presenter chooses to reset current state when sharing Given the presenter navigated to the application page And the presenter entered the following instructions at the prompt: | forward 10 | | right 90 | And the presenter clicked the button 'startSharing' When the presenter clicks the button 'reset' And the observer navigates to the presenter's sharing link Then the observer should see no lines And the presenter should see no lines And the observer should see the turtle at x = 0, y = 0, angle = 0 And the presenter should see the turtle at x = 0, y = 0, angle = 0 -
第一个
the presenter navigated to the application page已经工作,如果你运行npx cucumber-js,你可以验证这一点。 -
下一个步骤
the presenter entered the following instructions at the prompt与上一章的一个步骤非常相似。我们本可以选择在这里提取共性,就像我们处理browseToPageFor函数那样;然而,我们将等待测试和实现完成后再进行重构。现在,我们只是复制代码。打开features/support/sharing.steps.js并在代码底部添加以下步骤定义:When( "the presenter entered the following instructions at the prompt:", async function(dataTable) { for (let instruction of dataTable.raw()) { await this.getPage("presenter").type( "textarea", `${instruction}\n` ); await this.getPage( "presenter" ).waitForTimeout(3500); } } ); -
接下来是
the presenter clicked the button 'startSharing'。在这之后出现的行是第一个npx cucumber-js,你将获得这个函数的模板代码。将模板代码复制并粘贴到你的步骤定义文件中,如下面的代码块所示:When( "the presenter clicks the button {string}", function (string) { // Write code here that turns the phrase above // into concrete actions return "pending"; } );
两个 When 语句
这个场景有两个 When 语句,这是不寻常的。就像你在 Act 阶段的单元测试一样,你通常只想有一个 When 语句。然而,由于此时有两个用户一起工作,为这两个用户有一个单一的操作是有意义的,所以我们将在这个场合让我们的产品所有者免责。
-
这个步骤定义与我们之前编写的非常相似。按照以下代码块所示填写函数。这里有一个新的
waitForSelector调用。这个调用在我们继续之前等待按钮出现在页面上,这给了对话框渲染的时间:When( "the presenter clicks the button {string}", async function ( buttonId ) { await this.getPage( "presenter" ).waitForSelector(`button#${buttonId}`); await this.getPage( "presenter" ).click(`button#${buttonId}`); } ); -
第二个
Then子句。第一个是the observer should see no lines;运行npx cucumber-js并复制 Cucumber 提供的模板函数,如下面的代码块所示:Then("the observer should see no lines", function () { // Write code here that turns the phrase above // into concrete actions return "pending"; }); -
对于这个步骤,我们想要断言页面上没有线元素:
Then( "the observer should see no lines", async function () { const numLines = await this.getPage( "observer" ).$$eval("line", lines => lines.length); expect(numLines).toEqual(0); } ); -
运行
npx cucumber-js,你应该会看到这个步骤通过了,下一个步骤非常相似。复制你刚才编写的步骤定义,并修改它以适用于演示者,如下面的代码块所示。同样,我们稍后可以清理重复的部分:Then( "the presenter should see no lines", async function () { const numLines = await this.getPage( "presenter" ).$$eval("line", lines => lines.length); expect(numLines).toEqual(0); } ); -
现在运行 Cucumber,你会看到这个步骤失败了;这是我们遇到的第一次失败。它指向我们需要在代码库中做出的具体更改:
✖ And the presenter should see no lines Error: expect(received).toEqual(expected) Expected value to equal: 0 Received: 1 -
由于我们已经遇到了一个红色步骤,我们现在可以回过头来开始编写代码,使其变为绿色。然而,因为我们只有两个几乎相同的子句需要完成,我将选择在继续之前完成这些定义。Cucumber 告诉我们应使用哪个模板函数,所以现在添加如下:
Then( "the observer should see the turtle at x = {int}, y = {int}, angle = {int}", function (int, int2, int3) { // Write code here that turns the phrase above // into concrete actions return "pending"; }); -
我们需要定义几个辅助函数,可以告诉我们海龟当前的 x、y 和角度值。我们需要这样做,因为我们只有 SVG
polygon元素,它使用points字符串和transform字符串来定位海龟。我们的辅助函数将把这些字符串转换回数字。作为提醒,以下是海龟初始的位置:<polygon points="-5,5, 0,-7, 5,5" fill="green" stroke-width="2" stroke="black" transform="rotate(90, 0, 0)" />
我们可以使用第一个points坐标来计算x和y,通过将第一个数字加 5,从第二个数字减 5。角度可以通过将旋转的第一个参数减去 90 来计算。创建一个名为features/support/turtle.js的新文件,然后添加以下两个定义:
export const calculateTurtleXYFromPoints = points => {
const firstComma = points.indexOf(",");
const secondComma = points.indexOf(
",",
firstComma + 1
);
return {
x:
parseFloat(
points.substring(0, firstComma)
) + 5,
y:
parseFloat(
points.substring(firstComma + 1, secondComma)
) - 5
};
};
export const calculateTurtleAngleFromTransform = (
transform
) => {
const firstParen = transform.indexOf("(");
const firstComma = transform.indexOf(",");
return (
parseFloat(
transform.substring(
firstParen + 1,
firstComma
)
) - 90
);
}
-
在
feature/sharing.steps.js中,更新步骤定义,如下面的代码块所示:Then( "the observer should see the turtle at x = {int}, y = {int}, angle = {int}", async function ( expectedX, expectedY, expectedAngle ) { await this.getPage( "observer" ).waitForTimeout(4000); const turtle = await this.getPage( "observer" ).$eval( "polygon", polygon => ({ points: polygon.getAttribute("points"), transform: polygon.getAttribute("transform") }) ); const position = calculateTurtleXYFromPoints( turtle.points ); const angle = calculateTurtleAngleFromTransform( turtle.transform ); expect(position.x).toBeCloseTo(expectedX); expect(position.y).toBeCloseTo(expectedY); expect(angle).toBeCloseTo(expectedAngle); } ); -
最后,按照以下方式为演示者重复此步骤定义:
Then( "the presenter should see the turtle at x = {int}, y = {int}, angle = {int}", async function ( expectedX, expectedY, expectedAngle ) { await this.getPage( "presenter" ).waitForTimeout(4000); const turtle = await this.getPage( "presenter" ).$eval( "polygon", polygon => ({ points: polygon.getAttribute("points"), transform: polygon.getAttribute("transform") }) ); const position = calculateTurtleXYFromPoints( turtle.points ); const angle = calculateTurtleAngleFromTransform( turtle.transform ); expect(position.x).toBeCloseTo(expectedX); expect(position.y).toBeCloseTo(expectedY); expect(angle).toBeCloseTo(expectedAngle); } );
那是第一个测试;现在,让我们继续到第二个场景:
-
我们第二个场景的大部分步骤定义已经实现;只有两个还没有:
Then these lines should have been drawn for the observer: | x1 | y1 | x2 | y2 | | 0 | 0 | 10 | 0 | And these lines should have been drawn for the presenter: | x1 | y1 | x2 | y2 | | 0 | 0 | 10 | 0 |
我们已经在features/support/drawing.steps.js中有一个与这两个非常相似的步骤定义。让我们将这个逻辑提取到一个单独的模块中,这样我们就可以重用它。创建一个名为features/support/svg.js的新文件,然后从绘图步骤定义中复制以下代码:
import expect from "expect";
export const checkLinesFromDataTable = page =>
return async function (dataTable) {
await this.getPage(page).waitForTimeout(2000);
const lines = await this.getPage(page).$$eval(
"line",
lines =>
lines.map(line => ({
x1: parseFloat(line.getAttribute("x1")),
y1: parseFloat(line.getAttribute("y1")),
x2: parseFloat(line.getAttribute("x2")),
y2: parseFloat(line.getAttribute("y2"))
}))
);
for (let i = 0; i < lines.length; ++i) {
expect(lines[i].x1).toBeCloseTo(
parseInt(dataTable.hashes()[i].x1)
);
expect(lines[i].y1).toBeCloseTo(
parseInt(dataTable.hashes()[i].y1)
);
expect(lines[i].x2).toBeCloseTo(
parseInt(dataTable.hashes()[i].x2)
);
expect(lines[i].y2).toBeCloseTo(
parseInt(dataTable.hashes()[i].y2)
);
}
};
-
在
features/support/drawing.steps.js中,修改这些行应该已经被绘制步骤定义,使其现在使用此函数:import { checkLinesFromDataTable } from "./svg"; Then( "these lines should have been drawn:", checkLinesFromDataTable("user") ); -
我们最新共享场景的两个新步骤定义现在很简单。在
features/support/sharing.steps.js中,添加以下import语句和步骤定义:import { checkLinesFromDataTable } from "./svg"; Then( "these lines should have been drawn for the presenter:", checkLinesFromDataTable("presenter") ); Then( "these lines should have been drawn for the observer:", checkLinesFromDataTable("observer") );
您现在已经看到了如何编写较长的步骤定义以及如何将公共功能提取到支持函数中。
步骤定义完成后,是时候让这两个场景都通过了。
通过测试驱动生产代码来修复 Cucumber 测试
在本节中,我们将先进行一些初步设计,然后编写单元测试,以覆盖 Cucumber 测试的功能,然后使用这些测试来构建新的实现。
让我们进行一些初步设计:
-
当用户点击开始共享时,应该弹出一个带有重置按钮的对话框。
-
如果用户选择重置,Redux 存储将发送一个带有新
reset属性设置为true的START_SHARING动作:{ type: "START_SHARING", reset: true } -
如果用户选择共享他们的现有命令,则
START_SHARING动作将带有reset设置为false发送:{ type: "START_SHARING", reset: false } -
当用户点击
RESET动作应该发送到 Redux 存储。 -
在
RESET动作发生之后,才应该开始共享。
那就是我们所需要的所有初步设计。让我们继续集成Dialog组件。
添加对话框
既然我们知道我们要构建什么,那就让我们开始吧!为此,执行以下步骤:
-
打开
test/MenuButtons.test.js并跳过标题为当点击开始共享时触发 START_SHARING 动作的测试。我们暂时将这个连接断开。但我们会回来修复它:it.skip("dispatches an action of START_SHARING when start sharing is clicked", () => { ... }); -
在同一文件中,添加一个新的
import语句用于Dialog组件,并使用jest.mock进行模拟。Dialog组件已经在代码库中存在,但直到现在还没有被使用:import { Dialog } from "../src/Dialog"; jest.mock("../src/Dialog", () => ({ Dialog: jest.fn(() => <div id="Dialog" />), }); -
在你跳过的测试下面添加这个新测试。非常简单,它检查在点击适当的按钮时显示对话框:
it("opens a dialog when start sharing is clicked", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); expect(Dialog).toBeCalled(); }); -
在
src/MenuButtons.js中,向 JSX 添加一个新的Dialog元素,包括文件顶部的import语句。新组件应放置在返回的 JSX 的底部。然后测试应该通过:import { Dialog } from "./Dialog"; export const MenuButtons = () => { ... return ( <> ... <Dialog /> </> ); }; -
接下来,让我们设置
message属性以对用户更有用。将此添加到你的测试套件中:it("prints a useful message in the sharing dialog", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); expect(propsOf(Dialog).message).toEqual( "Do you want to share your previous commands, or would you like to reset to a blank script?" ); }); -
为了使这个测试通过,向你的实现添加
message属性:<Dialog message="Do you want to share your previous commands, or would you like to reset to a blank script?" /> -
现在,我们需要确保在点击分享按钮之前不显示对话框;添加以下测试:
it("does not initially show the dialog", () => { renderWithStore(<MenuButtons />); expect(Dialog).not.toBeCalled(); }); -
通过添加一个新的状态变量
isSharingDialogOpen来使这个测试通过。分享按钮在点击时将此设置为true。你需要在文件顶部添加useState的import语句:import React, { useState } from "react"; export const MenuButtons = () => { const [ isSharingDialogOpen, setIsSharingDialogOpen ] = useState(false); const openSharingDialog = () => setIsSharingDialogOpen(true); ... return ( <> ... {environment.isSharing ? ( <button id="stopSharing" onClick={() => dispatch(stopSharing())} > Stop sharing </button> ) : ( <button id="startSharing" onClick={openSharingDialog} > Start sharing </button> )} {isSharingDialogOpen ? ( <Dialog message="..." /> ) : null} </> ); }; -
现在,让我们添加一个测试来添加按钮到对话框。这是通过在
Dialog组件上指定buttons属性来完成的:it("passes Share and Reset buttons to the dialog", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); expect(propsOf(Dialog).buttons).toEqual([ { id: "keep", text: "Share previous" }, { id: "reset", text: "Reset" } ]); }); -
通过向
Dialog组件添加buttons属性来使这个测试通过,如下所示:{isSharingDialogOpen ? ( <Dialog message="..." buttons={[ { id: "keep", text: "Share previous" }, { id: "reset", text: "Reset" } ]} /> ) : null} -
对于下一个测试,我们将测试对话框是否关闭。首先在你的测试套件中定义一个新的
closeDialog辅助函数:const closeDialog = () => act(() => propsOf(Dialog).onClose()); -
添加下一个测试,检查一旦对话框调用了
onClose属性,Dialog组件就会消失:it("closes the dialog when the onClose prop is called", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); closeDialog(); expect(element("#dialog")).toBeNull(); }); -
通过在
DialogJSX 中添加以下行来使这个测试通过:<Dialog onClose={() => setIsSharingDialogOpen(false)} ... /> -
现在回到你跳过的测试,并修改它,使其与以下代码块相同。我们将修改
START_SHARINGRedux 动作以接受一个新的reset布尔变量:const makeDialogChoice = button => act(() => propsOf(Dialog).onChoose(button)); it("dispatches an action of START_SHARING when dialog onChoose prop is invoked with reset", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); makeDialogChoice("reset"); return expectRedux(store) .toDispatchAnAction() .matching({ type: "START_SHARING", reset: true }); }); -
为了使这个测试通过,转到
src/MenuButtons.js并修改startSharing函数,为创建的 Redux 动作添加一个reset属性。注意我们目前将值硬编码为true——我们将在即将到来的测试中进行三角测量:const startSharing = () => ({ type: "START_SHARING", reset: true, });
测试中的三角测量
查看第一章**,使用测试驱动开发的第一步,以了解三角测量的提醒以及为什么我们要这样做。
-
在
MenuButtons组件中,设置Dialog组件的onChoose属性:return ( <> ... {isSharingDialogOpen ? ( <Dialog onClose={() => setIsSharingDialogOpen(false)} onChoose={() => dispatch(startSharing())} ... /> ) : null} </> ); -
最后,我们需要添加一个新的测试,用于发送
false值给reset动作属性:it("dispatches an action of START_SHARING when dialog onChoose prop is invoked with share", () => { renderWithStore(<MenuButtons />); click(buttonWithLabel("Start sharing")); makeDialogChoice("share"); return expectRedux(store) .toDispatchAnAction() .matching({ type: "START_SHARING", reset: false }); }); -
为了使这个测试通过,修改
startSharing以接受一个button参数,然后使用它来设置reset属性:const startSharing = (button) => ({ type: "START_SHARING", reset: button === "reset", }); -
然后,最后,在
MenuButtons组件 JSX 中,设置Dialog元素的onChoose属性:onChoose={(button) => dispatch(startSharing(button))}
你现在已经完成了 Cucumber 测试中指定的第一个新功能。有一个对话框正在显示,并且一个reset布尔标志正在通过 Redux 存储发送。我们正在逐步接近一个可工作的解决方案。
更新 sagas 到重置或回放状态
现在,我们需要更新分享 saga 以处理新的重置标志:
-
打开
test/middleware/sharingSagas.test.js,并在START_SHARING嵌套describe块的末尾添加以下测试:it("puts an action of RESET if reset is true", async () => { store.dispatch({ type: "START_SHARING", reset: true, }); await notifySocketOpened(); await sendSocketMessage({ type: "UNKNOWN", id: 123, }); return expectRedux(store) .toDispatchAnAction() .matching({ type: "RESET" }); }); -
在
src/middleware/sharingSagas.js中修改startSharing,使其与以下代码块相同。别忘了将新的action参数添加到第一行:function* startSharing(action) { ... if (action.reset) { yield put({ type: "RESET" }); } } -
现在是棘手的第二个测试。如果
reset是false,我们希望重新播放所有当前的动作:it("shares all existing actions if reset is false", async () => { const forward10 = { type: "SUBMIT_EDIT_LINE", text: "forward 10", }; const right90 = { type: "SUBMIT_EDIT_LINE", text: "right 90" }; store.dispatch(forward10); store.dispatch(right90); store.dispatch({ type: "START_SHARING", reset: false, }); await notifySocketOpened(); await sendSocketMessage({ type: "UNKNOWN", id: 123, }); expect(sendSpy).toBeCalledWith( JSON.stringify({ type: "NEW_ACTION", innerAction: forward10, }) ); expect(sendSpy).toBeCalledWith( JSON.stringify({ type: "NEW_ACTION", innerAction: right90 }) ); }); -
要使这通过,我们可以使用
export命名空间中的toInstructions函数。我们还需要使用两个新的redux-saga函数:select和all。select函数用于检索状态,而all函数与yield一起使用,以确保在继续之前等待所有传递的调用完成。现在将那些import语句添加到src/middleware/sharingSagas.js中:import { call, put, takeLatest, take, all, select } from "redux-saga/effects"; import { eventChannel, END } from "redux-saga"; import { toInstructions } from "../language/export"; -
现在,通过在条件语句后面添加一个
else块来修改startSharing函数。if (action.reset) { yield put({ type: "RESET" }); } else { const state = yield select(state => state.script); const instructions = toInstructions(state); yield all( instructions.map(instruction => call(shareNewAction, { innerAction: { type: "SUBMIT_EDIT_LINE", text: instruction } }) ) ); } -
如果你现在运行测试,你会注意到有几个无关的失败。我们可以通过在我们的测试中的
startSharing辅助方法中为reset属性添加一个默认值来修复这些问题:const startSharing = async () => { store.dispatch({ type: "START_SHARING", reset: true }); ... };
这样就完成了功能;单元测试和 Cucumber 测试都应该通过。现在手动尝试一下也是个不错的选择。
在下一节中,我们将专注于重构我们的 Cucumber 测试,使它们运行得更快。
避免测试代码中的超时
在本节中,我们将通过用waitForSelector调用替换waitForTimeout调用,来提高我们的 Cucumber 测试运行的速度。
我们的大多数步骤定义都包含等待,在等待动画完成的同时暂停我们的测试脚本与浏览器的交互。以下是我们测试中的一个示例,它等待了 3 秒钟:
await this.getPage("user").waitForTimeout(3000);
不仅这个超时会减慢测试套件,这种等待方式也很脆弱,因为可能存在超时稍微太短而动画尚未完成的情况。在这种情况下,测试将间歇性失败。相反,等待期实际上相当长。随着更多测试的添加,超时累积,测试运行突然变得非常慢。
避免超时
无论自动化测试的类型如何,避免在测试代码中使用超时都是一个好主意。超时将显著增加运行测试套件所需的时间。几乎总是有方法可以避免使用它们,就像本节中突出显示的那样。
我们可以做的替代方案是修改我们的生产代码,在元素动画时通知我们,通过设置一个isAnimating类。然后我们使用 Puppeteer 的waitForSelector函数来检查这个类值的改变,完全替换waitForTimeout。
添加 HTML 类以标记动画状态
我们这样做是通过在动画运行时给 viewport 的div元素添加一个isAnimating类。
让我们从在Drawing元素准备好动画一个新 Logo 命令时添加isAnimating类开始:
-
在
test/Drawing.test.js中,在主Display上下文中的重置上下文下方添加一个新的嵌套describe块。然后,添加以下测试:describe("isAnimating", () => { it("adds isAnimating class to viewport when animation begins", () => { renderWithStore(<Drawing />, { script: { drawCommands: [horizontalLine] } }); triggerRequestAnimationFrame(0); expect( element("#viewport") ).toHaveClass("isAnimating"); }); }); -
在
src/Drawing.js中,更新 JSX 以在viewport元素上包含这个类名:return ( <div id="viewport" className="isAnimating" > ... </div> ); -
让我们进行三角测量,以便将这个状态变量放在合适的位置。为此,添加以下测试:
it("initially does not have the isAnimating class set", () => { renderWithStore(<Drawing />, { script: { drawCommands: [] } }); expect( element("#viewport") ).not.toHaveClass("isAnimating"); }); -
为了使这个测试通过,将
className更新为仅在commandToAnimate不为 null 时设置isAnimating:className={commandToAnimate ? "isAnimating" : ""}> -
作为最后的点缀,我们将添加一个可能不必要的测试。我们想要在动画完成后小心地移除
isAnimating类。然而,我们的实现已经处理了这个问题,因为当发生这种情况时,commandToAnimate将被设置为undefined。换句话说,我们不需要为此进行显式的测试,这个添加就完成了。然而,为了完整性,你可以添加这个测试:it("removes isAnimating class when animation is finished", () => { renderWithStore(<Drawing />, { script: { drawCommands: [horizontalLine] }, }); triggerAnimationSequence([0, 500]); expect(element("#viewport")).not.toHaveClass( "isAnimating" ); });
完成了添加isAnimating类功能。现在我们可以使用这个类作为替换waitForTimeout调用的手段。
更新步骤定义以使用 waitForSelector
我们已经准备好在我们的步骤定义中使用这种新行为,引入一个新的waitForSelector调用,等待元素上的isAnimating类出现(或消失):
-
在
features/support/world.js中,向World类添加以下两个方法。第一个方法等待isAnimating选择器在 DOM 中出现,第二个方法等待它消失:waitForAnimationToBegin(page) { return this.getPage(page).waitForSelector( ".isAnimating" ); } waitForAnimationToEnd(page) { return this.getPage(page).waitForSelector( ".isAnimating", { hidden: true } ); } -
在
features/support/drawing.steps.js中,搜索这个文件中的单个waitForTimeout调用,并将其替换为以下代码块:When( "the user enters the following instructions at the prompt:", async function (dataTable) { for (let instruction of dataTable.raw()) { await this.getPage("user").type( "textarea", `${instruction}\n` ); await this.waitForAnimationToEnd("user"); } } );
注意类转换
我们在每个指令输入后等待动画。这很重要,因为它反映了isAnimating类将在应用程序中添加和删除的方式。如果我们只有一个waitForAnimationToEnd函数作为页面上的最后一个指令,那么如果在一系列指令的中间捕获到isAnimating类的移除,而不是捕获最后一个,我们可能会提前退出步骤定义。
-
现在,打开
features/support/sharing.steps.js;这个文件中有一个与上一个类似的步骤,所以现在以相同的方式更新它:When( "the presenter entered the following instructions at the prompt:", async function(dataTable) { for (let instruction of dataTable.raw()) { await this.getPage("presenter").type( "textarea", `${instruction}\n` ); await this.waitForAnimationToEnd("presenter"); } } ); -
在文件底部,更新检查海龟位置的两个步骤定义:
Then( "the observer should see the turtle at x = {int}, y = {int}, angle = {int}", async function ( expectedX, expectedY, expectedAngle ) { await this.waitForAnimationToEnd("observer"); ... } ); Then( "the presenter should see the turtle at x = {int}, y = {int}, angle = {int}", async function ( expectedX, expectedY, expectedAngle ) { await this.waitForAnimationToEnd("presenter"); ... } ); -
打开
features/support/svg.js并更新其中的函数,如下所示:export const checkLinesFromDataTable = page => { return async function (dataTable) { await this.waitForAnimationToEnd(page); ... } }; -
如果你现在运行
npx cucumber-js,你会看到我们有一个测试失败,这与观察者的屏幕输出有关。它表明我们需要在加载观察者页面时等待动画。在这种情况下,我们需要在等待动画开始之前等待动画结束。我们可以通过向功能添加一个新的步骤来修复这个问题。打开features/sharing.feature并修改最后一个测试,在When部分包含一个第三个条目:When the presenter clicks the button 'keep' And the observer navigates to the presenter's sharing link And the observer waits for animations to finish
封装多个 When 子句
如果您对有三个When子句不满意,那么您总是可以将它们合并为一个单独的步骤。
-
在
features/support/sharing.steps.js中,在其他的When步骤定义之下添加这个新的步骤定义:When( "the observer waits for animations to finish", async function () { await this.waitForAnimationToBegin("observer"); await this.waitForAnimationToEnd("observer"); } );
您的测试现在应该通过了,并且它们应该运行得更快。在我的机器上,它们现在只需要之前四分之一的时间。
摘要
在本章中,我们探讨了如何将 Cucumber 集成到您团队的日常工作流程中。
您看到了一些 Cucumber 测试与单元测试不同的方式。您还学习了如何避免使用超时来保持测试套件快速运行。
我们现在已经完成了对Spec Logo世界的探索。
在本书的最后一章,我们将探讨 TDD 与其他开发者流程的比较。
练习
尽可能地从您的步骤定义中移除重复内容。
第十九章:在更广泛的测试领域中理解 TDD
除了测试驱动开发的机制之外,本书还涉及了一些关于 TDD 实践者心态的想法:何时何地“作弊”,系统重构,严格的 TDD 等等。
一些开发团队喜欢采用“快速行动,打破事物”的口号。TDD 则相反:放慢速度,深思熟虑。为了理解在实践中这意味着什么,我们可以将 TDD 与各种其他流行的测试技术进行比较。
本章将涵盖以下主题:
-
测试驱动开发作为一种测试技术
-
手动测试
-
自动化测试
-
完全不进行测试
到本章结束时,你应该对为什么以及如何与其他编程实践相比,我们实践 TDD(测试驱动开发)有一个很好的理解。
测试驱动开发作为一种测试技术
TDD 实践者有时喜欢说,TDD 不是关于测试;而是关于设计、行为或规范,而我们最终拥有的自动化测试只是一个额外的好处。
是的,TDD 是关于设计的,但 TDD 当然也是关于测试的。TDD 实践者关心他们的软件具有高质量,这与测试人员关心的是同一件事。
有时,人们会质疑 TDD 的命名,因为他们觉得“测试”这个概念混淆了实际的过程。原因在于开发者误解了构建“测试”的含义。典型的单元测试工具实际上几乎不提供如何编写良好测试的指导。结果证明,将测试重新构造成规范和示例是向开发者介绍测试的好方法。
所有自动化测试都很难编写。有时,我们会忘记编写重要的测试,或者构建脆弱的测试,编写宽松的期望,过度复杂化解决方案,忘记重构,等等。
不仅新手会遇到这个问题——每个人都会,包括专家。人们经常一团糟。这也是乐趣的一部分。发现 TDD 的乐趣需要一定的谦卑,并接受你大多数时候不会编写完美的测试套件。完美的测试套件确实非常罕见。
如果你很幸运,你的团队里有测试人员,你可能会认为 TDD 侵犯了他们的工作,甚至可能让他们失业。然而,如果你询问他们的意见,你无疑会发现他们非常希望开发者对他们的工作质量感兴趣。有了 TDD,你可以自己捕捉到所有那些微不足道的逻辑错误,而不需要依赖他人的手动测试。然后测试人员可以更好地利用他们的时间,专注于测试复杂用例和寻找遗漏的需求。
单元测试的最佳实践
以下是一些优秀的单元测试:
- 独立:每个测试应该只测试一件事,并只调用一个单元。我们可以采用许多技术来实现这一目标。仅举两个例子,协作者通常(但不总是)被模拟,示例数据应该是正确描述测试所需的最小数据集。
经典主义者与模拟主义者 TDD
你可能听说过伟大的 TDD 辩论,即经典主义者与模拟主义者的 TDD。其想法是,经典主义者不会使用模拟和存根,而模拟主义者会模拟所有协作者。在现实中,这两种技术都很重要。你在本书中已经看到了它们的使用。我鼓励你不要局限于单一的方法,而是实验并学会对两者都感到舒适。
-
简短,高度抽象:测试描述应该简洁。测试代码应突出显示对测试重要的所有代码片段,并隐藏任何所需但不相关的设备。
-
快速运行:使用测试替身而不是与系统资源(文件、网络连接等)或其他进程交互。不要在代码中使用超时,或依赖时间的流逝。
-
专注于可观察的行为:系统对外部世界的影响才是有趣的,而不是它如何做到这一点。在 React 的情况下,我们关注 DOM 交互。
-
分为三部分:这些部分是安排、行动和断言,也称为AAA模式。每个测试都应该遵循这个结构。
-
不要重复自己(DRY):始终花时间重构和清理你的测试,目标是可读性。
-
设计工具:优秀的测试帮助你弄清楚如何设计你的系统。这并不是说前置设计不重要。在本书的几乎每一章中,我们在开始测试之前都进行了一些设计。做一些思考,这样你就有了一个大致的方向。只是不要试图计划得太远,并且准备好在前进过程中完全放弃你的设计。
TDD 不是优秀设计的替代品。要成为一名优秀的 TDD 实践者,你还应该了解并练习软件设计。关于软件设计有许多书籍。不要局限于关于 JavaScript 或 TypeScript 的书籍;优秀的设计超越语言。
提高你的技术
以下是一些改进的一般性建议:
-
与他人合作:除了阅读这本书之外,提高 TDD 水平的最佳方式是与专家合作。由于 TDD 非常适合结对和团队编程,它可以给不同经验水平的团队提供结构。经验丰富的开发者可以使用小型测试的粒度来帮助提高经验不足的开发者的工作。
-
实验设计:TDD 为你提供了一个安全网,让你可以实验程序的风格和形状。利用这个安全网来了解更多关于设计的信息。你的测试会保护你。
-
学会放慢速度:TDD 需要大量的个人自律。不幸的是,没有余地可以马虎。你绝对不能走捷径;相反,要抓住每一个机会进行重构。一旦测试通过,就坐下来审视你的代码。在继续下一个测试之前,仔细看看你的当前解决方案,并认真思考它是否是最好的。
-
不要害怕推迟设计决策:有时,我们面临几个设计选择,知道选择哪个选项可能很棘手。即使是命名变量这样的简单行为也可能很困难。拥有设计感的一部分是知道何时推迟你的思考。如果你处于重构阶段,并发现自己正在权衡两个或更多选项,那就继续前进,添加另一个测试,然后回过头来审视你的设计。你通常会发现自己有更多的设计知识,并且更接近正确答案。
-
每天解决一个 kata:kata 是一种短期的练习,旨在反复练习以教授你某种技术。两个基本的 kata 是 硬币兑换器 和 罗马数字。更复杂的 kata 包括保龄球 kata、银行 kata 和康威的 生命游戏。钻石 kata 是我最喜欢的,还有排序算法。
-
参加编码 retreat:编码 retreat 涉及一天的对偶编程和 TDD,围绕 生命游戏 kata 展开。全球编码 retreat 日 在 11 月举行。来自世界各地的团队聚集在一起解决这个问题。这不仅有趣,而且是扩展你的 TDD 视野的好方法。
这涵盖了关于 TDD 的一般建议。接下来,让我们看看手动测试技术。
手动测试
如你所猜到的,手动测试意味着启动你的应用程序并实际使用它。
由于你的软件是你的创造性作品,自然地,你很想知道它的表现如何。你当然应该花时间做这件事,但把它视为休息和放松的机会,而不是你开发过程的一个正式部分。
与 使用 软件相比,开发 软件的缺点是使用它需要花费大量时间。听起来很傻,但指向、点击和输入都占用了宝贵的时间。此外,设置测试环境并准备好相关测试数据也需要时间。
因此,尽可能避免手动测试是很重要的。然而,在某些情况下,它是必要的,正如我们将在本节中发现的那样。
总是会有一种诱惑,在每一个特性完成后手动测试软件,只是为了验证它是否工作。如果你发现自己经常这样做,考虑一下你对单元测试的信心有多大。
如果你声称,“我对我的单元测试有 100% 的信心”,那你为什么还需要 使用 你的软件来证明它呢?
让我们看看一些具体的手动测试类型,从展示软件开始。
展示软件
至少有两个重要场合你应该始终手动测试:当你向客户和用户展示你的软件时,以及当你准备展示你的软件时。
准备意味着写下一份演示脚本,列出你想要执行的所有操作。在实际演示之前,至少练习你的脚本两遍。很多时候,排练会带来对脚本的修改,这就是为什么排练如此重要的原因。在正式演示之前,一定要确保你已经至少进行了一次不需要修改的完整演练。
测试整个产品
前端开发包括很多移动部件,包括以下内容:
-
需要支持的多个浏览器环境
-
CSS
-
分布式组件,如代理和缓存
-
认证机制
由于所有这些移动部件的交互,手动测试是必要的。我们需要检查所有部件是否能够很好地组合在一起。
或者,你可以使用端到端测试来达到相同的覆盖率;然而,这些测试的开发和维护成本也很高。
探索性测试
探索性测试是你希望你的 QA 团队做的事情。如果你没有与 QA 团队合作,你应该分配时间自己来做这件事。探索性测试涉及探索软件并寻找团队尚未考虑的缺失需求或复杂用例。
由于 TDD 在非常低的层面上工作,很容易错过或甚至误解需求。你的单元测试可能覆盖了 95%的情况,但你可能会不小心忘记剩下的 5%。当团队刚开始使用 TDD,或者由新手程序员组成时,这种情况经常发生。即使是经验丰富的 TDD 实践者,也会发生这种情况——即使是那些写 TDD 书籍的人!我们都会时不时犯错误。
一个非常常见的错误场景涉及到模拟。当一个类或函数签名发生变化时,该类或函数的任何模拟也必须更新。这一步经常被遗忘;单元测试仍然通过,错误只有在实际运行应用程序时才会被发现。
无 bug 的软件
TDD 可以给你更多的信心,但绝对没有保证 TDD 能保证无 bug 的软件。
随着时间和经验的积累,你将更擅长在它们到达 QA 团队之前发现所有那些讨厌的边缘情况。
探索性测试的替代方案是自动化验收测试,但就像端到端测试一样,这些测试的开发和维护成本很高,而且它们还要求有高水平的专业知识和团队纪律。
浏览器中的调试
调试总是耗时巨大。这可能是一种极其令人沮丧的经历,伴随着大量的焦虑。这就是我们进行测试驱动开发的一个主要原因:这样我们就永远不需要调试。我们的测试为我们做了调试。
相反,TDD 的一个缺点是,你的调试技能可能会退化。
对于 TDD 实践者来说,从理论上讲,调试应该是一个非常罕见的情况,或者至少是积极避免的情况。但总有需要调试的情况。
打印行调试是一种调试技术,其中代码库中充满了console.log语句,希望它们可以提供有关运行时错误的线索。我与许多程序员合作过,他们职业生涯的开始是 TDD;对于他们中的许多人来说,打印行调试是他们所知道的唯一调试形式。尽管这是一个简单的技术,但它也很耗时,涉及大量的尝试和错误,您完成工作后必须记得清理。有可能会忘记一个多余的console.log,然后它在生产环境中生效。
现代浏览器具有非常复杂的调试工具,直到最近,这些工具只能在“全功能”IDE(如 Visual Studio 或 IntelliJ)中想象得到。您应该抽出时间来了解所有标准的调试技术,包括设置断点(包括条件断点)、进入、退出和跳过、监视变量等等。
一个常见的反模式是使用调试技术来追踪一个错误,一旦发现,就修复它并继续下一个任务。您应该做的是编写一个失败的测试来证明错误的存在。就像魔法一样,测试已经为您完成了调试。然后,您可以修复错误,并且立即,测试会告诉您问题是否已修复,而无需您手动重新测试。想想您将节省多少时间!
查看进一步阅读部分,获取有关 Chrome 调试器的资源。
这涵盖了您将执行的主要手动测试类型。接下来,让我们看看自动化测试技术。
自动化测试
TDD 是一种自动化测试形式。本节列出了其他一些流行的自动化测试类型,以及它们与 TDD 的比较。
集成测试
这些测试检查两个或更多独立进程之间的交互。这些进程可以是同一台机器上的,也可以分布在网络中。然而,您的系统应该使用与生产环境中相同的通信机制,因此如果它向一个网络服务发出 HTTP 调用,那么它应该在您的集成测试中这样做,无论该网络服务在哪里运行。
集成测试应该使用与单元测试相同的单元测试框架编写,所有关于编写良好单元测试的规则都适用于集成测试。
集成测试中最棘手的部分是编排代码,这涉及到启动和停止进程,以及等待进程完成其工作。可靠地执行这些操作可能很困难。
如果你选择在单元测试中模拟对象,当你不模拟这些交互时,你需要至少一些对这些交互的覆盖,集成测试是这样做的一种方式。另一种方式是系统测试,如以下所述。
系统测试和端到端测试
这些是自动化测试,它们通过驱动 UI 来测试整个系统,通常(但不一定)是通过驱动 UI 来实现的。
当手动探索性测试开始占用过多时间时,它们是有用的。这种情况发生在代码库随着规模和年龄的增长而增长。
端到端测试的建设和维护成本很高。幸运的是,它们可以逐步引入,这样你就可以从小规模开始,证明它们的价值,然后再扩大其范围。
接受测试
接受测试是由客户或代表客户的代理(如产品负责人)编写的,其中“接受”指的是必须通过的质量关卡,以便发布的软件被视为完整。它们可能是自动化的,也可能不是,并且它们在系统级别指定行为。
客户应该如何编写这些测试?对于自动化测试,你通常可以使用系统测试工具,如 Cucumber 和 Cypress。我们在第十七章**,编写您的第一个 Cucumber 测试和第十八章**,由 Cucumber 测试引导的功能添加中看到的 Gherkin 语法是这样做的一种方式。
接受测试可以用来在开发人员和产品利益相关者之间建立信任。如果客户不断测试你的软件以寻找错误,这表明开发团队与外界之间的信任水平很低。如果接受测试开始捕获那些客户可能发现的错误,它们可以帮助提高这种信任。然而,与此同时,你也应该问自己为什么 TDD 一开始就没有捕获所有这些错误,并考虑如何改进你的整体测试流程。
基于属性和生成式测试
在传统的 TDD 中,我们找到一小组规范或示例来测试我们的函数。基于属性的测试不同:它基于对函数输入定义的测试生成大量测试。测试框架负责生成输入数据和测试。
例如,如果我有一个将华氏度转换为摄氏度的函数,我就可以使用生成式测试框架来生成针对大量随机整数华氏度测量值的测试,并确保每个值都能正确转换为摄氏度值。
基于属性的测试与 TDD(测试驱动开发)一样困难。它不是万能的灵丹妙药。找到正确的属性进行断言是具有挑战性的,尤其是如果你旨在以测试驱动的方式构建它们。
这种测试不会取代 TDD,但它是任何 TDD 实践者的工具箱中的另一个工具。
快照测试
这是一种流行的 React 应用程序测试技术。React 组件树被序列化为 JSON 字符串并存储到磁盘上,然后在测试运行之间进行比较。
React 组件树在几个重要场景中非常有用,包括以下内容:
-
当你的团队在 TDD 和一般程序设计方面经验不足,并且可以通过快照测试的安全网来增强信心时
-
当你正在使用的软件在生产的测试覆盖率为零,并且你希望在做出任何更改之前快速获得一定程度的信心时
质量保证团队有时会对软件在版本之间的视觉变化感兴趣,但他们可能不会想在你的单元测试套件中编写测试;他们会有自己的专用工具来做这件事。
快照测试当然是一个值得了解的有用工具,但要注意以下问题:
-
快照不是描述性的。它们不会超出说“这个组件树看起来和之前一样”。这意味着如果它们崩溃了,不会立即清楚为什么它们崩溃了。
-
如果快照在组件树的高层渲染,它们就会变得脆弱。脆弱的测试经常失败,因此需要花费大量时间来纠正。由于测试是在高层进行的,它们无法精确指出错误的位置,因此你将花费大量时间寻找失败的原因。
-
快照测试可以在两种情况下通过:首先,当组件树与之前测试的版本相同,其次,当找不到之前测试运行中的快照工件。这意味着绿色测试并不能给你带来完全的信心——它可能只是因为之前的工件缺失而变绿。
当编写良好的测试(任何类型的测试)时,你希望以下关于任何测试失败都是真实的:
-
非常快地确定失败是由于错误还是规范的变化
-
在错误的情况下,非常快地确定问题和错误的位置
TDD 是一种社区已经学到了足够多的、知道如何编写良好测试的成熟技术。我们在快照测试方面还没有达到这个水平。如果你绝对必须在代码库中采用快照测试,请确保你衡量它为你和你的团队提供的价值。
金丝雀测试
金丝雀测试是在你将软件发布给一小部分用户并观察发生了什么。对于拥有大量用户的 Web 应用程序来说,这可能很有用。金丝雀测试的一种形式涉及将每个请求发送到两个系统:运行中的系统和测试系统。用户只能感知到运行中的系统,但测试系统的结果由你记录和分析。然后可以观察到功能和性能的差异,而你的用户永远不会受到测试软件的影响。
金丝雀测试很有吸引力,因为从表面上看,它似乎非常具有成本效益,而且几乎不需要程序员进行任何思考。
与 TDD 不同,金丝雀测试无法帮助你设计软件,而且你可能需要一段时间才能得到任何反馈。
这就完成了我们对自动化测试领域的考察。我们本章开始时探讨了手动测试技术。现在,让我们以一个最终技术来结束本章:完全不进行测试!
完全不进行测试
有一种观点认为,TDD 不适用于某些它确实适用的场景——例如,如果你的代码是废弃的,或者一旦部署就被认为永远不需要修改。相信这一点几乎可以确保相反的情况是真实的。代码,尤其是没有测试的代码,往往会在其预期寿命之外继续存在。
删除代码的恐惧
除了减少更改代码的恐惧之外,测试还可以减少删除代码的恐惧。没有测试,你可能会阅读一些代码并想“也许有人用这段代码来达到我不太记得的目的。”有了测试,这就不会成为问题。你会阅读测试,看到由于需求的变化,测试不再适用,然后删除测试及其相应的生产代码。
然而,有一些情况下不编写测试是可以接受的。其中最重要的两个如下。
当质量不重要时
不幸的是,在许多环境中,质量并不重要。我们中的许多人都能理解这一点。我们为那些积极忽视质量的雇主工作过。这些人赚得足够的利润,以至于他们不需要或不想关心。关心质量,不幸的是,是一个个人的选择。如果你在一个不重视质量的团队中,将很难说服他们 TDD 是值得的。
如果你处于这种情况,并且你迫切希望使用 TDD,那么你有几个选择。首先,你可以花时间说服你的同事这是一个好主意。这从来都不是一件容易的事情。你也可以玩 TDD-by-stealth 游戏,在你开始之前不征求任何人的同意。如果这些选项都失败了,一些程序员可能会足够幸运,能够找到一家确实重视质量的替代雇主。
编写和删除代码
Spike 意味着不进行测试的编码。我们在未知领域时进行 spike。我们需要找到解决我们以前从未解决过的问题的可行方法,这很可能会涉及大量的尝试和错误,以及大量的回溯。在找到可行方法之前,找到不可行方法的可能性很高。在这种情况下编写测试没有太多意义,因为许多在过程中编写的测试最终都会被废弃。
假设,例如,我正在构建一个 WebSocket 服务器和客户端,但这是我第一次使用 WebSocket。这将是一个很好的 spike 候选者——我可以安全地探索 WebSocket API,直到我对将其集成到我的应用程序中感到舒适。
当你觉得你已经找到了一个可行的方案时,重要的是要停止 spiking。你不需要一个完整的解决方案,只需要一个能教会你足够知识,让你走上正确道路的方案。
在 TDD 的纯粹主义视角中,spiking 必须随后进行删除。如果你要进行 spike,你必须习惯于删除你的工作。不幸的是,说起来容易做起来难;很难清除创造性输出。你必须摆脱你的代码是神圣的信念。乐于将其丢弃。
在 TDD 的实用主义视角中,spiking 常常可以随后编写围绕 spike 代码的测试。我经常使用这个技巧。如果你是 TDD 的新手,在你自信能够想出一套测试序列,覆盖 spike 代码中所有必需的功能之前,避免使用这个特定的作弊技巧可能更明智。
纯粹主义者可能会说,你的 spike 代码可以包含冗余代码,并且它可能不是最简单的解决方案,因为测试并没有驱动实现。这个论点有一定的道理。
Spiking 和测试最后开发
Spiking 与测试最后的实践有关,但存在细微的差别。围绕 spike 编写代码是 TDD 的一个作弊行为,因为你希望你的最终测试看起来就像你一开始就使用了 TDD 一样。任何在你之后到来的人都不应该知道你作弊了。
测试最后,然而,是一种更宽松的测试方式,你先编写所有生产代码,然后编写一些单元测试来证明一些更重要的用例。以这种方式编写测试给你提供了一定程度的回归覆盖率,但没有 TDD 的其他好处。
摘要
成为 TDD 的优秀实践者需要极大的努力。这需要练习、经验、决心和纪律。
许多人尝试过 TDD 并失败了。其中一些人可能会得出结论说 TDD 是有缺陷的。但我并不认为它是有缺陷的。只是需要努力和耐心才能做对。
但究竟什么是做对的呢?
所有软件开发技术都是主观的。本书中的所有内容都是主观的;它不是正确的方式。这是一系列我喜欢使用并且发现成功的技巧集合。其他人也用其他技巧取得了成功。
TDD 的激动人心之处不在于过程的黑白、严格形式;而是在于灰色地带,我们可以定义(并完善)一个适合我们和同事的开发过程。TDD 循环给我们提供了刚好足够的结构,我们可以从中找到乐趣,用我们的规则和教条来充实它。
我希望你觉得这本书有价值且有趣。测试驱动 React 应用程序的方法有很多,我希望这能成为你发展测试实践的平台。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
- 有用的 Kata 资源:
github.com/sandromancuso/Bank-kata
www.natpryce.com/articles/000807.xhtml
-
全球程序员重置日:
www.coderetreat.org -
在 Chrome 开发者工具中开始调试 JavaScript:
developers.google.com/web/tools/chrome-devtools/javascript/ -
JavaScript 的基于属性的测试:
github.com/leebyron/testcheck-js


浙公网安备 33010602011771号