精通-React-测试驱动开发第二版-一-

49 阅读1小时+

精通 React 测试驱动开发第二版(一)

原文:zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这是一本关于教条的书籍。我的教条。它是一套原则、实践和仪式,我发现它们在构建 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,其中包含所有练习的完整解决方案。

你至少需要稍微熟悉 branchcheckoutclonecommitdiffmerge 命令,这些命令应该是足够的。

查看 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/err… 并填写表格。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 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,我们可以通过以下步骤创建我们的项目:

  1. 如果您正在跟随本书的 Git 仓库,请打开终端并导航到您已克隆的仓库目录。否则,只需导航到您通常存储工作项目的位置。

  2. 使用 mkdir appointments 创建一个新的目录,然后使用 cd appointments 将其设置为当前目录。

  3. 输入 npm init 命令。这将通过生成模板 package.json 文件来初始化一个新的 npm 项目。您将被提示输入有关项目的一些信息,但您只需接受所有默认设置 除了 test command 问题,对于这个问题,您应该输入 jest。这将使您能够通过使用 npm test 快捷命令来运行测试。

手动编辑 package.json 文件

在按照说明操作时,如果错过了测试命令的提示,不要担心;您可以在之后通过将 "test": "jest" 添加到生成的 package.json 文件的 scripts 部分来设置它。

  1. 现在继续使用 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 测试,并完成所有必要的步骤使其通过。

编写失败的测试

测试究竟*是什么?为了回答这个问题,让我们写一个。执行以下步骤:

  1. 在您的项目目录中,键入以下命令:

    mkdir test
    touch test/Appointment.test.js
    
  2. 在您喜欢的编辑器或 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)

你可以将 describeit 描述一起读作一个句子: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 和组件模拟*,你将对其进行一些调整以支持单个测试中的重新渲染。

为了在我们的测试中调用它,我们需要定义 componentcontainer。然后测试将具有以下形状:

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,原因将在后面变得清楚!

让我们把所有这些放在一起:

  1. 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"
      );
    });
    
  2. 由于我们同时使用 ReactDOM 命名空间和 JSX,我们需要在测试文件的顶部包含这两个标准的 React 导入,以便它能够正常工作,如下所示:

    import React from "react";
    import ReactDOM from "react-dom/client";
    
  3. 好吧,运行测试;它会失败。在输出中,你会看到以下代码:

    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 了。

让它通过

我们现在准备好让失败的测试通过。执行以下步骤:

  1. test/Appointment.test.js 中,在两个 React 导入下方添加一个新的 import 语句,如下所示:

    import { Appointment } from "../src/Appointment";
    
  2. 使用 npm test 运行测试。这次您会得到一个不同的错误,关键信息如下:

    Cannot find module '../src/Appointment' from 'Appointment.test.js'
    

默认导出

虽然 Appointment 被定义为导出,但它没有被定义为默认导出。这意味着我们必须使用花括号形式的导入(import { ... })来导入它。我们倾向于避免使用默认导出,因为这样做可以保持组件名称及其使用的一致性。如果我们更改组件的名称,那么所有导入它的地方都会中断,直到我们也更改它们。默认导出不是这种情况。一旦您的名称不一致,跟踪组件的使用就变得更加困难——您不能简单地使用文本搜索来找到它们。

  1. 让我们创建那个模块。在您的命令提示符中输入以下代码:

    mkdir src
    touch src/Appointment.js
    
  2. 在您的编辑器中,将以下内容添加到 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,请执行以下步骤:

  1. 前往 test/Appointment.test.js 的顶部并添加以下代码行:

    import { act } from "react-dom/test-utils";
    
  2. 然后,将包含 render 调用的行更改为以下内容:

    act(() => 
    ReactDOM.createRoot(container).render(component)
    );
    
  3. 现在再次运行您的测试,您应该会看到一个通过测试,但上面会打印出一个奇怪的警告,如下所示:

    > 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

  1. 让我们继续启用act函数。打开package.json并修改您的jest属性,使其如下所示:

    {
      ...,
      "jest": {
        "testEnvironment": "jsdom",
        "globals": {
          "IS_REACT_ACT_ENVIRONMENT": true
        }
      }
    }
    
  2. 现在再次运行您的测试,使用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 进行结对编程可以如此有趣的原因之一。结对可以玩乒乓。有时,你的搭档会写一个你可以轻易解决的测试,可能通过硬编码,然后你通过三角定位强迫他们做两个测试的艰难工作。他们需要移除硬编码并添加泛化。

让我们通过以下步骤进行三角定位:

  1. 将您的第一个测试复制一份,粘贴在第一个测试的下面,并更改测试描述以及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"
      );
    });
    
  2. 使用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的必要性,我们还将发现处理这类场景的重要技术。

我们必须跳过这个正在工作的测试,修复之前的测试,然后重新启用跳过的测试。现在让我们通过执行以下步骤来完成:

  1. 在你刚刚编写的第一个测试中,将it更改为it.skip。现在按照以下方式对第二个测试做同样的操作:

    it.skip("renders another customer first name", () => {
      ...
    });
    
  2. 运行测试。你会看到 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
    
  3. 在第一个测试中,按照以下方式将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"
      );
    });
    
  4. 使用npm test重新运行测试。它应该仍然通过。

是时候通过从函数名中移除.skip来将跳过的测试重新引入了。

  1. 在这个测试中执行与第一个测试相同的更新:将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"
      );
    });
    
  2. 现在运行测试应该会给我们原本预期的错误。不再有重复的文本内容,如下所示:

    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"
    
  3. 为了使测试通过,我们需要引入属性并在我们的组件中使用它。将Appointment的定义更改为如下,解构函数参数以提取客户属性:

    export const Appointment = ({ customer }) => (
      <div>{customer.firstName}</div>
    );
    
  4. 运行测试。我们预计这个测试现在会通过:

    PASS test/Appointment.test.js
     Appointment
    ✓ renders the customer first name (21ms)
    ✓ renders another customer first name (2ms)
    

干得好!我们的通过测试已经完成,并且我们已经成功定位并移除了硬编码。

在本节中,你编写了两个测试,在这个过程中,你发现了并克服了我们编写 React 组件自动化测试时面临的一些挑战。

现在我们已经让测试工作正常,我们可以更仔细地查看我们编写的代码。

重构你的工作

现在你已经得到了一个绿色的测试,是时候重构你的工作了。重构是调整代码结构而不改变其功能的过程。这对于保持代码库处于良好、可维护的状态至关重要。

很遗憾,重构步骤总是被遗忘的步骤。冲动是直接进入下一个功能。我们无法强调花时间简单地停下来 凝视 代码并思考改进方法的重要性。练习你的重构技能是成为开发者提升水平的一个可靠方法。

俗语“欲速则不达”在编程中就像在生活中一样适用。如果你养成了跳过重构阶段的习惯,你的代码质量可能会随着时间的推移而下降,这使得它更难工作,因此构建新功能的速度会变慢。

TDD 循环帮助你建立良好的个人纪律和习惯,例如持续重构。这可能需要前期更多的努力,但你会收获一个随着时间推移仍然可维护的代码库的回报。

不要重复自己

测试代码需要与生产代码一样多的关注和照顾。当你重构测试时,你将依赖的第一大原则是不要重复自己DRY)。“干燥测试”是所有 TDD 实践者经常重复的一个短语。

关键点是你希望你的测试尽可能简洁。当你看到存在于多个测试中的重复代码时,这是一个很好的迹象,表明你可以将这段重复代码提取出来。有几种不同的方法可以做到这一点,我们将在本章中介绍其中的一些。

你将在 第三章“重构测试套件”中看到进一步干燥测试的技术。

在测试之间共享设置代码

当测试包含相同的设置说明时,我们可以将这些说明提升为共享的 beforeEach 块。此块中的代码在每个测试之前执行。

我们的两个测试都使用了相同的两个变量:containercustomer。其中第一个,container,在每个测试中都是相同初始化的。这使得它成为 beforeEach 块的良好候选者。

执行以下步骤以引入你的第一个 beforeEach 块:

  1. 由于 container 需要在 beforeEach 块和每个测试中访问,我们必须在外部的 describe 范围内声明它。由于我们将在 beforeEach 块中设置其值,这也意味着我们需要使用 let 而不是 const。在第一个测试上方添加以下代码行:

    let container;
    
  2. 在以下声明下方添加以下代码:

    beforeEach(() => {
      container = document.createElement("div");
      document.body.replaceChildren(container);
    });
    
  3. 从你的两个测试中各自删除相应的两行。注意,由于我们在 describe 块的作用域中定义了 container,因此在 beforeEach 块中设置的值将在测试执行时对测试可用。

使用 let 而不是 const

当你在 describe 范围内使用 let 定义时,要小心。这些变量在每次测试执行之间默认不会被清除,并且共享的状态将影响每个测试的结果。一个很好的经验法则是,你应在 describe 范围内声明的任何变量都应在相应的 beforeEach 块中分配新值,或者在每个测试的第一部分,就像我们在这里所做的那样。

要更详细地了解在测试套件中使用 let 的方法,请访问 reacttdd.com/use-of-let

第三章**,重构测试套件中,我们将探讨一种在多个测试套件之间共享此设置代码的方法。

提取方法

两个测试中的 render 调用相同。考虑到它被 act 调用所包裹,它的长度也相当长。因此,提取整个操作并给它一个更有意义的名称是有意义的。

而不是直接提取出来,我们可以创建一个新的函数,该函数将 Appointment 组件作为其参数。为什么这样做有用的解释将在之后给出,但现在让我们执行以下步骤:

  1. 在第一个测试上方写下以下定义。注意,它仍然需要位于 describe 块内,因为它使用了 container 变量:

    const render = component =>
      act(() => 
        ReactDOM.createRoot(container).render(component)
      );
    
  2. 现在,将每个测试中的 render 调用替换为以下代码行:

    render(<Appointment customer={customer} />);
    
  3. 在前面的步骤中,我们内联了 JSX,直接将其传递给 render。这意味着你现在可以删除以 const component 开头的行。例如,你的第一个测试应该看起来像以下示例:

    it("renders the customer first name", () => {
      const customer = { firstName: "Ashley" };
      render(<Appointment customer={customer} />);
      expect(document.body.textContent).toContain(
        "Ashley"
      );
    });
    
  4. 重新运行你的测试并验证它们是否仍然通过。

在你的测试中突出差异

你想要突出的测试部分是不同测试之间的差异部分。通常,一些代码保持不变(例如 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 周期

图 1.1 – TDD 周期

TDD 周期的步骤是:

  1. 编写失败的测试:编写一个简短的测试来描述你想要的功能。执行你的测试并观察它失败。如果它没有失败,那么它是一个不必要的测试;删除它并编写另一个。

  2. 使其通过:通过编写最简单的能工作的生产代码来使测试通过。不用担心寻找整洁的代码结构;你可以稍后整理它。

  3. 重构你的代码:停下来,放慢速度,抵制继续进行下一个功能的冲动。努力使你的代码——无论是生产代码还是测试代码——尽可能干净。

这就是全部内容。你已经在前面两个部分中看到了这个周期的实际应用,我们将在本书的其余部分继续使用它。

简化你的测试过程

想想你到目前为止在这本书上投入的努力。你做了哪些最多的动作?它们如下:

  • src/Appointment.jstest/Appointment.test.js之间切换

  • 运行npm test并分析输出

确保你可以快速执行这些操作。

首先,你应该在你的编辑器中使用分屏功能。如果你还没有这样做,利用这个机会学习如何操作。在一侧加载你的生产模块,在另一侧加载相应的单元测试文件。

这是我们设置的一个图片;我们使用nvimtmux

图 1.2 – 在终端中运行 tmux 和 vim 的典型 TDD 设置

图 1.2 – 在终端中运行 tmux 和 vim 的典型 TDD 设置

你可以看到我们还在底部有一个小测试窗口来显示测试输出。

Jest 也可以监视你的文件,并在它们更改时自动运行测试。要启用此功能,将package.json中的test命令更改为jest --watchAll。这将在检测到任何更改时重新运行所有测试。

监视文件变化

Jest 的监视模式有一个选项,可以只运行已更改文件中的测试,但由于你的 React 应用将由许多不同的文件组成,每个文件都相互关联,因此最好运行所有内容,因为许多模块可能会出现故障。

摘要

测试就像我们学习中的安全带;我们可以在理解的基础上构建小块知识,层层叠加,不断向上,无需担心跌落。

在本章中,你已经学到了很多关于 TDD 体验的知识。

首先,你从头开始设置一个 React 项目,只引入运行所需的最小依赖。你已经使用 Jest 的describeitbeforeEach函数编写了两个测试。你发现了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 的草图

图 2.1 – 我们预约系统 UI 的草图

预先设计

当你使用 TDD 来构建新功能时,进行一点预先设计非常重要,这样你才能对实现的方向有一个大致的了解。

那就是我们现在需要的所有设计;让我们直接开始构建新的AppointmentsDayView组件。

创建新组件

在本节中,我们将创建AppointmentsDayView的基本形式:一天中的预约时间列表。我们目前不会为它构建任何交互行为。

我们将把我们的新组件添加到我们一直在使用的同一个文件中,因为到目前为止那里没有多少代码。执行以下步骤:

放置组件

我们并不总是需要为每个组件创建一个新的文件,尤其是当组件是短的功能组件时,比如我们的Appointment组件(一个单行函数)。将相关的组件或组件的小子树组合在一个地方可能会有所帮助。

  1. 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 周期的第一阶段时,我们可以这样做。一旦测试通过,我们就可以考虑代码的正确结构了。

  1. 运行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
    

让我们通过以下步骤来使这个测试通过:

  1. 为了解决这个问题,请将测试文件中的最后一个import语句更改为以下内容:

    import {
      Appointment,
      AppointmentsDayView,
    } from "../src/Appointment";
    
  2. src/Appointment.js中,在Appointment下面添加以下功能组件,如图所示:

    export const AppointmentsDayView = () => {};
    
  3. 再次运行你的测试。你将看到如下输出:

    AppointmentsDayView › renders a div with the right id
    expect(received).not.toBeNull()
    
  4. 最后,一个测试失败了!让我们按照以下方式放置那个div

    export const AppointmentsDayView = () => (
      <div id="appointmentsDayView"></div>
    );
    
  5. 你的测试现在应该通过了。让我们继续下一个测试。在test/Appointment.test.js中,在最后一个测试下面添加以下文本,仍然在AppointmentsDayViewdescribe块内:

    it("renders an ol element to display appointments", () => {
      render(<AppointmentsDayView appointments={[]} />);
      const listElement = document.querySelector("ol");
      expect(listElement).not.toBeNull();
    });
    
  6. 再次运行你的测试,你将看到以下文本所示的输出:

    AppointmentsDayView › renders an ol element to display appointments
    expect(received).not.toBeNull()
    Received: null
    
  7. 为了使测试通过,添加以下ol元素:

    export const AppointmentsDayView = () => (
      <div id="appointmentsDayView"> 
        <ol />
      </div>
    );
    
  8. 好的,现在让我们用每个预约的项目填充那个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()。两个记录中的每一个都使用这个作为基准日期。当我们处理日期时,非常重要的一点是我们应该基于同一时间点来安排所有事件,而不是多次从系统中获取当前时间。这样做是一个潜在的微妙错误。

  1. 再次运行npm test,你将看到以下输出:

    AppointmentsDayView › renders an li for each appointment
    expect(received).toHaveLength(expected)
    Expected length: 2
    Received length: 0
    Received object: []
    
  2. 为了解决这个问题,我们遍历提供的appointments属性,并渲染一个空的li元素:

    export const AppointmentsDayView = (
      { appointments }
    ) => (
      <div id="appointmentsDayView"> 
        <ol>
          {appointments.map(() => (
            <li />
          ))}
        </ol>
      </div>
    );
    

忽略未使用的函数参数

map 函数将为传递给它的函数提供一个 appointment 参数。由于我们目前还没有使用这个参数,我们不需要在函数签名中提及它——我们只需假装我们的函数没有参数即可,因此括号是空的。别担心,我们将在后续测试中需要这个参数,那时我们会添加它。

  1. 太好了,让我们看看 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)
    
  2. 我们的测试通过了,但我们收到了 React 的警告。它告诉我们要在每个子元素上设置一个键值。我们可以使用 startsAt 作为键,如下所示:

    <ol>
      {appointments.map(appointment => (
        <li key={appointment.startsAt} />
      ))}
    </ol>
    

测试键值

在 React 中测试键值没有简单的方法。为了做到这一点,我们需要依赖于内部 React 属性,这可能会引入风险,即如果 React 团队更改这些属性,测试可能会中断。

我们能做的就是设置一个键来消除这个警告信息。在一个理想的世界里,我们会有一个使用每个 li 键的 startsAt 时间戳的测试。让我们假设我们已经有了那个测试。

本节介绍了如何渲染列表的基本结构和其列表项。接下来,是时候填充这些项了。

指定列表项内容

在本节中,你将添加一个使用示例预约数组的测试,以指定列表项应显示每个预约的时间,然后你将使用该测试来支持实现。

让我们从测试开始:

  1. 在新的 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 是有意义的。然而,通常最好尽可能宽松地设定期望。严格的期望往往会在你对代码库进行最轻微的更改时崩溃。

  1. 将以下函数添加到 src/Appointment.js 中,该函数将 Unix 时间戳(我们从 setHours 的返回值中获取)转换为一天中的时间。你可以在文件的任何位置放置它;我们通常喜欢在使用之前定义常量,所以这应该放在文件顶部:

    const appointmentTimeOfDay = (startsAt) => {
      const [h, m] = new Date(startsAt)
        .toTimeString()
        .split(":");
      return `${h}:${m}`;
    }
    

理解语法

这个函数使用了 解构赋值模板字符串,这些是你可以用来使你的函数更简洁的语言特性。

良好的单元测试可以帮助我们学习高级语言语法。如果我们对函数的功能不确定,我们可以查找帮助我们弄清楚这些的测试。

  1. 使用前面的函数按如下方式更新 AppointmentsDayView

    <ol>
      {appointments.map(appointment => (
        <li key={appointment.startsAt}>
          {appointmentTimeOfDay(appointment.startsAt)}
        </li>
      ))}
    </ol>
    
  2. 运行测试应该显示一切正常:

    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 声明,因为它们永远不会改变。

  1. 为了做到这一点,将todaytwoAppointments的定义从其中一个测试移动到describe块的顶部,在beforeEach之上。然后,从两个测试中删除这些定义。

这个测试就到这里。接下来,是时候专注于添加点击行为。

选择要查看的数据

让我们在页面上添加一些动态行为。我们将使每个列表项都成为一个用户可以点击以查看该预约的链接。

在思考我们的设计时,我们需要以下几个部分:

  • 我们li中的button元素

  • 附着到那个button元素的onClick处理程序

  • 组件状态用于记录当前正在查看的预约

当我们测试 React 动作时,我们通过观察这些动作的后果来进行。在这种情况下,我们可以点击一个按钮,然后检查相应的预约现在是否已渲染在屏幕上。

我们将把这个部分分成两部分:首先,我们将指定组件应该如何初始显示,其次,我们将处理一个用于更改内容的点击事件。

初始数据选择

让我们首先断言每个li元素都有一个button元素:

  1. 如果今天没有预约,我们希望向用户显示一条消息。在AppointmentsDayViewdescribe块中添加以下测试:

    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."
      );
    });
    
  2. 通过在渲染输出的底部添加一条消息来使测试通过。我们目前不需要检查空的appointments数组;我们需要另一个测试来验证这一点。消息如下:

    return (
      <div id="appointmentsDayView">
        ...
        <p>There are no appointments scheduled for today.</p>
      </div>
    );
    
  3. 当组件首次加载时,我们应该显示当天的第一个预约。一个检查这一点的简单方法是在页面上查找客户的第一个名字。添加下一个测试,如下所示:

    it("selects the first appointment by default", () => {
      render(
        <AppointmentsDayView 
          appointments={twoAppointments}
        />
      );
      expect(document.body.textContent).toContain(
        "Ashley"
      );
    });
    
  4. 由于我们正在寻找客户的姓名,我们需要确保它在twoAppointments数组中可用。现在更新它,包括客户的第一个名字如下:

    const twoAppointments = [
      {
        startsAt: today.setHours(12, 0),
        customer: { firstName: "Ashley" },
      },
      {
        startsAt: today.setHours(13, 0),
        customer: { firstName: "Jordan" },
      },
    ];
    
  5. 通过修改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元素:

  1. 在你添加的最后一个测试下面添加以下测试。第二个期望是独特的,因为它正在检查按钮元素的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的末尾,这个测试就会通过。但幸运的是,做正确的事情和做错误的事情一样简单,所以我们可以选择做正确的事情。要使这个测试通过,我们只需要将现有内容包裹在新的标签中。

  1. 通过在AppointmentsDayView组件中将约会时间包裹在button元素中来使测试通过,如下所示:

    ...
    <li key={appointment.startsAt}>
      <button type="button">
        {appointmentTimeOfDay(appointment.startsAt)}
      </button>
    </li>
    ...
    
  2. 我们现在可以测试当按钮被点击时会发生什么。回到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。也许更重要的是,我们还想让我们的测试尽可能反映真实的浏览器环境。

  1. 继续运行测试。输出将如下所示:

    AppointmentsDayView › renders appointment when selected
        expect(received).toContain(expected)
    
        Expected substring: "Jordan"
        Received string:    "12:0013:00Ashley"
    

注意接收到的字符串中的全文。我们之所以获取列表的文本内容,是因为我们在期望中使用了document.body.textContent而不是更具体的内容。

期望的特定性

不要太在意客户名字在屏幕上的位置。测试document.body.textContent就像说“我想这个文本出现在某个地方,但我不在乎它在哪里。”通常,这足以进行测试。稍后,我们将看到在特定位置期望文本的技术。

为了使测试通过,我们现在需要做很多事情。我们需要引入状态,并添加处理程序。执行以下步骤:

  1. 将文件顶部的导入更新为拉入useState函数,如下所示:

    import React, { useState } from "react";
    
  2. 将常量定义包裹在花括号中,然后按照以下方式返回现有值:

    export const AppointmentsDayView = (
      { appointments }
    ) => {
      return (
        <div id="appointmentsDayView">
          ...
        </div>
      );
    };
    
  3. return语句上方添加以下代码行:

    const [selectedAppointment, setSelectedAppointment] =
      useState(0);
    
  4. 我们现在可以使用selectedAppointment而不是硬编码一个索引来选择正确的约会。在选择约会时,将返回值更改为使用这个新的状态值,如下所示:

    <div id="appointmentsDayView">
      ...
      <Appointment
        {...appointments[selectedAppointment]}
      />
    </div>
    
  5. map调用修改为包括其参数中的索引。让我们将其命名为i,如下所示:

    {appointments.map((appointment, i) => (
      <li key={appointment.startsAt}>
        <button type="button">
          {appointmentTimeOfDay(appointment.startsAt)}
        </button>
      </li>
    ))}
    
  6. 现在从button元素的onClick处理程序中调用setSelectedAppointment,如下所示:

    <button
      type="button"
      onClick={() => setSelectedAppointment(i)}
    >
    
  7. 运行你的测试,你应该会发现它们都是绿色的:

    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 目录包含一个更完整的示例数据集。

此列表也不需要测试驱动,以下是一些原因:

  1. 这是一个没有行为的静态数据列表。测试都是关于指定行为的,这里没有。

  2. 一旦我们开始使用我们的后端 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 的用武之地,我们现在可以介绍它,帮助我们快速手动测试,如下所示:

  1. 使用以下命令安装 webpack:

    npm install --save-dev webpack webpack-cli babel-loader
    
  2. 将以下代码添加到您的 package.json 文件的 scripts 部分:

    "build": "webpack",
    
  3. 您还需要为 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 文档。

  1. 在您的源目录中,运行以下命令:

    mkdir dist
    touch dist/index.xhtml
    
  2. 将以下内容添加到您刚刚创建的文件中:

    <!DOCTYPE html>
    <html>
      <head>
        <title>Appointments</title>
      </head>
      <body>
        <div id="root"></div>
        <script src="img/main.js"></script>
      </body>
    </html>
    
  3. 您现在可以使用以下命令运行构建:

    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
  1. 在你的浏览器中打开 dist/index.xhtml,欣赏你的作品!

以下截图显示了完成 练习 后的应用程序,其中添加了 CSS 和扩展的示例数据。要包含 CSS,你需要从 Chapter02/Complete 目录中提取 dist/index.xhtmldist/styles.css

图 2.2 – 到目前为止的应用程序

图 2.2 – 到目前为止的应用程序

在你将代码提交到 Git 之前...

确保按照以下方式将 dist/main.js 添加到你的 .gitignore 文件中:

echo "dist/main.js" >> .gitignore

main.js 文件是由 webpack 生成的,就像大多数生成的文件一样,你不应该将其提交到版本控制中。

在这个阶段,你可能还想添加 README.md 文件来提醒自己如何运行测试以及如何构建应用程序。

现在,你已经看到了如何在创建入口点时暂时放下 TDD:因为入口点很小且不太可能频繁更改,所以我们选择不对其进行测试驱动。

摘要

在本章中,你已经能够多次练习 TDD 循环,并感受到如何使用测试作为指南来构建一个功能。

我们首先设计了一个快速的原型,这帮助我们决定了我们的行动方案。我们构建了一个容器组件(AppointmentsDayView),它显示了一系列的预约时间,并且能够根据点击的预约时间显示单个 Appointment 组件。

我们随后着手建立一个基本的列表结构,然后扩展它以显示初始的 Appointment 组件,最后添加了 onClick 行为。

这种测试策略,即从基本结构开始,然后是初始视图,最后是事件行为,是测试组件的典型策略。

我们距离完全构建我们的应用程序还有一段距离。任何应用程序的前几个测试总是最难的,并且需要最长时间来编写。我们现在已经越过了这个障碍,所以从这里开始我们将更快地前进。

练习

  1. Appointment.jsAppointment.test.js 重命名为 AppointmentsDayView.jsAppointmentsDayView.test.js。如果多个组件构成一个层次结构,将它们包含在一个文件中是可以的,但你应该始终以该层次结构的根组件命名文件。

  2. 通过在页面上显示以下字段来完成 Appointment 组件。你应该使用 table HTML 元素来给数据一些视觉结构。这不应该影响你编写测试的方式。应该显示的字段如下:

    • 客户的姓氏,使用 lastName 字段

    • 客户电话号码,使用 phoneNumber 字段

    • 美容师姓名,使用 stylist 字段

    • 美容院服务,使用 service 字段

    • 预约备注,使用 notes 字段

  3. Appointment 组件中添加一个标题,以清楚地显示正在查看的预约时间。

  4. 存在一些重复的样本数据。我们在测试中使用了样本数据,同时我们也在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,来部分回答这个问题。它并不提供完整的答案来说明为什么它是必要的,但它确实暗示了一些关于“初始化”的概念。

让我们继续提取这段代码:

  1. 创建一个名为 test/reactTestExtensions.js 的新文件。这个文件最终将包含我们将在 React 组件测试中使用的所有辅助方法。

  2. 将以下内容添加到文件中。该函数在模块内部隐式地更新 container 变量。然后该变量被导出——我们的测试套件可以像访问“只读”常量一样访问这个变量:

    export let container;
    export const initializeReactContainer = () => {
      container = document.createElement("div");
      document.body.replaceChildren(container);
    }
    
  3. 移动到 test/AppointmentsDayView.test.js 文件。在现有的导入下面添加以下导入:

    import {
      initializeReactContainer,
      container,
    } from "./reactTestExtensions";
    
  4. 现在,将两个 beforeEach 块——记住每个 describe 块中都有一个——替换为以下代码:

    beforeEach(() => {
      initializeReactContainer();
    });
    
  5. 从两个 describe 块的顶部删除 let container 定义。

  6. 运行 npm test 并验证你的测试是否仍然通过。

现在,我们继续处理 render 函数?让我们将其移动到我们的新模块中。这次,它是一个直接的复制和替换工作:

  1. 从一个 describe 块中复制 render 的定义。

  2. 将其粘贴到 reactTestExtensions.js 文件中。为了参考,这里再次列出:

    export const render = (component) =>
      act(() => 
        ReactDOM.createRoot(container).render(component)
      );
    
  3. 你还需要在文件顶部添加以下导入:

    import ReactDOM from "react-dom/client";
    import { act } from "react-dom/test-utils";
    
  4. 在你的测试文件中,你现在可以更改测试扩展的导入,使其包括新的 render 函数,然后删除 container 导入:

    import {
      initializeReactContainer,
      render,
    } from "./reactTestExtensions";
    
  5. 从两个测试套件中删除两个 render 定义。

  6. 运行 npm test 并验证你的测试是否仍然通过。

到目前为止,我们已经提取了两个函数。我们还有一个函数要做:click 函数。然而,我们还可以创建一个额外的“动作”函数:click。现在就让我们来做这件事:

  1. 在你的测试扩展文件中创建 click 函数,如下所示:

    export const click = (element) =>
      act(() => element.click());
    
  2. 在你的测试文件中,调整你的导入:

    import {
      initializeReactContainer,
      container,
      render,
      click,
    } from "./reactTestExtensions";
    
  3. 在你的测试套件中,将每个 click 函数的调用替换为以下行:

    click(button);
    
  4. act 导入在测试套件中不再需要。请从你的测试文件中删除该导入。

  5. 运行 npm test 并验证你的测试是否仍然通过。

在测试代码中避免使用 act 函数

act 函数在测试中引起了很多杂乱,这并不有助于我们追求简洁。幸运的是,我们可以将其推入我们的扩展模块,然后就可以结束了。

记得我们的测试应该始终遵循的 安排-执行-断言 模式吗?好吧,我们现在已经从 安排执行 部分提取了一切。

我们在这里采取的方法,即使用导出的container变量,并不是唯一值得探索的方法。例如,您可以创建一个describe的包装函数,该函数自动包含一个beforeEach块并构建一个在describe块作用域内可访问的container变量。您可以将其命名为类似describeReactComponent的名称。

这种方法的优点是它涉及的代码要少得多——您将不会处理所有那些导入,并且可以在测试套件中删除您的beforeEach块。缺点是它非常巧妙,这并不总是维护性的好事情。它有点神奇,需要一定的先验知识。

话虽如此,如果您觉得这种方法吸引您,我鼓励您尝试一下。

在下一节中,我们将开始处理测试的断言部分。

使用 TDD 创建 Jest 匹配器

在我们之前的测试中,我们使用了各种expect函数调用:

expect(appointmentTable()).not.toBeNull();

在本节中,您将使用测试驱动的方法构建一个匹配器,以确保它正在做正确的事情。在构建测试套件的过程中,您将了解 Jest 匹配器 API。

您已经看到了相当多的匹配器:toBeNulltoContaintoEqualtoHaveLength。您也看到了它们如何通过not来否定。

匹配器是构建表达性强且简洁的测试的强大方式。您应该花些时间学习 Jest 提供的所有匹配器。

Jest 匹配器库

有很多不同的匹配器库作为 npm 包提供。尽管我们在这本书中不会使用它们(因为我们是从第一原理构建一切的),但您应该利用这些库。请参阅本章末尾的进一步阅读部分,以获取在测试 React 组件时对您有用的库列表。

通常,您会想构建匹配器。至少有几次情况会促使您这样做:

  • 您所编写的期望语句可能相当冗长、篇幅较长,或者用普通语言读起来并不顺畅。

  • 一些测试反复重复相同的期望组。这是您有一个可以编码到单个匹配器中的业务概念,该匹配器将专门针对您的项目的迹象。

第二点很有趣。如果您在多个测试中多次编写相同的期望,您应该像对待生产源代码中的重复代码一样对待它。您会将其提取到一个函数中。在这里,匹配器起到了相同的作用,只不过使用匹配器而不是函数可以帮助您记住这一行代码是关于您软件的特殊事实声明:一个规范。

每个测试一个期望

你通常应该只为每个测试设定一个期望。“未来的你”会感谢你保持事情简单!(在第五章添加复杂表单交互中,我们将探讨一个多个期望有益的情况。)

你可能会听到这个指南,立刻感到惊恐。你可能想象会有无数的小测试爆炸。但如果你准备好编写匹配器,你可以为每个测试设定一个期望,同时仍然保持测试数量在可控范围内。

我们在本节将要构建的匹配器被称为 toContainText。它将替换以下期望:

expect(appointmentTable().textContent).toContain("Ashley");

它将替换成以下形式,这稍微更容易阅读:

expect(appointmentTable()).toContainText("Ashley");

下面是终端上的输出效果:

图 3.1 – 当 toContainText 匹配器失败时的输出

图 3.1 – 当 toContainText 匹配器失败时的输出

让我们开始吧:

  1. 创建一个名为 test/matchers 的新目录。这是匹配器的源代码和测试将存放的地方。

  2. 创建新的 test/matchers/toContainText.test.js 文件。

  3. 按照下面的示例编写第一个测试。这个测试引入了一些新概念。首先,它显示 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);
      });
    });
    
  4. 创建另一个新文件,命名为 test/matchers/toContainText.js。这个第一个测试很容易通过:

    export const toContainText = (
      received,
      expectedText
    ) => ({
      pass: true
    });
    
  5. 我们需要三管齐下才能到达真正的实现。按照下面的示例编写下一个测试:

    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);
    });
    
  6. 现在,继续实现我们的匹配器,如下所示。在这个阶段,你有一个正在工作的匹配器——它只需要被连接到 Jest:

    export const toContainText = (
      received,
      expectedText
    ) => ({
      pass: received.textContent.includes(expectedText)
    });
    
  7. 在我们使用这个功能之前,先填充一下预期返回值的第二个属性: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 的值不需要在失败之前被评估。但更复杂的是,消息应该根据期望是否被否定而改变。如果 passfalse,那么 message 函数应该假设匹配器是在“正面”意义上被调用的——换句话说,没有 .not 修饰符。但如果 passtrue,并且 message 函数最终被调用,那么可以安全地假设它已经被否定。我们需要为这个否定情况编写另一个测试,这个测试稍后会出现。

  1. 此函数使用一个 stripTerminalColor 函数,我们现在应该在测试套件上方定义它。它的目的是移除任何添加颜色的 ASCII 转义码:

    const stripTerminalColor = (text) =>
        text.replace(/\x1B\[\d+m/g, "");
    

测试 ASCII 转义码

正如您已经看到的,当 Jest 打印出测试失败时,您会看到一大堆红色和绿色的彩色文本。这是通过在文本字符串中打印 ASCII 转义码来实现的。

这是一个难以测试的事情。因此,我们做出了实用主义的选择,不去麻烦测试颜色。相反,stripTerminalColor 函数从字符串中移除这些转义码,这样您就可以测试文本输出,就像它是纯文本一样。

  1. 通过使用 Jest 的 matcherHintprintExpected 函数使该测试通过,如下所示。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 匹配器实用函数的最佳方式是阅读它们的源代码。如果您愿意,也可以完全避免使用它们 - 没有义务使用它们。

  1. 现在是复杂部分。添加以下测试,它指定了使用否定匹配器时的失败期望场景。消息应该反映匹配器已被否定,如下所示:

    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")`
      );
    });
    
  2. 要使其通过,向 matcherHint 传递一个新选项:

    ...
    matcherHint(
      "toContainText",
      "element",
      printExpected(expectedText),
      { isNot: pass }
    );
    ...
    
  3. 需要添加最后一个测试。我们可以打印出元素的 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"`);
    });
    
  4. 通过调整您的匹配器代码使其通过,如下所示。注意新 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 };
    };
    
  5. 是时候将测试插入 Jest 中了。为此,创建一个名为 test/domMatchers.js 的新文件,内容如下:

    import {
      toContainText
    } from "./matchers/toContainText";
    expect.extend({
      toContainText,
    });
    
  6. 打开 package.json 并更新您的 Jest 配置,以便在运行测试之前加载此文件:

    "jest": {
      ...,
      "setupFilesAfterEnv": ["./test/domMatchers.js"]
    }
    
  7. 您的新匹配器已准备好使用。打开 test/AppointmentsDayView.test.js 并更改所有使用 expect(<element>.textContent).toEqual(<text>)expect(<element>.textContent).toContain(<text>) 形式的测试。它们应该替换为 expect(<element>).toContainText(<text>)

  8. 运行您的测试;您应该看到它们仍然全部通过。花点时间玩一下,看看您的匹配器是如何工作的。首先,将其中一个期望的文本值更改为错误的内容,并观察匹配器失败。看看输出消息的样子。然后,将期望值改回正确的内容,但通过将其更改为 .not.toContainText 来否定匹配器。最后,将您的代码恢复到全绿色状态。

为什么我们要进行匹配器的测试驱动?

您应该为任何不仅仅是简单地调用其他函数或设置变量的代码编写测试。在本章的开始,您提取了 renderclick 等函数。这些函数不需要测试,因为您只是将同一行代码从一个文件移植到另一个文件。但这个匹配器做了一些更复杂的事情——它必须返回一个符合 Jest 所需模式的对象。它还使用了 Jest 的实用函数来构建有用的消息。这种复杂性需要测试。

如果您正在为库构建匹配器,您应该对匹配器的实现更加小心。例如,我们没有麻烦去检查接收到的值是否是 HTML 元素。这没关系,因为这个匹配器只存在于我们的代码库中,我们控制了它的使用方式。当您将匹配器打包用于其他项目时,您也应该验证函数输入是否是您期望看到的值。

您现在已经成功驱动测试了您的第一个匹配器。随着本书的进展,您将有更多机会练习这项技能。现在,我们将继续进行清理工作的最后一部分:创建一些流畅的 DOM 辅助函数。

提取 DOM 辅助函数

在本节中,我们将提取一些小函数,这将帮助我们的测试变得更加易读。与刚刚构建的匹配器相比,这将更加直接。

reactTestExtensions.js 模块已经包含了您使用过的三个函数:initializeReactContainerrenderclick

现在,我们将添加四个新的:elementelementstypesOftextOf。这些函数旨在帮助您的测试读起来更像普通英语。让我们看一个例子。以下是我们的测试期望之一:

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 匹配器应用于数组时,将检查每个数组具有相同数量的元素,并且每个元素出现在相同的位置。

我们已经将原始的三行代码缩减为仅仅一行!

让我们继续构建这些新的辅助函数:

  1. 打开 test/reactTestExtensions.js 并在文件底部添加以下定义。您会注意到元素使用了 Array.from。这样做是为了使结果数组可以被 typesOftextOf 映射。

    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);
    
  2. 打开 test/AppointmentsDayView.test.js 并将扩展导入更改为包括所有这些新函数:

    import {
      initializeReactContainer,
      render,
      click,
      element,
      elements,
      textOf,
      typesOf,
    } from "./reactTestExtensions";
    
  3. 现在,进行搜索并替换document.querySelectorAll,将每个出现的位置替换为elements。运行npm test并验证测试是否仍然通过。

  4. 搜索并替换document.querySelector,将每个出现的位置替换为element。再次运行你的测试并检查一切是否正常。

  5. 你将看到测试在预约时间渲染。用这个期望替换现有的期望:

    expect(textOf(elements("li"))).toEqual([
      "12:00", "13:00"
    ]);
    
  6. 找到"has a button element in each li"测试,并用以下单个期望替换现有的期望。注意,如果你的期望测试整个数组,那么对数组长度的期望就不再必要了:

    expect(typesOf(elements("li > *"))).toEqual([
      "button",
      "button",
    ]);
    
  7. 最后三个测试使用elements("button")[1]提取屏幕上的第二个按钮。将这个定义向上推,紧接在beforeEach块下方,并给它一个更描述性的名称:

    const secondButton = () => elements("button")[1];
    
  8. 现在,你可以在三个测试中使用这个功能。现在就更新它们。例如,中间的测试可以更新如下:

    click(secondButton());
    expect(secondButton().className).toContain("toggled");
    
  9. 作为最后的润色,将出现在某些测试中的listChildlistElement变量内联化——换句话说,移除变量的使用,并在期望中直接调用函数。例如,"renders an ol element to display appointments"测试可以按照以下方式重写期望:

    expect(element("ol")).not.toBeNull();
    
  10. 再次运行npm test并验证一切是否仍然正常。

并非所有辅助函数都需要提取

你会注意到你提取的辅助函数都非常通用——它们没有提及正在测试的具体组件。尽可能保持辅助函数的通用性是好的。另一方面,有时拥有非常本地化的辅助函数很有帮助。在你的测试套件中,你已经有一个名为appointmentsTable的和一个名为secondButton的。这些应该保留在测试套件中,因为它们是本地化的。

在本节中,你看到了我们简化测试套件的最终技术,即提取流畅的辅助函数,这些函数有助于保持期望简短,并使它们读起来像普通的英语。

你还看到了在数组上运行期望而不是对单个项目有期望的技巧。这并不总是合适的行动方案。你将在第五章中看到这个例子,添加复杂表单交互

摘要

本章重点介绍了改进我们的测试套件。可读性至关重要。你的测试充当了软件的规范。每个组件测试都必须清楚地说明组件的期望。当测试失败时,你希望尽可能快地了解为什么它失败了。

你已经看到,这些优先级通常与我们对良好代码的通常想法相冲突。例如,在我们的测试中,我们愿意牺牲性能,如果这使测试更易读。

如果您以前使用过 React 测试,想想平均测试的长度。在本章中,您已经看到了几种保持测试简短的方法:构建特定领域的匹配器和提取用于查询 DOM 的小函数。

您还学会了如何提取 React 初始化代码以避免测试套件中的杂乱。

在下一章,我们将回到为我们的应用添加新功能:使用表单进行数据录入。

练习

使用您刚刚学到的技术,创建一个名为 toHaveClass 的新匹配器,以替换以下期望:

expect(secondButton().className).toContain("toggled");

在您的新匹配器设置完成后,它应该如下所示:

expect(secondButton()).toHaveClass("toggled"); 

此匹配器也有否定形式:

expect(secondButton().className).not.toContain("toggled");

您的匹配器应该适用于此表单并显示适当的失败信息。

进一步阅读

为了了解更多关于本章所涉及的主题,请查看以下资源: