JavaScript-应用测试指南-全-

JavaScript 应用测试指南(全)

原文:Testing JavaScript Application

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

《测试 JavaScript 应用程序》是我六年前希望读过的测试书籍。当时,我是一个质量保证(QA)实习生。这是我第一次进入软件行业。不幸的是,它并没有要求我去做我最喜欢的事情:在键盘上施展咒语。相反,我不得不手动浏览屏幕,点击按钮,填写表格,并确保我们构建的软件按预期工作。

“一定有更好的方法,”我想。于是我开始为机器编写咒语,以解放我成为我想成为的创造性巫师。

经过 18 个月后,我认为我已经弄懂了大部分内容。那时,我已经通过自动化自己从 QA 角色转变为软件工程师。

一旦我开始编写应用程序,我脑海中就出现了更多问题。在 QA 领域投入了相当长的时间后,我不想依赖他人构建出能正常工作的软件。我也不想浪费我宝贵的咒语制作时间点击按钮和填写表格,就像我过去做的那样。

再次,我认为“一定有更好的方法。”那时我开始阅读更多关于软件测试的内容。现在我有权访问源代码,我发现我可以更有信心、更快速地构建软件。此外,我可以解放我的质量保证(QA)朋友们,让他们能够进行更多创造性和主动性的工作,而不仅仅是将软件推给他们手动测试。

困难的部分是找到能够教我如何做到的材料。尽管我有时可以在网上找到有用的文章,但其中大部分已经过时或只关注测试难题的一小部分。

将这些碎片组合在一起是学习软件测试最具挑战性的部分。软件工程师是否应该始终编写测试?如果是这样,应该编写哪些类型的测试,为什么,以及多少?软件开发和 QA 如何结合?

多年前,没有一篇内容能够回答所有这些问题。我想读的那本书不存在;因此,我决定自己写。

虽然好的内容散布在互联网的各个角落,但其中很大一部分仍然没有写成文字。测试难题的大部分仍然在那些维护他人使用的测试库的人的大脑中处于无结构状态。

在《测试 JavaScript 应用程序》中,我将这些碎片以可理解的方式组合在一起。我将我从多年阅读和实践工作经验中学到的知识与我维护数百万用户使用的测试库(如 Chai 和 Sinon)时所发现的知识结合起来。

我坚信,优秀的测试实践是每件成功软件的核心。这些实践帮助人们以更短的时间、更少的金钱编写出更好的软件。最重要的是,它们使我们摆脱了繁琐的工作,给我们时间去做我们最擅长的事情:创造软件,对我来说,这仍然很大程度上像魔法一样。

致谢

我母亲的闹钟总是在早上 6:00 之前响起,就像我的闹钟一样。如果没有她,我不知道我怎么能写出你即将读到的 500 页内容,其中大部分是在世界沉睡时写成的。我的母亲教会了我纪律和努力的价值,我非常感激这一点。

就像她一样,许多其他人也值得我感谢他们教给我的课程和给予我的帮助。

在这些人中,我最想感谢的是我的家人,他们在大西洋的另一边为我加油。我的父亲,Hercílio,他总是说他支持我去做任何我想做的事情;我的妹妹,Luiza,是我认识的最善良的人;还有我的母亲,Patrícia,我在第一段就赞扬了她的辛勤工作。

除了他们,我还要感谢我的祖父母,他们在我父母工作的时候照顾我,尤其是我的祖母,Marli Teixeira da Costa,我将这本书献给她。

无论在巴西的过去有多么艰难,她总是尽她所能为我提供工作所需的一切,从书籍到电脑。在工作日,她通常会为我准备午餐,并在我家附近的大学里为我提供一间房间,这样我就可以休息,以便在课堂上集中注意力。

除了我的家人,还有一些人没有他们,我就无法完成这项工作:Douglas Melo 和 Lorenzo Garibotti,他们教会了我真正的友谊意味着什么;Ana Zardo,她让我意识到世界比我想象的要大;我的治疗师 Alex Monk,他帮助我应对变化并处理我频繁的存在的危机;以及 Gideon Farrell,他带我来到伦敦,并继续信任我,帮助我做到最好。

我还必须感谢多年来 JavaScript 开源社区的所有人,他们教会了我很多东西:我在大学遇到的 Lucas Vieira,他是我知道的最有才华的工程师之一;Carl-Erik Kopseng,他把我带入 Sinon.js,并在 2017 年与我一起工作;以及邀请我成为 Chai.js 核心维护者的 Keith Cirkel,他一直是一个支持我的朋友。当我三年前搬到英国时,他帮助我保持稳定。我很高兴互联网让我与这样出色的人联系在一起!

向所有审稿人致谢:Sergio Arbeo、Jeremy Chen、Giuseppe De Marco、Lucian Enache、Foster Haines、Giampiero Granatella、Lee Harding、Joanna Kupis、Charles Lam、Bonnie Malec、Bryan Miller、Barnaby Norman、Prabhuti Prakash、Dennis Reil、Satej Sahu、Liza Sessler、Raul Siles、Ezra Simeloff、Deniz Vehbi、Richard B. Ward 和 Rodney Weis,你们的建议帮助使这本书变得更好。

最后,我要感谢我的编辑和 Manning 团队,Helen Stergius、Dennis Sellinger 和 Srihari Sridharan,他们审阅了每一页,并耐心回答我在整个过程中提出的无数问题。

Obrigado.

关于这本书

《Testing JavaScript Applications》使用实际示例教你如何测试你的 JavaScript 代码,并解释了在决定编写哪些测试以及何时编写测试时应考虑哪些因素。

除了涵盖最流行的 JavaScript 测试工具和测试最佳实践,本书还解释了不同类型的测试如何相互补充,以及它们如何融入你的开发过程,以便你可以在更短的时间内、以更少的错误和更大的信心构建更好的软件。

应该阅读这本书的人

我主要写了《Testing JavaScript Applications》这本书是为了给初级开发人员和那些认为“必须有一种更好的方法”来构建工作软件但尚未弄清楚如何的软件工程师。

本书假设读者已经能够编写代码,但不需要任何关于软件测试的先验知识。

除了涵盖编写测试的实用方面,它还解释了为什么测试很重要以及它们如何影响你的项目,并赋予你根据你的情况做出最佳可能决策的能力。

本书是如何组织的:路线图

本书包含 12 章,分为三个部分。

《Testing JavaScript Applications》的第一部分涵盖了自动化测试是什么,为什么它们很重要,不同类型的自动化测试,以及每种类型的测试如何影响你的项目。

  • 第一章解释了什么是自动化测试以及编写它们的优点。

  • 第二章涵盖了不同类型的自动化测试,并教你每种测试的优缺点,这样你就知道在决定编写哪些测试时要考虑什么。此外,它还教你可以应用于所有类型测试的基本模式。

第二部分使用实际示例教你如何编写你在第一部分中学到的不同类型的测试。

  • 第三章涵盖了帮助你最大限度地利用测试的测试技术。它教你如何在测试套件中组织多个测试以获得精确的反馈,如何编写详尽的断言以捕获更多错误,以及测试期间你应该隔离代码的哪些部分。此外,它解释了代码覆盖率是什么以及如何衡量它,并展示了它有时可能具有误导性。

  • 第四章教授如何为后端应用程序编写测试。它涵盖了你应该考虑的使你的应用程序可测试的基本方面,演示了如何测试你的服务器路由及其中间件,以及如何处理外部依赖项,如数据库或第三方 API。

  • 第五章介绍了帮助您降低后端测试成本、使它们更快、更可靠的技术。它通过教你如何消除不可预测的测试、如何并行运行测试以及如何减少它们之间重叠的方式来实现。

  • 第六章描述了如何测试纯 JavaScript 前端应用程序。本章解释了如何在测试框架内模拟浏览器环境,并演示了如何编写与你的应用程序界面交互、与浏览器 API 交互以及处理 HTTP 请求和 WebSockets 的测试。

  • 第七章涵盖了 React 测试生态系统。它基于前一章所学内容,解释了 React 应用程序的测试是如何工作的。此外,它为你概述了你可以用来测试 React 应用程序的不同工具,并演示了如何编写你的第一个 React 测试。此外,它还提供了如何将类似技术应用于其他 JavaScript 库和框架的技巧。

  • 第八章深入探讨了测试 React 应用程序的实践。在本章中,我将解释如何测试相互交互的组件,如何测试组件的样式,以及快照测试是什么,以及决定是否应该使用它时需要考虑什么。此外,你还将了解组件级验收测试的重要性,以及这种实践如何帮助你更快地构建更好的 React 应用程序。

  • 第九章讲述了测试驱动开发(TDD)。它解释了如何应用这种软件开发技术,为什么采用它是有益的,以及在何时进行。除了涵盖 TDD 的实践方面,它还解释了这种技术如何影响团队,以及如何创造一个 TDD 可以成功的环境。它还涵盖了 TDD 与一种称为行为驱动开发(BDD)的实践的关系,这有助于改善不同利益相关者之间的沟通,并提高你的软件质量。

  • 第十章描述了基于 UI 的端到端测试是什么,以及它们如何影响你的业务。它还解释了这些测试与其他类型测试的不同之处,并教你如何决定何时编写它们。

  • 第十一章涵盖了基于 UI 的端到端测试的实践方面。本章将教你如何编写你的第一个基于 UI 的端到端测试,如何使它们健壮和可靠,以及如何在多个浏览器上运行它们。此外,它描述了如何将视觉回归测试纳入你的测试中,并解释了这种新型测试可能如何有帮助。

第三部分涵盖了可以放大编写测试对业务产生积极影响的互补技术。

  • 第十二章描述了持续集成和持续交付是什么,以及为什么它们是有益的技术,并解释了在项目中应用它们所需了解的基本要素。

  • 第十三章涵盖了与测试互补的技术、工具和技术。它讨论了类型如何帮助你捕获错误并使你的测试更高效,解释了代码审查如何提高你的代码质量,并涵盖了文档和监控在构建工作软件中的影响。此外,它描述了如何更快、更有信心地调试你的代码。

我建议读者在阅读其他章节之前,先顺序阅读前三章。这几章介绍了基本的测试概念以及它们之间的关系。阅读这些章节是至关重要的,因为您将需要其中的信息来充分利用任何后续章节。

然后,读者可以直接跳转到他们最感兴趣的章节,具体取决于他们想要测试的软件类型。

理想情况下,读者应该在已经实施测试并希望了解如何补充他们的测试技术和基础设施之后,才阅读第十二章和第十三章。

关于代码

《测试 JavaScript 应用程序》包含许多实用示例。所有这些示例都可以在本书的 GitHub 仓库中找到,读者可以在github.com/lucasfcosta/testing-javascript-applications找到该仓库。在那个仓库中,我已经将示例分到了每个章节的文件夹中。在每个章节的文件夹内,我已经根据部分对示例进行了分组。

内联代码和单独的代码列表都使用与这个类似的固定宽度字体格式化,以便您能够将其与普通文本区分开来。有时代码也会被加粗以突出显示与章节中先前步骤相比已更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新整理了缩进,以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中包括行续续标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。许多列表都有代码注释,突出显示重要概念。

我在本书中对每个重要的示例都进行了注释,以突出重要概念并向读者解释每段代码的作用。

本书示例的代码也可以从 Manning 网站下载,网址为www.manning.com/books/testing-javascript-applications

系统要求

本书的所有代码示例都是在 macOS Catalina 上编写和测试的。然而,它们应该可以在所有平台上运行,包括 Linux 和 Windows。

为了使本书的示例运行,你可能需要做的唯一更改是根据你使用的 shell 和操作系统调整环境变量的设置方式。例如,如果你在 Windows 机器上使用 PowerShell,你不能仅仅在命令前加上VAR_NAME=value来设置环境变量的值。

要运行本书的示例,您必须在您的机器上安装 Node.js 和 NPM。这两者通常捆绑在一起。当您安装 Node.js 时,您通常也会获得 NPM。要下载和安装这两款软件,您可以遵循 nodejs.org/en/download/ 上的说明。我在构建本书示例时使用的 Node.js 和 NPM 版本分别是 12.18 和 6.14。

liveBook 讨论论坛

购买《Testing JavaScript Applications》包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在论坛中就本书发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 livebook.manning.com/#!/book/testing-javascript-applications/ discussion。您还可以在 livebook.manning.com/#!/discussion 了解更多关于 Manning 论坛和行为准则的信息。

Manning 对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Lucas da Costa 是一名软件工程师、已出版作者、国际演讲者和专业问题解决者。作为开源社区的活跃成员,他是最受欢迎的 JavaScript 测试库 Chai 和 Sinon 的核心维护者。此外,他还为 Jest 和 NodeSchool 等众多其他项目做出了贡献。

在过去几年中,Lucas 在 10 多个国家的众多软件工程会议上进行了演讲。

他的内容自愿翻译成了多种语言,包括俄语、普通话、法语、葡萄牙语和西班牙语,并被用作世界各地多个软件工程课程的参考材料。

Lucas 喜欢有观点的书籍、漂亮的代码、精心设计的散文、命令行界面和 Vim。事实上,他如此热爱 Vim,以至于在他的脚踝上纹了一个 :w

关于封面插图

《测试 JavaScript 应用程序》封面上的插图被标注为“巴黎市民”。这幅插图取自雅克·Grasset de Saint-Sauveur(1757–1810)的作品集,名为《Costumes civils de actuals de toue les peuples connus》,1788 年出版于法国。每一幅插图都是手工精心绘制和着色的。Grasset de Saint-Sauveur 收藏中的丰富多样性生动地提醒我们,仅仅 200 年前,世界各地的城镇和地区在文化上有多么不同。他们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的着装,就可以轻易地识别出他们居住的地方以及他们的职业或社会地位。

自那以后,我们的着装方式已经改变,当时的地区多样性已经逐渐消失。现在很难区分不同大陆、不同城镇、地区或国家的人们。也许我们用更丰富多彩的个人生活——当然,是更丰富多彩、节奏更快的技术生活——来换取了文化多样性。

在这个难以区分一本计算机书籍与另一本的时代,曼宁通过基于两百年前丰富多样的地区生活的书封面,庆祝了计算机行业的创新精神和主动性,这些生活被 Grasset de Saint-Sauveur 的画作重新赋予了生命。

第一部分:测试 JavaScript 应用程序

无论您是为叔叔的面包店设计网站,还是为股票交易平台设计,您软件的最关键特性就是它是否能够运行。如果您的网站直观且设计美观,您叔叔的客户肯定会订购更多的奶酪蛋糕。同样,如果您的平台快速且响应迅速,华尔街的经纪人会赚更多的钱。然而,如果您的软件不可靠,用户会明显忽视在性能和设计上投入的所有努力。

如果一个程序无法运行,那么它无论多么美观或快速都没有关系。最终,孩子们想要更多的糖,经纪人想要交易更多的股票。他们都不需要更多的软件。

《测试 JavaScript 应用程序》的第一部分解释了自动化测试如何帮助您提供人们想要的东西:能够运行的软件。此外,它还教您如何以更短的时间、更大的信心交付该软件。

在第一章中,我将介绍自动化测试,并描述它们如何帮助您和您的团队。

第二章介绍了多种自动化测试类型。它解释了何时编写每种类型的测试,每种类型的优缺点,以及您将在整本书中应用的基本模式。

1 自动化测试简介

本章涵盖

  • 自动化测试是什么

  • 编写自动化测试的目标

  • 自动化测试如何帮助你更快、更自信地编写更好的代码

当从你叔叔的面包店到国家的经济,所有事情都运行在软件上时,对新能力的需求呈指数级增长,而频繁交付能够正常工作的软件变得愈发关键——希望是每天多次。这正是自动化测试存在的目的。程序员偶尔手动测试软件的奢侈时代已经一去不复返了。现在,编写测试不仅是一种良好的实践,而且已成为行业标准。如果你现在搜索职位发布,几乎所有的职位都要求具备一定程度的自动化软件测试知识。

无论你有多少客户或你处理的数据量有多大,编写有效的测试对于任何规模的公司来说都是一项有价值的实践,从风险投资支持的硅谷巨头到你自己最近刚刚起步的初创公司。对于所有规模的项目,测试都是推荐的,因为它们促进了开发者之间的沟通,并帮助你避免缺陷。由于这些原因,拥有测试的重要性与参与项目的开发者数量和与之相关的失败成本成比例增长。

本书面向的是那些已经能够编写软件但尚未编写测试或不知道为什么这样做至关重要的专业人士。在编写这些页面时,我脑海中想的是那些刚刚从训练营毕业或最近获得第一份开发工作,并希望成长为资深人员的人。我期望读者了解 JavaScript 的基础知识,并理解像承诺(promises)和回调(callbacks)这样的概念。你不需要成为 JavaScript 专家。如果你能编写出能够正常运行的程序,那就足够了。如果你恰好适合这个角色,并且关心生产最有价值的软件——能够正常运行的软件——这本书就是为你而写的。

本书不是针对质量保证专业人士或非技术经理。它从开发者的角度覆盖了主题,侧重于他们如何利用测试的反馈以更快的速度生产更高质量的代码。我不会讨论如何执行手动或探索性测试,也不会讨论如何编写错误报告或管理测试工作流程。这些任务还不能自动化。如果你想了解更多关于它们的信息,建议你寻找针对 QA 角色的书籍。

在整本书中,你将主要使用 Jest 工具。你将通过为几个小型应用程序编写实际的自动化测试来学习。对于这些应用程序,你将使用纯 JavaScript 和流行的库,如 Express 和 React。熟悉 Express 有帮助,尤其是熟悉 React,但即使你不熟悉,简要的研究也应该足够。我会从头开始构建所有示例,并尽可能少地假设知识,所以我建议你在进行之前进行研究。

在第一章中,我们将介绍将渗透到所有后续实践的概念。我发现,糟糕的测试的最主要原因可以追溯到对测试是什么以及它们可以和应该实现什么的不理解,所以这就是我要从哪里开始的地方。

一旦我们了解了测试是什么以及编写测试的目标,我们就会讨论多个案例,其中编写测试可以帮助我们在更短的时间内生产出更好的软件,并促进各种开发者之间的协作。当我们开始编写第二章中的第一个测试时,这些概念基础将至关重要。

1.1 自动化测试是什么?

路易斯叔叔在纽约没有机会,但在伦敦,他因其香草芝士蛋糕而闻名。由于他卓越的受欢迎程度,他很快就注意到,仅用笔和纸经营面包店是无法扩展的。为了跟上激增的订单,他决定雇佣他认识的最好的程序员来建立他的在线商店:你。

他的要求很简单:客户必须能够从面包店订购商品,输入送货地址,并在网上结账。一旦你实现了这些功能,你决定确保商店能够适当地工作。你创建了数据库,初始化它们,启动服务器,并在你的机器上访问网站以尝试订购一些蛋糕。在这个过程中,假设你发现了一个错误。例如,你注意到你一次只能有一个商品单位在你的购物车中。

对于路易斯来说,如果网站带着这样的缺陷上线,那将是一场灾难。众所周知,一次只能吃一个马卡龙,因此,路易斯的拿手好菜——马卡龙将无人问津。为了避免这种情况再次发生,你决定认为添加多个商品单位是一个始终需要测试的场景。

你可以选择手动检查每个发布版本,就像旧式的流水线那样。但这不是一个可扩展的方法。这需要太长时间,而且在任何手动过程中,都很容易出错。为了解决这个问题,你必须用代码来代替你自己,即客户。

让我们思考一下用户是如何告诉你的程序将某物添加到购物车中的。这个练习有助于确定哪些动作流程的部分需要被自动化测试所取代。

用户通过网站与您的应用程序交互,该网站向后端发送 HTTP 请求。此请求通知addToCart函数他们想要将哪些项目和多少单位添加到购物车中。通过查看发送者的会话来识别客户的购物车。一旦项目被添加到购物车中,网站会根据服务器的响应进行更新。此过程如图 1.1 所示。

图片

图 1.1 订单的动作流程

注意:f(x)表示法只是我选择用于本书图表中代表函数的图标。它并不一定表示函数的参数是什么。

让我们用可以调用addToCartFunction的软件组件替换客户。现在,您不需要依赖某人手动添加项目到购物车并查看响应。相反,您有一段代码为您执行验证。那是一个自动化测试。

自动化测试 自动化测试是自动执行测试软件任务的程序。它们与您的应用程序接口,执行操作,并将实际结果与您之前定义的预期输出进行比较。

您的测试代码创建了一个购物车,并告诉addToCart向其中添加项目。一旦收到响应,它会检查请求的项目是否已存在,如图 1.2 所示。

图片

图 1.2 测试addToCart的动作流程

在您的测试中,您可以模拟用户只能向购物车添加单个马卡龙的精确场景:

  1. 创建一个购物车实例。

  2. 调用addToCart并告诉它向该购物车添加一个马卡龙。

  3. 检查购物车是否包含两个马卡龙。

通过使您的测试重现导致错误发生的步骤,您可以证明这个特定的错误不再发生。

我们将要编写的下一个测试是确保能够将多个马卡龙添加到购物车中。这个测试创建了自己的购物车实例,并使用addToCart函数尝试向其中添加两个马卡龙。在调用addToCart函数后,您的测试会检查购物车的内容。如果购物车的内容符合您的预期,它会告诉您一切正常。我们现在可以确信可以添加两个马卡龙到购物车中,如图 1.3 所示。

图片

图 1.3 检查是否可以将多个马卡龙添加到购物车中的测试动作流程

现在客户可以拥有他们想要的任意数量的马卡龙——正如它应该的那样——让我们假设您尝试模拟客户会进行的购买:10,000 个马卡龙。令人惊讶的是,订单通过了,但路易叔叔没有那么多马卡龙库存。由于他的面包店仍然是一家小企业,他也不能在这么短的时间内完成如此巨大的订单。为了确保路易可以准时向每个人提供完美的甜点,他要求您确保客户只能订购库存中的商品。

为了确定哪些操作流程的部分需要由自动化测试替换,让我们定义当客户将商品添加到购物车时应发生什么,并相应地调整我们的应用程序。

当客户点击网站上的“添加到购物车”按钮,如图 1.4 所示,客户端应向服务器发送一个 HTTP 请求,告诉它将 10,000 个马卡龙添加到购物车。在将它们添加到购物车之前,服务器必须咨询数据库以检查库存是否足够。如果库存量小于或等于请求的数量,马卡龙将被添加到购物车,服务器将向客户端发送响应,相应地更新。

注意:你应该为你的测试使用单独的测试数据库。不要用测试数据污染你的生产数据库。

测试将添加和操作各种数据,这可能导致数据丢失或数据库处于不一致状态。

使用单独的数据库也更容易确定错误的根本原因。因为你完全控制测试数据库的状态,客户的操作不会干扰你的测试结果。

图 1.4

图 1.4 添加仅可用的商品到购物车的期望操作流程

这个错误甚至更加关键,所以你需要加倍小心。为了使你的测试更有信心,你可以在实际修复错误之前编写它,这样你就可以看到它是否按预期失败。

唯一有用的测试类型是当你的应用程序不工作时会导致失败的测试

这个测试就像之前的那个一样:它用一段软件代码替换用户,并模拟其操作。不同的是,在这种情况下,你需要额外添加一步,从库存中移除所有马卡龙。测试必须设置场景并模拟导致错误发生的操作;参见图 1.5。

一旦测试到位,修复错误也会更快。每次你进行更改时,你的测试都会告诉你错误是否已消失。你不需要手动登录数据库,移除所有马卡龙,打开网站,并尝试将它们添加到你的购物车。测试可以为你更快地完成这些操作。

因为你也编写了一个测试来检查客户是否可以将多个商品添加到购物车,如果你的修复导致其他错误再次出现,该测试会警告你。测试提供快速反馈,并使你更有信心软件能正常工作。

图 1.5

图 1.5 测试检查我们是否可以将售罄商品添加到购物车的必要步骤

然而,我必须警告你,自动化测试并不是生产出能正常工作的软件的万能药。测试不能证明你的软件能工作;它们只能证明它不能工作。如果你在购物车中添加 10,001 个马卡龙仍然导致其可用性被忽略,除非你测试了这个特定的输入,否则你不会知道。

测试就像实验。你将我们对软件如何工作的期望编码到你的测试中,因为它们在过去通过了,你选择相信你的应用程序在未来将以相同的方式表现,尽管这并不总是正确的。你拥有的测试越多,这些测试越接近真实用户的行为,它们给你的保证就越多。

自动化测试也不能消除手动测试的需求。像最终用户那样验证你的工作,并投入时间进行探索性测试仍然是必不可少的。因为本书的目标读者是软件开发人员而不是质量保证分析师,所以在本章的上下文中,我将经常将开发过程中经常进行的不必要的手动测试过程称为手动测试

1.2 自动化测试的重要性

测试很重要,因为它们能给你快速且可靠的反馈。在本章中,我们将详细探讨如何通过使开发工作流程更加统一和可预测,以及便于重现问题和记录测试用例,来提高软件开发过程的速度和精确度,从而缩短交付高质量软件所需的时间。

1.2.1 可预测性

具有可预测的开发过程意味着在实现功能或修复错误的过程中防止意外行为的引入。减少开发过程中的惊喜数量也使得任务更容易估计,并导致开发者更少地回顾他们的工作。

手动确保你的整个软件按预期工作是一个耗时且容易出错的过程。测试通过减少你编写代码并获得反馈所需的时间来改进这个过程,因此可以更快地修复错误。编写代码和收到反馈之间的距离越小,开发过程就越可预测

为了说明测试如何使开发更具可预测性,让我们想象 Louis 要求你添加一个新功能。他希望客户能够跟踪他们订单的状态。这个功能将帮助他花更多的时间烘焙,而不是花时间接电话来向客户保证他们的订单将准时到达。Louis 对芝士蛋糕充满热情,而不是对电话。

如果你在没有自动化测试的情况下实现跟踪功能,你将不得不手动完成整个购物流程来查看它是否工作,如图 1.6 所示。每次你需要再次测试时,除了重启服务器外,你还需要清除你的数据库以确保它们处于一致状态,打开你的浏览器,将商品添加到购物车,安排配送,完成结账,然后你才能最终测试跟踪订单。

图 1.6 测试跟踪订单的步骤

在你可以手动测试这个功能之前,它需要在网站上可访问。你需要编写它的接口以及客户端与之交互的大块后端代码。

没有自动化测试会导致你在检查功能是否工作之前编写过多的代码。如果你每次更改都必须经历一个漫长而繁琐的过程,你将一次编写更大的代码块。因为编写更大的代码块需要很长时间才能获得反馈,到你最终收到反馈时,可能已经太晚了。你在测试之前已经编写了过多的代码,现在有更多的地方可以隐藏错误。在成千上万的新代码行中,你刚刚看到的错误在哪里?

图

图 1.7 对于trackOrder函数的测试可以直接调用该函数,因此你不需要触及应用程序的其他部分。

使用如图 1.7 所示的自动化测试,你可以在获得反馈之前编写更少的代码。当你的自动化测试可以直接调用trackOrder函数时,你可以在确认trackOrder正常工作之前避免触及应用程序中不必要的部分。

当你在编写了 10 行代码之后测试失败,你只需要担心这 10 行代码。即使错误不在这 10 行代码中,它也变得更容易检测出是哪一行引发了其他地方的异常行为。

如果你破坏了应用程序的其他部分,情况可能会变得更糟。如果你在结账过程中引入了错误,你需要检查你的更改对其产生了怎样的影响。你做的更改越多,找到问题所在就越困难。

当你拥有如图 1.8 所示的自动化测试时,它们可以在出现问题后立即提醒你,这样你就可以更容易地纠正方向。如果你经常运行测试,你将能够在你破坏它时立即获得关于应用程序哪个部分出错的精确反馈。记住,一旦你编写了代码,获得反馈所需的时间越少,你的开发过程就越可预测

图

图 1.8 自动化测试可以单独检查你的代码部分,并在你破坏它时立即提供关于什么出错的精确反馈。

我经常看到开发者不得不丢弃工作,因为他们一次做了太多的更改。当这些更改导致应用程序的许多部分出现问题时,他们不知道从哪里开始。从头开始比修复他们已经造成的混乱要容易得多。你有多少次这样做过?

1.2.2 可重复性

一个特定任务包含的步骤越多,人类在遵循这些步骤时犯错误的可能性就越大。自动化测试使得重现错误和确保它们不再存在变得更加容易和快捷。

为了让客户跟踪订单状态,他们必须经过多个步骤。他们需要将商品添加到购物车中,选择送货日期,并通过结账流程。为了测试你的应用程序并确保它对客户有效,你必须做同样的事情。这个过程相当长且容易出错,你可以以许多不同的方式处理每个步骤。通过自动化测试,我们可以确保这些步骤被严格遵循。

假设你在测试应用程序时发现了一些错误,比如能够用空购物车或无效的信用卡结账。为了找到这些错误,你必须手动完成一系列步骤。

为了避免这些错误再次发生,你必须重现导致每个错误的完全相同的步骤。如果测试案例列表太长或步骤太多,人为错误的空间就更大了。除非你有一个每次都必须严格遵循的清单,否则错误会悄悄溜进来(见图 1.9)。

订购蛋糕是你一定会记得检查的事情,但订购-1 个蛋糕,或者甚至NaN个蛋糕呢?人们会忘记并犯错误,因此软件会出错。人类应该做人类擅长的事情,而执行重复性任务并不是其中之一。

图片

图 1.9 测试每个功能时必须遵循的步骤

即使你决定为这些测试案例维护一个清单,你也将不得不承担保持该文档始终更新的开销。如果你忘记更新它,并且发生了测试案例中没有描述的事情,那么错的是应用程序还是文档?

自动化测试每次执行时都会执行完全相同的操作。当机器运行测试时,它既不会忘记任何步骤,也不会犯错误。

1.2.3 协作

每个品尝过路易斯班夫提尼派的人都知道,他离成为明星的Great British Bake Off只有一步之遥。如果你在软件方面做得一切正确,也许有一天他会从旧金山到圣彼得堡的每个地方都开设面包店。在这种情况下,一个开发者显然是不够的。

如果你雇佣其他开发者与你一起工作,突然之间,你开始有新的和不同的担忧。如果你正在实施一个新的折扣系统,而爱丽丝正在实施生成优惠券的方法,如果你的结账程序更改使得客户无法将优惠券应用于他们的订单,你会怎么办?换句话说,你如何确保你的工作不会干扰她的工作,反之亦然?

如果爱丽丝首先将她的功能合并到代码库中,你必须问她你应该如何测试她的工作以确保你的工作没有破坏它。合并你的工作将消耗你的时间和爱丽丝的时间。

你和爱丽丝手动测试更改所花费的努力,在将你的工作与她的工作集成时也必须重复。除此之外,还需要额外的努力来测试这两个更改之间的集成,如图 1.10 所示。

图片

图 1.10 手动测试时在每个开发阶段验证更改所需的工作量

除了耗时之外,这个过程还容易出错。你必须记住在您的工作和爱丽丝的工作中测试的所有步骤和边缘情况。即使你记得,你仍然需要严格按照这些步骤进行。

当程序员为他们的功能添加自动化测试时,每个人都会受益。如果爱丽丝的工作有测试,你就不需要问她如何测试她的更改。当你合并这两部分工作的时候,你可以简单地运行现有的自动化测试,而不是再次经历整个手动测试过程。

即使你的更改建立在她的基础上,测试也将作为最新的文档来指导进一步的工作。编写良好的测试是开发者能拥有的最佳文档。因为它们需要通过,所以它们总是最新的。如果你无论如何都要编写技术文档,为什么不写一个测试呢?

如果你的代码与爱丽丝的代码集成,你也将添加更多自动化测试,这些测试将覆盖你和她工作之间的集成。这些新测试将在后续开发者实现相关功能时使用,因此可以节省他们的时间。每次你进行更改时都编写测试,可以创造一个良性的协作循环,其中一个开发者帮助那些将要接触代码库该部分的人(见图 1.11)。

这种方法减少了沟通的开销,但并没有消除沟通的需求,这是每个项目成功的基础。自动化测试显著提高了协作过程,但与代码审查等其他实践相结合时,它们会变得更加有效。

图片

图 1.11 存在自动化测试时在每个开发阶段验证更改所需的工作量

软件工程中最具挑战性的任务之一是让多个开发者高效协作,而测试是实现这一目标最有用的工具之一。

1.2.4 速度

路易不在乎你使用哪种语言,更不用说你对多少测试感兴趣了。路易只想卖糕点、蛋糕以及他能制作出的任何其他甜点。路易关心的是收入。如果更多的功能能让客户更满意并带来更多收入,那么他希望你尽可能快地提供这些功能。只有一个前提:它们必须能正常工作。

对于企业来说,重要的是速度和正确性,而不是测试。在前面的所有章节中,我们讨论了测试如何通过使其更具可预测性、可重复性和协作性来改进开发过程,但最终,这些好处仅因为它们帮助我们以更短的时间生产更好的软件。

当你产生代码的时间更短,证明它没有特定的错误,并将其与其他人的工作集成时,业务就会成功。当你防止回归时,业务就会成功。当你使部署更安全时,业务就会成功。

因为编写测试需要时间,所以它们确实有成本。但我们坚持编写测试,因为其带来的好处远远超过了缺点。

初始时,编写测试也可能很耗时,甚至比手动测试还多,但随着你运行它的次数越多,你从中提取的价值就越大。如果你手动测试需要一分钟,而你编写一个自动化测试需要五分钟,一旦它运行第五次,它就会收回成本——相信我,这个测试将会运行超过五次。

与手动测试相比,手动测试始终需要相同的时间或更多,而自动化测试会导致运行测试所需的时间和精力几乎降至零。随着时间的推移,手动测试的总投入增长得更快。编写自动化测试和执行手动测试之间的这种努力差异在图 1.12 中得到了说明。

图 1.12

图 1.12 手动测试与自动化测试随时间投入的努力对比

编写测试就像购买股票。你可能需要 upfront 支付一大笔钱,但你会长期持续获得回报。就像在金融领域一样,你将进行的投资类型——以及你是否会进行投资——取决于你需要回款的时间。长期项目是最能从测试中受益的。项目运行时间越长,节省的精力越多,你可以在新功能或其他有意义的活动上投入得更多。例如,像在披萨驱动的黑客马拉松中制作的短期项目,受益不大。它们存活的时间不够长,无法证明通过测试节省的努力是合理的。

上次路易斯问你,如果你不编写这么多测试,能否更快地交付功能时,你并没有使用金融类比。你告诉他,这就像提高烤箱温度以更快地让蛋糕熟透一样。边缘会被烧焦,但中间仍然生硬。

摘要

  • 自动化测试是自动执行测试软件任务的程序。这些测试将与你的应用程序交互,并将其实际输出与预期输出进行比较。当输出正确时,它们会通过,并在输出不正确时提供有意义的反馈。

  • 永远不会失败的测试是无用的。拥有测试的目标是当应用程序出现异常时,测试不再出现失败。

  • 你无法证明你的软件是有效的。你只能证明它不是。测试表明特定的错误不再存在——而不是没有错误。几乎无限数量的可能输入可以提供给你的应用程序,测试所有这些是不切实际的。测试倾向于覆盖你之前看到的错误或你想要确保会正常工作的特定情况。

  • 自动化测试缩短了编写代码和获取反馈之间的距离。因此,它们使你的开发过程更加结构化,并减少了意外情况的数量。可预测的开发过程使估计任务变得更容易,并允许开发者更少地回顾他们的工作。

  • 自动化测试总是遵循一系列精确的步骤。它们不会忘记或出错。它们确保测试用例被彻底执行,并使重现错误变得更加容易。

  • 当测试自动化时,返工和沟通开销会减少。开发者可以立即验证他人的工作,并确保他们没有破坏应用程序的其他部分。

  • 编写良好的测试是开发者所能拥有的最佳文档。因为测试需要通过,所以它们必须始终是最新的。它们展示了 API 的使用方式,并帮助他人理解代码库的工作原理。

  • 企业不关心你的测试。企业关心的是盈利。最终,自动化测试是有帮助的,因为它们通过帮助开发者更快地交付高质量软件来提高利润。

  • 在编写测试时,你需要在创建它们上投入额外的时间,这会 upfront 支付一大笔代价。然而,你会在回报中获取价值。测试运行得越频繁,为你节省的时间就越多。因此,项目的生命周期越长,测试就越关键。

2 应该测试什么以及何时测试?

本章涵盖

  • 不同类型的测试及其使用时机

  • 编写你的第一个自动化测试

  • 如何平衡耦合、维护和成本

在上一章中,为了便于解释测试是什么以及它们的益处,我将所有不同类型的测试放入一个单一的大概念框中。我展示了处理数据库的测试、直接调用一个函数的测试以及调用多个函数的测试。在本章中,我将测试从那个框中取出,并将它们放入单独的架子,每个架子都包含不同类型的测试。

理解测试如何适应不同的类别是至关重要的,因为不同类型的测试服务于不同的目的。例如,在制造汽车时,单独测试发动机和点火系统是至关重要的,但同样重要的是确保它们能协同工作。如果不这样,发动机和点火系统都将毫无用处。当所有部件都到位后,测试人们是否能够驾驶汽车同样重要,否则没有人会去任何地方。

当我们构建软件时,我们希望有类似的保证。我们希望确保我们的函数在独立以及集成状态下都能正常工作。而且,当我们把这些函数全部整合到一个应用程序中时,我们希望确保客户能够使用它。

这些不同类型的测试服务于不同的目的,运行频率不同,完成所需时间也不同。有些更适合指导你通过开发阶段,而有些则可以在功能完成后更容易地进行测试。有些测试直接与你的代码接口,而有些则通过图形界面与你的应用程序交互,就像最终用户一样。决定使用哪种测试以及何时使用是你的职责。

我将通过为小型函数和应用程序编写示例来教你这些不同类型的测试。在整个章节中,我将避免过度规定。相反,我将专注于每种测试的结果和缺点,以便你可以做出自己的决定。我希望赋予你权力,在项目开发的各个阶段决定哪种类型的测试将对你最有益,并给你一种如何将不同类型的测试融入工作流程的感觉。

了解这些不同的标签是有帮助的,因为它们帮助你决定在每种情况下你的测试应该覆盖什么以及不应该覆盖什么。在现实中,这些定义有些模糊。你很少会主动地为不同类型的测试贴上标签,但知道标签的存在并为每个标签提供良好的示例对于创建强大的质量保证和与同事进行明确沟通是无价的。

2.1 测试金字塔

路易斯的面包店致力于生产东伦敦有史以来最好的糕点。路易斯和他的团队仔细检查每一个成分,以确保它们新鲜且质量上乘。他的奶酪蛋糕的所有部分也是如此。从外壳到面糊,食谱中的每一步都要经过严格的质量控制,以检查其质地和一致性。对于每个制作的奶酪蛋糕,路易斯还确保烤一个“样品”:一小块单独的蛋糕供他品尝——这是一种甜蜜的奖励,也是路易斯的奶酪蛋糕无疑是美味无比的最终证明。

当你保持你的甜点达到如此高的标准时,你不想你的软件落后。为此,我们可以从路易斯确保他的烘焙食品是镇上最好的方式中学到很多东西。

就像低质量的成分会毁掉一块蛋糕一样,编写不良的函数会毁掉一个软件组件。如果你的函数不起作用,那么你的整个应用程序也不会。测试这些微小的软件组件是实现高质量数字产品的第一步。

下一步是确保这个过程中的所有中间产品都与其各个部分一样高质量。当将这些函数组合成更大的组件时,就像将成分混合成面团一样,你必须确保混合物与其单个项目一样好。

最后,就像路易斯像他的顾客一样品尝蛋糕一样,我们也必须像我们的用户一样尝试我们的软件。如果所有模块都工作正常,但应用程序本身不工作,那么它就是一个无用的产品。

  • 测试单个成分。

  • 测试主要成分组合成中间产品。

  • 测试最终产品。

迈克·科恩的测试金字塔(图 2.1)——这个隐喻的名字指定了这个部分——来源于这样一个想法,即你的软件的不同部分必须以不同的方式和不同的频率进行测试。

图片

图 2.1 迈克·科恩的测试金字塔

它将测试分为以下三个类别:

  • UI 测试

  • 服务测试

  • 单元测试

在金字塔中,测试的层级越高,它们运行的频率越低,提供的价值越大。顶部的测试很少,底部的测试很多。

单元测试证明了你的软件中最原子单位的品质:你的函数。服务测试确保这些函数作为服务整合时能正常工作。UI 测试通过用户界面与你的软件交互,从用户的角度验证你的工作。

金字塔各层的尺寸表示我们应该编写多少种此类测试。它们在金字塔中的位置暗示了这些测试提供的保证有多强。测试在金字塔中的位置越高,它的价值就越大。

回到我们的烘焙类比:单元测试类似于检查单个原料。这是一个相对快速且便宜的任务,可以在整体过程的早期多次进行,但与进一步的质量控制步骤相比,它提供的价值很小。单元测试位于金字塔的底部,因为我们有很多这样的测试,但它们的质量保证并不像其他测试那样严格。

服务测试类似于检查食谱的中间产品。与检查单个原料相比,这些测试相对更复杂,只能在整体过程的后期阶段进行。尽管如此,它们提供了更有力的证据,表明即将出现一个美味的芝士蛋糕。它们适合位于金字塔的中间部分,因为你应该有比单元测试更少的服务测试,并且因为它们提供了更强的质量保证。

UI 测试类似于完成芝士蛋糕后品尝它。它们告诉你最终产品是否符合你的预期。要执行这些测试,你必须已经完成了整个食谱,并且有一个成品。它们位于金字塔的顶部,因为这些测试应该是最不规律的,并且是提供最严格保证的测试。

金字塔的每一层测试都是建立在下一层之上的。它们都帮助我们断言最终产品的质量,但在不同的过程阶段。例如,如果没有新鲜的原料,你就无法做出豪华的面糊。此外,没有豪华的面糊,你就无法做出美味的芝士蛋糕。

警告:这个术语在整个行业中并不一致使用。你可能会看到人们用不同的名字来指代这些相同的类别。这些类别之间的界限模糊,就像我们在看到源代码时区分不同类型的测试一样。

迈克的金字塔在一般情况下是一个优秀的心智框架。将测试分为不同的类别对于确定我们应该编写多少每种类型的测试以及它们应该多久运行一次非常有帮助。但我发现按目标划分测试是有问题的,无论是功能、服务还是接口。

例如,如果你正在编写针对 Web 应用的测试,是否应该将其所有测试都视为 UI 测试?尽管你在测试客户端本身,但你可能需要对单个功能进行单独的测试,以及与 GUI 实际交互的其他测试。如果你的产品是一个 RESTful API,并且你通过发送 HTTP 请求来测试它,这是服务测试还是 UI 测试?尽管你在测试一个服务,但 HTTP API 是提供给用户的一个接口。

与其按测试的目标来划分测试,我建议我们根据测试的范围广度来区分测试。测试覆盖你软件的比重越大,它在金字塔中的位置就越高。

这个修订后的金字塔(如图 2.2 所示)也将测试分为三个类别,但标签不同,并使用每个测试的隔离级别作为其划分的主要标准。新的标签如下:

  • 端到端测试

  • 集成测试

  • 单元测试

图 2.2 原始测试金字塔的修订版

单元测试与迈克原始的金字塔相同。它们验证软件的最基本的构建块:其函数。与第一章中单个函数直接交互的测试属于这一类别。这些测试的范围是最小的,并且只断言单个函数的质量。

集成测试验证软件的不同部分如何协同工作。调用函数并检查数据库中是否有更新项的测试属于这一类别。第一章中的集成测试示例确保只能将可用的项目添加到购物车中。这些测试的范围比单元测试的范围更广,但比端到端测试的范围小。它们断言过程中间步骤的质量。

端到端测试从用户的角度验证您的应用程序,尽可能将您的软件视为黑盒。控制网络浏览器并通过点击按钮和验证标签与您的应用程序交互的测试属于这一类别。端到端测试相当于品尝一块奶酪蛋糕的样品。其范围是整个应用程序及其功能。

与现实世界一样,测试不一定需要属于某一类别或另一类别。很多时候,它们会介于各组之间,这是可以的。这些类别并不是为了我们在每个测试上方写标签而存在的。它们存在是为了指导我们编写更好、更可靠的软件,指示我们应该编写哪些测试,何时编写,以及编写多少。有关不同类型测试不同方面的详细比较,请参阅表 2.1。

表 2.1 各类测试的特点

单元测试 集成测试 端到端测试
目标 单个函数 可观察行为和多个函数之间的集成 面向用户的功能
数量 许多——每个函数几个测试 比较频繁——每个可观察行为几个测试 稀疏——每个功能几个测试
速度 非常快——通常几毫秒 平均——通常几秒以内 慢——通常几秒或更复杂的情况下几分钟
执行频率 在函数开发过程中多次执行 在功能开发过程中定期执行 功能完成时
反馈级别 单个函数的具体问题输入和输出 问题行为 错误的功能
成本 便宜——通常规模小,更新、运行和理解速度快 中等——中等规模,执行速度合理 贵——运行时间长,往往更易出错且复杂
对应用程序的了解 互连——需要直接访问代码本身;处理其功能 处理功能,但也通过直接访问代码;需要访问数据库、网络或文件系统等组件 尽可能不了解代码;通过用户界面与应用程序交互
主要目标 在开发期间提供快速反馈,帮助重构,防止回归,并通过提供使用示例来记录代码的 API 保证第三方库得到充分使用,并检查被测试的单元是否执行了必要的副作用,例如记录或与独立服务交互 保证应用程序对最终用户有效

使用这个新的分类法,让我们思考如何将特定的测试示例进行分类,以及它们将如何融入我们修订后的测试金字塔中。

如果你的最终产品是一个 RESTful API,向其发送请求的测试就是一种端到端测试。如果你构建了一个与该 API 通信的 Web 应用程序,那么从用户角度打开网页并与它交互的测试也是端到端测试,但它们应该放在金字塔的更高位置。

对你的 React 组件的测试位于集成和单元层之间。你可能正在测试 UI,但你通过与 React API 集成,通过与应用程序的单个部分交互来引导你的开发过程。

注意:记住不要过于担心测试是否适合某一类别。金字塔作为一个思维框架,帮助你思考围绕软件想要创建的不同类型的保证。因为每个软件都是不同的,某些金字塔可能底部较窄或顶部较宽,但作为一般规则,你应该努力保持金字塔的形状。

2.2 单元测试

就像没有新鲜原料就无法制作美味的甜点一样,没有编写良好的函数就无法编写优秀的软件。单元测试帮助你确保软件的最小单元,即你的函数,按照你的预期行为。在本节中,你将编写你的第一个自动化测试:单元测试。

为了精确地可视化这些测试覆盖的范围,假设面包店的在线商店(其组件如图 2.3 所示)由一个 React 客户端和一个与数据库和电子邮件服务通信的 Node.js 后端组成。

图 2.3 面包店的网站基础设施

你将要编写的测试将覆盖这个应用程序的一小部分。它们将仅处理你服务器内的单个函数。

单元测试位于金字塔的底部,因此它们的范围,如图 2.4 所示,很小。随着我们向上移动,你会看到测试覆盖的表面会增大。

图 2.4 单元测试的范围

图 2.4 单元测试的范围

首先,编写列表 2.1 中所示的函数,这将作为你的测试目标。创建一个名为 Cart.js 的文件,并编写一个具有 addToCart 函数的 Cart 类。

单元测试 大多数与测试相关的文献将你的测试目标称为 单元测试对象

注意:本书中的所有代码也都在 GitHub 上提供,地址为 github.com/lucasfcosta/testing-javascript-applications

列表 2.1 Cart.js

class Cart {
    constructor() {
        this.items = [];
    }

    addToCart(item) {
        this.items.push(item);
    }
}

module.exports = Cart;

现在考虑如何测试 addToCart 函数。一种方法是将它集成到实际应用程序中并使用它,但这样我们会遇到我们在第一章中提到的涉及时间、可重复性和成本的问题。

在能够测试你的代码之前,必须编写整个应用程序,这需要编写太多的代码,在知道它是否工作之前。此外,如果它不起作用,将很难找到错误。一种更快的方法是编写代码,导入你的 Cart,使用其 addToCart 函数,并验证结果。

继续编写一个 Cart.test.js 文件,导入你的 Cart,使用其 addToCart 函数,并检查购物车中是否有你预期的商品,如列表 2.2 所示。

列表 2.2 Cart.test.js

const Cart = require("./Cart.js");

const cart = new Cart();
cart.addToCart("cheesecake");

const hasOneItem = cart.items.length === 1;
const hasACheesecake = cart.items[0] === "cheesecake";

if (hasOneItem && hasACheesecake) {                                      ❶
  console.log("The addToCart function can add an item to the cart");
} else {                                                                 ❷
  const actualContent = cart.items.join(", ");                           ❸

  console.error("The addToCart function didn't do what we expect!");
  console.error(`Here is the actual content of the cart: ${actualContent}`);

  throw new Error("Test failed!");
}

❶ 如果两个检查都成功,将在控制台打印成功信息

❷ 如果任何测试失败,将打印错误信息

❸ 在测试的错误信息中创建一个以逗号分隔的实际商品列表以显示

当你使用 node 执行此文件 Cart.test.js 时,它会告诉你代码是否能够成功将芝士蛋糕添加到购物车中——即时且精确的反馈。

恭喜!你已经编写了你的第一个测试。

测试设置一个场景,执行目标代码,并验证输出是否与预期相符。因为测试往往遵循这个相同的公式,所以你可以使用工具来抽象出代码的测试特定关注点。例如,这些关注点之一就是比较实际输出是否与预期输出匹配。

Node.js 本身带有一个内置模块,称为 assert,用于执行这些检查,在测试的上下文中,我们称之为 断言。它包含比较对象和如果实际输出与预期不符则抛出带有有意义信息的错误的功能。

注意:您可以在 nodejs.org/api/assert.html 找到 Node.js 内置 assert 库的文档。

使用 assertdeepStrictEqual 函数来比较实际输出与预期输出,从而缩短测试,如下所示。

列表 2.3 Cart.test.js

const assert = require("assert");
const Cart = require("./Cart.js");

const cart = new Cart();
cart.addToCart("cheesecake");

assert.deepStrictEqual(cart.items, ["cheesecake"]);       ❶

console.log("The addToCart function can add an item to the cart");

❶ 比较第一个和第二个参数,如果它们的值不同,则抛出一个有洞察力的错误

使用断言库可以使你摆脱确定对象是否相等的复杂逻辑。它还会生成有意义的输出,因此你不必自己操作字符串。

尝试向作为assert.deepStrictEqual第二个参数传递的数组中添加一个新项目,以便你可以看到断言失败时它产生的输出类型。

现在假设你实现了一个removeFromCart函数,如下所示。

列表 2.4 Cart.js

class Cart {
  constructor() {
    this.items = [];
  }

  addToCart(item) {
    this.items.push(item);
  }

  removeFromCart(item) {
    for (let i = 0; i < this.items.length; i++) {
      const currentItem = this.items[i];
      if (currentItem === item) {
        this.items.splice(i, 1);
      }
    }
  }
}

module.exports = Cart;

你会如何测试它?可能,你会写一些像以下这样的代码。

列表 2.5 Cart.test.js

const assert = require("assert");
const Cart = require("./Cart.js");

const cart = new Cart();                     ❶
cart.addToCart("cheesecake"); )              ❶
cart.removeFromCart("cheesecake");           ❷

assert.deepStrictEqual(cart.items, []);      ❸

console.log("The removeFromCart function can remove an item from the cart");

❶ 向购物车添加项目

❷ 移除最近添加的项目

❸ 检查购物车的项目属性是否为空数组

首先,你的测试通过向购物车添加芝士蛋糕来设置一个场景。然后它调用你想要测试的函数(在这种情况下,removeFromCart)。最后,它检查购物车的内容是否与你预期的相符。再次,同样的公式:设置、执行和验证。这个序列也被称为三个 A 模式:安排(arrange)、行动(act)、断言(assert)

现在你有多个测试,考虑一下你如何将它们添加到你的Cart.test.js中。如果你直接在旧测试之后粘贴你的新测试,如果第一个测试失败,它将不会运行。你还将必须小心地为两个测试中的变量赋予不同的名称。但最重要的是,它将变得难以阅读和解释每个测试的输出。说实话,这会变得有些混乱。

测试运行器可以解决这个问题。它们使你能够以综合的方式组织和运行多个测试,提供有意义的且易于阅读的结果。

目前,在 JavaScript 生态系统中最受欢迎的测试工具被称为 Jest。它是我在这本书中会使用的主要工具。

Jest 是由 Facebook 创建的测试框架。它专注于简洁性,因此它包含了你开始编写测试所需的一切。

让我们安装 Jest,这样我们就可以更简洁地编写单元测试。使用命令npm install -g jest全局安装它。

没有配置文件jest.config.jspackage.json文件,Jest 将不会运行,所以请记住将package.json文件添加到包含你的代码的文件夹中。

提示:你可以通过运行npm init -y快速向文件夹添加默认的package.json文件。

现在,你将不再手动使用 Node.js 运行测试文件,而是使用 Jest 并告诉它加载和执行测试。

注意:默认情况下,Jest 加载所有以.test.js.spec.js结尾的文件,或者名为tests的文件夹内的测试。

通过将它们封装到 Jest 添加到全局作用域的 test 函数中,为 Jest 运行测试准备你的测试。你可以使用此函数在单个文件中组织多个测试,并指示应该运行什么。它将测试的名称作为第一个参数,将包含实际测试的回调函数作为第二个参数。

一旦将之前的测试封装到 Jest 的 test 函数中,你的 Cart.test.js 文件应该看起来像这样。

列表 2.6 Cart.test.js

const assert = require("assert");
const Cart = require("./Cart.js");

test("The addToCart function can add an item to the cart", () => {          ❶
  const cart = new Cart();                                                  ❷
  cart.addToCart("cheesecake");                                             ❸

  assert.deepStrictEqual(cart.items, ["cheesecake"]);                       ❹
});

test("The removeFromCart function can remove an item from the cart", () => {❺
  const cart = new Cart();                                                  ❻
  cart.addToCart("cheesecake");                                             ❻
  cart.removeFromCart("cheesecake");                                        ❼

  assert.deepStrictEqual(cart.items, []);                                   ❽
});

❶ 将第一个测试封装到不同的命名空间中,隔离其变量并生成更易读的输出

❷ 安排:创建一个空购物车

❸ 执行:执行 addToCart 函数

❹ 断言:检查购物车是否包含新添加的项目

❺ 将第二个测试封装到不同的命名空间中

❻ 安排:创建一个空购物车,并向其中添加一个项目

❼ 执行:执行 removeFromCart 函数

❽ 断言:检查购物车是否为空

注意你是如何通过委托该任务给 Jest 来消除之前用于确定如何生成输出的 if 语句的。每当测试失败时,Jest 都会提供精确的差异,以便你可以看到实际输出与预期输出有何不同。为了了解 Jest 的反馈有多好,尝试更改一个断言使其失败。

最后,为了避免在测试中使用除 Jest 之外的其他任何东西,将 assert 库替换为 Jest 自带的替代品:expectexpect 模块就像 Node.js 的 assert 模块一样,但它针对 Jest 进行了定制,有助于它提供更加有用的反馈。

test 函数一样,在 Jest 中运行测试时,expect 函数在全局范围内可用。expect 函数接受断言的实际主题作为参数,并返回一个对象,该对象提供了不同的 匹配器 函数。这些函数验证实际值是否与你的期望相符。

Jest 的 deepStrictEqual 等价于 toEqual。将第一个测试的 deepStrictEqual 替换为 toEqual 应该会使你的代码看起来类似于以下列表。

列表 2.7 Cart.test.js

test("The addToCart function can add an item to the cart", () => {
  const cart = new Cart();
  cart.addToCart("cheesecake");

  expect(cart.items).toEqual(["cheesecake"]);     ❶
});

❶ 比较 expect 的目标值(提供给 expect 的参数)与传递给 toEqual 的参数值

尝试通过在第二个测试中也替换 deepStrictEqual 来消除导入 Node.js 的 assert 库的必要性。

重要提示:在“严格”相等检查和“深度”相等检查之间存在差异。“深度相等”验证两个不同的对象是否具有相等的值。“严格相等”验证两个引用是否指向同一个对象。在 Jest 中,你使用 toEqual 执行深度相等检查,使用 toBe 执行严格相等检查。阅读 Jest 的文档了解 toEqual 匹配器的更多信息。它可在 jestjs.io/docs/en/expect#toequalvalue 找到。

到目前为止,你一直使用 Jest 的全局安装来运行你的测试,这并不是一个好主意。如果你使用的是仅在 Jest 最新版本中可用的断言,而你的某个同事的全局安装版本比你的旧,如果断言的行为从一个版本到另一个版本发生了变化,测试可能会失败。

你希望测试只在应用程序出现问题时失败,而不是当人们运行不同版本的测试框架时。

通过运行 npm install jest --save-dev 来安装 Jest 作为 devDependency 解决这个问题。它应该是一个 devDependency,因为它在你发布应用程序时不需要可用。它只需要在开发者的机器上可用,这样他们才能在下载项目并运行 npm install 后执行测试。

运行那个命令后,你会看到你的 package.json 文件现在列出了其 devDependencies 中的 Jest 的特定版本。

注意:你注意到你的 package.json 中的 Jest 版本前面有一个 ^ 吗?这个 ^ 表示当运行 npm install 时,NPM 将安装 Jest 的最新 major 版本。换句话说,最左边的版本号将不会改变。

理论上,在遵循语义版本化实践的情况下,任何非主要升级都应该向后兼容,但现实中它们并不总是这样。要强制 NPM 在运行 npm install 时安装 Jest 的确切版本,请删除 ^

我强烈建议读者了解更多关于语义版本化是什么以及它是如何工作的信息。semver.org 是一个关于这个主题的优秀资源。

你的项目依赖项,包括 Jest,都在 node_modules 文件夹中。你可以通过运行位于 node_modules/.bin/jest 的构建版本来运行你在 package.json 中指定的 Jest 的特定版本。现在执行那个文件。你会看到它产生了与之前相同的输出。

尽管如此,每次我们想要运行测试时,仍然需要输入项目 Jest 安装的完整路径,这仍然很麻烦。为了避免这种情况,编辑你的 package.json 文件,并创建一个 test 脚本,这样每次运行 npm test 命令时都会执行项目的 Jest 安装。

在你的 package.json 中的 scripts 下添加一个 test 属性,并指定它应该运行 jest 命令,如下所示。

列表 2.8 package.json

{
  "name": "5_global_jest",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"           ❶
  },
  "devDependencies": {
    "jest": "²⁶.6.0"
  }
}

❶ 当运行 npm test 时,会运行项目的 jest 可执行文件

在创建这个 NPM 脚本后,每当有人想要执行你的项目测试时,他们可以运行 npm test。他们不需要知道你使用的是哪个工具,也不必担心他们可能需要传递给它的任何其他选项。package.json 中的 test 脚本内的任何命令都会运行。

注意:当你运行在 package.json 脚本中定义的命令时,它会启动一个新的 shell 环境,该环境将 ./node_modules/.bin 添加到其 PATH 环境变量中。因为这个 PATH,你不需要在命令前加上 ./node_modules/.bin 前缀。默认情况下,你安装的任何库都将被优先考虑。

作为练习,我建议添加更多操作购物车中项目的函数,并使用其他 Jest 匹配器为它们编写测试。

一旦你添加了更多的测试,尝试重构 Cart 类,使其方法不会修改由购物车的 items 属性引用的数组,并查看测试是否仍然通过。

在重构时,你想要确保你可以在保持相同功能的同时以不同的方式塑造你的代码。因此,拥有严格的单元测试是在重构过程中获得快速和精确反馈的绝佳方式。

单元测试通过在编写代码时提供快速反馈,帮助你自信地迭代,正如我们在第九章详细讨论测试驱动开发时将看到的。因为单元测试的范围仅限于一个函数,它们的反馈是狭窄且精确的。它们可以立即告诉哪个函数失败了。这样的严格反馈使得编写和修复代码更快。

这些测试成本低廉且易于编写,但它们只覆盖了你应用程序的一小部分,它们提供的保证较弱。仅仅因为函数在几个独立情况下工作良好,并不意味着你的整个软件应用程序也能工作。为了最大限度地利用这些狭窄且成本低的测试,你应该编写很多这样的测试。

考虑到单元测试数量众多且成本低廉,运行速度快且频繁,我们将这些测试放在测试金字塔的底部,如图 2.5 所示。它们是其他测试建立的基础。

图 2.5 测试金字塔中单元测试的位置

2.3 集成测试

当查看应用程序的基础架构图时,你会看到集成测试的范围,如图 2.6 所示,比单元测试的范围更广。它们检查你的函数如何交互以及你的软件如何与第三方集成。

图 2.6 集成测试的范围

集成测试帮助你确保你的软件的不同部分可以协同工作。例如,它们帮助你验证你的软件是否适当地与第三方 RESTful API 通信,或者它是否可以操作数据库中的项目。

让我们从创建最经典的集成测试示例之一开始:一个与数据库通信的测试。在本节的示例中,我将使用 knexsqlite3 包。Knex 是一个查询构建器,可以在 sqlite3 之上操作。Knex 将使您更容易与 sqlite3 数据库接口。因为这两个包需要在应用程序运行时可用,所以您必须将它们作为依赖项而不是开发依赖项安装。请运行 npm install --save knex sqlite3 来完成此操作。

注意:默认情况下,NPM 将保存这些包并自动将它们添加为依赖项。您可以通过在 install 命令后附加 --save 选项来使此操作明确。

将您的数据库配置放在项目根目录下名为 knexfile.js 的文件中。它应该包含以下内容。

列表 2.9 knexfile.js

module.exports = {
  development: {
    client: "sqlite3",                         ❶
    connection: { filename: "./dev.sqlite" },  ❷
    useNullAsDefault: true                     ❸
  }
};

❶ 使用 sqlite3 作为数据库客户端

❷ 指定数据库将存储其数据文件的文件

❸ 使用 NULL 而不是 DEFAULT 为未定义的键

与您在上一章中使用的 Cart 类不同,这次您将创建一个包含购物车 id 和其所有者名称的表。然后,您将创建一个单独的表来存储每个购物车中的项目。

注意:因为这本书是关于测试而不是数据库,所以我选择了最简单的数据库设计。要了解更多关于数据库系统,我强烈推荐由 Ramez Elmasri 和 Shamkant B. Navathe 编写的《数据库系统基础》(Pearson,2016)。

当使用 Knex 时,您通过 migrations 定义您表的结构。Knex 使用一个数据库表来跟踪已运行的迁移和新迁移。它使用这些记录来确保您的数据库始终具有当前的模式。

通过运行 ./node_modules/.bin/knex migrate:make --env development create_carts 创建一个空迁移,使用您的项目安装的 Knex。此命令将在 migrations 目录中创建一个以当前时间开始并以 create_carts.js 结尾的文件。使用以下代码创建 cartscart_items 表。

列表 2.10 CURRENTTIMESTAMP_create_carts.js

exports.up = async knex => {                                ❶
  await knex.schema.createTable("carts", table => {         ❷
    table.increments("id");
    table.string("username");
  });

  await knex.schema.createTable("carts_items", table => {   ❸
    table.integer("cartId").references("carts.id");         ❹
    table.string("itemName");
  });
};

exports.down = async knex => {                              ❺
  await knex.schema.dropTable("carts");
  await knex.schema.dropTable("carts_items");
};

❶ 导出的 up 函数将数据库迁移到下一个状态。

❷ 为应用程序的购物车创建一个包含用户名列和自动递增的 id 列的表

❸ 创建一个用于跟踪每个购物车中项目的 carts_items 表

❹ 创建一个引用 carts 表中购物车 id 的 cartId 列

❺ 导出的 down 函数将数据库迁移到之前的状态,删除购物车和 carts_items 表。

要执行 migrations 文件夹中的所有迁移,请运行 ./node_modules/.bin/knex migrate:latest

现在,您最终可以创建一个模块,其中包含向您的 SQLite 数据库添加项的方法,如下所示。

列表 2.11 dbConnection.js

const db = require("knex")(require("./knexfile").development);    ❶

const closeConnection = () => db.destroy();                       ❷

module.exports = {
  db,
  closeConnection
};

❶ 为开发数据库设置连接池

❷ 断开连接池

列表 2.12 cart.js

const { db } = require("./dbConnection");

const createCart = username => {
  return db("carts").insert({ username });                    ❶
};

const addItem = (cartId, itemName) => {
  return db("carts_items").insert({ cartId, itemName }); )    ❷
};

module.exports = {
  createCart,
  addItem
};

❶ 在购物车表中插入一行

❷ 在carts_items表中插入一行,引用传递的 cartId

尝试在另一个文件中导入createCartaddItem函数,并使用它们向你的本地sqlite数据库添加项目。完成操作后,别忘了使用closeConnection断开与数据库的连接;否则,你的程序将永远不会终止。

要测试cart.js模块中的功能,你可以遵循与我们在第一章中使用类似的模式。首先,你设置一个场景。然后调用你想要测试的函数。最后,检查它是否产生了预期的结果。

在将 Jest 作为devDependency安装后,为createCart编写一个测试。它应该确保数据库是干净的,创建一个购物车,然后检查数据库是否包含你刚刚创建的购物车。

列表 2.13 cart.test.js

const { db, closeConnection } = require("./dbConnection");
const { createCart } = require("./cart");

test("createCart creates a cart for a username", async () => {
  await db("carts").truncate();                                ❶
  await createCart("Lucas da Costa");
  const result = await db.select("username").from("carts");    ❷
  expect(result).toEqual([{ username: "Lucas da Costa" }]);
  await closeConnection();                                     ❸
});

❶ 删除购物车表中的每一行

❷ 选择购物车表中所有项目的用户名列的值

❸ 断开连接池

这次,你有异步函数需要通过使用await来等待。需要使用await将导致你将传递给 Jest 的test函数的函数变为async函数。

每当测试返回一个 promise——就像async函数做的那样——它将等待 promise 解决,然后再将测试标记为完成。如果返回的 promise 被拒绝,测试将自动失败。

返回 promise 的另一种选择是使用 Jest 提供的done回调。当调用done时,测试将完成,如下所示。

列表 2.14 cart.test.js

const { db, closeConnection } = require("./dbConnection");
const { createCart } = require("./cart");

test("createCart creates a cart for a username", done => {
  db("carts")
    .truncate()                                             ❶
    .then(() => createCart("Lucas da Costa"))
    .then(() => db.select("username").from("carts"))
    .then(result => {
      expect(result).toEqual([{ username: "Lucas da Costa" }]);
    })
    .then(closeConnection)                                  ❷
    .then(done);                                            ❸
});

❶ 删除购物车表中的每一行,并返回一个你将显式链式其他操作的 promise

❷ 断开连接池

❸ 完成测试

我认为这看起来更丑,但它也能工作。

警告:在将done参数添加到测试函数时要小心。如果你忘记调用它,你的测试会因为超时而失败。使用真值参数调用done也会导致你的测试失败。即使你从接受done作为参数的测试中返回一个 promise,你的测试也只有在done被调用时才会终止。

现在为addItem函数添加测试。

列表 2.15 cart.test.js

const { db, closeConnection } = require("./dbConnection");
const { createCart, addItem } = require("./cart");

// ...

test("addItem adds an item to a cart", async () => {
  await db("carts_items").truncate();
  await db("carts").truncate();

  const username = "Lucas da Costa";
  await createCart(username);
  const { id: cartId } = await db
    .select()
    .from("carts")
    .where({ username });                 ❶
  await addItem(cartId, "cheesecake");
  const result = await db.select("itemName").from("carts_items");

  expect(result).toEqual([{ cartId, itemName: "cheesecake" }]);
  await closeConnection();
});

❶ 选择购物车表中用户名列与测试中使用的用户名匹配的所有行

如果你执行这两个测试,你会遇到一个错误。错误说第二个测试“无法获取数据库连接”。这是因为一旦第一个测试完成,它就会通过调用closeConnection关闭连接池。为了避免这种错误,我们必须确保仅在所有测试运行之后才调用closeConnection

由于在测试运行后执行此类清理操作相当常见,Jest 提供了名为afterEachafterAll的钩子。这些钩子在全局范围内可用。它们接受作为参数的函数,这些函数将在每个测试之后或所有测试之后执行。

让我们在所有测试运行完毕后添加一个afterAll钩子来关闭连接池,并从测试内部移除对closeConnection的调用。

列表 2.16 cart.test.js

const { db, closeConnection } = require("./dbConnection");
const { createCart, addItem } = require("./cart");

afterAll(async () => await closeConnection());       ❶

// ...

test("addItem adds an item to the cart", async () => {
  await db("carts_items").truncate();
  await db("carts").truncate();

  const [cartId] = await createCart("Lucas da Costa");
  await addItem(cartId, "cheesecake");

  const result = await db.select().from("carts_items");
  expect(result).toEqual([{ cartId, itemName: "cheesecake" }]);
});

❶ 在所有测试完成后拆毁连接池,返回一个 promise 以便 Jest 知道钩子何时完成

Jest 还提供了beforeAllbeforeEach钩子,如列表 2.17 所示。因为你的所有测试在运行之前都需要清理数据库,所以你可以将这种行为封装到beforeEach钩子中。如果你这样做,就无需在每个测试中重复那些truncate语句。

列表 2.17 cart.test.js

const { db, closeConnection } = require("./dbConnection");
const { createCart, addItem } = require("./cart");

beforeEach(async () => {                      ❶
  await db("carts").truncate();
  await db("carts_items").truncate();
});
afterAll(async () => await closeConnection());

test("createCart creates a cart for a username", async () => {
  await createCart("Lucas da Costa");
  const result = await db.select("username").from("carts");
  expect(result).toEqual([{ username: "Lucas da Costa" }]);
});

test("addItem adds an item to the cart", async () => {
  const username = "Lucas da Costa";
  await createCart(username);
  const { id: cartId } = await db
    .select()
    .from("carts")
    .where({ username });
  await addItem(cartId, "cheesecake");
  const result = await db.select("itemName").from("carts_items");
  expect(result).toEqual([{ cartId, itemName: "cheesecake" }]);
});

❶ 在每个测试之前清除 carts 和 carts_items 表

这些测试有助于确保你的代码能够正常工作,以及你使用的 API 表现如你所预期。如果你有任何错误的查询,但它们仍然是有效的 SQL 查询,这些测试会捕捉到它们。

就像“单元测试”这个术语一样,“集成测试”对不同的人来说意味着不同的事情。正如我之前提到的,我建议你不要过于纠结于标签。相反,考虑你的测试范围有多大。范围越大,它在金字塔中的位置就越高。无论你称之为“集成”测试还是“端到端”测试,其实并没有那么重要。重要的是要记住,测试的范围越大,它提供的质量保证就越强,但运行时间就越长,需要的数量就越少。

考虑到单元测试的特点,它们应该位于金字塔的中间,如图 2.7 所示。

图 2.7 DaCosta

图 2.7 测试金字塔中集成测试的位置

当确保你的程序中的多个部分能够协同工作或正确集成第三方软件是基本要求时,你应该编写集成测试。

例如,如果你使用像 React 这样的库,你的软件必须适当地与之集成。React 的行为对于你的应用程序来说至关重要,因此你必须与 React 一起测试你的代码。同样的原则也适用于与数据库或计算机文件系统的交互。你依赖于这些外部软件的工作方式,因此检查你是否正确使用它们是明智的。

这种类型的测试提供了很大的价值,因为它帮助你验证你的代码是否按预期工作,以及你使用的库是否也是如此。尽管如此,重要的是要强调,集成测试的目标不是测试任何第三方软件本身。集成测试的目的是检查是否正确地与之交互。

如果你正在使用一个库来发送 HTTP 请求,例如,你不应该为该库的getpost方法编写测试。你应该编写测试来查看你的软件是否正确地使用了这些方法。测试请求库是作者的责任,而不是你的。而且,如果他们的作者没有编写测试,那么重新考虑其采用可能更好。

在单元测试中将你的代码隔离出来可以非常有利于编写快速简单的测试,但单元测试不能保证你像预期的那样使用其他软件组件。

我们将在第三章中更多地讨论更隔离的测试与更集成测试之间的权衡。

2.4 端到端测试

端到端测试是最粗粒度的测试。这些测试通过以用户的方式与应用程序交互来验证你的应用程序。

他们不会像单元测试那样直接使用你的软件代码。相反,端到端测试从外部角度与之交互。如果可能使用按钮或访问页面而不是调用函数或检查数据库,他们会这样做。通过采取这种高度解耦的方法,他们最终覆盖了应用程序的大部分区域,如图 2.8 所示。他们依赖于客户端的正常工作以及后端所有软件组件的正常工作。

图片

图 2.8 端到端测试的范围

用于验证是否可以添加商品到购物车的端到端测试不会直接调用addToCart函数。相反,它会打开你的 Web 应用程序,点击上面写着“添加到购物车”的按钮,然后通过访问列出其商品的页面来检查购物车的内容。这样的测试在测试金字塔的顶部。

即使是此应用程序的 REST API 也可以有自己的端到端测试。对你的商店后端进行的端到端测试会发送一个 HTTP 请求来添加商品到购物车,然后发送另一个请求来获取其内容。然而,这个测试在测试金字塔中位于上一个测试之下,因为它只覆盖了API。使用其 GUI 测试应用程序的范围更广,因为它包括 GUI 以及它发送请求的 API。

再次强调,将测试标记为端到端、集成或单元测试不是我们的主要目标。测试金字塔旨在帮助我们了解测试的角色、价值和频率。端到端测试在金字塔(图 2.9)中的位置告诉我们,这类测试非常有价值,并且你需要较少的数量。只需几个就可以覆盖你应用程序的大部分区域。相比之下,单元测试关注单个函数,因此需要更频繁地进行。

图片

图 2.9 端到端测试在测试金字塔中的位置

端到端测试避免使用应用程序的任何私有部分,因此它们非常接近用户的操作。你的测试越接近用户与你的应用程序交互,它们给你的信心就越大。因为端到端自动化测试最接近模拟真实用例场景,所以它们提供了最大的价值。

注意 在测试术语中,不了解应用程序内部结构的测试被称为黑盒测试。了解应用程序内部结构的测试被称为白盒测试

测试不一定完全属于一个或另一个类别。它们越少依赖于应用程序的实现细节,它们就越像是“黑盒”。相反,对于更“白盒”的测试,情况也是如此。

这些测试通常需要更多的时间来运行,因此运行频率较低。与单元测试不同,每次保存文件时运行端到端测试是不可行的。端到端测试更适合开发过程的后期阶段。它们可以通过彻底检查应用程序的功能是否适用于客户,在允许开发者合并拉取请求或执行部署之前提供帮助。

2.4.1 测试 HTTP API

由于对 RESTful API 的测试只需要一个能够执行 HTTP 请求并检查响应的客户端,我们可以在 Jest 中编写它们。在这些示例中,你将使用isomorphic-fetch来执行 HTTP 请求。

这些测试将涵盖应用程序的整个后端,以及它暴露的 HTTP API,如图 2.10 所示。

图片

图 2.10 通过 HTTP API 解决后端问题的测试范围

你只需要 Jest 和isomorphic-fetch来编写测试,而不是应用程序的运行时,所以将它们作为开发依赖项安装。

你将要使用的构建 API 的 Web 框架是 Koa。它简单、有效且小巧。它非常适合我们在本书中想要做的事情:专注于测试。因为 Koa 没有自带路由器,所以你还需要安装koa-router来将不同的请求映射到不同的操作。

我们的服务器将有两个路由:一个用于向购物车添加项目,另一个用于从购物车中移除项目。要向购物车添加项目,客户端必须向POST /carts/:username/items/:item发送包含项目数组的请求体。要检索购物车的商品内容,他们必须向GET /carts/:username/items发送请求。

为了使这个测试尽可能简单,目前请避免接触数据库。专注于编写测试,并保持用户购物车状态在内存中。

以下代码将在端口3000上启动一个服务器。这个服务器可以添加和检索购物车的商品。

列表 2.18 server.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

const carts = new Map();                                      ❶

router.get("/carts/:username/items", ctx => {                 ❷
  const cart = carts.get(ctx.params.username);
  cart ? (ctx.body = cart) : (ctx.status = 404);              ❸
});

router.post("/carts/:username/items/:item", ctx => {          ❹
  const { username, item } = ctx.params;
  const newItems = (carts.get(username) || []).concat(item);
  carts.set(username, newItems);
  ctx.body = newItems;                                        ❺
});

app.use(router.routes());                                     ❻

module.exports = app.listen(3000);                            ❼

❶ 存储应用程序状态的 Map

❷ 处理对 GET /carts/:username/items 的请求,列出用户的购物车中的商品

❸ 如果购物车已被找到,应用程序将返回 200 状态码和找到的购物车。否则,它将返回 404 状态码。

❹ 处理向 POST /carts/:username/items/:item 发送的请求,向用户的购物车添加项目

❺ 返回购物车的新内容

❻ 将路由附加到 Koa 实例

❼ 将服务器绑定到端口 3000

注意:我选择了 Koa 和 koa-router,因为它们很受欢迎,并且 API 直观。如果你不熟悉 Koa 或 koa-router,你可以在 koajs.comgithub.com/ZijianHe/koa-router 找到文档。

如果你更熟悉其他框架,如 Express 或 NestJS,请不要犹豫,使用它。端到端测试不应该关心你如何实现服务器,只要你的实现对于相同的请求提供相同的输出即可。

端到端测试只关心从用户的角度来看你的应用程序。

现在,编写一个使用 HTTP 请求添加项目到购物车并检查购物车内容的测试。

即使你正在发送 HTTP 请求而不是调用函数,你的测试的一般公式应该是相同的:arrangeactassert

为了更容易执行请求,你可以在测试中添加以下辅助函数。

列表 2.19 server.test.js

const fetch = require("isomorphic-fetch");

const apiRoot = "http://localhost:3000";

const addItem = (username, item) => {                                    ❶
  return fetch(`${apiRoot}/carts/${username}/items/${item}`, {
    method: "POST"
  });
};

const getItems = username => {                                           ❷
  return fetch(`${apiRoot}/carts/${username}/items`, { method: "GET" });
};

❶ 向添加用户购物车项目的路由发送 POST 请求

❷ 向列出用户购物车内容的路由发送 GET 请求

在添加了这些辅助函数之后,你就可以在测试本身中使用它们,使测试比其他方式更短。

列表 2.20 server.test.js

require("./server");

// Your helper functions go here...

test("adding items to a cart", async () => {
  const initialItemsResponse = await getItems("lucas");             ❶
  expect(initialItemsResponse.status).toEqual(404);                 ❷

  const addItemResponse = await addItem("lucas", "cheesecake");     ❸
  expect(await addItemResponse.json()).toEqual(["cheesecake"]);     ❹

  const finalItemsResponse = await getItems("lucas");               ❺
  expect(await finalItemsResponse.json()).toEqual(["cheesecake"]);  ❻
});

❶ 列出用户的购物车中的项目

❷ 检查响应的状态是否为 404

❸ 向用户购物车添加项目的请求

❹ 检查服务器是否响应了购物车的新内容

❺ 再次发送请求以列出用户的购物车中的项目

❻ 检查服务器的响应是否包含你添加的项目

运行这个测试,看看会发生什么。你会注意到测试通过了,但 Jest 没有退出。为了检测导致这种情况的原因,你可以使用 Jest 的 detectOpenHandles 选项。当使用此标志运行 Jest 时,它会告诉你什么阻止了你的测试退出。

注意:如果你使用 NPM 脚本来运行 Jest,就像我们之前做的那样,请向其中添加 -- 并然后添加你想要传递给脚本的选项。例如,要将 --detectOpenHandles 传递给 Jest,你需要运行 npm test -- --detectOpenHandles

当你使用此选项时,Jest 会警告你说问题来自 app .listen

Jest has detected the following 1 open handle potentially keeping Jest from  exiting:

  ●  TCPSERVERWRAP
      21 | app.use(router.routes());
      22 |
    > 23 | app.listen(3000);
         |     ^
      24 |
      at Application.listen (node_modules/koa/lib/application.js:80:19)
      at Object.<anonymous> (server.js:23:5)

你在测试运行之前启动了服务器,但在测试完成后没有停止它!

为了避免测试永远不退出,Jest 允许你使用 forceExit 选项。如果你将此选项添加到运行 Jest 的 NPM 脚本中,如以下所示,你可以确保在运行 npm test 时测试将总是退出。

列表 2.21 package.json

{
  "name": "1_http_api_tests",
  "version": "1.0.0",
  "scripts": {
    "test": "jest --forceExit"    ❶
  },
  "devDependencies": {
    "isomorphic-fetch": "².2.1",
    "jest": "²⁶.6.0"
  },
  "dependencies": {
    "koa": "².11.0",
    "koa-router": "⁷.4.0"
  }
}

❶ 运行项目的 jest 可执行文件,包括 forceExit 选项

避免测试挂起的一个更优雅的方法是在它们完成后停止服务器。Koa 允许你通过调用其 close 方法来关闭你的服务器。添加一个调用 app.closeafterAll 钩子应该足以使你的测试优雅地退出。

列表 2.22 server.test.js

// Assign your server to `app`
const app = require("./server");

// Your tests...

afterAll(() => app.close());       ❶

❶ 所有测试完成后停止服务器

如果你清理了你的打开句柄,你就不需要使用 forceExit 选项。避免这个选项更明智,因为它允许你确保应用程序没有持有任何外部资源,例如数据库连接。

作为练习,添加一个从购物车中删除项目的路由,然后为它编写一个测试。别忘了,因为你的服务器将状态保存在内存中,你必须在每次测试之前清理它。如果你需要帮助来弄清楚如何做到这一点,请查看包含本书示例的存储库github.com/lucasfcosta/testing-javascript-applications,以找到完整的解决方案。

为 HTTP API 编写测试是确保服务遵循既定“契约”的绝佳方法。当多个团队必须开发不同的服务时,这些服务必须具有明确定义的通信标准,你可以通过测试来强制执行这些标准。测试将帮助防止服务之间无法通信。

HTTP API 测试的范围很广,但仍然比针对 GUI 的测试范围窄。包含 GUI 的测试检查整个应用程序,而 HTTP API 的测试仅探测其后端。由于这种范围上的差异,我们将测试金字塔中的端到端测试区域细分,并将 HTTP API 测试放在 GUI 测试之下,如图 2.11 所示。

图片

图 2.11 HTTP API 测试在测试金字塔中的位置

2.4.2 测试 GUI

GUI 测试覆盖你的整个应用程序。它们将使用其客户端与后端交互,因此会触及你的堆栈的每一块,如图 2.12 所示。

图片

图 2.12 GUI 测试的范围

编写 GUI 的端到端测试涉及特定的要求,因此需要特殊的工具。

用于端到端测试 GUI 的工具需要能够与网页元素(如按钮和表单)交互。由于这些要求,它们需要能够控制真实浏览器。否则,测试将无法精确地模拟用户的行为。

目前,最流行的 UI 测试工具是 Cypress、TestCafe 和 Selenium。你可以使用这些工具通过 JavaScript 控制它们,使浏览器与你的应用程序交互。

UI 测试的整体结构与我们已经看到的测试类型相似。UI 测试仍然需要你设置场景、执行操作,然后进行断言。UI 测试与其他类型测试的主要区别在于,你的操作不是仅仅调用函数或执行请求,而是通过浏览器进行,断言依赖于网页的内容。

尽管测试的三个 A 模式(即自动化、敏捷和适应性)适用于 UI 测试,但设置测试运行环境的整个过程往往更为复杂,尤其是当你需要启动整个应用程序及其所有独立服务时。你可能需要处理的不仅仅是单一软件,而是多个。

GUI 测试也揭示了众多新的关注点,大多与真实浏览器行为的非规律性有关。等待页面加载、文本渲染、元素准备交互,或网页执行 HTTP 请求并更新自身,都是通常令人头疼的操作。它们往往不可预测,不同的机器完成这些操作所需的时间可能不同。

由于这些测试覆盖了应用程序的所有部分,它们在测试金字塔中占据最高的位置,如图 2.13 所示。它们运行时间最长,但同时也提供了最强有力的保证。

图 2.13 GUI 测试在测试金字塔中的位置

由于端到端测试 UI 与所有其他类型的测试显著不同,因此它有自己的独特章节。在第十章中,我们将比较各种工具,介绍最佳实践,并解决这种新型测试出现的问题。

2.4.3 验收测试和端到端测试并不相同

人们经常将验收测试与端到端测试混淆。验收测试是一种旨在从业务角度验证应用程序是否工作的实践。它验证软件是否对业务想要针对的最终用户来说是可接受的

端到端测试是一种从工程角度验证整个应用程序的测试类型。它关注的是正确性而不是功能。

由于验收测试关注的是功能性需求——即应用程序能做什么——而这正是可以通过端到端测试来完成的,因此这两个概念之间可能存在一些重叠。

并非所有端到端测试都是验收测试,也并非所有验收测试都是端到端测试。你可以通过端到端测试来执行验收测试——而且很多时候你可能会这么做。

端到端测试非常适合这种验证,因为它们可以覆盖简单单元测试无法覆盖的方面,例如网页的外观或应用程序对特定操作的响应时间。

正如我之前提到的,由于端到端测试最接近用户行为,因此在验收测试方面提供了更强的保证。尽管如此,也可以使用单元测试或集成测试进行验收测试。例如,在测试发送给用户的电子邮件是否包含所需内容时,你可能想编写一个单元测试来检查生成的文本。

2.5 探索性测试和 QA 的价值

当你没有像路易斯那样的硅谷式预算时——你需要找到更便宜的方式来测试你的软件。并不是每个人都能负担得起一个充满质量分析师和测试员的整个部门。

随着自动化测试的兴起,手动 QA 的需求急剧下降。这并不是因为拥有一个专业的 QA 团队没有用,而是因为其中一些任务在自动化后可以更便宜、更快、更精确。

到目前为止,你还没有感觉到需要有一个质量分析师。每天,你都在学习如何编写更好的测试,这有助于你确保软件在没有太多人为干预的情况下运行。

到目前为止,你的同事可能已经足够可靠,可以测试他们自己的工作。在绝大多数情况下,你的部署可能没有引入任何关键故障。而且,让我们说实话,如果某人不能及时订购蛋糕,这并不是一场悲剧。失败的中位成本很低。缺陷肯定对业务有害,但考虑到关键故障很少是由于你严格的自动化测试造成的,因此雇佣人员进行手动测试的好处并不超过其成本。

除了失败的成本不足以证明雇佣一个质量分析师的合理性之外,引入一个质量分析师可能会增加发布更改所需的时间。机器的反馈速度远快于人类,并且通信开销更少。

但所有业务都在不断发展,尤其是当所有者将如此多的心——和糖——投入其中时。如果路易斯决定烘焙婚礼蛋糕,例如,他业务的失败成本可能会大幅增加。

婚礼蛋糕是人们一生中购买的最昂贵的碳水化合物之一。挑选一个很具挑战性,而且直到婚礼当天它到达时,担心它更是压力重重。

为了增加客户下单的可能性,路易斯还希望为他们提供各种定制功能。这些功能可能复杂到可以上传一个可以 3D 打印并放置在蛋糕顶部的模型——未来已经到来。

现在,路易斯有一个极其复杂且至关重要的功能,它将代表业务收入的大部分。这两个因素推动了质量分析师的必要性,现在其成本是合理的。在未来,你不得不发布的类似功能越多,这种需求就越明显。

复杂的功能通常有很多边缘情况,它们被用户接受的要求也更严格。我们不仅关心用户是否能够以任何形式塑造他们的蛋糕,还关心他们是否足够容易地做到这一点。重要的是不仅功能是否工作,而且它们是否满足我们的客户需求,以及它们是否易于使用。这种验收测试——至少到目前为止——几乎是不可能由机器完成的。

到目前为止,我们对 QA 专业人员和机器的比较相当不公平。我们一直在比较计算机擅长的事情和人类最不擅长的事情:快速且完美地执行重复性任务。对用户更有利的比较是在创造性任务和同理心方面。只有人类能够想到多种好奇的方式来使用一个功能。只有人们能够站在他人的立场上思考,考虑软件是否令人愉悦。

即使测试也需要有人来编写。机器只有在被教会如何执行测试之后才能执行测试。一旦你发现了一个阻止某人将奶酪蛋糕添加到购物车中的错误,因为你也在订购马卡龙,你就可以编写一个测试来避免这个特定的错误再次发生。问题是,直到你考虑过这种情况可能发生,否则不会有针对它的测试。只有在你最初看到它们发生的情况下,你才能添加防止错误再次发生的测试——回归测试。

程序员的测试通常确保当有人订购蛋糕时,软件将如何表现。质量保证团队的测试通常确保当有人订购-91344794 个蛋糕时,软件将如何表现。这种愿意测试好奇场景的意愿是雇佣 QA 专业人员的另一个优势。他们是探索性测试的优秀资源。

探索性测试是有用的,因为它可以覆盖程序员没有考虑到的案例。一旦质量保证团队发现新的错误,他们可以向开发团队报告,开发团队将修复它并添加一个测试来确保它不会再次发生。

能干的 QA 专业人员与开发团队协作。他们通过提供关于质量保证团队发现的错误的反馈,帮助开发者改进自动测试。

预防错误发生的最佳方式是编写自动测试,尝试重现它们。实际上,预防特定错误正是自动测试所能做到的全部。自动测试无法确定软件是否工作,因为它们无法测试所有可能的输入和输出。当质量保证团队帮助开发者扩展可能存在问题的输入和输出范围时,软件变得更加安全。

另一方面,开发者通过编写严格的自动测试来帮助质量保证团队更好地工作。软件能够自动完成的工作越多,它就能为质量保证团队节省更多时间,让他们去做只有人类才能完成的任务,比如探索性测试。

当雇佣 QA 人员时,你应该最关心的是它是否会在这两者之间产生对抗关系。这是最无效的事情之一。

如果 QA 团队将开发者视为对手,他们将会把所有修复都视为最高优先级,而不是与开发者沟通并就什么对业务更有利达成一致。例如,如果一个小缺陷的动画阻碍了一个包含关键新功能的发布,公司就会错过收入。这种固执会增加团队之间的挫败感和压力,并使发布周期更长。

当开发者对 QA 持有对抗态度时,他们会忽视问题。他们不会在将代码交给 QA 专业人士之前彻底测试他们的代码,因为最终他们认为质量是 QA 团队独有的责任,而不是企业的责任。他们认为他们的成功是尽可能快地发布功能,因此他们将所有测试委托给他人。这种粗心大意导致无法测试的软件,并最终导致更多错误被发布。

注意:有些人会争论在敏捷开发中永远不应该有 QA 团队。每当听到这种二分法论点时,我往往持怀疑态度。每个项目都是独特的,因此,对成功的约束和要求也不同。我相信敏捷的 QA 方法。我主张将 QA 整合到开发过程中。公司不应该在重大发布前运行一大批测试,而应该将 QA 整合到交付单个任务的过程中。这种方法缩短了反馈循环,同时仍然确保了令人满意的正确性和可用性水平。

2.6 测试、成本和收入

嘿,让我告诉你一个秘密:路易斯并不在乎你是否编写测试。只要你能在更短的时间内生产出可工作的软件,你不妨使用古老的巫术。在商业中,只有两件事是重要的:增加收入和降低成本。

企业关心漂亮的代码,因为它有助于程序员减少错误,并以快速和可预测的速度生产代码。组织良好的代码更容易理解,并且有更少的地方让错误隐藏。它减少了挫败感,使程序员的工作更加刺激。反过来,动态和令人满意的环境使他们保持动力,并使他们更长时间地留在公司。漂亮的代码不是目标——它是达到目标的手段。

反直觉的是,生产无缺陷的软件也不是目标。想象一下,你添加了一个错误,导致顾客每买 10 个芝士蛋糕就能免费得到一个马卡龙。如果这个错误提高了利润,你不妨保留它。当一个错误变成一个功能时,你不会仅仅为了遵守原始规范而修复它。我们修复错误,因为在绝大多数情况下,它们会减少收入并增加成本。

即使编写代码也不是你的工作。你的工作是帮助公司增加收入并减少成本。你编写的代码越少,越好,因为代码越少,维护成本越低。用 10 行代码实现新功能的花费远低于用一千行代码实现。当你写出解决问题的优雅解决方案时,你的业务并不会繁荣。当功能快速且易于实现,因此成本更低,并能带来更多价值时,你的业务才会繁荣。

TIP 帕特里克·麦肯齐(Patrick McKenzie)撰写了一篇关于商业经济学与软件工程交叉领域的精彩博客文章。这是一篇经典之作,我强烈推荐,可在www.kalzumeus.com/2011/10/28/dont-call-yourself-a-programmer找到。

在第一章中,我们讨论了测试如何帮助企业在较低的成本下创造收入。但我们可以如何构建测试本身,使其尽可能具有成本效益?

向成本效益测试迈出的第一步是牢记你必须维护的测试你才需要付费。当路易斯(Louis)要求你进行更改时,他并不关心你只花了五分钟来更改应用程序,却花了两个小时来更新其测试。对业务来说,重要的是你花了超过两个小时来交付更改。你更新应用程序代码或测试所需的时间长短无关紧要。测试也是代码。维护一百行代码的成本与维护一百行测试的成本相同。编写糟糕的代码很昂贵,因为它需要花费大量时间来更改,同样,编写糟糕的测试也是如此。

减少测试成本的下一步是减少其中的重复。当你注意到重复的模式时,不要害怕创建抽象。创建单独的实用函数可以使测试更短,编写更快。使用抽象可以降低成本,并激励开发者更频繁地编写测试。例如,在关于端到端测试的章节中,我们编写了辅助函数,以便更容易执行 HTTP 请求。这些辅助函数使我们免于反复重写整个获取逻辑。让我们回顾一下这个例子,来谈谈好的和坏的模式。

考虑以下两个示例。

列表 2.23 badly_written.test.js

const { app, resetState } = require("./server");
const fetch = require("isomorphic-fetch");

test("adding items to a cart", done => {
  resetState();
  return fetch(`http://localhost:3000/carts/lucas/items`, {
    method: "GET"
  })
    .then(initialItemsResponse => {
      expect(initialItemsResponse.status).toEqual(404);
      return fetch(`http://localhost:3000/carts/lucas/items/cheesecake`, {
        method: "POST"
      }).then(response => response.json());
    })
    .then(addItemResponse => {
      expect(addItemResponse).toEqual(["cheesecake"]);
      return fetch(`http://localhost:3000/carts/lucas/items`, {
        method: "GET"
      }).then(response => response.json());
    })
    .then(finalItemsResponse => {
      expect(finalItemsResponse).toEqual(["cheesecake"]);
    })
    .then(() => {
      app.close();
      done();
    });
});

列表 2.24 well_written.test.js

const { app, resetState } = require("./server");
const fetch = require("isomorphic-fetch");

const apiRoot = "http://localhost:3000";

const addItem = (username, item) => {
  return fetch(`${apiRoot}/carts/${username}/items/${item}`, {
    method: "POST"
  });
};

const getItems = username => {
  return fetch(`${apiRoot}/carts/${username}/items`, { method: "GET" });
};

beforeEach(() => resetState());
afterAll(() => app.close());

test("adding items to a cart", async () => {
  const initialItemsResponse = await getItems("lucas");
  expect(initialItemsResponse.status).toEqual(404);

  const addItemResponse = await addItem("lucas", "cheesecake");
  expect(await addItemResponse.json()).toEqual(["cheesecake"]);

  const finalItemsResponse = await getItems("lucas");
  expect(await finalItemsResponse.json()).toEqual(["cheesecake"]);
});

想想哪一个更难阅读,以及为什么。

我发现第一个示例更难阅读。处理承诺和发送请求所需的逻辑混淆了每个测试的意图。这种复杂性使得理解测试做什么变得更加困难,因此,更改也花费了更长的时间。在第二个测试中,我们将获取和添加购物车项的逻辑封装到单独的函数中。这种抽象使得理解测试中的每个步骤更容易。我们越快理解测试做什么,就越快能够更改它,成本也就越低。

如果你必须更改服务器端点的 URL,考虑一下这些样本中哪一个更容易更新。

更新第二个代码样本要容易得多,因为你不需要重写每个测试中使用的 URL。通过更新这些函数,你会修复所有使用它们的测试。一次更改可以影响多个测试,因此可以降低它们的维护成本。当涉及到消除重复时,应用于你的代码的原则也适用于你的测试。

现在考虑你必须添加更多测试的情况。使用这些样本中的哪一个,这项任务会更困难?

如果你继续重复,将测试添加到第一个样本肯定需要更长的时间,因为你必须复制并调整上一个测试中的大量逻辑。你的测试套件将变得冗长,因此更难调试。相比之下,第二个样本便于编写新的测试,因为每个请求只需一行,并且易于理解。在第二个样本中,你也不必担心管理复杂的嵌套承诺链。

除了保持测试可读性和避免重复之外,降低测试成本的关键态度之一是使它们松散耦合。你的测试应该断言你的代码做什么,而不是如何做。理想情况下,你只有在应用程序表现出与测试预期不同的行为时才需要更改它们。

考虑以下函数。

列表 2.25 pow.js

const pow = (a, b, acc = 1) => {
  if (b === 0) return acc;
  const nextB = b < 0 ? b + 1 : b - 1;
  const nextAcc = b < 0 ? acc / a : acc * a;
  return pow(a, nextB, nextAcc);
};

module.exports = pow;

这个函数使用递归计算幂。对这个函数的一个良好测试应该提供给它一些输入并检查它是否产生正确的输出。

列表 2.26 pow.test.js

const pow = require("./pow");

test("calculates powers", () => {
    expect(pow(2, 0)).toBe(1);
    expect(pow(2, -3)).toBe(0.125);
    expect(pow(2, 2)).toBe(4);
    expect(pow(2, 5)).toBe(32);
    expect(pow(0, 5)).toBe(0);
    expect(pow(1, 4)).toBe(1);
});

这个测试对pow函数的工作方式没有任何假设。如果你重构了pow函数,它仍然应该通过。

重构pow函数,使其使用循环,然后重新运行你的测试。

列表 2.27 pow.js

const pow = (a, b) => {
  let result = 1;
  for (let i = 0; i < Math.abs(b); i++) {
    if (b < 0) result = result / a;
    if (b > 0) result = result * a;
  }

  return result;
}

module.exports = pow;

由于函数仍然正确,测试通过了。这个测试是成本效益的,因为它只写了一次,但能够多次验证你的函数。如果你的测试检查了无关的实现细节,那么每次你更新一个函数时,即使它仍然工作,你也必须更新它们。你希望测试只在函数的可观察行为发生变化时失败

然而,这个规则有例外。有时你必须处理副作用或调用第三方 API。如果这些实现细节对你的软件功能至关重要,那么测试它们是明智的。让我们使用以下函数作为例子。

列表 2.28 cartController.js

const addItemToCart = async (a, b) => {
  try {
    return await db("carts_items").insert({ cartId, itemName });
  } catch(error) {
    loggingService(error);        ❶
    throw error;
  }
}

module.exports = addItemToCart;

❶ 记录一个错误

在这个函数中,你想要确保记录任何客户在将项目添加到购物车时可能遇到的错误。

如果你认为记录错误对于调试你的应用程序至关重要,你应该通过测试来强制执行它。你应该有测试来验证当发生错误时addToCart是否调用loggingService。在这种情况下,检查这个实现细节很重要,因为你想要强制执行它。

我喜欢把测试看作是保证。每当我想要确认我的应用程序以某种方式行为时,我都会为它编写一个测试。如果你要求一个函数以特定方式实现,你可以将这个要求编码到自动化测试中。

不要担心你是否在检查实现细节。担心你是否在检查相关的行为。

在断言loggingService是否被调用方面的一个替代方案是检查它写入的日志文件。但这种方法也有缺点。如果你决定更改loggingService的实现方式,使其将日志记录到不同的文件,那么addItemToCart的测试——以及可能依赖于这种相同行为的许多其他测试——也会失败,如图 2.14 所示。

图 2.14

图 2.14 当你有多个测试检查loggingService是否写入正确的文件时,当你更改loggingService时,所有这些测试都会失败。因为你需要更新的测试更多,所以更改loggingServices的成本会增加。

通过断言addToCart调用loggingService(一个实现细节),你可以在loggingService更改时避免无关的测试失败,如图 2.15 所示。如果你对loggingService有严格的测试,那么当你更改loggingService写入的文件时,它们将是唯一会中断的测试。更少的破坏性测试意味着你需要更新的测试更少,因此维护它们的成本也更低。

图 2.15

图 2.15 如果你更改loggingService写入的文件,它的测试将是唯一会失败的。addItemToCart测试将继续通过,因为它们正在做你期望的事情:使用日志服务。通过以这种方式构建你的测试,你将需要更新的测试更少,并且关于你的软件中哪些部分不符合测试的反馈将更加精确。

注意:当我们谈到第三章中的模拟、存根和间谍时,我们将讨论如何编写检查函数调用的测试。目前,最重要的是理解你为什么要这样做。

当你创建相互补充的测试时,你创造了我认为的传递性保证。例如,如果你有确保函数a工作的测试,那么只需检查其他函数是否调用了函数a,而不是在每次测试中都重新检查其行为,你就可以做得很好。

传递性保证是降低测试成本的好方法。它们与抽象一样工作——它们减少了耦合。不是所有测试都重复检查相同的行为,而是将这项责任委托给另一个测试。传递性保证是测试层面的封装。

如果你必须断言一个函数的实现细节,建议创建一个传递性保证,这样你就可以将这个检查封装到单独的测试中。尽管这种分离使测试远离现实,从而降低了其价值,但它可以显著降低其维护成本。

你的任务是平衡测试的维护成本与它们提供的价值。严格的测试可以提供优秀的细粒度反馈,但如果它们耦合度过高,维护成本会很高。另一方面,从不失败的测试不会产生信息。在可维护性和严格的质量控制之间取得平衡,是使一个好的测试员成为优秀测试员的关键。

小贴士:在测试方面,最热烈的辩论之一是人们是否应该为每行代码创建一个测试。

正如我在这本书中多次提到的,我不喜欢绝对化的思考。单词“总是”(always)是危险的,单词“从不”(never)也是如此。我想说的是,你的代码将要存活的时间越长,编写测试就越重要。

测试产生的价值取决于它运行的频率。如果一个测试为你节省了五分钟的手动测试时间,那么当你运行了第 15 次时,你就节省了一个小时。

例如,如果你在黑客马拉松中,你可能不应该添加太多测试(如果有的话)。在黑客马拉松中,你写的代码通常会比主办方提供的咖啡和披萨先消失。因此,它将没有足够的机会提供价值。

你可能应该避免编写测试的另一个情况是,如果你正在探索特定的 API 或只是尝试可能性。在这种情况下,先玩一玩,只有在你对想要做的事情有信心之后才编写测试可能更明智。

当决定你是否应该编写测试时,考虑一下,一段特定的代码将存活的时间越长,添加测试就越重要。

摘要

  • 所有测试都遵循一个类似的公式:它们设置一个场景,触发一个动作,并检查产生的结果。通过使用三个 A 的助记符:安排(arrange)、行动(act)和断言(assert),这个模式很容易记住。

  • 测试运行器是我们用来编写测试的工具。它们为你提供了组织和从测试中获得可读性和有意义输出的便捷方式。一些测试运行器,如 Jest,还附带断言库,这有助于我们比较动作的实际输出与预期输出。

  • 为了方便测试的设置和拆卸过程,Jest 为你提供了可以在测试过程的各个阶段运行的钩子。你可以使用beforeEach在每次测试之前运行一个函数,使用beforeAll在所有测试之前运行一次,使用afterEach在每次测试之后运行,使用afterAll在所有测试之后运行一次。

  • 测试金字塔是一个视觉隐喻,帮助我们根据它们应该运行的频率、应该存在的数量、它们的范围大小以及它们产生的质量保证强度,将测试类型分为不同的类别。随着我们攀登金字塔,测试变得越来越稀缺,价值更高,覆盖范围更广,运行频率更低。

  • 单元测试是为了针对函数而设计的。它们对于在尽可能细粒度的层面上断言软件质量至关重要,提供快速而精确的反馈。这些测试导入你的函数,提供输入,并检查输出是否符合预期。

  • 集成测试是为了确保应用程序的不同部分可以协同工作。它们验证你是否适当地使用了第三方库,例如数据库适配器。这些测试通过你的软件进行操作,但可能需要访问外部组件,如数据库或文件系统,以设置场景并检查你的应用程序是否产生了预期的结果。

  • 端到端测试针对程序的所有层运行。它们不是直接调用函数,而是像用户一样与你的应用程序交互:例如,通过使用浏览器或发送 HTTP 请求。它们将应用程序视为一个“黑盒”。这些测试由于最接近真实用例场景,因此提供了最强的质量保证。

  • 接受测试与端到端测试不同。接受测试侧重于验证你的应用程序是否满足功能需求。这些测试从业务角度验证你的用户是否可接受,考虑到你的目标用户。另一方面,端到端测试侧重于从工程角度验证你的应用程序是否正确。端到端测试可以作为接受测试,但并非所有接受测试都需要是端到端测试。

  • 自动化测试不能完全取代质量保证专业人员。自动化测试通过让 QA 分析师有更多时间从事只有人类才能完成的任务,如探索性测试或提供以用户为中心的详细反馈,来补充 QA 分析师的工作。

  • 质量保证(QA)和开发团队必须协作工作,而不是将对方视为对手。开发者应该编写严格的自动化测试来缩短反馈循环并支持 QA 的验证任务。QA 专业人员应与工程和产品团队沟通,以确定优先级,并提供有关如何改进产品的详细反馈,而不是将标准定得无法触及。

  • 测试,就像代码一样,与它们相关的维护成本。你不得不更频繁地更新测试,它们就越昂贵。你可以通过保持代码可读性、避免重复、减少测试与应用程序代码之间的耦合,以及将验证分离到多个测试中以创建传递性保证,来降低测试的成本。

第二部分:编写测试

本书的前一部分读起来像一门烹饪历史课程;第二部分则像一本完整的烹饪指南,其中包含大量图片和详细的食谱。

第二部分将通过实际示例教您如何测试不同类型的 JavaScript 应用程序。在本部分中,您将编写大量不同类型的测试,学习使测试可靠和可维护的技术,并了解每种情况下应使用哪些工具以及这些工具如何工作。

在第三章中,您将学习更全面地组织测试的技术,编写灵活可靠的断言,隔离和工具化代码的不同部分,以及确定要测试什么和不要测试什么。这些技术将帮助您收集更多有价值的反馈,降低测试成本,同时不牺牲其检测错误的能力。

第四章教您如何测试后端应用程序。在其中,您将学习如何测试后端 API 路由和中间件,以及如何处理外部依赖,例如数据库或第三方 API。

第五章涵盖了高级后端测试技术,这些技术将帮助您使测试更快,同时降低成本,并保持测试的可靠性。在本章中,您将学习如何并行运行测试,消除非确定性,并高效地构建测试套件。

在第六章中,您将学习如何测试纯 JavaScript 前端应用程序。在其中,您将了解如何在测试中模拟浏览器的环境,以及如何测试进行 HTTP 请求、与 WebSocket 接口和依赖于浏览器 API 的客户端应用程序。

第七章向您介绍了 React 测试生态系统。它解释了如何为测试 React 应用程序设置环境,概述了您可以使用的不同测试工具,并解释了这些工具的工作原理。此外,它还教您如何编写您的第一个 React 测试。

在您有机会熟悉 React 测试生态系统后,第八章将深入探讨 React 测试实践。它将教您如何测试相互交互的组件;解释快照测试是什么,如何进行,以及何时使用它;并探讨测试组件样式的不同技术。此外,它还将向您介绍组件故事,并解释组件级别的验收测试如何帮助您在更短的时间内构建更好的 React 应用程序。

第九章详细讨论了测试驱动开发(TDD)。它教您什么是测试驱动开发,如何进行,何时使用它,以及它如何帮助您。此外,它还阐明了它与行为驱动开发的关系,并为您准备在 TDD 可以成功的环境中创建适当的环境。

第十章介绍了基于 UI 的端到端测试及其使用时机,并概述了您可以使用的不同工具来编写此类测试。

最后,第十一章涵盖了基于 UI 的端到端测试的实用方面。它教你如何编写这类测试,如何使这些测试变得健壮和可靠的最佳实践,以及如何在多个浏览器上运行测试。此外,它介绍了一种称为视觉回归测试的技术,并解释了这种实践如何有助于捕捉错误并促进不同团队之间的协作。

3 测试技术

本章涵盖

  • 全面组织你的测试

  • 编写灵活且健壮的断言

  • 为测试隔离和配置代码的部分

  • 定义选择测试和不测试的策略

  • 学习代码覆盖率是什么以及如何衡量它

编写良好的测试有两个主要特点:它们只在应用程序行为异常时才会失败,并且它们会精确地告诉你出了什么问题。在本章中,我们将关注帮助你实现这两个目标的技巧。

例如,如果你为addToCart函数编写了测试,你不希望该函数仍然正常工作时测试失败。如果测试确实失败了,它将产生额外的成本,因为你将不得不花费时间更新它。理想情况下,你的测试应该足够敏感,以捕捉尽可能多的错误,但足够健壮,只有在必要时才失败。

考虑到你的addToCart函数的测试因为一个很好的原因而失败了,但如果它们的反馈无法理解,或者当它们不应该失败时,10 个其他无关的测试失败了,那么这些测试仍然不会特别有帮助。一个精心设计的测试套件会为你提供高质量的反馈,以便尽可能快地解决问题。

在本章中,为了实现高质量的反馈和健壮且敏感的测试,我将重点关注如何组织测试、编写断言、隔离代码以及选择测试的内容和方式。

学习如何全面组织你的测试将导致更好的反馈和更少的重复。这将使测试更容易阅读、编写和更新。组织良好的测试是高效测试实践的开始。

扩展 Jest 的断言,理解它们的语义,并学习如何为每个特定情况选择最准确的断言,这将帮助你获得更好的错误信息,并使你的测试更加健壮,同时不失对错误的敏感性。隔离你的代码将帮助你更快地编写测试,并减少待测试单元的大小,使确定错误的根本原因变得更加容易。有时,甚至可能无法使用隔离技术来测试代码的特定部分。

但如果你无法确定你将测试什么,最重要的是你不会测试什么,那么这些学科都没有价值,这是我在本章末尾要涵盖的内容。你将学习如何使用这些技术来减少你必须编写的测试数量,而不会降低你的质量标准,从而降低成本。

由于没有一种适合所有情况的软件测试方法,我将解释每种情况下涉及的权衡,并专注于让你在每个案例中做出最佳决策。这种以结果为导向的方法将帮助你找到在敏感性和提供有用反馈的测试之间,以及不会增加软件维护负担的测试之间的更好平衡。

此外,在本章的最后部分,你将了解代码覆盖率。在其中,我将解释如何理解你的测试覆盖了哪些代码片段,以及最重要的是,哪些代码片段没有被覆盖。此外,你还将了解如何对代码覆盖率报告采取行动,以及为什么覆盖率测量有时可能会误导。

改进你的测试编写方式将节省你的开发时间并创建更可靠的保证。这将帮助你更快、更自信地交付更多软件。

3.1 组织测试套件

在路易斯的面包店,每个助手和糕点师都可以在任何时候轻松找到任何原料。每种原料都有自己的单独货架,而货架在面包店中的位置也因原料在烘焙过程中的常用程度而异。物品的排列有明确的逻辑。例如,面粉就放在鸡蛋货架旁边,靠近面包师将这些原料变成丝滑顺滑面糊的操作台。

这种系统性的安排使得面包店的员工更容易并行工作,并找到和使用他们需要的任何物品。因为同种类的原料都放在一起,也容易知道何时订购更多。路易斯的面包店不会让任何原料腐烂或缺货。

有序的测试对软件制作的影响与有序的厨房对蛋糕制作的影响相同。有序的测试通过允许开发者尽可能少地发生冲突来并行工作,从而促进协作。当开发者将测试紧密地放在一起时,他们减少了应用程序的整体维护负担。它们使软件易于维护,因为它们减少了重复,同时增加了可读性。组织你的测试的第一步是决定你将使用什么标准来区分它们。

让我们考虑你已经将下单和跟踪订单的代码拆分为两个独立的模块:cartControllerorderController,如图 3.1 所示。

图 3.1 下单模块和订单跟踪模块

尽管这些模块相互集成,但它们具有不同的功能,因此它们的测试应该分别编写。将cartControllerorderController的测试分别写入不同的文件已经是一个很好的开始,但将这些模块内的功能分开同样有价值。

要在文件内创建不同的测试组,你可以将它们嵌套在一个describe块中。例如,对于cartController模块,你的测试文件可能看起来如下。

列表 3.1 cartController.test.js

describe("addItemToCart", () => {                  ❶
    test("add an available item to cart", () => {
        // ...
    });

    test("add unavailable item to cart", () => {
        // ...
    });

    test("add multiple items to cart", () => {
        // ...
    });
});

describe("removeFromCart", () => {                ❷
    test("remove item from cart", () => {
        // ...
    });
});

❶ 将不同的测试分组到一个名为 addItemToCart 的块中

❷ 将不同的测试分组到一个名为 removeFromCart 的块中

你还可以使用 Jest 的 describe 块来将辅助函数限制在单个测试组的范围内。例如,如果你有一个用于向库存中添加项目的实用函数,而不是将其添加到文件的整个作用域中,你可以将其放置在需要它的 describe 块中,如下所示,并如图 3.2 所示。

列表 3.2 cartController.test.js

describe("addItemToCart", () => {
  const insertInventoryItem = () => {                             ❶
    // Directly insert an item in the database's inventory table
  };

  // Tests...
  test("add an available item to cart", () => {
    // ...
  });
});

❶ 此函数仅在 describe 块的回调函数中可用。

图 3.2 addIteToCart 测试及其辅助函数的分组

describe 块内嵌套实用函数有助于指示哪些测试需要它们。如果 insertInventoryItemaddItemToCart 函数的 describe 块内,你可以确信它仅对该测试组是必要的。当你以这种方式组织测试时,由于你知道在哪里查找它们使用的函数和变量,因此测试变得更容易理解且更易于更改。

这些 describe 块也会改变钩子的作用域。任何 beforeAllafterAllbeforeEachafterEach 钩子都相对于它们所在的 describe 块,如图 3.3 中的示例所示。例如,如果你想将特定的设置程序应用于文件中的几个测试,而不是所有测试,你可以将这些测试分组,并在这些测试的 describe 块中编写你的 beforeEach 钩子,如下所示。

列表 3.3 cartController.test.js

describe("addItemToCart", () => {
  const insertInventoryItem = () => { /* */ };

  let item;
  beforeEach(async () => {                   ❶
    item = await insertInventoryItem();
  });

  // Tests...
  test("add an available item to cart", () => {
    // You can use `item` here
  });
});

describe("checkout", () => {
  test("checkout non-existing cart", () => {
    // The previous `beforeEach` hook
    // does not run before this test
  });
});

❶ 在 addItemToCart 描述块中的每个测试之前运行一次

图 3.3 嵌套如何确定钩子将应用于哪些测试

注意:在上面的示例中,Jest 将等待 insertInventoryItem 钩子解决,然后才会继续进行测试。

就像在异步测试中一样,异步钩子在 Jest 继续执行之前会运行到完成。如果一个钩子返回一个 promise 或接受 done 作为参数,Jest 将等待 promise 解决或 done 被调用,然后才会运行文件中的其他钩子或测试。

这同样适用于每个钩子。例如,如果你使用了一个 beforeAll 钩子,它将在放置在 describe 块内的所有测试之前运行一次,如下所示,并如图 3.4 所示。

列表 3.4 cartController.test.js

describe("addItemToCart", () => {
  const insertInventoryItem = () => { /* */ };

  let item;
  beforeEach(async () => {                   ❶
    item = await insertInventoryItem();
  });

  // Tests...
});

describe("checkout", () => {
  const mockPaymentService = () => { /* */ };

  beforeAll(mockPaymentService);             ❷

  test("checkout non-existing cart", () => { /* */ });
});

❶ 在 addItemToCart 描述块中的每个测试之前运行

❷ 在 checkout 描述块中的所有测试之前运行一次

图 3.4 不同类型的钩子如何应用于不同的测试组

默认情况下,位于任何 describe 块之外的钩子适用于测试文件的整个范围,如下所示。

列表 3.5 cartController.test.js

beforeEach(clearDatabase);                      ❶

describe("addItemToCart", () => {
  const insertInventoryItem = () => { /* */ };

  let item;
  beforeEach(async () => {                      ❷
    item = await insertInventoryItem();
  });

  test("add an available item to cart", () => { /* */ });
});

describe("checkout", () => {
  const mockPaymentService = () => { /* */ };

  beforeAll(mockPaymentService);                ❸

  test("checkout nonexisting cart", () => { /* */ });
});

afterAll(destroyDbConnection)                   ❹

❶ 在文件中的每个测试之前运行,无论测试位于哪个 describe 块中

❷ 在 addItemToCart 描述块中的每个测试之前运行

❸ 在 checkout 描述块中的所有测试之前运行一次

❹ 在文件中的所有测试完成后运行一次

Jest 从最外层到最内层执行钩子。在上一个示例中,执行顺序将是以下:

  1. beforeEachclearDatabase

  2. beforeEachinsertInventoryItem

  3. test ➝ 向购物车添加可用项目

  4. beforeEachclearDatabase

  5. beforeAllmockPaymentService

  6. test ➝ 检查不存在的购物车

  7. afterAlldestroyDbConnection

嵌套生命周期钩子具有与嵌套实用函数类似的优点。你知道确切的位置在哪里查找它们,以及它们应用的范围。

3.1.1 分解测试

理想情况下,测试应该尽可能小,并专注于检查被测试单元的单一方面。

让我们以添加项目到购物车的路由的测试为例。这次,让我们考虑在添加项目到购物车时也会更新库存。为了符合新的规范,你将修改第二章中添加项目到购物车的路由。

列表 3.6 server.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

const carts = new Map();                                       ❶
const inventory = new Map();                                   ❷

router.post("/carts/:username/items/:item", ctx => {           ❸
  const { username, item } = ctx.params;
  if (!inventory.get(item)) {                                  ❹
    ctx.status = 404;
    return;
  }

  inventory.set(item, inventory.get(item) - 1);
  const newItems = (carts.get(username) || []).concat(item);   ❺
  carts.set(username, newItems);                               ❻
  ctx.body = newItems;                                         ❼
});

app.use(router.routes());

module.exports = {
  app: app.listen(3000),                                       ❽
  inventory,
  carts
};

❶ 存储用户购物车的内 容。每个用户名都对应一个表示购物车中项目的字符串数组。

❷ 存储库存的状态。每个项目名称都对应一个表示其数量的数字。

❸ 处理对 POST /carts/:username/items/:item 的请求,向用户的购物车添加项目

❹ 只有当项目有库存时才将项目添加到购物车中;如果没有,则响应状态为 404

❺ 创建一个包含请求参数中项的新数组

❻ 更新用户的购物车为新数组中的项

❼ 响应:返回新的项目数组

❽ 将服务器绑定到端口 3000,并通过 app 属性导出

注意:这次我想只关注添加项目到购物车的路由。因为你将不会编写端到端测试,所以你应该导出 inventorycarts。我们将在本章中编写的测试可以与您已经编写的端到端测试共存,因为它们的粒度级别不同。

尽管先前的端到端测试耦合度较低,并且从用户的角度提供了更强的保证,但本章的测试运行时间更短,可以一次覆盖应用的小部分,正如您将在分解测试时注意到的那样。

现在,按照以下方式编写此路由的测试文件。

列表 3.7 server.test.js

const { app, inventory, carts } = require("./server");
const fetch = require("isomorphic-fetch");

const apiRoot = "http://localhost:3000";

const addItem = (username, item) => {
  return fetch(`${apiRoot}/carts/${username}/items/${item}`, {
    method: "POST"
  });
};

describe("addItem", () => {
  test("adding items to a cart", async () => {
    inventory.set("cheesecake", 1);                                  ❶
    const addItemResponse = await addItem("lucas", "cheesecake");    ❷
    expect(await addItemResponse.json()).toEqual(["cheesecake"]);    ❸
    expect(inventory.get("cheesecake")).toBe(0);                     ❹

    expect(carts.get("lucas")).toEqual(["cheesecake"]);              ❺

    const failedAddItem = await addItem("lucas", "cheesecake");      ❻
    expect(failedAddItem.status).toBe(404);                          ❼
  });
});

afterAll(() => app.close());

❶ 安排:将库存中的芝士蛋糕数量设置为 1

❷ 行动:向添加芝士蛋糕到购物车的路由发送请求

❸ 断言:检查响应是否为仅包含新添加芝士蛋糕的数组

❹ 断言:验证库存中芝士蛋糕的数量为 0

❺ 断言:验证用户的购物车中只包含新添加的芝士蛋糕

❻ 行动:向用户的购物车发送请求以添加另一个芝士蛋糕

❼ 断言:检查最后一个响应的状态是否为 404

尽管对 addItem 的测试是严格的,但它对正在测试的路由的许多方面进行了断言。它验证以下内容:

  1. 如果 addItem 更新了购物车的内 容

  2. 如果路由的响应正确

  3. 如果库存已更新

  4. 如果路由拒绝向购物车添加已售罄的商品

如果应用程序不满足这些期望中的任何一个,测试将失败。当这个测试失败时,因为您依赖于四个不同的断言,您将无法立即知道问题是什么。因为测试在断言失败时停止,一旦修复了测试,您还需要重新运行它以查看断言之后的任何断言是否也会失败。

如果我们将这些检查分成多个测试,在单次执行中可以立即知道addItem路由的所有问题,如下所示。

列表 3.8 server.test.js

const { app, inventory, carts } = require("./server");
const fetch = require("isomorphic-fetch");

const apiRoot = "http://localhost:3000";

const addItem = (username, item) => {
  return fetch(`${apiRoot}/carts/${username}/items/${item}`, {
    method: "POST"
  });
};

describe("addItem", () => {
  beforeEach(() => carts.clear());                                   ❶
  beforeEach(() => inventory.set("cheesecake", 1));                  ❷

  test("correct response", async () => {                             ❸
    const addItemResponse = await addItem("lucas", "cheesecake");
    expect(addItemResponse.status).toBe(200);
    expect(await addItemResponse.json()).toEqual(["cheesecake"]);
  });

  test("inventory update", async () => {                             ❹
    await addItem("lucas", "cheesecake");
    expect(inventory.get("cheesecake")).toBe(0);
  });

  test("cart update", async () => {                                  ❺
    await addItem("keith", "cheesecake");
    expect(carts.get("keith")).toEqual(["cheesecake"]);
  });

  test("soldout items", async () => {                                ❻
    inventory.set("cheesecake", 0);
    const failedAddItem = await addItem("lucas", "cheesecake");
    expect(failedAddItem.status).toBe(404);
  });
});

afterAll(() => app.close());

❶ 在 addItem 描述块中的每个测试之前清空所有购物车

❷ 在每个测试之前,将库存中可用的芝士蛋糕数量设置为 1

❸ 尝试向用户的购物车中添加一块芝士蛋糕,并验证响应体和状态

❹ 验证在向用户的购物车添加商品后库存中芝士蛋糕的数量

❺ 尝试向用户的购物车中添加一块芝士蛋糕,并验证购物车的商品内容

❻ 验证当商品不可用时应该失败的请求的响应

由于这些测试很小,它们也更容易阅读。

每个测试中的断言越少,反馈越细粒度,您识别缺陷所需的时间就越少。

3.1.2 并行性

如果你有四个测试文件,每个文件需要一秒钟,依次运行它们将总共需要四秒钟,如图 3.5 所示。随着测试文件数量的增加,总执行时间也会增加。

图 3.5 依次运行测试时会发生什么

为了加快测试速度,Jest 可以将它们并行运行,如图 3.6 所示。默认情况下,Jest 将并行化不同文件中的测试用例。

并行化测试 并行化测试意味着使用不同的线程同时运行测试用例。

图 3.6 并行运行测试时会发生什么

如果测试很好地隔离,并行化测试可能是有益的,但如果它们共享数据,则可能存在问题。例如,如果您有两个使用相同数据库表的测试文件,它们的结果可能取决于它们运行的顺序。

如果您无法隔离测试,可以通过传递 Jest 的runInBand选项使它们按顺序运行。使测试慢而可靠比快而不可靠要好。

不稳定的测试 当一个测试的结果可能会改变,即使被测试的单元和测试本身保持不变时,该测试被称为“不稳定的”

# To run tests sequentially
jest --runInBand

# Or, if you have encapsulated the `jest` command into an NPM script
npm test -- --runInBand

如果您有可以在测试套件内同时运行的测试,可以使用test.concurrent来指示 Jest 应该并发执行哪些测试,如下所示。

列表 3.9 addItemToCart.test.js

describe("addItemToCart", () => {
  test.concurrent("add an available item to cart", async () => { /* */ }); ❶
  test.concurrent("add unavailable item to cart", async () => { /* */ });  ❶
  test.concurrent("add multiple items to cart", async () => { /* */ });    ❶
});

❶ 这些测试将并发运行,所以请确保隔离每个测试使用的数据。

要控制同时运行的测试数量,你可以使用 --maxConcurrencyOption 并指定 Jest 可以同时运行多少个测试。要管理用于运行测试的工作线程数量,你可以使用 --maxWorkers 选项并指定要启动多少个线程。

并行化测试可以显著加快执行时间。而且,因为运行快的测试会激励你更频繁地运行它们,所以我强烈建议你采用这种方法。它的唯一缺点是,你必须小心确保测试得到良好的隔离。

在本书中,我将解释在构建每种应用时,拥有确定性测试的重要性。

3.1.3 全局钩子

有时你可能需要在所有测试开始之前或结束后执行钩子。例如,你可能需要启动或停止数据库进程。

Jest 允许你通过两个配置选项 globalSetupglobalTeardown 来设置全局钩子。你可以在你的 jest.config.js 文件中指定这些选项。如果你还没有创建一个,你可以将它放在项目根目录中的 package.json 文件旁边。

小贴士:你可以使用 Jest 的 CLI 快速创建配置文件。当你运行 jest --init 时,你将需要回答几个问题,这些问题将用于生成你的 jest.config.js 文件。

传递给 globalSetupglobalTeardown 的文件名应该导出 Jest 在所有测试运行前后将调用的函数,如下所示。

列表 3.10 jest.config.js

module.exports = {
  testEnvironment: "node",
  globalSetup: "./globalSetup.js",           ❶
  globalTeardown: "./globalTeardown.js"      ❷
};

❶ Jest 在所有测试开始前会运行此文件导出的异步函数一次。

❷ Jest 在所有测试结束后会运行此文件导出的异步函数一次。

例如,一个初始化数据库的设置文件可能看起来像这样:

列表 3.11 globalSetup.js

const setup = async () => {
  global._databaseInstance = await databaseProcess.start()
};

module.exports = setup;

分配给 global 对象的值,如前面所示,也将可在 globalTeardown 钩子中使用。

假设你在 globalSetup 中设置了一个数据库实例并将其分配给 _databaseInstance,你可以在测试完成后使用相同的变量来停止该进程,如下所示。

列表 3.12 globalTeardown.js

const teardown = async () => {
   await global._databaseInstance.stop()
};

module.exports = teardown;

如果设置和清理函数是异步的,就像我们刚才写的那些,Jest 将在继续之前运行它们直到完成。

3.1.4 原子性

在组织测试时,请考虑任何测试都应能够在与其他所有测试隔离的情况下充分运行。单独运行一个测试应该与在成千上万的测试中运行它没有区别。

例如,考虑你之前为 addItem 函数编写的几个测试。为了这个例子,我已经从下面的 describe 块中移除了 beforeEach 钩子。

列表 3.13 server.test.js

// ...

describe("addItem", () => {
  test("inventory update", async () => {                        ❶
    inventory.set("cheesecake", 1);
    await addItem("lucas", "cheesecake");
    expect(inventory.get("cheesecake")).toBe(0);
  });

  test("cart update", async () => {                             ❷
    await addItem("keith", "cheesecake");
    expect(carts.get("keith")).toEqual(["cheesecake"]);
  });

  test("soldout items", async () => {                           ❸
    const failedAddItem = await addItem("lucas", "cheesecake");
    expect(failedAddItem.status).toBe(404);
  });
});

// ...

❶ 将可用的芝士蛋糕数量设置为 1,并检查向购物车添加一个芝士蛋糕是否足以更新库存

❷ 尝试向用户的购物车中添加一块芝士蛋糕,并检查购物车的内容是否为一个包含单个芝士蛋糕的数组

❸ 尝试添加一块芝士蛋糕,并期望服务器的响应状态为 404

在这种情况下,如果第一个测试已经运行,第二个测试将始终失败。另一方面,第三个测试依赖于第一个测试的成功。

当测试相互干扰时,确定错误的根本原因可能很困难。非原子性的测试会让你怀疑问题是在你的测试还是你的代码中。

具有原子性测试也有助于你更快地获得反馈。因为你可以单独运行一个测试,你不需要等待长时间测试套件完成才能知道你编写的代码是否工作。

为了保持测试的原子性,记住编写好的设置和拆卸钩子至关重要。为了保持原子性,向先前的示例中添加一个将芝士蛋糕添加到库存中的beforeEach钩子,并添加另一个清空用户购物车的钩子,如下所示。

列表 3.14 server.test.js

// ...

describe("addItem", () => {
  beforeEach(() => carts.clear());                     ❶
  beforeEach(() => inventory.set("cheesecake", 1));    ❷

  test("inventory update", async () => {
    await addItem("lucas", "cheesecake");
    expect(inventory.get("cheesecake")).toBe(0);
  });

  test("cart update", async () => {
    await addItem("keith", "cheesecake");
    expect(carts.get("keith")).toEqual(["cheesecake"]);
  });

  test("soldout items", async () => {
    const failedAddItem = await addItem("lucas", "cheesecake");
    expect(failedAddItem.status).toBe(404);
  });
});

// ...

❶ 在每个测试之前,清空所有购物车

❷ 在每次测试之前,将库存中的芝士蛋糕数量设置为 1

现在,即使有这些钩子,最后一个测试也会失败。你添加的第一个beforeEach钩子将一个cheesecake插入到库存中,因此不会导致最后一个测试中的addItem函数失败。

因为这个最后一个测试是唯一一个不需要芝士蛋糕可用的测试,所以最好避免另一个钩子。相反,你可以在测试本身中将芝士蛋糕的数量简单地设置为零,如下所示。

列表 3.15 server.test.js

// ...

describe("addItem", () => {
  beforeEach(() => carts.clear());
  beforeEach(() => inventory.set("cheesecake", 1));

  test("inventory update", async () => {
    await addItem("lucas", "cheesecake");
    expect(inventory.get("cheesecake")).toBe(0);
  });

  test("cart update", async () => {
    await addItem("keith", "cheesecake");
    expect(carts.get("keith")).toEqual(["cheesecake"]);
  });

  test("soldout items", async () => {
    inventory.set("cheesecake", 0);                               ❶
    const failedAddItem = await addItem("lucas", "cheesecake");
    expect(failedAddItem.status).toBe(404);
  });
});

// ...

❶ 将库存中的芝士蛋糕数量设置为 0

尽管以干净和简洁的方式封装重复行为非常出色,但钩子可能会使你的测试更难以阅读,因为它们增加了测试与其设置和拆卸过程之间的距离。

避免特定情况的钩子可以使测试更易于理解,因为它使得所有相关信息都更接近实际的测试代码。

在决定是否编写钩子或实用函数时,我建议你考虑你需要多频繁地重现某个场景。如果该场景对于套件中的几乎所有测试都是必需的,我建议你使用hook并将其视为该套件测试的“先决条件”。另一方面,如果你不需要在每次测试中都设置或拆卸完全相同的元素,实用函数可能是一个更好的选择。

3.2 编写好的断言

要识别独特的蛋糕,需要独特的面包师。在检查面糊的稠度或蛋糕的质地时,优秀的糕点师知道要寻找什么。没有严格的质量控制,你无法烘焙美味的甜点。

同样,优秀的工程师知道在他们所写的软件中寻找什么。他们编写健壮且精确的断言,尽可能多地捕获错误,而不显著增加维护成本。

在本节中,我将向您介绍一些技巧,帮助您编写更好的断言。您将学习如何使它们尽可能多地捕获错误,而无需频繁更新测试,从而减少额外的维护负担。

3.2.1 断言和错误处理

没有断言的测试只有在应用程序代码无法运行时才会失败。例如,如果您有一个 sum 函数,您必须添加断言以确保它完成它必须完成的工作。否则,它可能做任何事情。没有断言,您只是确保 sum 函数运行到完成。

为了确保您的测试包含断言,Jest 提供了使测试在未运行您期望的断言数量时失败的实用工具。

例如,考虑一个 addToInventory 函数,该函数向商店的库存中添加项目并返回新的可用数量。如果指定的数量不是一个数字,它应该失败,并且不应该向库存中添加任何项目,如下所示。

列表 3.16 inventoryController.js

const inventory = new Map();

const addToInventory = (item, n) => {
  if (typeof n !== "number") throw new Error("quantity must be a number");
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + n;
  inventory.set(item, newQuantity);
  return newQuantity;
};

module.exports = { inventory, addToInventory };

在测试此函数时,您必须小心不要创建一个可能导致永远不运行断言的执行路径。以下是一个示例。

列表 3.17 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.set("cheesecake", 0));

test("cancels operation for invalid quantities", () => {
  try {
    addToInventory("cheesecake", "not a number");
  } catch (e) {
    expect(inventory.get("cheesecake")).toBe(0); )      ❶
  }
});

❶ 只有在 addToInventory 调用抛出错误时才运行的断言

此测试将通过,但您不会知道它是否通过,是因为 addToInventory 函数没有向库存中添加项目,还是因为它没有抛出任何错误。

如果您注释掉抛出错误的行并重新运行测试,如以下所示,您将看到,尽管函数是错误的,但测试仍然通过。

列表 3.18 inventoryController.js

const inventory = new Map();

const addToInventory = (item, n) => {
  // Commenting this line still makes tests pass
  // if (typeof n !== "number") throw new Error("quantity must be a number");
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + n;
  inventory.set(item, newQuantity);
  return newQuantity;
};

module.exports = { inventory, addToInventory };

为了确保您的测试将运行断言,您可以使用 expect.hasAssertions,这将导致如果测试没有运行至少一个断言,则测试失败。

请确保您的测试通过添加 expect.hasAssertions 来运行断言。

列表 3.19 inventoryController.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.set("cheesecake", 0));

test("cancels operation for invalid quantities", () => {
  expect.hasAssertions();                                  ❶

  try {
    addToInventory("cheesecake", "not a number");
  } catch (e) {
    expect(inventory.get("cheesecake")).toBe(0);
  }
});

❶ 如果测试没有执行至少一个断言,则会导致测试失败

现在考虑您还想要添加一个断言来确保库存中只有一个项目。

列表 3.20 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.set("cheesecake", 0));

test("cancels operation for invalid quantities", () => {
  expect.hasAssertions();

  try {
    addToInventory("cheesecake", "not a number");
  } catch (e) {
    expect(inventory.get("cheesecake")).toBe(0);
  }

  expect(Array.from(inventory.entries())).toHaveLength(1)       ❶
});

❶ 总是执行的断言

即使 catch 块没有执行,之前的测试仍然可能通过。测试中的 expect.hasAssertions 调用将确保任何断言运行,而不是所有断言都运行。

为了解决这个问题,您可以使用 expect.assertions 来显式确定您期望运行多少个断言。例如,如果您想运行两个断言,请使用 expect.assertions(2)。使用 expect.assertions 将导致您的测试在执行的断言数量与您确定的数量不匹配时失败,如以下所示。

列表 3.21 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.set("cheesecake", 0));

test("cancels operation for invalid quantities", () => {
  expect.assertions(2);                                   ❶

  try {
    addToInventory("cheesecake", "not a number");
  } catch (e) {
    expect(inventory.get("cheesecake")).toBe(0);
  }

  expect(Array.from(inventory.entries())).toHaveLength(1)
});

❶ 如果测试没有执行两个断言,则会导致测试失败

由于断言计数并不总是实用的,一个更简单、更易读的替代方案是检查函数调用是否抛出错误。要执行此断言,请使用 Jest 的toThrow,如下所示。

列表 3.22 inventoryController.test.js

// ..

test("cancels operation for invalid quantities", () => {
  expect(() => addToInventory("cheesecake", "not a number")).not.toThrow();❶
  expect(inventory.get("cheesecake")).toBe(0);
  expect(Array.from(inventory.entries())).toHaveLength(1)
});

❶ 如果addToInventory函数抛出错误,则测试失败

由于toThrow通常使测试更简洁、更易读,我倾向于更喜欢它。我使用它来验证应该抛出错误和不应该抛出错误的函数。

3.2.2 松散断言

编写测试的目标是当你的应用程序没有按照你的意愿执行时,测试应该失败。在编写断言时,你想要确保它们足够敏感,以便在出现任何错误时使测试失败。

再次以你的addToInventory函数为例。对于这个函数,你可以编写一个断言来确保addToInventory的结果是Number类型。

列表 3.23 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.clear());               ❶

test("returned value", () => {
  const result = addToInventory("cheesecake", 2);
  expect(typeof result).toBe("number");            ❷
});

❶ 清空库存

❷ 检查结果是否为数字

现在考虑这个断言允许多少种可能的结果。JavaScript 中的数字可以从5e-324精确到1.7976931348623157e+308。鉴于这个巨大的范围,很明显,断言接受的可能的值集太大,如图 3.7 所示。这个断言可以保证addToInventory函数不会返回例如Stringboolean,但它不能保证返回的数字是正确的。顺便说一句,你知道在 JavaScript 中什么也被认为是Number吗?NaN,代表不是数字。

console.log(typeof NaN); // 'number'

图片

图 3.7 类型断言接受的结果范围

一个断言接受的值越多,它就越宽松

使这个断言接受更少的值——使其“更严格”——的一种方法就是期望结果大于某个特定的值,如下所示。

列表 3.24 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.clear());

test("returned value", () => {
  const result = addToInventory("cheesecake", 2);
  expect(result).toBeGreaterThan(1);                 ❶
});

❶ 期望结果大于 1

如图 3.8 所示,toBeGreaterThan断言极大地减少了接受的结果数量,但它仍然比应有的宽松得多。

图片

图 3.8 toBeGreaterThan断言接受的结果范围

你能写出的最严格且最有价值的断言是只允许单个结果通过的断言,如下面的列表和图 3.9 所示。

图片

图 3.9 严格的toBe断言接受的结果范围

列表 3.25 inventoryController.test.js

const { inventory, addToInventory } = require("./inventoryController");

beforeEach(() => inventory.clear());

test("returned value", () => {
  const result = addToInventory("cheesecake", 2);
  expect(result).toBe(2);                           ❶
});

❶ 期望结果正好是 2

理想情况下,你的断言应该接受单个结果。如果你的断言通常允许许多结果,这可能是一个迹象表明你的代码不是确定性的,或者你对它了解不够充分。松散的断言使测试更容易通过,但它们使这些测试的价值降低,因为它们可能在应用程序产生无效输出时不会失败。编写更紧密的断言会使测试在应用程序代码有问题时更难通过,从而更容易捕获错误

确定性代码 当给定相同的输入时,总是产生相同输出的代码被称为确定性代码。

例如,一个验证数组是否包含值的断言通常表明你不知道整个数组应该是什么样子。理想情况下,你应该编写一个检查整个数组的断言。

否定断言——确保输出不匹配另一个值的断言——也会生成松散的断言。例如,当你断言输出不是2时,你接受了一个巨大的值范围(所有类型的所有值,但不是2),如图 3.10 所示。尽可能避免编写否定断言

当你想测试不依赖于你无法控制的因素,如真正的随机性时,编写松散的断言是可以接受的。假设你正在测试一个生成随机数字数组的函数。在测试这个函数时,你可能想检查数组的长度和其项的类型,但不需要检查数组的确切内容。

图 3.10 否定断言接受的结果范围

提示 尽管 Jest 有一系列断言——你可以在jestjs.io/docs/en/expect中找到它们——但我建议读者尽可能坚持使用toBetoEqual,因为它们非常严格。

为了更容易控制你的断言有多松散,Jest 有非对称匹配器。非对称匹配器允许你确定 Jest 应该松散验证特定输出的哪些方面,以及应该紧密验证哪些方面。

假设你有一个返回按名称索引的库存内容的函数。出于审计目的,此函数还将包括生成信息时的日期,如下所示。

列表 3.26 inventoryController.js

const inventory = new Map();

// ...

const getInventory = () => {
  const contentArray = Array.from(inventory.entries());
  const contents = contentArray.reduce(                   ❶
    (contents, [name, quantity]) => {
      return { ...contents, [name]: quantity };
    },
    {}
  );

  return { ...contents, generatedAt: new Date() };        ❷
};

module.exports = { inventory, addToInventory, getInventory };

❶ 创建一个对象,其键是库存项目的名称,其值是每个项目的相应数量

❷ 返回一个新对象,包括contents中的所有属性和一个日期

在测试此函数时,你的日期将在每次测试运行时更改。为了避免断言库存报告生成的确切时间,你可以使用非对称匹配器来确保generatedAt字段将包含一个日期。对于其他属性,你可以有紧密的断言,如下面的代码片段所示。

列表 3.27 inventoryController.test.js

const { inventory, getInventory } = require("./inventoryController");

test("inventory contents", () => {
  inventory
    .set("cheesecake", 1)
    .set("macarroon", 3)
    .set("croissant", 3)
    .set("eclaire", 7);
  const result = getInventory();

  expect(result).toEqual({              ❶
    cheesecake: 1,
    macarroon: 3,
    croissant: 3,
    eclaire: 7,
    generatedAt: expect.any(Date)       ❷
  });
});

❶ 期望结果与传递给 toEqual 方法的对象匹配

❷ 允许 generatedAt 属性为任何日期

非对称匹配器可以执行许多不同的验证。例如,它们可以检查一个字符串是否与正则表达式匹配,或者一个数组是否包含特定项。查看 Jest 的文档以了解哪些匹配器是默认可用的。

3.2.3 使用自定义匹配器

在上一节中,我们看到了尽管我们希望我们的断言尽可能严格,但在某些情况下,在验证值时仍然需要灵活。

就像将行为封装到函数中一样,你还可以将你的验证封装到新的匹配器中。

假设,例如,你正在编写一个测试来确保 getInventory 中的 generatedAt 字段不是未来的日期。你可以这样做的一种方法是通过手动比较时间戳,如下所示。

列表 3.28 inventoryController.test.js

const { inventory, getInventory } = require("./inventoryController");

test("generatedAt in the past", () => {
  const result = getInventory();

  const currentTime = Date.now() + 1;                                   ❶

  const isPastTimestamp = result.generatedAt.getTime() < currentTime;   ❷
  expect(isPastTimestamp).toBe(true);                                   ❸
});

❶ 将当前时间戳增加一毫秒以确保比较的时间戳不会相同。或者,你可以在调用 Date.now 之前等待一毫秒。

❷ 检查结果 generatedAt 属性是否小于测试生成的属性,并存储一个布尔值

❸ 检查存储的布尔值是否为真

当这个测试通过时,它可以非常出色,但当它失败时,它的反馈可能不如你预期的那么清晰。例如,尝试将 generatedAt 属性中的年份设置为 3000,以便你可以看到测试失败时会发生什么。

列表 3.29 inventoryController.js

const inventory = new Map();

// ...

const getInventory = () => {
  const contentArray = Array.from(inventory.entries());
  const contents = contentArray.reduce((contents, [name, quantity]) => {
    return { ...contents, [name]: quantity };
  }, {});

  return {
    ...contents,
    generatedAt: new Date(new Date().setYear(3000))      ❶
  };
};

module.exports = { inventory, addToInventory, getInventory };

❶ 创建一个公元 3000 年的日期

运行你的测试应该会产生以下输出:

FAIL  ./inventoryController.test.js
 ✕ generatedAt in the past (7ms)

 ● generatedAt in the past

   expect(received).toBe(expected) // Object.is equality

   Expected: true
   Received: false

正如你所见,Jest 生成的差异没有提供太多信息。它说你期望 truefalse,但它没有告诉你断言的主题是什么。当测试以这样的通用差异失败时,你需要重新阅读其代码以确定出了什么问题,以及实际结果和预期结果之间的确切差异是什么。

为了获得更精确的断言,我们将使用 jest-extendedjest-extended 模块扩展了 Jest 的断言,为你提供了更好、更灵活的检查。

注意:你可以在 github.com/jest-community/jest-extended 找到 jest-extended 和其断言的文档。

前往并安装 jest-extended 作为开发依赖项。

要设置 jest-extended 以使用其断言,更新你的 jest .config.js 文件,并将 jest-extended 添加到应在设置测试环境之后运行的文件列表中,如下所示。

列表 3.30 jest.config.js

module.exports = {
  testEnvironment: "node",
  setupFilesAfterEnv: ["jest-extended"]      ❶
};

❶ 通过 jest-extended 扩展 Jest 的断言

完成这些后,你将能够使用 jest-extended 中的任何断言。

为了使测试的反馈更清晰,我们将使用 toBeBefore 断言,该断言检查一个 Date 是否在另一个 Date 之前。更新您的测试,使其使用这个新的断言,如下所示。

列表 3.31 inventoryController.test.js

const { getInventory } = require("./inventoryController");

test("generatedAt in the past", () => {
  const result = getInventory();
  const currentTime = new Date(Date.now() + 1);          ❶
  expect(result.generatedAt).toBeBefore(currentTime);    ❷
});

❶ 创建一个比当前时间早一毫秒的日期。或者,您也可以在生成 Date 之前等待一毫秒。

❷ 期望结果中的 generatedAt 属性在上一行生成的日期之前

现在,当这个测试失败时,Jest 提供的反馈将更加精确:

FAIL  ./inventoryController.test.js
 ✕ generatedAt in the past (11ms)

 ● generatedAt in the past

   expect(received).toBeBefore()

   Expected date to be before 2020-02-23T15:45:47.679Z but received:
     3000-02-23T15:45:47.677Z

现在,您确切地知道测试检查了什么,以及两个日期之间的差异是什么。

使用精确断言可以使您通过指示 Jest 应该产生什么样的输出来提高测试反馈的质量。

使用精确断言的测试更容易阅读,修复所需时间也更短,因为更容易理解哪里出了问题。

3.2.4 循环断言

循环断言是比较您的应用程序代码与自身的断言。您应该避免循环断言,因为当比较代码的结果时,您的测试将 永远不会 失败。

假设,例如,您创建了一个返回库存内容的路由。此路由使用您已经拥有的 getInventory 函数,如下所示。

列表 3.32 server.js

// ...

router.get("/inventory", ctx => (ctx.body = getInventory()));

// ...

为了便于测试此路由,您可能会倾向于在测试中使用 getInventory

列表 3.33 server.test.js

// ...

test("fetching inventory", async () => {
  inventory.set("cheesecake", 1).set("macarroon", 2);
  const getInventoryResponse = await sendGetInventoryRequest("lucas");

  // For the sake of this example, let's not compare the `generatedAt` field's value
  const expected = {                                             ❶
    ...getInventory(),
    generatedAt: expect.anything()                               ❷
  };

  // Because both the route and `expected` were generated based on `getInventory`
  // you are comparing two outputs which come from the exact same piece of code:
  // the unit under test!
  expect(await getInventoryResponse.json()).toEqual(expected);   ❸
});

// ...

❶ 将 getInventory 函数的结果中的属性复制到一个新对象中,并包含一个生成的 generatedAt 属性,其值是一个非对称匹配器

❷ 允许 generatedAt 属性具有任何值

❸ 比较服务器响应与测试中创建的对象

这种方法的问题在于,由于路由和测试都依赖于相同的代码片段 (getInventory),您最终是在比较应用程序本身。如果 getInventory 路由存在问题,它不会导致这个测试失败,因为您期望的结果也是错误的。

例如,尝试将 getInventory 改变,使其为每个项目返回 1000 作为数量。

列表 3.34 server.test.js

const inventory = new Map();

const getInventory = () => {
  const contentArray = Array.from(inventory.entries());          ❶
  const contents = contentArray.reduce((contents, [name]) => {   ❷
    return { ...contents, [name]: 1000 };
  }, {});

  return { ...contents, generatedAt: new Date() };               ❸
};

module.exports = { inventory, addToInventory, getInventory };

❶ 使用库存条目创建一个键值对数组

❷ 创建一个对象,其键是库存项目名称,其值始终设置为 1000,代表每个项目的相应数量

❸ 将 contents 中的每个属性复制到一个新对象中,该对象还包含一个 generatedAt 键,其值为一个 Date

即使库存中项目的数量现在错误,您的路由测试仍然会通过。

如果您已经单独测试了应用程序的不同部分,循环断言通常不会成为大问题。例如,在上一个案例中,即使路由的测试没有捕获到错误,对 inventoryController 本身的彻底测试也会。

无论你是否可以在单独的测试中捕获它,即使测试不应该通过,路由的测试也会通过。这种不准确的反馈可能会导致混淆,如果你没有对inventoryController进行严格的测试,那么错误可能会悄悄地进入生产环境。

如果在断言中明确写出预期结果,那么这个测试将会更好。这将使测试更易于阅读,并有助于调试,如下所示。

列表 3.35 server.test.js

// ...

test("fetching inventory", async () => {
  inventory.set("cheesecake", 1).set("macarroon", 2);
  const getInventoryResponse = await sendGetInventoryRequest("lucas");
  const expected = {                                                   ❶
    cheesecake: 1,
    macarroon: 2,
    generatedAt: expect.anything()
  };

  // Notice how both the `actual` and `expected`
  // outputs come from different places.
  expect(await getInventoryResponse.json()).toEqual(expected);         ❷
});

// ...

❶ 不使用任何依赖项创建一个对象字面量

❷ 期望服务器的响应与测试中创建的对象字面量匹配

在可能的情况下,为你的测试创建单独的实用函数,而不是仅仅重用应用程序的代码。有一点重复或硬编码的预期结果比有永远无法失败的测试更好。

3.3 测试替身:模拟、存根和间谍

模拟、存根和间谍是用于修改和替换应用程序部分的对象,以简化或启用测试。作为一个整体,它们被称为测试替身。

  • 间谍记录与函数使用相关的数据,而不干扰其实现。

  • 存根记录与函数使用相关的数据,并改变其行为,无论是通过提供替代实现还是返回值。

  • 模拟会改变一个函数的行为,但它们不仅记录其使用的信息,还预先编程了期望。

注意:工程师们经常混淆模拟存根间谍这些术语,尽管在正式上,这些术语有不同的定义。

尤其是在 Jest 的上下文中,你经常会看到人们将存根和间谍称为模拟。这种混淆发生是因为 Jest 的 API 和文档倾向于使用名称模拟来指代所有类型的测试替身。

具有讽刺意味的是,如果我们遵循最广泛接受的模拟定义,那么它就是 Jest 不包含的唯一类型的测试替身。

为了提高可读性和符合大多数人的词汇,在这本书中,我使用了模拟这个动词,意思是“用测试替身替换”。

在我们的第一个例子中,让我们考虑出于责任原因,你想要记录每次有人向库存中添加项目时的日志。

要实现此功能,你将使用pino,这是一个轻量级的库,其文档可以在getpino.io找到。请继续安装pino作为你应用程序的依赖项,如下所示。然后,创建一个logger文件,其中将包含你将使用的logger实例。我们将使用它来公开我们想要的日志功能。

列表 3.36 logger.js

const pino = require("pino");

const pinoInstance = pino();

const logger = {
  logInfo: pinoInstance.info.bind(pinoInstance),      ❶
  logError: pinoInstance.error.bind(pinoInstance)     ❶
};

module.exports = logger;

❶ 由于 bind 的存在,这些函数中的 this 值将始终是此文件中的 Pino 实例,而不是日志对象。

现在你有了日志记录器,修改addToInventory函数,使其在向库存中添加项目时记录,如下所示。

列表 3.37 inventoryController.js

const logger = require("./logger");

const inventory = new Map();

const addToInventory = (item, quantity) => {
  if (typeof quantity !== "number")
    throw new Error("quantity must be a number");
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + quantity;
  inventory.set(item, newQuantity);
  logger.logInfo({ item, quantity }, "item added to the inventory");    ❶
  return newQuantity;
};

module.exports = { inventory, addToInventory };

❶ 记录添加到库存的项目

您可以通过运行 node 的 REPL 并执行以下代码来查看日志是如何工作的:

$ node
> const { addToInventory } = require("./inventoryController");
> addToInventory("cheesecake", 2);

这将导致你的日志记录器将类似以下内容写入你的控制台:

{
    "level":30,
    "time":1582531390531,
    "pid":43971,
    "hostname":"your-machine",
    "item":"cheesecake",
    "quantity":2,
    "msg":"item added to the inventory",
    "v":1
}

由于这个要求至关重要,让我们假设你决定添加一个测试来强制 addToInventory 正确记录传递给它的所有项目。

在这种情况下,日志是一个难以从 addToInventory 函数外部观察到的副作用。如果你的测试无法访问它,你将如何确保日志发生了?

为了解决这个问题,你将使用一个 间谍间谍可以记录对函数的任何调用。在这种情况下,你将使用间谍来跟踪对 logger.logInfo 函数的调用,以便你可以在之后对这些调用进行断言。

注意:我们不想测试日志是否真的发生了。测试日志库(pino)是库作者的职责。在上面的示例中,我选择相信日志记录器是有效的。而不是添加冗余的测试,我只是检查日志方法是否以预期的参数被调用。

验证调用而不是日志记录器的实际行为简化了测试并使其更快,但这并不一定保证被测试的单元记录了任何信息。

验证日志本身将取决于端到端测试。这种类型的测试将能够访问日志记录器写入的文件或流。

选择要编写的测试类型,正如我们在上一章中讨论的,取决于你的目标和你可以投入多少来实现它。

为了实验你的第一个间谍,创建一个测试来测试日志功能,并使用 jest.spyOn 间谍 logger.logInfo 函数。一旦你有了 logger.logInfo 的间谍,调用 addInventory 函数并记录 logger.logInfo 以查看其内部内容。

列表 3.38 inventoryController.js

const logger = require("./logger");
const { addToInventory } = require("./inventoryController");

test("logging new items", () => {
  jest.spyOn(logger, "logInfo");      ❶
  addToInventory("cheesecake", 2);
  console.log(logger.logInfo);
});

❶ 将日志记录器的 logInfo 方法包装成间谍

你的测试中的 console.log 将显示 Jest 将 logger.logInfo 包装成一个具有许多属性的功能,这些属性允许你访问和操作 logInfo 的使用数据,如下所示:

{ [Function: mockConstructor]
  _isMockFunction: true,
  getMockImplementation: [Function],
  mock: [Getter/Setter],
  mockClear: [Function],
  mockReset: [Function],
  mockRestore: [Function],
  mockReturnValueOnce: [Function],
  mockResolvedValueOnce: [Function],
  mockRejectedValueOnce: [Function],
  mockReturnValue: [Function],
  mockResolvedValue: [Function],
  mockRejectedValue: [Function],
  mockImplementationOnce: [Function],
  mockImplementation: [Function],
  mockReturnThis: [Function],
  mockName: [Function],
  getMockName: [Function]
}

在你刚刚记录的间谍中,包含每个调用信息的记录的属性是 mock。更新你的 console.log 以记录 logger.logInfo.mock。当你再次运行测试时,你应该看到以下内容:

{
  calls: [ [ [Object], 'item added to the inventory' ] ],
  instances: [ Pino { ... } ],
  invocationCallOrder: [ 1 ],
  results: [ { type: 'return', value: undefined } ]
}

在你的测试中,你想要确保 logger.logInfo 使用正确的值被调用,因此你将使用 logger.logInfo.mock.calls 来比较实际参数与你预期的参数,如下所示。

列表 3.39 inventoryController.js

const logger = require("./logger");
const { addToInventory } = require("./inventoryController");

test("logging new items", () => {
  jest.spyOn(logger, "logInfo");
  addToInventory("cheesecake", 2);                                      ❶

  const firstCallArgs = logger.logInfo.mock.calls[0];                   ❷
  const [firstArg, secondArg] = firstCallArgs;

  // You should assert on the usage of a spy only _after_ exercising it
  expect(firstArg).toEqual({ item: "cheesecake", quantity: 2 });        ❸
  expect(secondArg).toEqual("item added to the inventory");             ❹
});

❶ 测试 addToInventory 函数,该函数随后应调用被包装成间谍的日志记录器的 logInfo 函数

❷ 传递给 logInfo 函数第一次调用的参数

❸ 期望第一次调用的第一个参数匹配一个包含项目名称和数量的对象

❹ 检查第一次调用的第二个参数是否与预期消息匹配

每次调用 logger.logInfo 都会将一条新记录添加到 logger.logInfo.mock.calls 中。该记录是一个包含函数被调用时使用的参数的数组。例如,如果您想确保 logger.logInfo 只被调用一次,您可以对 logger.logInfo.mock.calls 的长度进行断言。

列表 3.40 inventoryController.test.js

const logger = require("./logger");
const { addToInventory } = require("./inventoryController");

test("logging new items", () => {
  jest.spyOn(logger, "logInfo");
  addToInventory("cheesecake", 2);

  expect(logger.logInfo.mock.calls).toHaveLength(1);     ❶

  const firstCallArgs = logger.logInfo.mock.calls[0];
  const [firstArg, secondArg] = firstCallArgs;

  expect(firstArg).toEqual({ item: "cheesecake", quantity: 2 });
  expect(secondArg).toEqual("item added to the inventory");
});

❶ 预期日志记录器的 logInfo 函数被调用一次

为了展示在多个测试中监视,向 getInventory 函数添加日志记录,以便我们可以为它编写测试,如下所示。

列表 3.41 inventoryController.js

const logger = require("./logger");

const inventory = new Map();

// ...

const getInventory = () => {
  const contentArray = Array.from(inventory.entries());
  const contents = contentArray.reduce((contents, [name, quantity]) => {
    return { ...contents, [name]: quantity };
  }, {});

  logger.logInfo({ contents }, "inventory items fetched");     ❶
  return { ...contents, generatedAt: new Date() };
};

module.exports = { inventory, addToInventory, getInventory };

❶ 每次运行 getInventory 时记录一条消息和库存的内容

现在 getInventory 已经具有日志记录功能,为它添加一个测试。因为您需要监视 logger.logInfo 并在每个测试前清除库存,所以使用您在前一章中学到的知识来组织必要的钩子。

列表 3.42 inventoryController.test.js

const logger = require("./logger");
const {
  inventory,
  addToInventory,
  getInventory
} = require("./inventoryController");

beforeEach(() => inventory.clear());                           ❶

beforeAll(() => jest.spyOn(logger, "logInfo"));                ❷

describe("addToInventory", () => {
  test("logging new items", () => {
    addToInventory("cheesecake", 2);

    expect(logger.logInfo.mock.calls).toHaveLength(1);

    const firstCallArgs = logger.logInfo.mock.calls[0];
    const [firstArg, secondArg] = firstCallArgs;

    expect(firstArg).toEqual({ item: "cheesecake", quantity: 2 });
    expect(secondArg).toEqual("item added to the inventory");
  });
});

describe("getInventory", () => {
  test("logging fetches", () => {
    inventory.set("cheesecake", 2);
    getInventory("cheesecake", 2);                             ❸

    expect(logger.logInfo.mock.calls).toHaveLength(1);         ❹

    const firstCallArgs = logger.logInfo.mock.calls[0];
    const [firstArg, secondArg] = firstCallArgs;

    expect(firstArg).toEqual({ contents: { cheesecake: 2 } }); ❺
    expect(secondArg).toEqual("inventory items fetched");      ❻
  });
});

❶ 在每个测试前清空库存

❷ 在所有测试之前监视日志记录器的 logInfo 函数一次

❸ 练习 getInventory 函数,然后它应该调用包装日志记录器 logInfo 函数的监视器

❹ 预期日志记录器的 logInfo 函数被调用一次

❺ 检查传递给日志记录器 logInfo 函数的第一个参数是否与预期的库存内容匹配

❻ 预期传递给日志记录器 logInfo 函数的第二个参数与预期消息匹配

当运行这两个测试时,您将注意到第二个测试将失败。Jest 将告诉您它期望 logger.logInfo.mock.calls 只被调用一次,但实际上它被调用了两次,如下所示:

getInventory
  ✕ logging fetches (5ms)

● getInventory › logging fetches

  expect(received).toHaveLength(expected)

  Expected length: 1
  Received length: 2
  Received array:  [
    [
      {"item": "cheesecake", "quantity": 2},
      "item added to the inventory"
    ],
    [
      {"item": "cheesecake", "quantity": 2},
      "item added to the inventory"
    ]
  ]

通过查看差异,我们可以看到接收到的数组仍然包含第一个测试中的调用记录。这是因为,就像所有其他类型的对象一样,监视器会保留其状态,直到您重置它们。

要重置 logger.logInfo 监视器的状态,您可以在每个测试后使用 afterEach 调用 logger.logInfo.mockClear。监视器的 mockClear 函数将重置 spy.mock.callsspy.mock.instances 数组,如下所示。

列表 3.43 inventoryController.test.js

const logger = require("./logger");

// ...

beforeAll(() => jest.spyOn(logger, "logInfo"));

afterEach(() => logger.logInfo.mockClear());      ❶

// ...

❶ 在每个测试后,重置测试替身在它的模拟属性中记录的使用信息

在每个测试后清除模拟应该使您的测试再次通过。

提示:当您的测试包含多个测试替身时,而不是手动清除每个替身,您可以在单个 beforeEach 钩子中使用 jest.clearAllMocks 一次性重置所有替身。

或者,您可以在 jest.config.js 文件中添加一个 clearMocks 属性,并将其值设置为 true,以在每个测试前自动清除所有测试替身的记录。

尝试添加更多的日志记录并自行测试。例如,尝试使用 logger .logError 来记录每次 addToInventory 失败时,因为传递的 quantity 参数不是一个数字。

完成后,按照以下方式重新运行你的测试,并检查 Jest 的输出:

PASS  ./inventoryController.test.js
  addToInventory
    ✓ logging new items (7ms)
    ✓ logging errors (1ms)
  getInventory
    ✓ logging fetches (1ms)

{"level":30,"time":1582573102047,"pid":27902,"hostname":"machine","item":"cheesecake","quantity":2,"msg":"item added to the inventory","v":1}
{"level":30,"time":1582573102053,"pid":27902,"hostname":"machine","contents":{"cheesecake":2},"msg":"inventory items fetched","v":1}
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.873s
Ran all test suites.

完美!所有测试都通过了,但你的摘要仍然被实际的日志消息所污染。

这些消息仍然被写入控制台,因为间谍 并不 替换你正在监视的实际方法。相反,它们将该方法包装在一个 spy 中,并允许调用通过。

为了避免污染测试的输出,将 logger.logInfo 函数的实现替换为一个虚拟函数。为此,调用间谍的 mockImplementation 方法,并传递一个使用 jest.fn 创建的虚拟函数。

TIP 你可以使用 jest.fn 快速创建存根。你可以创建一个除了跟踪其使用情况外什么都不做的存根,或者你可以传递一个函数来包装它。

因为你的测试组织得很好,你只需要更新设置间谍的 beforeAll 钩子,如以下所示。

列表 3.44 inventoryController.test.js

const logger = require("./logger");
const {
  inventory,
  addToInventory,
  getInventory
} = require("./inventoryController");

beforeEach(() => inventory.clear());

beforeAll(() => {                                               ❶
  jest.spyOn(logger, "logInfo").mockImplementation(jest.fn())
});

afterEach(() => logger.logInfo.mockClear());

describe("addToInventory", () => {
  test("logging new items", () => {
    addToInventory("cheesecake", 2);

    expect(logger.logInfo.mock.calls).toHaveLength(1);

    const firstCallArgs = logger.logInfo.mock.calls[0];
    const [firstArg, secondArg] = firstCallArgs;

    expect(firstArg).toEqual({ item: "cheesecake", quantity: 2 });
    expect(secondArg).toEqual("item added to the inventory");
  });
});

// ...

❶ 将日志的 logInfo 实现替换为一个虚拟函数

现在你已经将 logger.logInfo 替换成了一个虚拟函数,你将不再在测试摘要中看到实际的日志。

通过用你自己的实现替换 logger.logInfo 函数,你已经创建了一个存根。存根 替换 函数的原始实现。存根,就像间谍一样,跟踪函数的使用情况,以便你可以在以后断言它。

重要 在 Jest 中,所有存根都是间谍,但并非所有间谍都是存根。

在你之前的测试中,你将 logger.logInfo 替换成了一个虚拟函数,但实际上你可以传递任何函数给 mockImplementation。传递给 mockImplementation 的函数将替换你在 spyOn 中指定的函数,并且它将仍然具有与间谍相同的所有功能。

为了演示这一点,尝试更新 addToInventory,如下一列表所示,以便在每次日志条目中包含进程的内存使用情况。为此,我们将使用 Node 的 process.memoryUsage 函数,其文档可以在 nodejs.org/api/process.html#process_process_memoryusage 找到。

列表 3.45 inventoryController.js

// ...

const addToInventory = (item, quantity) => {
  if (typeof quantity !== "number")
    throw new Error("quantity must be a number");
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + quantity;
  inventory.set(item, newQuantity);
  logger.logInfo(                                                ❶
    { item, quantity, memoryUsage: process.memoryUsage().rss },
    "item added to the inventory"
  );
  return newQuantity;
};

// ...

❶ 当向库存中添加项目时,记录项目的名称、数量和进程的内存使用情况

这个新字段现在应该会使你的 addToInventory 测试失败,因为它没有期望日志条目包含 memoryUsage

你可以通过使用非对称匹配器和期望 memoryUsage 包含任何 Number 来解决这个问题。这种方法的问题在于,它不能保证 memoryUsage 字段中的 Number 来自 process.memoryUsage().rss

为了使测试再次通过并确保 memoryUsage 字段来自正确的位置,你可以向 mockImplementation 提供你自己的函数,并断言你已知将返回的值,如以下所示。

列表 3.46 inventoryController.test.js

// ...

describe("addToInventory", () => {
  beforeEach(() => {                          ❶
    jest.spyOn(process, "memoryUsage")
      .mockImplementation(() => {
        return { rss: 123456, heapTotal: 1, heapUsed: 2, external: 3 };
      });
  });

  test("logging new items", () => {
    addToInventory("cheesecake", 2);

    expect(logger.logInfo.mock.calls).toHaveLength(1);

    const firstCallArgs = logger.logInfo.mock.calls[0];
    const [firstArg, secondArg] = firstCallArgs;

    expect(firstArg).toEqual({
      item: "cheesecake",
      quantity: 2,
      memoryUsage: 123456                     ❷
    });
    expect(secondArg).toEqual("item added to the inventory");
  });
});

// ...

❶ 在每个测试之前,将进程的 memoryUsage 函数替换为返回包含静态值的对象的测试替身

❷ 期望记录器 logInfo 函数注册的信息包括测试替身返回的对象中的内存

警告 使用越多替身,你的测试就越不像程序在运行时的行为,因此它们创建的质量保证就越弱。

例如,如果 memoryUsage 函数已被弃用,那么即使你的程序无法工作,测试仍然会通过。

使用替身时要深思熟虑。使用它们来使测试运行更快,并隔离你无法控制的依赖项或因素,但请确保你也有端到端测试来覆盖替身无法覆盖的情况。

你可以通过使用 mockReturnValue 来使 beforeEach 钩子更短,如下所示。它允许你提供预设响应,而无需自己创建函数。

列表 3.47 inventoryController.test.js

// ...

beforeEach(() => {
  jest
    .spyOn(process, "memoryUsage")
    .mockReturnValue({               ❶
      rss: 123456,
      heapTotal: 1,
      heapUsed: 2,
      external: 3
    });
});

// ....

❶ 使进程的 memoryUsage 函数始终返回具有相同值的对象

就像 spies 一样,stubs 将保持其状态,包括你定义的预设行为,直到它们被重置。要重置一个替身,你可以调用它的 mockReset 方法。调用 mockReset 将导致它重置所有调用记录和任何模拟行为,但它将保持为 spy。要完全恢复原始实现,你应该调用 mockRestore

  • mockClear 删除测试替身的记录,但保持替身位置不变。

  • mockReset 删除测试替身的记录和任何预设行为,但保持替身位置不变。

  • mockRestore 完全删除替身,恢复原始实现。

提示 Jest 的所有重置方法都有全局函数,允许你一次性清除、重置或恢复所有测试替身。为了帮助你避免在每次测试时手动编写钩子,Jest 还允许你向 jest.config.js 添加选项,这些选项可以自动为你重置替身。这些选项是 clearMocksresetMocksrestoreMocks

如果你想要尝试为多次调用尝试不同的内存值,可以通过使用 mockReturnValueOnce 来确定单个调用的预设响应,如下所示。这个函数使得为依赖于多次调用函数的测试设置预设响应变得容易得多。

列表 3.48 inventoryController.test.js

// ...

beforeEach(() => {
  jest
    .spyOn(process, "memoryUsage")
    .mockReturnValueOnce({            ❶
      rss: 1,
      heapTotal: 0,
      heapUsed: 0,
      external: 0
    });
    .mockReturnValueOnce({            ❷
      rss: 2,
      heapTotal: 0,
      heapUsed: 0,
      external: 0
     });
    .mockReturnValueOnce({            ❸
      rss: 3,
      heapTotal: 0,
      heapUsed: 0,
      external: 0
    });
});

// ....

❶ 在第一次调用中指定要返回的对象

❷ 在第二次调用中指定要返回的对象

❸ 在第三次调用中指定要返回的对象

3.3.1 模拟导入

到目前为止,我们在模拟导入的 logger 对象属性时还没有遇到任何问题。但现在让我们看看如果你尝试直接导入并使用其方法会发生什么。首先,改变你的导入方式,以便直接获取 logInfologError 函数。

列表 3.49 inventoryController.js

const { logInfo, logError } = require("./logger");       ❶

// ...

❶ 提取导出的 logInfologError 函数,并将它们绑定到相同的名称

然后,而不是调用 logger.logInfologger.logError,直接调用你在上一步中导入的函数。例如,addToInventory 函数看起来如下所示。

列表 3.50 inventoryController.js

const { logInfo, logError } = require("./logger");

const inventory = new Map();

const addToInventory = (item, quantity) => {
  if (typeof quantity !== "number") {
    logError(                                                     ❶
      { quantity },
      "could not add item to inventory because quantity was not a number"
    );
    throw new Error("quantity must be a number");
  }
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + quantity;
  inventory.set(item, newQuantity);
  logInfo(                                                        ❷
    { item, quantity, memoryUsage: process.memoryUsage().rss },
    "item added to the inventory"
  );
  return newQuantity;
};

// ...

❶ 直接调用由 logger 导出的 logError 函数

❷ 直接调用由 logger 导出的 logInfo 函数

如果你开始直接使用这些函数,你将看到你的测试开始失败。通过查看差异,你可能会发现你的测试替身没有记录任何调用,好像它们从未生效过。为了理解为什么会这样,让我们首先了解 spyOn 的工作原理。

当你使用 spyOn 时,你用一个测试替身包装的函数的引用替换了一个函数的引用,如图 3.11 所示。使用 spyOn 实质上是对指定属性进行了重新赋值。

图 3.11 使用 spyOn 时会发生什么

通过直接导入和使用 logger 中的函数,你最终不会访问到 jest.spyOn 替换的引用。在这种情况下,你可以在 require 时立即获得原始函数的引用,但 Jest 替换的是 logger 中的引用,如图 3.12 所示。

图 3.12 使用 spyOn 时直接引用会发生什么

为了解决这个问题,你可以简单地回到导入 logger 并访问其属性,但在许多情况下,你可能无法这样做。如果你正在导入直接导出函数的模块,或者你只是不想将你的函数分配给一个对象,你需要更好的替代方案。这就是 jest.mock 发挥作用的时候。

jest.mock 函数允许你确定当模块被导入时应该返回什么。通过使用 jest.mock,例如,你可以替换 inventoryController 在导入 ./logger 时接收到的原始 logger,如下所示。

列表 3.51 inventoryController.test.js

// ...

jest.mock("./logger", () => ({     ❶
  logInfo: jest.fn(),
  logError: jest.fn()
}));

// ...

❶ 导致导入 logger 解析为一个对象,其 logInfo 和 logError 函数是测试替身

以这种方式使用 jest.mock 将会改变模块导入 logger 时接收到的值,包括你的测试。它们现在将获得你传递给 jest.mock 的函数返回的值,而不是接收原始的 logger

将上述代码片段添加到 inventoryController.test.js 中,现在应该可以使所有测试再次通过。

注意:jest.fn() 函数返回空的测试替身。它们将记录有关其使用的信息,但不会配置任何预设行为。它们的 API 与使用 spyOn 创建的测试替身相同。你仍然可以使用 mockReturnValuemockReturnValueOnce 等方法。

要在测试中再次获取原始的 logger 模块,你可以使用 jest.requireActual 如下所示。

列表 3.52 inventoryController.test.js

// ...

const originalLogger = jest.requireActual("./logger");     ❶

jest.mock("./logger", () => ({
  logInfo: jest.fn(),
  logError: jest.fn()
}));

// ...

❶ 导入实际的 logger

当你想要替换模块的一些导出而不是所有导出时,jest.requireActual 函数很有用。在这种情况下,你可以将原始模块与包含你的测试替身的一个模块合并,如下所示。

列表 3.53 inventoryController.test.js

// ...

jest.mock("./logger", () => {                               ❶
  const originalLogger = jest.requireActual("./logger");    ❷
  const partiallyMockedLogger = { logInfo: jest.fn() };
  return { ...originalLogger, ...partiallyMockedLogger };   ❸
});

// ...

❶ 导致导入日志记录器解析为回调函数返回的对象

❷ 导入实际的日志记录器

❸ 通过合并原始日志记录器中的属性与一个对象(其 logInfo 属性是一个测试替身)来返回一个新的对象

如果你需要频繁地模拟一个模块,Jest 有一个替代方案可以帮助你避免每次使用 jest.mock 时都必须传递一个替换函数。

通过在模块所在目录的相邻目录中创建一个名为 __mocks__ 的目录,一旦为该文件调用 jest.mock,所有对该模块的导入都将自动解析到 __mocks__ 中的同名文件。这种模拟称为 手动模拟

为了避免在多个测试中模拟 logger,例如,你可以重新组织你的应用程序目录如下:

.
|---- logger.js
|---- __mocks__
|   |---- logger.js
|
|---- inventoryController.js
|---- inventoryController.test.js
|---- node_modules
|---- package.json
|---- package-lock.json

__mocks__/logger.js 中,你必须导出一个值来替换原始日志记录器,如下所示。

列表 3.54 mocks/logger.js

module.exports = {
  logInfo: jest.fn(),
  logError: jest.fn()
};

一旦完成这些,你就不必在每一个文件中传递一个替换函数给 jest.mock,你只需调用 jest.mock 并给出日志记录器的路径。

列表 3.55 inventoryController.test.js

// ...

jest.mock("./logger");      ❶

// ...

❶ 导致导入日志记录器解析为 mocks/logger.js 文件导出的对象

如果你完全不想调用 jest.mock,你可以在你的 jest.config.js 文件中添加一个名为 automock 的属性,并将其值设置为 true。当 automock 选项开启时,无论你是否之前调用过 jest.mock,所有导入都将解析为你的手动模拟。

注意:在本节中,我们看到了创建测试替身的一些不同方法。为了选择你将要使用的方法,考虑一下你试图“模拟”的是什么。

  • 如果你正在模拟一个对象的属性,你可能应该使用 jest.spyOn

  • 如果你正在模拟一个导入,你可能应该使用 jest.mock

  • 如果你必须在多个测试文件中使用相同的替换,理想情况下,你应该在 __mocks__ 文件夹上放置一个手动模拟。

3.4 选择要测试的内容

洛 uis 的面包店生产的甜点比镇上任何其他地方都多,员工数量减半,风味翻倍。为了保持速度和质量,洛 uis 仔细考虑了哪些质量控制检查要执行以及何时执行。他熟悉帕累托法则,并专注于产生 80%结果的 20%的测试。

与洛 uis 一样,我们作为工程师,可以专注于产生 80%结果的 20%的测试。知道要测试什么是有价值的,但确定不测试什么更为重要。

理想情况下,你应该拥有触及你应用程序每一行代码、运行每个执行分支并断言你想要强制执行的所有行为的测试。但在现实中,事情并不像路易斯的面包店那样甜蜜。现实世界的项目有紧迫的截止日期和有限的资源。取决于你如何使你的软件尽可能安全,并尽可能降低成本。

当谈到质量保证时,拥有许多测试可能很好,但更新所有这些测试可能过于繁重。重构一个拥有大量测试的大型代码库可能需要你花费更多的时间来更新测试,而不是更新代码库本身。正因为这种成本,拥有少量但质量保证更强的测试比拥有许多不令人信服的测试更好。

3.4.1 不要测试第三方软件

选择负责任的供应商,例如,可能花费了路易斯一些时间,但它确实节省了他很多麻烦。因为他可以相信他的供应商为他提供高质量的原料,所以他不必花太多时间检查它们到达面包店时的情况。

就像路易斯对他的供应商很挑剔一样,我们也应该对我们使用的第三方软件非常挑剔。只要其他人的软件经过良好测试,我们就不必花时间自己测试它。你应该只测试你写的软件

正如我们在第二章中看到的,拥有确保你正确使用第三方软件的端到端测试是明智的,但你不应该只编写覆盖第三方软件本身的测试。

让我们再次考虑第二章中看到的addItem函数,该函数在下一个列表中展示。这个函数通过在数据库中插入一行来向购物车添加一个项目。

列表 3.56 cart.js

const db = require("knex")(require("./knexfile").development);

const addItem = (cartId, itemName) => {
  return db("carts_items").insert({ cartId, itemName });
};

module.exports = { createCart };

在这种情况下,你想测试knex是否在数据库中插入项目。这是库作者的职责。

在这种情况下,你有以下两种选择:a) 用测试替身替换knex,并检查它是否被正确调用;或者 b) 启动一个测试数据库,调用createCart,并检查数据库以查看行是否如预期那样被插入。

在这两种情况下,你都不会测试knex本身。你始终关注对库的使用。测试其他人的库是徒劳的。这几乎总是意味着你正在花费时间编写已经存在于其他地方的测试。

即使你为库添加的测试在项目的自身仓库中不存在,将其提交到上游版本也比保留在你自己的代码库中更好。当你将测试添加到上游项目中时,所有使用它的人都会受益,包括你,你将不会是唯一一个负责保持它们更新的人。协作创造了安全且经过良好测试的软件的良性循环。

3.4.2 要模拟,还是不要模拟:这是一个问题

在理想的测试世界中,每个项目都应该有不同隔离级别的测试。你应该有单元测试,尽可能隔离一个函数。你应该有集成测试,有一定的模拟程度,但仍然验证你的软件的不同部分是否可以一起工作。你还应该有端到端测试,几乎不做任何模拟,如果有的话。

在现实中,我们通常无法负担得起如此细致地编写测试,因此我们必须决定哪些代码部分我们将隔离,哪些不会。

再次考虑我们在上一节中看到的相同的addItem函数。

列表 3.57 cart.js

const db = require("knex")(require("./knexfile").development);

const addItem = (cartId, itemName) => {
  return db("carts_items").insert({ cartId, itemName });
};

module.exports = { createCart };

因为我们不希望测试knex,正如我们之前提到的,我们可以选择用测试替身——“模拟”来替换knex,或者调用函数然后直接检查数据库。

在理想的世界里,我们会有时间编写两个测试:一个带有测试替身,一个不带。然而,在现实世界中,我们必须选择哪个在时间和精力上成本最低,同时提供最大价值。

在这种情况下,模拟knex需要大量的工作。为此,你需要创建一个本质上模拟数据库并复制knex与之交互的软件。

knex创建一个测试替身不仅耗时,而且容易出错。这会变得非常复杂,你可能还需要为你的测试编写测试。然后,当knex有更新时,你也必须更新你的测试替身。

现在考虑一下如果不替换knex为测试替身会有多难。

没有测试替身,你将不得不启动数据库并确保你的测试与其他测试很好地隔离。这些额外的步骤会使测试稍微慢一些,但它们使编写测试变得容易得多,也快得多。

例如,看看我们在第二章为这个函数编写的测试。

列表 3.58 cart.js

const { db, closeConnection } = require("./dbConnection");
const { createCart } = require("./cart");

test("addItem adds an item to a cart", async () => {
  await db("carts_items").truncate();
  await db("carts").truncate();

  const [cartId] = await createCart("Lucas da Costa");
  await addItem("cheesecake");

  const result = await db.select().from("carts_items");
  expect(result).toEqual([{ cartId, itemName: "cheesecake" }]);
  await closeConnection();
});

这个测试几乎和只检查函数返回值的测试一样简单。唯一的区别是,你必须在检查之前访问数据库以获取结果,而不是将返回值保存到变量中。

比较模拟knex的难度和它产生的小好处,以及编写集成测试的容易程度和它产生的巨大价值,很明显,集成测试是这种情况下的最佳选择。

作为一条经验法则,如果模拟依赖项太复杂,就不要模拟它

只替换你的软件中容易替换或根本不能用于测试的部分,例如与付费第三方服务的集成。

能够编写不同隔离级别的测试对于获得精确的反馈非常好,但它会创建大量不必要的重叠。这些重叠的测试变得有些多余,并且在质量方面没有提供显著的好处时,会增加成本。

模拟过多是多少?

当你模拟应用程序的某个特定层时,你会阻止你的测试触及它下面的所有内容。

例如,假设你有一个使用cartController模块的路由。cartController使用一个notifier模块,该模块反过来调用第三方 API。

通过模拟CartController,你选择测试其下所有层;你不会运行通知模块或第三方 API 中的代码。

你选择的模拟层越表面,你选择测试的就越多。你越想隐藏复杂性,就越早应该进行模拟。

在处理遗留应用程序或没有测试的应用程序时,模拟特别有益。通过使用mocks,你可以隐藏尚未重构的层,从而避免测试尚未准备好的代码。在图 3.13 中,你可以看到根据测试替身放置的位置,哪些层被隐藏。

图 3.13 通过模拟应用程序的每一层隐藏的复杂性

编写模拟的一个缺点是它们将测试场景与实际用例场景隔离开来。因此,包含更多模拟的测试提供较弱的质量保证,并使你对自己的信心降低。

通常,过多的模拟是指你可以不使用模拟就轻松测试的软件部分。

3.4.3 当有疑问时,选择集成测试

单元测试提供极快且极其精确的反馈,但它们不提供强大的质量保证。另一方面,端到端测试是我们能得到的最强质量保证,但它们通常需要花费很多时间来运行,提供通用的反馈,并且编写起来往往很耗时。

在单元测试和端到端测试之间的是集成测试,它可以给我们带来两全其美的效果。它们提供了相当强的质量保证,运行速度合理,并且编写起来通常很快。有时,由于我们不需要进行太多的模拟,集成测试甚至比更孤立的测试编写得更快。

再次强调,对于你的addItem函数,考虑每种测试类型需要做什么。

列表 3.59 cart.js

const db = require("knex")(require("./knexfile").development);

const addItem = (cartId, itemName) => {
  return db("carts_items").insert({ cartId, itemName });
};

module.exports = { createCart };
  • 一个非常孤立的单元测试将模拟knex并仅检查addItem是否正确使用knex

  • 一个集成测试将调用addItem并检查数据库。

  • 一个完整的端到端测试将启动服务器,打开浏览器,点击添加商品到购物车的按钮,并检查购物车列表页面。

在这种情况下,单元测试不会提供稳固的保证。由于你将不得不进行所有这些模拟,因此编写它也会很困难,正如我之前提到的。

端到端测试可以增强对软件工作的信心。但编写它和运行它都会很具挑战性,并且会花费很多时间。

另一方面,集成测试编写起来很快,因为我们不需要使用任何模拟,而且它的运行速度比端到端测试快得多。因为这个测试会触及数据库,它为函数按预期工作提供了一个安全的保证。这是一种编写成本较低且提供最大益处的测试类型。

集成测试通常在成本和收益之间取得更好的平衡,所以,在不确定的情况下,选择集成测试。

注意 理想情况下,你应该尽量保持金字塔形状,但拥有高比例的集成测试也可以帮助你降低成本。

3.5 代码覆盖率

代码覆盖率是一个指标,表示当你运行测试时,你的代码执行了多少。

要理解代码覆盖率是如何工作的,考虑一下根据你执行哪个测试,removeFromCart函数的哪些行会被执行。

列表 3.60 Cart.test.js

// ...

const addToInventory = (item, quantity) => {
  if (typeof quantity !== "number") {
    logError(
      { quantity },
      "could not add item to inventory because quantity was not a number"
    );
    throw new Error("quantity must be a number");
  }
  const currentQuantity = inventory.get(item) || 0;
  const newQuantity = currentQuantity + quantity;
  inventory.set(item, newQuantity);
  logInfo(
    { item, quantity, memoryUsage: process.memoryUsage().rss },
    "item added to the inventory"
  );
  return newQuantity;
};

// ...

如果你运行一个传递quantity参数的测试,且该参数的类型是number,例如,第一个if语句内的所有行都不会执行。

列表 3.61 inventoryController.test..js

const { addToInventory } = require("./inventoryController");

jest.mock("./logger");                    ❶

test("passing valid arguments", () => {   ❷
  addToInventory("cheesecake", 2);
});

❶ 用测试替身替换记录器,以确保测试的输出不会被记录器的消息污染

❷ 覆盖 addToInventory 函数中大约 80%的行的测试

如果你只考虑这个函数,你会注意到你的测试覆盖了大约 80%的addToInventory函数的行。如果这些行中有任何无效语句,你的测试将能够捕获它们。然而,如果你在未被覆盖的 20%的行中有无效语句,你的测试将无法检测到。

通过查看你的测试没有触及到的代码部分,你可以检测到可能的盲点并创建测试来覆盖它们。

例如,在这种情况下,你可以编写一个测试,将字符串作为addItemToCart的第二个参数传递,以便覆盖该函数的剩余行,如下所示。

列表 3.62 inventoryController.test..js

// ...

test("passing valid arguments", () => {           ❶
  addToInventory("cheesecake", 2);
});

test("passing invalid arguments", () => {         ❷
  try {
    addToInventory("cheesecake", "should throw");
  } catch (e) {
    // ...
  }
});

❶ 覆盖 addToInventory 函数中大约 75%的行的测试

❷ 覆盖 addToInventory 函数中剩余行的测试

通过关注你的测试覆盖率,你能够检测到测试套件中的盲点并使其更加全面。

重要 通过测量代码的哪些部分被覆盖了,最重要的是,哪些没有被覆盖,你可以确保在自动化测试过程中所有可能的执行分支都运行了。

3.5.1 自动化覆盖率报告

要查看一个报告,该报告显示你的测试执行了代码的哪些部分,请运行项目的 Jest 可执行文件并使用--coverage选项。

提示 如果你使用 NPM 脚本来运行测试,正如我推荐的那样,你可以使用npm test -- --coverage来获取覆盖率报告。

一旦 Jest 完成测试运行并收集有关你的测试正在执行的代码部分的数据,它将在你的项目根目录下创建一个名为coverage的文件夹。这个新文件夹包含完整的覆盖率报告。

最后,要查看哪些代码部分被覆盖,请尝试使用浏览器打开coverage目录中lcov-report文件夹内的index.html。此报告将以红色突出显示任何未由您的测试执行的代码片段。

TIP 除了使用这些报告来了解您的盲点之外,您还可以使用 Jest 生成的其他“机器友好”文件来使用自动化工具跟踪您的覆盖率。

例如,您可以将覆盖率报告上传到第三方工具,该工具允许您跟踪代码覆盖率随时间的变化。

此外,您可以使用版本控制检查(您将在第十二章中学习),以防止其他人合并减少测试覆盖代码量的代码。

3.5.2 覆盖率类型

当使用 Jest 自动生成代码覆盖率报告时,您应该在测试摘要的底部看到一个表格,指示目标文件中代码的覆盖率百分比。

那个表格包含了四个覆盖率度量:语句覆盖率、分支覆盖率、函数覆盖率和行覆盖率。所有这些度量都代表了您的测试执行了代码的哪些部分,但它们的度量单位不同,如下所示:

  • 语句覆盖率考虑了您代码中的总语句数以及运行了多少条语句。

  • 分支覆盖率考虑了您的测试通过了多少执行路径,考虑到可能采取的总路径数。

  • 函数覆盖率考虑了在您的代码中运行的函数数量与代码中包含的总函数数量之间的比例。

  • 行覆盖率考虑了您的测试执行了多少行代码,而不管这些行包含多少语句或这些行位于哪些执行路径上。

所有这些类型的覆盖率都很重要,但我最关注的是分支覆盖率。

分支覆盖率表明,在我的测试中,我的代码已经通过了它可能通过的所有可能的执行路径。因此,它保证了每当我的代码需要“做出选择”时,所有可能的选择都得到了验证。

3.5.3 覆盖率的好处和坏处

代码覆盖率并不表明您的测试有多好。完全有可能覆盖 100%的代码,但仍然让错误溜走。

例如,假设您有一个函数,如果两个数都是偶数则求和,如果至少一个是奇数则相除,如下所示。

列表 3.63 math.js

function sumOrDivide(a, b) {
  if (a % 2 === 0 && b % 2 === 0) {
    return a + b;
  } else {
    return a / b;
  }
}

如果您编写的测试同时运行了这个函数的两个执行分支,但没有进行任何断言,那么您将获得 100%的覆盖率,但您可能引入的错误将不会被捕获。

列表 3.64 math.test.js

test("sum", () => {
  sumOrDivide(2, 4);
});

test("multiply", () => {
  sumOrDivide(2, 6);
});

如果您更改此函数,使其始终返回"cheesecake",例如,您的覆盖率将保持 100%,并且您的测试仍然会通过。

如果不通过编写断言进行必要的观察,您可能拥有高覆盖率,但无法捕获任何错误

此外,你的覆盖率可能表明你的测试运行了代码的所有可能的执行分支,但不是所有可能的错误输入。

例如,如果你将1作为这个函数的第一个参数,将0作为第二个参数,那么你的函数将返回Infinity,这可能不是你想要的结果。

覆盖率代表你的测试覆盖了你代码的多少,而不是它通过了多少可能的输入。因此,除非你测试了所有可能的输入,否则你不能保证你会找到错误,而这是一项相当困难的任务。

小贴士:为了理解为什么测试所有可能的输入是困难的,如果不是不可能的,想想 JavaScript 中你可以表示多少不同的数字。

覆盖率测量的另一个问题是,它们表明了哪些可能的执行分支运行了,但没有表明所有这些分支的可能组合。

假设特定的执行路径组合抛出错误。在这种情况下,你可能看不到它,因为尽管所有分支都被覆盖了,但导致错误发生的特定分支组合可能没有运行。

由于这些原因,覆盖率本身是一个糟糕的指标。它可能显示了程序代码的哪些部分被覆盖了,但它并没有表明它的哪些可能的行为被覆盖了,正如詹姆斯·O·科普林在他的精彩文章“为什么大多数单元测试都是浪费”中解释的那样(rbcs-us.com/site/assets/files/1187/why-most-unit-testing-is-waste.pdf)。

我将 100%覆盖率定义为检查了所有可能的方法路径的所有可能组合,复制了所有方法可访问的数据位的所有可能配置,在执行路径上的每条机器语言指令。其他任何东西都只是关于正确性的非正式断言。一个函数中可能的执行路径数量是有限的:比如说 10。这些路径与所有全局数据(包括实例数据,从方法范围来看是全局的)和形式参数的可能状态配置的笛卡尔积确实非常大。而且,这个数字与类内方法可能顺序的笛卡尔积是可数的无限大。如果你输入一些典型的数字,你很快就会得出结论,如果你能获得比 10¹² 更好的覆盖率,那你就很幸运了。

—詹姆斯·O·科普林

重要提示:代码覆盖率给你的唯一保证是程序可以运行,而不是它运行正确。

我不单独使用覆盖率作为指标,而是用它来了解我忘记覆盖程序中的哪些部分,并确保我的团队能够始终朝着更多覆盖率而不是更少覆盖率的方向前进。

摘要

组织测试套件

  • 通过在describe块内嵌套不同的测试组来组织你的测试。

  • 将测试嵌套到多个块中可以使你封装变量、函数甚至钩子,这些在它们放置的测试块中成为相对的。

  • 在组织测试时,避免重叠。每个测试都应该断言被测试单元的单一方面,以便生成准确的反馈。

  • 默认情况下,不同文件中的测试将并行运行。并行运行测试可以使测试运行得更快,因此鼓励开发者更频繁地运行它们。

  • 任何特定的测试都不应该依赖于其他测试。测试应该是原子的,这样你就可以轻松地确定错误的起因,以及你是否在测试或应用程序代码中存在问题。

编写好的断言

  • 总是要确保你的测试运行断言。没有断言的测试不会检查被测试单元是否做了它应该做的事情。它只能确保应用程序代码可以运行,而不会做其他事情。

  • 断言应该允许尽可能少的通过结果。理想情况下,断言应该只允许一个结果通过。

  • 松散的断言——允许多个输出通过——可以用来处理非确定性,例如处理真正的随机性或日期。

  • 循环断言使用应用程序的一部分来测试自身。这可能会引起问题,因为如果你在测试中使用的部分有错误,它们也会产生不正确的预期输出。

测试双倍:模拟、存根和间谍

  • 模拟、存根和间谍是用于修改和替换应用程序部分以简化或启用测试的对象。

  • 与间谍只记录与函数使用相关的数据不同,存根允许你通过提供替代结果或甚至替代实现来修改其行为。

选择要测试的内容

  • 拥有大量的测试对于创建可靠的质量保证有帮助,但它们可能难以更新。因此,确定要测试的内容与确定不要测试的内容一样重要。

  • 避免测试第三方软件。这是第三方软件作者的职责。如果你想为它添加测试,请向库的存储库做出贡献,这样每个人都能从中受益,包括你,你不必自己维护这些测试。

  • 更多的模拟会使测试与现实相差更远,因此价值更低。如果模拟太困难,或者如果它会导致你的测试变得没有价值,因为它只触及应用程序的很少部分,请避免模拟。

  • 不同的测试类型可以生成大量的重叠。如果你必须只选择一种测试类型,最好选择集成测试。集成测试运行得相对较快,编写起来通常比较容易,并提供可靠的质量保证。

代码覆盖率

  • 代码覆盖率是一个指标,表示当你运行测试时,你的代码执行了多少。

  • 通过测量代码覆盖率,您可以了解哪些代码部分您忘记测试,因此,为它们添加必要的验证。

  • 代码覆盖率可以针对语句、分支、函数或行进行测量。

  • 高代码覆盖率并不意味着您的测试是好的。即使您的代码 100%被覆盖,仍然可能让错误滑过,因为代码覆盖率没有考虑到可以传递给程序的所有可能的输入或其执行分支的所有可能的组合。

  • 您应该使用代码覆盖率报告来了解哪些代码片段您忘记测试,并确保您的团队正在提交测试,并朝着更多的覆盖率前进,而不是确定测试的质量。

4 测试后端应用程序

本章涵盖

  • 为你的后端构建测试环境

  • 测试你的服务器路由和中间件

  • 在测试中处理数据库

  • 管理对外部服务的依赖

许多年来,JavaScript 一直被当作一种仅用于客户端的语言。它曾经只在浏览器内运行。一旦 Node.js 出现,大约在 2009 年左右,人们开始使用 JavaScript 编写应用程序的前端后端。

Node.js 使一种全新的 JavaScript 应用程序成为可能,并为塑造 JavaScript 测试生态系统做出了巨大贡献。由于 JavaScript 开发者现在能够实现不同类型的应用程序,他们还必须想出新的测试方法和新的测试工具。

在本章中,我将重点介绍如何测试 Node.js 所支持的最显著类型的应用程序:用 JavaScript 编写的后端。

你已经学到的关于组织测试、断言和测试替身的内容在本章中仍然至关重要。在本章中,你将学习与在服务器上下文中应用这些技术相关的细微差别。

测试后端与测试其他类型的程序(如独立模块或前端应用程序)显著不同。它涉及处理文件系统、数据库、HTTP 请求和第三方服务。

因为这些组件对于你的后端应用程序的正常运行至关重要,所以在编写测试时必须考虑它们。否则,你可能找不到关键缺陷。

如果,例如,你没有检查应用程序是否正确地将行添加到数据库中的表,或者如果你的服务器为路由返回了错误的 HTTP 状态码,错误可能会滑入生产环境。如果你正在处理第三方 API,你如何确保你的应用程序能够应对该服务不可用的情况?

除了缺陷外,测试还可以揭示安全漏洞。通过检查端点是否需要必要的身份验证头,你可以确保未经授权的客户端无法访问敏感信息或修改属于其他用户的资料。

此外,测试 Web 服务器是确保其遵循消费者所依赖的“合同”的有效方式。当多个服务需要通信时,保证每个部署都将保留这些服务期望的接口至关重要。

我将通过编写服务器并在添加新功能时对其进行测试来涵盖这些主题。我将用作示例的应用程序将相当复杂,以便尽可能准确地模拟你在日常工作中遇到的情况。

此应用程序将是 Louis 面包店在线商店的后端。它将处理 HTTP 请求,处理身份验证,与数据库交互,并集成第三方 API。

在 4.1 节中,我将讨论应用程序的需求以及如何为测试设置它。在本节中,你将为你的后端应用程序编写多种不同类型的测试,并学习如何根据第二章的测试金字塔来构建你的测试环境。

4.2 节包含如何测试 HTTP 端点的深入示例。它介绍了验证你的路由的新工具和技术,并详细说明了你应该更加关注的方面,包括认证和中间件。

由于绝大多数后端应用程序依赖于外部依赖项,我将在 4.3 节中教你如何处理它们。在本节的示例中,涉及数据库和第三方 API,你将学习如何在测试的上下文中考虑依赖项,以便在不使测试过于脆弱或复杂的情况下获得可靠的质量保证。

4.1 构建测试环境结构

要使产品或流程可测试,它必须以测试为导向进行设计。在路易斯的面包店,当制作蛋糕时,员工会分别制作中间部分,仔细检查它们,然后才将它们组合在一起。从食谱的开始到结束,员工都知道每个步骤要验证什么。因为他们有一个强大的质量控制流程,所以他们不仅限于尝试最终产品。急于完成所有事情可能会使确定一批蛋糕是否符合通常的高标准变得更加困难,如果不符合,可能会使找出问题所在变得更加困难。

同样地,旨在可测试的软件必须以测试为导向进行设计,尤其是在处理后端应用程序时。这类应用程序往往涉及许多动态部分,如果这些部分没有暴露或分离成更小的部分,它们可能会使测试变得困难,甚至可能根本无法编写测试。

例如,考虑你之前创建的addToInventory路由。如果你的应用程序使用私有内存Map来存储数据,不在cartController中公开任何函数,并且直接将信息记录到控制台,那么测试的空间就不大了。你所能做的最好的事情就是发送一个 HTTP 请求并检查其响应,如图 4.1 所示。

图 4.1

图 4.1 如果应用程序没有考虑到测试,测试可以访问的内容

尽管那将是一个有效的端到端测试,但在那种情况下,可能很难获得细粒度的反馈。如果你的测试也无法访问应用程序的数据存储,你无法确保它已经从一个有效状态转换到另一个状态。在更极端的情况下,如果你的路由依赖于认证,你的测试必须能够发送认证请求。否则,即使是端到端测试也可能无法编写。

可测试的软件被分解成更小的可访问部分,您可以单独测试它们

您的应用程序代码越容易访问,模拟错误场景和更复杂的边缘情况就越容易。其部分越细粒度,测试的反馈就越精确。

在本节中,我将向您展示如何开发一种结构化的方法来划分您的应用程序并对其进行测试。您将逐步将其分解成更小的部分,并验证每一个部分。

我将此部分分为单元测试、集成测试和端到端测试,因为我们之前看到的那样,这是组织测试和理解测试的最有效方式,并保持高质量标准。

注意:为了专注于测试并避免重写 API,我将使用与您在第二章中编写的类似的服务器。您可以在github.com/lucasfcosta/testing-javascript-applications找到本章和前几章中每个示例的代码。

4.1.1 端到端测试

让我们看看 server.js 文件,并思考如何完成这个任务。

此文件有三个路由:一个返回购物车项目,两个添加和从其中删除项目。当更新购物车的内容时,应用程序也会相应地更新库存。

列表 4.1 server.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

let carts = new Map();
let inventory = new Map();

router.get("/carts/:username/items", ctx => {                ❶
  const cart = carts.get(ctx.params.username);
  cart ? (ctx.body = cart) : (ctx.status = 404);
});

router.post("/carts/:username/items/:item", ctx => {         ❷
  const { username, item } = ctx.params;
  const isAvailable = inventory.has(item) && inventory.get(item) > 0;
  if (!isAvailable) {
    ctx.body = { message: `${item} is unavailable` };
    ctx.status = 400;
    return;
  }

  const newItems = (carts.get(username) || []).concat(item);
  carts.set(username, newItems);
  inventory.set(item, inventory.get(item) - 1);
  ctx.body = newItems;
});

router.delete("/carts/:username/items/:item", ctx => {      ❸
  const { username, item } = ctx.params;
  if (!carts.has(username) || !carts.get(username).includes(item)) {
    ctx.body = { message: `${item} is not in the cart` };
    ctx.status = 400;
    return;
  }

  const newItems = (carts.get(username) || []).filter(i => i !== item);
  inventory.set(item, (inventory.get(item) || 0) + 1);
  carts.set(username, newItems);
  ctx.body = newItems;
});

app.use(router.routes());

module.exports = app.listen(3000);                          ❹

❶ 返回购物车的项目

❷ 向购物车添加一个项目

❸ 从购物车中删除一个项目

❹ 导出绑定到端口 3000 的服务器实例

因为这个应用程序除了其路由外没有公开任何内容,所以您只能通过发送 HTTP 请求与之交互。您也没有访问其状态,因此您只能检查对您的 HTTP 请求的响应。换句话说,您只能编写端到端测试,即使如此,您也无法确保应用程序已进入一致的状态。

此 API 是一个无法穿透的黑盒代码。您无法设置复杂的场景,并且如图 4.2 所示,您对其内部发生的事情没有太多了解。

图 4.2 当应用程序未公开其任何部分时,测试可用性如何

让我们考虑这些限制,并尝试为添加项目到购物车的路由编写端到端测试。

列表 4.2 server.test.js

const app = require("./server.js");                   ❶

const fetch = require("isomorphic-fetch");

const apiRoot = "http://localhost:3000";

afterAll(() => app.close());

describe("add items to a cart", () => {
  test("adding available items", async () => {
    const response = await fetch(                     ❷
      `${apiRoot}/carts/test_user/items/cheesecake`,
      { method: "POST" }
    );

    expect(response.status).toEqual(200);             ❸
  });
});

❶ 导致 server.js 文件执行,绑定一个服务器实例到端口 3000

❷ 通过向服务器发送请求尝试向用户的购物车添加一个芝士蛋糕

❸ 检查响应的状态是否为 200

这个测试将失败,因为我们试图添加的项目,一个芝士蛋糕,是不可用的。但如果应用程序没有公开其库存,我们将如何使这个项目可用?

测试需要能够设置一个场景——提供一个初始状态——执行您的应用程序,并检查输出和最终状态是否正确。为了解决这个问题,公开您的 inventory,并更新您的测试,如下所示。

列表 4.3 server.js

// ...

module.exports = {
  app: app.listen(3000),    ❶
  inventory                 ❷
};

❶ 通过一个名为 app 的属性导出一个绑定到 3000 端口的服务器实例

❷ 通过具有相同名称的属性导出服务器的库存

列表 4.4 server.test.js

const { app, inventory } = require("./server.js");           ❶

// ...

afterEach(() => inventory.clear());                          ❷

describe("add items to a cart", () => {
  test("adding available items", async () => {
    inventory.set("cheesecake", 1);                          ❸
    const response = await fetch(                            ❹
      `${apiRoot}/carts/test_user/items/cheesecake`,
      { method: "POST" }
    );

    expect(response.status).toEqual(200);                    ❺
    expect(await response.json()).toEqual(["cheesecake"]);   ❻
    expect(inventory.get("cheesecake")).toEqual(0);          ❼
  });
});

❶ 导入服务器实例和库存

❷ 在每个测试后清除库存

❸ 安排:将库存中芝士蛋糕的数量设置为 1

❹ 行动:发送一个尝试向用户的购物车添加一块芝士蛋糕的请求

❺ 断言:检查响应的状态是否为 200

❻ 断言:检查响应体是否与购物车的商品内容匹配

❼ 断言:验证库存中芝士蛋糕的数量为 0

现在测试通过了。你有了设置场景所需的州的一部分,你需要与之交互的路由,以及你需要检查的响应,如图 4.3 所示。注意,你能够添加额外的验证来检查库存的状态是否一致,仅仅是因为你暴露了它。

图 4.3 addItemToCart 测试暴露的内容

继续添加测试来检查包含购物车的状态部分是否表现适当。如果你很有信心,可以为所有其他路由添加类似的端到端测试。

在编写端到端测试时,提供对状态以及客户或前端客户端用来与后端交互的界面的访问。对路由的访问将允许测试锻炼应用程序,而对状态的访问将允许测试设置一个场景——创建初始状态——并检查新状态是否有效。

注意:我们将在本章后面介绍如何用外部数据库替换内存中的数据库。现在,专注于理解为什么要以及如何分离应用程序的不同部分。

4.1.2 集成测试

尽管端到端测试提供了最强的可靠性保证,但如果你只有端到端测试,维护你的应用程序可能会变得更加昂贵。端到端测试运行时间较长,并且生成的反馈较粗。因为在你从端到端测试中提取任何价值之前,路由需要是完整的,所以它也花费了更长的时间来给你反馈。

获取更早和更细粒度反馈的智能策略是将你的路由中的代码移动到单独的模块中,这样你就可以单独暴露它们的函数并编写测试。

你可以从将库存和购物车的交互分离到不同模块中的单独函数开始。首先,创建一个名为 inventoryController.js 的文件,并添加一个从库存中删除项目的函数。

列表 4.5 inventoryController.js

const inventory = new Map();                                 ❶

const removeFromInventory = item => {                        ❷
  if (!inventory.has(item) || !inventory.get(item) > 0) {
    const err = new Error(`${item} is unavailable`);
    err.code = 400;
    throw err;
  }

  inventory.set(item, inventory.get(item) - 1);
};

module.exports = { inventory, removeFromInventory };         ❸

❶ 在此文件中封装对包含库存内容的 Map 的引用

❷ 从库存中删除一个项目

❸ 导出库存和 removeFromInventory 函数

在你的 cartController 中,你可以创建一个函数,该函数使用 inventoryController 来添加一个可用的项目到购物车中。

列表 4.6 cartController.js

const { removeFromInventory } = require("./inventoryController");   ❶

const carts = new Map();                                            ❷

const addItemToCart = (username, item) => {                         ❸
  removeFromInventory(item);
  const newItems = (carts.get(username) || []).concat(item);
  carts.set(username, newItems);
  return newItems;
};

module.exports = { addItemToCart, carts };                          ❹

❶ 从 inventoryController 导入 removeFromInventory 函数

❷ 在此文件中封装对包含购物车及其内容的 Map 的引用

❸ 向用户的购物车添加一个项目

❹ 导出包含购物车的 Map 和 addItemToCart 函数

使用这些函数,然后你可以更新 server.js 并使添加到购物车的路由更加简洁。

列表 4.7 server.js

// ...

// Don't forget to remove the initialization of `carts` and `inventory` from the top of this file

const { carts, addItemToCart } = require("./cartController");     ❶
const { inventory } = require("./cartController");

router.post("/carts/:username/items/:item", ctx => {
  try {
    const { username, item } = ctx.params;
    const newItems = addItemToCart(username, item);               ❷
    ctx.body = newItems;
  } catch (e) {
    ctx.body = { message: e.message };
    ctx.status = e.code;
    return;
  }
});

// ...

❶ 从 cartController 导入包含购物车的 Map 和 addItemToCart 函数

❷ 在负责添加购物车项目的路由中使用导入的 addItemToCart 函数

一旦你将 server.test.js 文件更新为从正确的模块导入 cartsinventory,所有测试应该都能通过。

列表 4.8 server.test.js

const { app } = require("./server.js");                         ❶
const { carts, addItemToCart } = require("./cartController");   ❷
const { inventory } = require("./inventoryController");         ❸

// ...

❶ 导入服务器的实例,使其绑定到端口 3000

❷ 从 cartController 导入包含购物车的 Map 和 addItemToCart 函数

❸ 从 inventoryController 导入库存

通过使你的软件更加模块化,你可以使其更易于阅读和测试。随着更多独立的模块,你可以编写更细粒度的测试。例如,你可以开始编写与端到端测试共存的集成测试。

创建一个名为 cartController.test.js 的文件,并编写一个只覆盖 addItemToCart 函数的测试,如下所示,如图 4.4 所示。

列表 4.9 cartController.test.js

const { inventory } = require("./inventoryController");
const { carts, addItemToCart } = require("./cartController");

afterEach(() => inventory.clear());                                    ❶
afterEach(() => carts.clear());                                        ❷

describe("addItemToCart", () => {
  test("adding unavailable items to cart", () => {
    carts.set("test_user", []);                                        ❸
    inventory.set("cheesecake", 0);                                    ❹

    try {
      addItemToCart("test_user", "cheesecake");                        ❺
    } catch (e) {
      const expectedError = new Error(`cheesecake is unavailable`);
      expectedError.code = 400;

      expect(e).toEqual(expectedError);                                ❻
    }

    expect(carts.get("test_user")).toEqual([]);                        ❼
    expect.assertions(2);                                              ❽
  });
});

❶ 在每个测试后清除库存

❷ 在每个测试后清除购物车

❸ 安排:将测试用户的购物车设置为空数组

❹ 安排:将库存中芝士蛋糕的数量设置为 0

❺ 行动:发送一个请求尝试向测试用户的购物车添加一块芝士蛋糕

❻ 断言:期望请求的错误与测试中创建的错误匹配

❼ 断言:期望测试用户的购物车保持为空

❽ 断言:确保测试执行了两个断言

图 4.4 每个端到端和集成测试可以访问的应用程序的部分

这样的测试不依赖于要发送请求的路由。它不依赖于身份验证、头信息、URL 参数或特定的请求体。它直接检查你的业务逻辑。尽管在考虑整个应用程序时,这个测试提供的质量保证不那么可靠,但它编写起来更便宜,并且提供了关于后端更小部分的更细粒度的反馈。

作为一项练习,尝试也为 inventoryController 中的 removeFromInventory 函数添加测试。

注意:你可以继续将应用程序的业务逻辑移动到 cartsControllerinventoryController 模块,直到你的 server.js 不再需要操作全局的 inventorycarts 映射。

如果你这样做重构,你会注意到你的应用程序不需要在任何地方导入 inventorycarts。但是,因为你的测试依赖于它,你必须

仅为了测试而暴露代码的一部分,即使你的应用程序代码不需要这些部分被暴露,也不是问题。

你添加的测试实际上看起来并不像集成测试。那是因为,目前,你将所有应用程序的数据存储在内存中。我选择将这些测试归类为集成测试,因为它们处理应用程序的全局状态。当我们用真实数据库替换内存中的数据时,你会注意到这个定义是多么地合适。

为了更好地针对集成测试,尝试将日志添加到你的应用程序中。编写一个logger.js文件,并使用fs模块将日志写入/tmp/logs.out

注意:如果你使用的是 Windows,你可能需要更改记录器将消息追加到的路径。

列表 4.10 logger.js

const fs = require("fs");

const logger = {
  log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n")      ❶
};

module.exports = logger;                                          ❷

❶ 同步地将消息追加到/tmp/logs.out 文件

❷ 导出记录器

使用这个记录器模块,你可以让addToItemToCart在客户将商品添加到购物车时写入logs.out文件,如下所示。

列表 4.11 cartController.js

// ...

const logger = require("./logger");

const addItemToCart = (username, item) => {
  removeFromInventory(item);
  const newItems = (carts.get(username) || []).concat(item);
  carts.set(username, newItems);
  logger.log(`${item} added to ${username}'s cart`);      ❶
  return newItems;
};

// ...

❶ 当用户将商品添加到购物车时,将消息追加到/tmp/logs.out 文件

为了测试它,向cartController.test.js添加一个集成测试,调用addItemToCart函数并检查日志文件的内容,如下所示,并在图 4.5 中展示。

列表 4.12 cartController.js

// ...

const fs = require("fs");

describe("addItemToCart", () => {
  beforeEach(() => {
    fs.writeFileSync("/tmp/logs.out", "");                              ❶
  });

  // ...

  test("logging added items", () => {
    carts.set("test_user", []);                                         ❷
    inventory.set("cheesecake", 1);                                     ❸

    addItemToCart("test_user", "cheesecake");                           ❹

    const logs = fs.readFileSync("/tmp/logs.out", "utf-8");             ❺
    expect(logs).toContain("cheesecake added to test_user's cart\n");   ❻
  });
});

❶ 在每个测试之前同步清除日志文件

❷ 安排:将测试用户的购物车设置为空数组

❸ 安排:将库存中芝士蛋糕的数量设置为 1

❹ 行动:发送一个请求尝试向测试用户的购物车添加一块芝士蛋糕

❺ 同步地读取日志文件

❻ 断言:期望日志包含一条消息,告知测试用户的购物车中添加了芝士蛋糕

图 4.5

图 4.5 集成测试将能够访问与你的应用程序交互的所有依赖项。

当涉及到测试后端时,集成测试将覆盖多个函数和外部组件之间的交互,这些组件可能是你的应用程序所依赖的。例如,外部 API、数据库、全局状态或文件系统。与单元测试不同,集成测试不一定总是使用测试替身来隔离你的代码与外部依赖。例如,它们可能会伪造第三方 API 的响应,但不会为数据库使用测试替身。

注意:在本章的其余部分,我们将讨论何时使用测试替身,并解释为什么伪造第三方 API 的响应但针对真实数据库进行测试是一个好主意。

4.1.3 单元测试

端到端和集成测试提供了最可靠的质量保证,但没有单元测试,编写后端应用程序可能会变得难以管理。有了单元测试,你可以一次针对一小块软件,减少编写代码后获得反馈所需的时间。

在后端应用程序的上下文中,单元测试非常适合那些不依赖于其他外部依赖项的函数,例如数据库或文件系统。

作为我们单元测试的目标,我将使用一个及时示例。在我撰写这一章的时候,由于 COVID-19,我开始隔离。由于许多人开始囤积食物,很难找到面包、蛋糕和布朗尼来帮助缓解被锁在里面的紧张情绪。正如许多负责任的企业所做的那样,让我们假设路易斯想要确保他的客户一次只能购买任何商品的三件。这种限制确保了在我们在这些艰难时期,每个人都能享受到一块甜美的甜点。

这个函数,它将被添加到cartController模块中,看起来是这样的。

列表 4.13 cartController.js

const compliesToItemLimit = cart => {
  const unitsPerItem = cart.reduce((itemMap, itemName) => {    ❶
    const quantity = (itemMap[itemName] || 0) + 1;
    return { ...itemMap, [itemName]: quantity };
  }, {});

  return Object.values(unitsPerItem)                           ❷
    .every(quantity => quantity < 3);                          ❸
};

❶ 创建一个对象,其键是购物车中项目的名称,其值是每个项目的相应数量

❷ 创建一个数组,其元素是购物车中每个项目的数量

❸ 返回一个布尔值,指示每个项目的数量是否小于 3

尽管compliesToItemLimit函数在cartController之外没有使用,但请确保像这里所示那样导出它,以便你可以在测试中使用它。

列表 4.14 cartController.js

// ...

module.exports = { addItemToCart, carts, compliesToItemLimit };

现在,最后,向你的cartController.test.js文件中添加一个新的describe块,并为compliesToItemLimit函数编写测试。

列表 4.15 cartController.test.js

// ...

const { carts, addItemToCart, compliesToItemLimit } = require("./cartController");

// ...

describe("compliesToItemLimit", () => {
  test("returns true for carts with no more than 3 items of a kind", () => {
    const cart = [                                                         ❶
      "cheesecake",
      "cheesecake",
      "almond brownie",
      "apple pie"
    ];
    expect(compliesToItemLimit(cart)).toBe(true);                          ❷
  });

  test("returns false for carts with more than 3 items of a kind", () => {
    const cart = [                                                         ❸
      "cheesecake",
      "cheesecake",
      "almond brownie",
      "cheesecake",
      "cheesecake"
    ];
    expect(compliesToItemLimit(cart)).toBe(false);                         ❹
  });
});

❶ 安排:创建一个包含不超过两种同类商品的购物车

❷ 执行并断言:测试compliesToItemLimit函数,并期望它返回 true

❸ 安排:创建一个包含四块芝士蛋糕的购物车

❹ 执行并断言:测试compliesToItemLimit函数,并期望它返回 false

如图 4.6 所示,这个测试隔离了compliesToItemLimit函数,并可以告诉你它是否工作,而无需设置复杂的场景或依赖其他代码。它非常适合快速迭代,因为它允许你在编写函数后立即对其进行测试。

它节省了你使用addItemToCart和设置更复杂的集成测试场景(无论是在数据库中还是在应用程序的全局状态中)的努力。它也不需要你处理任何 HTTP 请求或响应,正如你在一个端到端测试中必须做的那样,如果你必须处理诸如身份验证之类的方面,这可能会变得更加庞大。

图 4.6 集成测试将能够访问与你的应用程序交互的所有依赖项。

单元测试在你需要时立即提供精确的反馈:尽可能快。

作为一项练习,尝试将此函数集成到 addItemToCart 中,并编写集成和端到端测试以验证此行为。考虑一下这需要多少工作量,以及与之前编写的单元测试相比,你获得反馈所需的时间更长。

4.2 测试 HTTP 端点

测试 HTTP 端点与其他代码部分的测试合理不同,因为你不是直接与被测试的单元进行交互。相反,你只能通过 HTTP 请求与你的应用程序进行交互。在本节中,我将向您介绍一种简单但稳健的方法来测试您的 API。

在我们开始编写端点测试之前,我们必须选择一个合适的工具。到目前为止,我们一直通过使用 isomorphic-fetch 来测试我们的应用程序。因为 isomorphic-fetch 包是为了发送请求而设计的,但并非专门用于测试 API,所以使用它会有一些开销。你必须手动在 fetch 上创建包装器,使其使用起来不那么繁琐,并且你的断言将与 fetch 函数的实现紧密耦合。这些断言需要更多的努力来更新,并且当它们失败时,它们的反馈不会像应该的那样清晰。

我选择用于测试 HTTP 端点的工具是 supertest,其文档可以在github.com/visionmedia/supertest找到。supertest 包是一个测试工具,它结合了发送 HTTP 请求和断言这些请求响应的能力。因为 supertest 是建立在 superagent(一个执行 HTTP 请求的库)之上的,所以当 supertest 本身无法完成你想要的功能时,你可以选择使用 superagent 的广泛 API。这种结构使 supertest 更加灵活和可靠。

在本节中,你将重构端到端测试,以便它们使用 supertest 而不是 fetch,因此首先将 supertest 作为 dev-dependency 安装。为此,你可以执行 npm install --save-dev supertest

request 函数(由 supertest 默认导出的函数)可以接受一个 API 的地址,并返回一个对象,允许你指定你想要使用哪个 HTTP 动词调用哪个路由。一旦你确定了要执行哪个请求,你就可以链式添加断言以确保响应将符合你的预期。

例如,重构添加可用项目到购物车的测试。不要使用 fetch 来检查其 HTTP 状态和响应,而是使用 supertestrequest

列表 4.16 server.test.js

const request = require("supertest");

// ...

describe("add items to a cart", () => {
  test("adding available items", async () => {
    inventory.set("cheesecake", 1);                            ❶
    const response = await request(apiRoot)                    ❷
      .post("/carts/test_user/items/cheesecake")
      .expect(200);

    expect(response.body).toEqual(["cheesecake"]);             ❸
    expect(inventory.get("cheesecake")).toEqual(0);            ❹
    expect(carts.get("test_user")).toEqual(["cheesecake"]);    ❺
  });

  // ...
});

❶ 安排:将库存中芝士蛋糕的数量设置为 1

❷ 执行和断言:向 /carts/test_user/items/cheesecake 发送 POST 请求,并期望响应的状态为 200

❸ 断言:期望响应体是一个包含一个芝士蛋糕的数组

❹ 断言:期望库存中不再有芝士蛋糕

❺ 断言:期望测试用户的购物车中只包含一个芝士蛋糕

因为request返回一个Promise,我们可以使用await等待它解析并将解析后的值分配给response。这个response将包含body和其他许多相关信息,你可以在你的断言中使用这些信息。

尽管你可以访问响应的所有数据,但你也可以避免编写单独的断言来检查它。相反,你可以继续使用supertest来确保,例如,头也符合你的期望。在下一个摘录中,你可以看到如何检查响应的Content-Type头是否正确设置。

列表 4.17 server.test.js

// ...

describe("add items to a cart", () => {
  test("adding available items", async () => {
    inventory.set("cheesecake", 1);
    const response = await request(apiRoot)           ❶
      .post("/carts/test_user/items/cheesecake")
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual(["cheesecake"]);
    expect(inventory.get("cheesecake")).toEqual(0);
    expect(carts.get("test_user")).toEqual(["cheesecake"]);
  });

  // ...
});

❶ 行动和断言:向/carts/test_user/items/cheesecake 发送 POST 请求,并期望响应的状态码为 200,并且 Content-Type 头匹配 json

当使用supertest时,你可以避免在测试中硬编码你的 API 地址。相反,你可以导出一个 Koa 实例并将其传递给request。用 Koa 实例而不是地址来传递将使你的测试通过,即使你更改了服务器绑定的端口。

列表 4.18 server.test.js

const { app } = require("./server.js");

// ...

describe("add items to a cart", () => {
  test("adding available items", async () => {
    inventory.set("cheesecake", 1);
    const response = await request(app)           ❶
      .post("/carts/test_user/items/cheesecake")
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual(["cheesecake"]);
    expect(inventory.get("cheesecake")).toEqual(0);
    expect(carts.get("test_user")).toEqual(["cheesecake"]);
  });

  // ...
});

❶ 将请求发送到服务器实例所在的位置,这样你就不必硬编码其地址

到目前为止,我们还没有发送带有请求体的请求。让我们调整添加项目到购物车的路由,使其能够接受包含多个项目的请求体。为了服务器能够理解 JSON 请求体,我们需要使用koa-body-parser包。

要使用koa-body-parser,将其作为依赖项安装,并附加一个中间件,该中间件将解析请求体并更新上下文中的解析内容,如图 4.7 所示。

列表 4.19 server.js

const Koa = require("koa");
const Router = require("koa-router");
const bodyParser = require("koa-body-parser");

// ...

const app = new Koa();
const router = new Router();

app.use(bodyParser());        ❶

// ...

❶ 设置 body-parser,使其将解析后的请求体附加到上下文的 request.body 属性

图片

图 4.7 body-parser中间件如何处理 JSON 请求体

中间件按照定义,中间件是介于两个其他软件层之间的任何软件层。

在 Koa 的情况下,中间件位于接收到的初始请求和最终匹配的路由之间。

如果你把你的服务器想象成你房子的管道系统,那么中间件就是将水流送到你家的管道,而路由则是水流出来的地方——比如厨房的水龙头或花园的水管。

一旦body-parser设置好了,更新路由使其使用请求体的内容。它应该使用item属性来确定要添加什么,以及使用quantity属性来确定数量。

列表 4.20 server.js

app.use(bodyParser());

// ...

router.post("/carts/:username/items", ctx => {
  const { username } = ctx.params;
  const { item, quantity } = ctx.request.body;     ❶

  for (let i = 0; i < quantity; i++) {             ❷
    try {
      const newItems = addItemToCart(username, item);
      ctx.body = newItems;
    } catch (e) {
      ctx.body = { message: e.message };
      ctx.status = e.code;
      return;
    }
  }
});

❶ 从请求体中提取项目属性和数量属性

❷ 尝试将请求的项目数量添加到用户的购物车中

最后,让我们在我们的测试中使用send方法向这个路由发送 JSON 请求体。

列表 4.21 server.test.js

describe("add items to a cart", () => {
  test("adding available items", async () => {
    inventory.set("cheesecake", 3);                              ❶
    const response = await request(app)                          ❷
      .post("/carts/test_user/items")
      .send({ item: "cheesecake", quantity: 3 })                 ❸
      .expect(200)                                               ❹
      .expect("Content-Type", /json/);                           ❺

    const newItems = ["cheesecake", "cheesecake", "cheesecake"];
    expect(response.body).toEqual(newItems);                     ❻
    expect(inventory.get("cheesecake")).toEqual(0);              ❼
    expect(carts.get("test_user")).toEqual(newItems);            ❽
  });

  // ...
});

❶ 安排:将库存中的芝士蛋糕数量设置为 3

❷ 行动:向/cart/test_user/items 发送 POST 请求

❸ 将请求体设置为包含要添加的项目名称和数量的对象

❹ 断言:期望响应的状态为 200

❺ 断言:期望响应的 Content-Type 标头匹配 json

❻ 断言:期望响应的正文是一个包含三个芝士蛋糕的数组

❼ 断言:期望库存中没有芝士蛋糕

❽ 断言:期望用户的购物车是一个包含三个芝士蛋糕的数组

注意:supertest 支持所有 superagent 的功能,包括发送多种不同类型的有效载荷。例如,如果你想测试接受文件上传的路由,你可以发送文件。

test("accepts file uploads", async () => {
  const { body } = await request(app)
    .post("/users/test_user/profile_picture")
    .attach('avatar', 'test/photo.png')              ❶

  expect(body)                                       ❷
    .toEqual({
      message: "profile picture updated successfully!"
    });
});

❶ 操作:在向 /users/test_user/profile_picture 发送 POST 请求时,将测试/照片.png 文件附加到头像字段

❷ 断言:期望响应的正文包含一条消息,告知图片已成功更新

superagent 包是 supertest 受欢迎的部分原因。

注意:要查看 superagentsupertest 背后的包)能做什么,请查看其文档:visionmedia.github.io/superagent/

作为一项练习,尝试更新 server.test.js 中的其他测试,使其也使用 superagent。如果你感到好奇,可以尝试接受除 JSON 之外的其他类型的正文,例如文件上传。

4.2.1 测试中间件

就像任何其他代码片段一样,你可以单独测试你的中间件,或者与依赖它的软件组件(在这种情况下是路由)一起测试。在本小节中,你将创建自己的中间件并学习如何测试它。然后我们将比较这两种方法的优缺点,以便你可以选择最适合你项目的策略。

你将创建的中间件,如图 4.8 所示,将负责在用户尝试访问添加或从购物车中删除项目的路由时检查用户的凭据。

图 4.8 认证中间件的作用

创建一个名为 authenticationController.js 的新文件。在其中,你将放置实现此中间件所需的所有代码。

让我们从创建一个存储客户账户的映射开始。这个映射将按客户用户名索引,每个条目将包含他们的电子邮件和散列密码。

注意:加密散列函数允许你将输入映射到固定大小的输出,该输出不能映射回原始输入。

通过散列用户的密码,你可以避免开发者访问它们,即使你的数据库被入侵,攻击者也无法访问你的客户账户。

因为我们要专注于测试,所以这本书中的例子都很简单且直观。我建议你在实现自己的生产级应用时,对该主题进行更多研究。

为了哈希密码,我们将使用 Node.js 的crypto模块。使用crypto,我们可以创建一个Hash对象,用用户的密码更新它,并生成一个digest——即运行哈希对象的内容通过加密哈希函数,产生一个不可逆的输出。

列表 4.22 server.js

const crypto = require("crypto");
const users = new Map();

const hashPassword = password => {
  const hash = crypto.createHash("sha256");    ❶
  hash.update(password);                       ❷
  return hash.digest("hex");                   ❸
};

module.exports = { users };

❶ 创建一个使用 sha256 生成哈希摘要的对象

❷ 使用密码更新哈希

❸ 计算密码的哈希,并返回一个十六进制编码的字符串

现在为用户创建一个注册路由。这个路由将用户保存到authenticationController中的users Map。

列表 4.23 server.js

const { users, hashPassword } = require("./authenticationController");

// ...

router.put("/users/:username", ctx => {                      ❶
  const { username } = ctx.params;
  const { email, password } = ctx.request.body;
  const userAlreadyExists = users.has(username);
  if (userAlreadyExists) {
    ctx.body = { message: `${username} already exists` };
    ctx.status = 409;
    return;
  }

  users.set(
    username,
    { email, passwordHash: hashPassword(password) }          ❷
  );
  return (ctx.body = { message: `${username} created successfully.` });
});

// ...

❶ 如果用户不存在,则创建一个用户

❷ 保存用户时,哈希密码,并将其存储在 passwordHash 属性中

就像我们对其他路由所做的那样,我们也可以为这个路由编写端到端测试。

从创建一个新账户并检查服务器响应以及users Map 中保存的用户开始测试。

列表 4.24 server.test.js

// ...

const { users, hashPassword } = require("./authenticationController.js");

afterEach(() => users.clear());                      ❶

// ...

describe("create accounts", async () => {
  test("creating a new account", () => {
    const response = await request(app)              ❷
      .put("/users/test_user")
      .send({ email: "test_user@example.org", password: "a_password" })
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual({                  ❸
      message: "test_user created successfully"
    });

    expect(users.get("test_user")).toEqual({         ❹
      email: "test_user@example.org",
      passwordHash: hashPassword("a_password")
    });
  });
});

❶ 在每个测试之前清除所有用户

❷ 行为和断言:向创建用户的路由发送请求,并期望其响应的状态为 200,并且其 Content-Type 头与 json 匹配

❸ 断言:验证响应体的消息

❹ 断言:检查存储的用户是否具有预期的电子邮件,以及其 passwordHash 属性是否对应于请求中发送的密码的哈希

作为一项练习,创建一个测试来验证当有人尝试创建一个重复用户时会发生什么。这个测试应该向用户的Map中添加一个用户,发送一个带有相同用户名的添加用户请求,并检查服务器的响应。它应该期望响应的状态为409,并且其message属性表示已存在具有传递的用户名。如果您需要帮助,这个测试在这个书的 GitHub 仓库中,网址为github.com/lucasfcosta/testing-javascript-applications

注意这些端到端测试是如何使用hashPassword函数的,您也会在您的中间件中使用它。因为端到端测试只是简单地信任它将工作,所以您必须创建一个传递性保证,这样您就只需测试一次,如下所示。这个传递性保证帮助您避免在每次使用hashPassword时都需要重新测试。

列表 4.25 authenticationController.test.js

const crypto = require("crypto");
const { hashPassword } = require("./authenticationController");

describe("hashPassword", () => {
  test("hashing passwords", () => {
    const plainTextPassword = "password_example";
    const hash = crypto.createHash("sha256");              ❶

    hash.update(plainTextPassword);                        ❷
    const expectedHash = hash.digest("hex");               ❸
    const actualHash = hashPassword(plainTextPassword);    ❹
    expect(actualHash).toBe(expectedHash);                 ❺
  });
});

❶ 创建一个使用 sha256 生成哈希摘要的对象

❷ 使用密码更新哈希

❸ 生成密码的哈希摘要,并返回一个十六进制编码的字符串

❹ 练习 hashPassword 函数,传递之前在测试中用于生成哈希摘要的相同密码

❺ 期望 hashPassword 函数返回的哈希摘要与测试中生成的哈希摘要相同

尽管这个测试与hashPassword函数的实现类似,但它保证了如果它发生变化,你会收到警告。任何修改hashPassword的人也将不得不更新其测试,因此测试通过确保这个人知道他们改变的结果来提供价值。换句话说,有人破坏hashPassword而不意识到它的可能性变得更小。

hashPassword的测试(图 4.9)是我们测试中间件的一部分。它保证我们的中间件的一部分已经工作——这是一个细粒度的反馈,我们可以在此基础上构建。这个单元测试覆盖了我们中间件将使用的一小部分。

图片

图 4.9 与此单元测试交互的中间件部分

继续添加一个函数,该函数接收用户名和密码,并验证这些凭证是否有效。你将使用这个函数在中间件中验证用户。

列表 4.26 authenticationController.js

const credentialsAreValid = (username, password) => {     ❶
  const userExists = users.has(username);
  if (!userExists) return false;

  const currentPasswordHash = users.get(username).passwordHash;
  return hashPassword(password) === currentPasswordHash;
};

❶ 接收用户名和密码,如果用户存在且密码的哈希值与用户的 passwordHash 属性匹配,则返回 true;否则返回 false。

再次,你可以为这个函数添加测试。因为它与应用程序的数据交互——目前是在全局状态中,但很快将在数据库中——我们可以将这些测试视为集成测试。你已经上升到了金字塔的更高层次,现在正在测试你未来中间件的另一层。下面是一个这样的测试示例,如图 4.10 所示。

列表 4.27 authenticationController.test.js

// ...

afterEach(() => users.clear());                          ❶

// ...

describe("credentialsAreValid", () => {
  test("validating credentials", () => {
    users.set("test_user", {                             ❷
      email: "test_user@example.org",
      passwordHash: hashPassword("a_password")           ❸
    });

    const hasValidCredentials = credentialsAreValid(     ❹
      "test_user",
      "a_password"
    );
    expect(hasValidCredentials).toBe(true);              ❺
  });
});

❶ 在每个测试之前清除所有用户

❷ 安排:直接将用户保存到用户的 Map 中

❸ 使用 hashPassword 函数为用户的 passwordHash 属性生成哈希摘要

❹ 行动:调用 credentialsAreValid 函数,并传递用户的用户名和明文密码

❺ 断言:期望 credentialsAreValid 函数已将凭证视为有效

图片

图 4.10 与此集成测试交互的中间件部分

现在我们能够创建用户账户并验证凭证,我们必须创建一个中间件,以便每个路由器都知道谁在发送请求。这个中间件将读取authorization头部的内容,找到相应的用户,并将其附加到context

你打算用作中间件的函数将接受context和一个next回调作为参数。当中间件调用next时,它将调用后续的中间件。

注意:在这个例子中,我将使用基本访问认证来验证我们的用户。简而言之,这种认证方法包括发送一个包含Basic username:password的 Base64 编码字符串,并将其作为authorization头部的值。您可以在tools.ietf.org/html/rfc7617找到完整的规范。

你的中间件函数应该看起来像这样。

列表 4.28 authenticationController.js

const authenticationMiddleware = async (ctx, next) => {
  try {
    const authHeader = ctx.request.headers.authorization;        ❶
    const credentials = Buffer.from(                             ❷
      authHeader.slice("basic".length + 1),
      "base64"
    ).toString();
    const [username, password] = credentials.split(":");         ❸

    if (!credentialsAreValid(username, password)) {              ❹
      throw new Error("invalid credentials");
    }
  } catch (e) {                                                  ❺
    ctx.status = 401;
    ctx.body = { message: "please provide valid credentials" };
    return;
  }

  await next();                                                  ❻
};

❶ 从授权头中提取值

❷ 使用 Base64 解码授权头中 basic 后的字符串

❸ 在“:”处拆分授权头的解码内容以获取用户名和密码

❹ 如果凭据无效,则抛出错误

❺ 如果解析或验证授权头时出现任何错误,则返回 401 状态

❻ 调用下一个中间件

最后,我们可以在隔离和路由中测试中间件本身。

如果您要测试中间件函数,您可以将它导入到测试文件中,并传递您可以检查的参数,如下所示。

列表 4.29 authenticationController.test.js

describe("authenticationMiddleware", () => {
  test("returning an error if the credentials are not valid", async () => {
    const fakeAuth = Buffer.from("invalid:credentials")        ❶
      .toString("base64");
    const ctx = {
      request: {
        headers: { authorization: `Basic ${fakeAuth}` }
      }
    };

    const next = jest.fn();
    await authenticationMiddleware(ctx, next);                 ❷
    expect(next.mock.calls).toHaveLength(0);                   ❸
    expect(ctx).toEqual({                                      ❹
      ...ctx,
      status: 401,
      body: { message: "please provide valid credentials" }
    });
  });
});

❶ 安排:创建无效凭据,并对其进行 Base64 编码

❷ 行动:直接调用身份验证中间件函数,传递一个包含无效凭据和占位符作为下一个中间件函数的上下文对象

❸ 断言:期望代表下一个中间件的占位符没有被调用

❹ 断言:期望响应具有 401 状态并包含一条消息,告知用户提供有效的凭据

作为练习,尝试添加测试,使用有效凭据调用中间件函数,并检查next回调是否被调用。

前一个测试非常出色,可以确保函数本身工作正常,但它最多只是一个集成测试。它没有验证中间件是否强制客户端发送有效的凭据以访问路由。

目前,尽管中间件函数工作正常,但服务器并未使用它。因此,每位客户都可以无需提供凭据就访问任何路由。

让我们使用authenticationMiddleware函数来确保所有以/carts开头的路由都需要身份验证。

列表 4.30 server.js

// ...

const {
  users,
  hashPassword,
  authenticationMiddleware
} = require("./authenticationController");

// ...

app.use(async (ctx, next) => {            ❶
  if (ctx.url.startsWith("/carts")) {
    return await authenticationMiddleware(ctx, next);
  }

  await next();
});

// ...

❶ 如果请求的 URL 路径以/carts 开头,则使用身份验证中间件;否则,继续到下一个中间件

如果您重新运行测试,您将看到以/carts开头的路由测试已经开始失败,正如预期的那样。这些测试失败是因为它们没有提供有效的凭据。

为了使旧测试通过,您需要在发送带有supertest的请求时创建用户并提供有效的凭据。

首先,创建一个名为createUser的函数,该函数将在users映射中插入用户。为了便于以后编写测试,还请在您的服务器测试中保存您将用于Authentication头的内容。

列表 4.31 server.test.js

// ...
const { users, hashPassword } = require("./authenticationController.js");

const user = "test_user";
const password = "a_password";
const validAuth = Buffer.from(`${user}:${password}`)     ❶
  .toString("base64");
const authHeader = `Basic ${validAuth}`;
const createUser = () => {                               ❷
  users.set(user, {
    email: "test_user@example.org",
    passwordHash: hashPassword(password)
  });
};

// ...

❶ 创建 Base64 编码的凭据

❷ 创建一个用户,其用户名和密码与 Base64 编码的凭据匹配

最后,为每个测试块添加一个beforeEach钩子,其测试单元是一个需要身份验证的路由。

您可以使用supertestset方法发送有效的authHeader,这允许您设置头部信息。

列表 4.32 server.test.js

// ...

describe("add items to a cart", () => {
  beforeEach(createUser);                                       ❶

  test("adding available items", async () => {
    inventory.set("cheesecake", 3);                             ❷
    const response = await request(app)                         ❸
      .post("/carts/test_user/items")
      .set("authorization", authHeader)
      .send({ item: "cheesecake", quantity: 3 })
      .expect(200)
      .expect("Content-Type", /json/);

    const newItems = ["cheesecake", "cheesecake", "cheesecake"];
    expect(response.body).toEqual(newItems);                    ❹
    expect(inventory.get("cheesecake")).toEqual(0);             ❺
    expect(carts.get("test_user")).toEqual(newItems);           ❻
  });

  // ...
});

❶ 在每次测试之前,创建一个用户,其用户名和密码与存储在本文件范围内的那些匹配。这个钩子可以被认为是测试的“安排”阶段的一部分。

❷ 安排:将库存中的芝士蛋糕数量设置为 3

❸ 执行和断言:使用测试范围内的凭证发送一个请求,将项目添加到用户的购物车中,并期望请求成功

❹ 断言:期望响应体是一个包含三个芝士蛋糕的数组

❺ 断言:期望库存中没有芝士蛋糕

❻ 断言:期望测试用户的购物车是一个包含三个芝士蛋糕的数组

作为练习,尝试修复所有其他仍然失败的测试。你可以在github.com/lucasfcosta/testing-javascript-applications找到完整的解决方案。

注意我们是如何通过向这个中间件所依赖的不同软件组件添加测试来建立多个可靠性层,如图 4.11 所示。我们首先为hashPassword编写了小的单元测试。然后我们编写了集成测试来检查验证凭证的功能。最后,我们能够通过单独调用它和向其他路由发送请求来测试中间件本身。

图 4.11 与此集成测试交互的中间件部分

在构建这个中间件的过程中,你实际上看到了如何在软件开发的不同部分获得不同类型的保证。

由于这个中间件拦截了许多路由的请求,向这些路由发送请求并检查它们是否需要身份验证是测试它的最可靠方式。但仅仅因为端到端测试提供了最强的质量保证,并不意味着它们是你应该编写的唯一测试。

正如你在本节中看到的,单元测试和集成测试对我们来说也非常有用,可以帮助我们快速获取关于构成authenticationMiddleware的各个部分的反馈。而且,随着我们进行重构,它们将继续通过提供快速且更精确的反馈来创造价值。然而,如果你可用的时间和资源过于受限,你可能通过编写端到端测试比编写其他任何类型的测试获得更多的好处。

4.3 处理外部依赖

几乎找不到一个不依赖于另一件完全独立的软件的后端应用程序。因此,你需要确保你的应用程序与这些依赖项适当地交互。

在本节中,你将学习如何在测试软件时处理外部依赖。作为示例,我们将使用后端应用程序中最常见的两个依赖项:数据库和第三方 API。

在开发这些示例的过程中,我将专注于解释每个决策背后的理由,以便你可以将这些技术应用到其他类似的情况中。

注意:本节中我们将编写的所有测试都将使用相同的数据库。如果使用相同数据库的测试同时运行,它们可能会相互干扰。

因为,正如我们在第三章中看到的,Jest 并行运行不同的文件,除非你顺序运行它们,否则你的测试将是不稳定的。

要顺序运行测试,你可以更新package.json中的test脚本以包含--runInBand选项,或者你可以直接将其传递给npm test,如下所示:npm test -- --runInBand

在本章的后面部分,你将学习如何并行化涉及使用数据库的测试。

4.3.1 与数据库的集成

到目前为止,你一直使用全局状态的一部分来存储你的应用程序数据。将数据存储在内存中的问题是,每次应用程序重启时,这些数据都会丢失。

在现实世界中,大多数人使用数据库来存储应用程序的状态。尽管可以模拟与数据库的交互,但这相当棘手且需要大量努力,正如我们在第三章中讨论的那样。模拟数据库也会使测试与生产环境分离,因此更容易出现错误。

与数据库交互的测试也可能增加维护开销。例如,在设置和清理测试场景时,你必须特别小心。否则,数据库模式的小幅变化在更新测试时可能会产生大量工作。

在我们讨论任何特定的技术或最佳实践之前,让我们重构应用程序,使其使用数据库而不是在内存中存储状态。

使用数据库设置你的第一个测试

正如你在第二章中所做的那样,设置knex模块,你将使用它来与数据库交互。安装knex和你想要使用的数据库管理系统。在这些示例中,我将使用sqlite3

$ npm install knex sqlite3

首先,创建一个knexfile文件,指定你将使用哪种数据库管理系统以及如何连接到它,如下所示。

列表 4.33 knexfile.js

module.exports = {
  development: {
    client: "sqlite3",                          ❶
    connection: { filename: "./dev.sqlite" },   ❷
    useNullAsDefault: true                      ❸
  }
};

❶ 使用 sqlite3 作为数据库客户端

❷ 指定数据库存储数据的文件

❸ 使用 NULL 而不是 DEFAULT 来处理未定义的键

注意:这些示例使用sqlite3,因为它是最容易设置的数据库管理系统。通过使用sqlite3,我们可以专注于测试,而不是专注于设置数据库。

如果你更喜欢使用 Postgres 或 MySQL,例如,请随意使用它们。

你可以在knexjs.org找到如何设置许多不同数据库管理系统的说明。

现在你已经配置了 Knex,创建一个封装数据库连接的文件。记住还要添加一个关闭数据库连接的方法。这个方法将确保我们在测试后释放资源。

列表 4.34 dbConnection.js

const knex = require("knex")
const knexConfig = require("./knexfile").development;    ❶

const db = knex(knexConfig);                             ❷

const closeConnection = () => db.destroy();              ❸

module.exports = {
  db,
  closeConnection
};

❶ 导入连接到开发数据库所需的配置

❷ 为开发数据库设置连接池

❸ 断开连接池

在您可以使用数据库之前,您需要为购物车、用户和库存创建表。为此,通过运行./node_modules/.bin/knex migrate:make --env development initial_schema来创建一个migration。现在您将在migrations文件夹中找到迁移文件,创建必要的表。

注意:在这个迁移过程中,您将使用 Knex 的 schema builder API 来操作表。您可以在knexjs.org/#Schema找到其文档。

列表 4.35 20200325082401_initial_schema.js

exports.up = async knex => {                                 ❶
  await knex.schema.createTable("users", table => {          ❷
    table.increments("id");
    table.string("username");
    table.unique("username");
    table.string("email");
    table.string("passwordHash");
  });

  await knex.schema.createTable("carts_items", table => {    ❸
    table.integer("userId").references("users.id");
    table.string("itemName");
    table.unique("itemName");
    table.integer("quantity");
  });

  await knex.schema.createTable("inventory", table => {      ❹
    table.increments("id");
    table.string("itemName");
    table.unique("itemName");
    table.integer("quantity");
  });
};

exports.down = async knex => {                               ❺
  await knex.schema.dropTable("inventory");
  await knex.schema.dropTable("carts_items");
  await knex.schema.dropTable("users");
};

❶ 导出的up函数将数据库迁移到下一个状态。

❷ 为应用程序的用户创建一个表。每个用户必须有一个 ID、一个唯一的用户名、一个电子邮件和一个密码。

❸ 创建carts_items表以跟踪每个用户的购物车中的物品。每一行将包括物品的名称、数量以及属于该用户的 ID。

❹ 创建一个库存表,用于跟踪库存中的物品。

❺ 导出的down函数将数据库迁移到上一个状态,删除购物车、carts_items 和用户表。

要执行此迁移,请在终端中运行./node_modules/.bin/knex migrate:latest。此命令将执行所有必要的迁移,以将您的数据库带到最新状态。如果不存在,它将创建一个文件来存储您的数据,并使用最新的模式更新它。

现在,您最终可以更新其他模块,使它们使用您的数据库而不是全局状态的一部分。

首先,更新authenticationController.js文件。为了验证凭证,它将不再像以前那样从全局users映射中获取用户,而是从数据库中获取用户。

我们也不再需要users映射,所以别忘了将其删除。

列表 4.36 authenticationController.js

const { db } = require("./dbConnection");

// ...

const credentialsAreValid = async (username, password) => {
  const user = await db("users")                             ❶
    .select()
    .where({ username })
    .first();
  if (!user) return false;
  return hashPassword(password) === user.passwordHash;       ❷
};

// ...

module.exports = {
  hashPassword,
  credentialsAreValid,
  authenticationMiddleware
};

❶ 从数据库中获取与函数传入的用户名匹配的用户

❷ 对传入的密码进行哈希处理,并将其与数据库中存储的用户passwordHash进行比较

在此更改之后,对credentialsAreValid函数的测试应该会失败,因为它依赖于从authenticationController导入的全局状态。更新该测试,以便通过向数据库添加用户来设置场景,而不是更新全局状态的一部分。

提示:您不必在每次运行jest时都运行所有测试。

您可以将文件名作为jest的第一个参数传递,以指定要执行哪个文件,使用-t选项指定要执行的测试。

例如,如果您只想运行authenticationController.test.js文件中credentialsAreValid块内的测试,可以执行jest authenticationController.test.js -t="credentialsAreValid"

如果你像我们之前做的那样,在 npm 脚本中使用 jest,你可以在传递这些选项给脚本之前添加一个 --。例如,你可以运行 npm test -- authenticationController.test.js -t="credentialsAreValid"

列表 4.37 authenticationController.test.js

// Don't forget to also remove unnecessary imports
const { db } = require("./dbConnection");

beforeEach(() => db("users").truncate());                      ❶

// ...

describe("credentialsAreValid", () => {
  test("validating credentials", async () => {
    await db("users").insert({                                 ❷
      username: "test_user",
      email: "test_user@example.org",
      passwordHash: hashPassword("a_password")
    });

    const hasValidCredentials = await credentialsAreValid(     ❸
      "test_user",
      "a_password"
    );
    expect(hasValidCredentials).toBe(true);                    ❹
  });
});

// ...

❶ 不是清除用户映射,而是清除数据库中的用户表

❷ 安排:使用 hashPassword 函数生成 passwordHash 列表的值,将测试用户插入数据库

❸ 行动:通过传递测试用户的用户名和明文密码来练习 credentialsAreValid 函数

❹ 断言:期望 credentialsAreValid 已将凭据视为有效

credentialsAreValid 函数的测试现在应该通过,但同一文件中 authenticationMiddleware 的测试仍然会失败。它们失败是因为我们将 credentialsAreValid 设计为异步的,但在 authenticationMiddleware 函数中我们没有等待其结果。

按照以下代码更新 authenticationMiddleware 函数,以便在继续之前等待 credentialsAreValid 完成。

列表 4.38 authenticationController.test.js

// ...

const authenticationMiddleware = async (ctx, next) => {
  try {
    const authHeader = ctx.request.headers.authorization;
    const credentials = Buffer.from(
      authHeader.slice("basic".length + 1),
      "base64"
    ).toString();
    const [username, password] = credentials.split(":");

    const validCredentialsSent = await credentialsAreValid(       ❶
      username,
      password
    );
    if (!validCredentialsSent) throw new Error("invalid credentials");
  } catch (e) {
    ctx.status = 401;
    ctx.body = { message: "please provide valid credentials" };
    return;
  }

  await next();
};

// ...

❶ 等待 credentialsAreValid 解析

作为练习,尝试更新你的应用程序的其余部分以及相应的测试。重构后,应用程序和测试都不应依赖于任何内存状态。如果你想直接跳到下一节,你可以在本书的 GitHub 仓库中找到重构后的应用程序,网址为 mng.bz/w9VW

即使你的更新后的 authenticationController.js 文件测试通过,你仍然有两个问题需要解决。如果你继续更新其余的测试,你可能已经注意到了以下情况:

  • 用于测试的数据库和用于运行应用程序的数据库是相同的。不区分数据库可能会导致测试删除或覆盖关键数据。

  • 你必须记住在运行任何测试之前迁移和清除你的数据库。如果你忘记这样做,你的测试可能会因为从不一致的状态开始而失败。

让我们看看我们如何解决这些问题并改进我们的测试。

使用单独的数据库实例

如果你的测试使用与应用程序运行时相同的数据库实例,它们可能会覆盖或删除数据。测试也可能失败,因为数据库的初始状态可能与应有的状态不同。

通过为你的测试使用单独的数据库实例,你可以在开发环境中保护应用程序的数据,并且因为这些仍然是真实数据库,可以使测试尽可能接近现实。

注意:你可以在本书的 GitHub 仓库 chapter4>3 _dealing_with_external_dependencies>2_separate_database_instances 中找到本小节完整的代码,github.com/lucasfcosta/testing-javascript-applications

目前,你的 knexfile 只导出了一个配置,称为 development

列表 4.39 knexfile.js

module.exports = {
  development: {
    client: "sqlite3",
    connection: { filename: "./dev.sqlite" },
    useNullAsDefault: true
  }
};

这是你在 dbConnection.js 文件中使用的配置。正如你在 dbConnection.js 的第二行中看到的,我们使用了 knexfile.js 导出的对象的 development 属性。

列表 4.40 dbConnection.js

const knex = require("knex")
const knexConfig = require("./knexfile").development;

const db = knex(knexConfig);

// ...

因为你的项目中的每个文件都使用 dbConnection.js 与数据库交互,它控制着你的后端连接到哪个数据库。如果你更改 dbConnection 连接到的数据库,你将更改整个应用程序的数据库实例。

首先,在 knexfile 中创建一个新的配置,这样你就可以连接到不同的数据库。我建议将其命名为 "test"

列表 4.41 knexfile.js

module.exports = {                       ❶
  test: {                                ❷
    client: "sqlite3",
    connection: { filename: "./test.sqlite" },
    useNullAsDefault: true
  },
  development: {                         ❸
    client: "sqlite3",
    connection: { filename: "./dev.sqlite" },
    useNullAsDefault: true
  }
};

❶ 导出一个具有两个属性的对象:test 和 development

❷ 定义测试数据库的配置

❸ 定义开发数据库的配置

现在,为了连接到使用 test.sqlite 文件而不是 dev.sqlite 的测试数据库,并更新 dbConnection.js 文件。不要在 knexfile.js 中使用 development 配置,而使用 test

列表 4.42 dbConnection.js

const knex = require("knex")
const knexConfig = require("./knexfile").test;     ❶

const db = knex(knexConfig);                       ❷

const closeConnection = () => db.destroy();

module.exports = {
  db,
  closeConnection
};

❶ 从 knexfile 导出测试属性

❷ 使用测试数据库的配置来创建一个连接池

如果你尝试重新运行你的测试,你会看到它们会失败,因为 test 数据库还没有被创建。

要创建 test 数据库——因此是 test.sqlite 文件——运行 ./node_modules/.bin/knex migrate:latest,将 test 传递给 env 选项。这决定了运行迁移时使用哪个环境。你应该运行的命令看起来像这样:./node_modules/.bin/knex migrate:latest --env test

一旦 test 数据库被创建和更新,所有测试都应该通过。

现在你有两个不同的数据库可以连接,你必须为你的测试使用一个,为运行应用程序使用另一个。

要确定应用程序正在哪个环境中运行,我们可以传递一个环境变量并在 .js 文件中读取它。要读取环境变量,你可以读取全局 process 对象中的 env 属性。

让我们快速检查环境变量是如何工作的。首先,创建一个文件,将 process.env.EXAMPLE 记录到控制台。

列表 4.43 example.js

console.log(process.env.EXAMPLE);

现在,使用 EXAMPLE="any value" 运行该文件 node example.js。你应该会在你的控制台看到 any value 被记录。

更新你的 dbConnection.js 文件,使其能够连接到一个可以通过 NODE_ENV 环境变量指定的数据库。

列表 4.44 dbConnection.js

const environmentName = process.env.NODE_ENV;                  ❶
const knex = require("knex");
const knexConfig = require("./knexfile")[environmentName];     ❷

const db = knex(knexConfig);

const closeConnection = () => db.destroy();

module.exports = {
  db,
  closeConnection
};

❶ 获取 NODE_ENV 环境变量的值

❷ 使用分配给 environmentName 的 NODE_ENV 环境变量的值来确定选择哪个数据库配置

现在,当你使用 NODE_ENV=development node server.js 运行你的应用程序时,例如,它将连接到 development 数据库。

当使用 Jest 运行测试并连接到 test 数据库时,你不需要做任何更改。Jest 自动将 NODE_ENV 设置为 test,因此当运行测试时,它将使你的应用程序连接到 test 数据库。

维护原始状态

每次运行测试针对数据库实例时,你必须确保它存在并且是最新的。否则,你的测试可能无法运行。正如你开始使用不同的数据库实例进行测试时所看到的,直到你执行迁移命令 ./node_modules/.bin/knex migrate :latest --env test,测试才运行。

每个尝试运行你的应用程序测试的新开发者都会遇到相同的问题,直到他们自己运行 migrate 命令。每当架构发生变化时,他们都必须记得再次运行 migrate 命令。否则,测试可能会神秘地失败,其他人可能需要很长时间才能弄清楚他们必须做什么来解决这个问题。

为了让每个人的生活更轻松,你可以自动化迁移数据库的过程。通过确保在运行测试之前数据库是最新的,你可以使用我们在第三章中看到的全局设置钩子。

注意:你可以在本书的 GitHub 仓库 chapter4/3 _dealing_with_external_dependencies/3_maintaining_a_pristine_state 中找到本小节的完整代码,github.com/lucasfcosta/testing-javascript-applications

创建一个 jest.config.js 文件,并指定 Jest 在运行测试之前应该执行一个名为 migrate Databases.js 的文件,如下所示。

列表 4.45 jest.config.js

module.exports = {
  testEnvironment: "node",
  globalSetup: "<rootDir>/migrateDatabases.js",     ❶
};

❶ 在所有测试之前运行 migrateDatabases.js 脚本导出的异步函数一次

migrateDatabases.js 中,你可以使用 knex 来运行迁移。

列表 4.46 migrateDatabases.js

const environmentName = process.env.NODE_ENV || "test";             ❶
const environmentConfig = require("./knexfile")[environmentName];   ❷
const db = require("knex")(environmentConfig);

module.exports = async () => {
    await db.migrate.latest();                                      ❸

    await db.destroy();                                             ❹
};

❶ 将 NODE_ENV 环境变量的值分配给 environmentName;如果它为空,则分配 test

❷ 使用 environmentName 中的值来确定选择哪个数据库配置

❸ 将数据库迁移到最新状态

❹ 关闭数据库连接,以便测试不会挂起

这个全局钩子确保在运行任何测试之前,将有一个具有最新架构的数据库可用。

现在你已经自动化了创建和迁移数据库的过程,你应该自动化另外两个任务:截断(清空)每个表的内容和从数据库断开连接。截断表确保测试从原始状态开始,从数据库断开连接确保 Jest 在测试完成后不会挂起。

要配置 Jest 在运行每个测试文件之前应该执行哪些代码片段,请将 setupFilesAfterEnv 选项添加到你的 jest.config.js 文件中。setupFilesAfterEnv 中指定的文件将在 Jest 初始化后运行,因此可以访问 Jest 创建的全局变量。

首先,告诉 Jest 在运行每个测试文件之前执行 truncateTables.js,如下所示。

列表 4.47 jest.config.js

module.exports = {
  testEnvironment: "node",
  globalSetup: "./migrateDatabases.js",
  setupFilesAfterEnv: ["<rootDir>/truncateTables.js"]        ❶
};

❶ 在每个测试文件之前运行 truncateTables.js

备注:<rootDir> 符号表示 Jest 应该根据项目的根目录解析文件。如果不指定 <rootDir>,Jest 将根据每个测试文件解析设置文件。

然后,你的 truncateTables.js 文件应该使用全局的 beforeEach 函数来确定在每个测试之前应该截断表格。

列表 4.48 truncateTables.js

const { db } = require("./dbConnection");
const tablesToTruncate = ["users", "inventory", "carts_items"];     ❶

beforeEach(() => {
  return Promise.all(tablesToTruncate.map(t => {                    ❷
    return db(t).truncate();
  }));
});

❶ 定义要截断的表格列表

❷ 在每个测试之前截断列表中的每个表格

这个全局钩子允许你移除每个测试文件中重复的 beforeEach 钩子,以清理你拥有的表格,如下所示:

// No need for these anymore!
beforeEach(() => db("users").truncate());
beforeEach(() => db("carts_items").truncate());
beforeEach(() => db("inventory").truncate());

从单个位置擦除数据也确保你不会因为忘记清理某个表格或另一个表格而导致测试失败。每个测试都保证从空状态开始运行。当每个测试从干净的状态开始时,它们也不会相互干扰。并且因为你确切知道每个测试开始时可用的是什么数据,所以在调试时可以轻松跟踪其操作。

如果需要保留某些数据,例如发送认证请求的账户,你可以向 setupFilesAfterEnv 添加另一个脚本来完成此操作,如下所示。

列表 4.49 jest.config.js

module.exports = {
  // ...
  setupFilesAfterEnv: [              ❶
    "<rootDir>/truncateTables.js",
    "<rootDir>/seedUser.js"          ❷
  ]
}

❶ 定义在运行每个测试文件之前要运行的脚本列表

❷ 在每个测试文件之前运行 seedUser.js

再次,一个全局设置文件帮助你消除了许多重复的 beforeEach 钩子。

继续创建 seedUser.js 文件,Jest 应该运行此文件。

列表 4.50 seedUser.js

const { db } = require("./dbConnection");
const { hashPassword } = require("./authenticationController");

const username = "test_user";
const password = "a_password";
const passwordHash = hashPassword(password);
const email = "test_user@example.org";
const validAuth = Buffer.from(`${username}:${password}`).toString("base64");❶
const authHeader = `Basic ${validAuth}`;

global._tests.user = {                                                      ❷
  username,
  password,
  email,
  authHeader
};

beforeEach(async () => {                                                    ❸
  const [id] = await db("users").insert({ username, email, passwordHash });
  global._tests.user.id = id;
});

❶ 生成用于授权头的 Base64 编码的凭据

❷ 将用户信息附加到全局命名空间,包括生成的授权头

❸ 在每个测试之前用测试用户填充数据库

一旦你让 Jest 在每个测试之前创建一个用户,更新所有测试,以便它们访问 global._tests.user 而不是必须自己创建和检索用户。

重要:为了使这个示例简短,我使用了 global 状态来存储数据。在设置全局数据时,你必须非常小心。更好的替代方案是创建一个可以创建用户并导出的单独模块。然后,在你的测试中,你可以在不访问 global 的情况下从该文件导入 user。你可以在本书的 GitHub 仓库中找到一个如何做到这一点的示例。

你为测试刚刚创建的基准状态正式称为固定基准场景。固定基准场景可以是初始数据库状态,就像你做的,也可以涉及创建文件或设置第三方依赖项。事实上,如果我们字面地理解固定基准场景这个术语,甚至你的beforebeforeEach钩子,这些准备测试运行的钩子也可以被认为是固定基准场景。

固定基准场景:固定基准场景是为测试设置的基准场景。固定基准场景确保测试可以运行并产生可重复的结果。

目前,我们仍然可以消除一个重复的钩子:确保每个测试从数据库断开连接的那个钩子。多亏了这个钩子在所有文件中都存在,一旦测试完成,Jest 就不会挂起。

afterAll(() => db.destroy());        ❶

❶ 在所有测试完成后,关闭数据库连接,以确保测试不会挂起

即使setupFilesAfterEnv脚本在测试文件之前运行,你也可以使用它们来设置afterEachafterAll钩子。

创建一个disconnectFromDb.js文件,并添加一个调用db.destroyafterAll钩子,如下所示。

列表 4.51 disconnectFromDb.js

const { db } = require("./dbConnection");

afterAll(() => db.destroy());

现在你已经完成了所有的setupFilesAfterEnv钩子,请确保使用以下代码更新你的jest.config.js

列表 4.52 jest.config.js

module.exports = {
  testEnvironment: "node",
  globalSetup: "./migrateDatabases.js",
  setupFilesAfterEnv: [
    "<rootDir>/truncateTables.js",
    "<rootDir>/seedUser.js",
    "<rootDir>/disconnectFromDb.js"
  ]
};

在处理后端应用程序时,保持状态清洁可能很棘手。正如我们所见,引入第三方依赖项(如数据库)可以为你的测试添加额外的复杂性。你必须担心你的代码,以及每个依赖项涉及的状态和设置过程。

在可能的情况下,尽量将用于管理这些依赖项的代码片段集中起来,无论是全局设置文件、全局钩子,还是其他实用脚本,就像我们在本节中看到的那样。模块化测试,就像模块化代码一样,使更改更快、更容易,因为它们要求你同时更新的地方更少。

4.3.2 与其他 API 的集成

除了销售伦敦最甜美的甜点外,路易斯的面包店还出售烘焙原料。路易斯注意到最近越来越多的人开始自己烘焙面包,所以他决定抓住这个趋势,从中获利,并将利润再投资到他的生意中。

为了让客户更有可能购买烘焙原料,他认为在每个原料页面上包含食谱建议是个好主意。不幸的是,你的开发团队——你——没有时间或资源为面包店销售的数十种物品中的每一种都整理一份食谱列表。

你可以不自己创建这份食谱列表,而是使用第三方食谱 API 来扩展你的应用程序。

首先,创建一个路由,允许客户端获取库存项的详细信息,如下所示。

列表 4.53 server.js

// ...

router.get("/inventory/:itemName", async ctx => {      ❶
  const { itemName } = ctx.params;
  ctx.body = await db
    .select()
    .from("inventory")
    .where({ itemName })
    .first();
});

// ...

❶ 响应发送到/inventory/:itemName 的 GET 请求,返回库存表中找到的物品信息

要测试这个路由,我们可以向库存中添加一个物品,向它发送请求,并检查应用程序的响应。

列表 4.54 server.test.js

// ...

describe("fetch inventory items", () => {
  const eggs = { itemName: "eggs", quantity: 3 };
  const applePie = { itemName: "apple pie", quantity: 1 };

  beforeEach(async () => {                                      ❶
    await db("inventory").insert([eggs, applePie]);
    const { id: eggsId } = await db
      .select()
      .from("inventory")
      .where({ itemName: "eggs" })
      .first();
    eggs.id = eggsId;
  });

  test("can fetch an item from the inventory", async () => {    ❷
    const response = await request(app)
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual(eggs);
  });
});

❶ 在库存中添加三个鸡蛋和一个苹果派

❷ 向/inventory/eggs 发送 GET 请求,并期望响应体包含项目的 ID、名称和可用数量

现在,让我们让应用程序通过 HTTP 从第三方 API 获取食谱。在这些示例中,我将使用 Recipe Puppy API,其文档可以在www.recipepuppy.com/about/api找到。

尝试发送一个GET请求到http://www.recipepuppy.com/api?i=eggs以获取涉及鸡蛋的结果列表,例如。注意响应的格式,以便你可以在新的路由中使用它。

安装isomorphic-fetch包,然后让你的路由执行对 Recipe Puppy API 的 HTTP 调用。然后,将此请求响应的一部分附加到返回的对象中,如下所示。

列表 4.55 server.js

const fetch = require("isomorphic-fetch");

// ...

router.get("/inventory/:itemName", async ctx => {
  const response = await fetch(                                    ❶
    `http://recipepuppy.com/api?i=${itemName}`
  );
  const { title, href, results: recipes } = await response.json();
  const inventoryItem = await db                                   ❷
    .select()
    .from("inventory")
    .first();

  ctx.body = {                                                     ❸
    ...inventoryItem,
    info: `Data obtained from ${title} - ${href}`,
    recipes
  };
});

❶ 向 Recipe Puppy API 发送请求

❷ 在数据库中找到该项目

❸ 响应包含项目的详细信息、从 Recipe Puppy API 获取的食谱以及关于数据来源的消息

为了在这次更改后使测试通过,让我们让它执行对 Recipe Puppy API 的相同请求,并使用其响应来验证你的应用程序返回的内容。

列表 4.56 server.test.js

describe("fetch inventory items", () => {

  // ...

  test("can fetch an item from the inventory", async () => {
    const thirdPartyResponse = await fetch(                   ❶
      "http://recipepuppy.com/api?i=eggs"
    );
    const { title, href, results: recipes } = await thirdPartyResponse.json();

    const response = await request(app)                       ❷
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual({                           ❸
      ...eggs,
      info: `Data obtained from ${title} - ${href}`,
      recipes
    });
  });
});

❶ 向 Recipe Puppy API 发送请求以获取包含鸡蛋的食谱

❷ 向你自己的服务器的/inventory/eggs 路由发送 GET 请求,并期望它成功

❸ 期望你自己的服务器的响应包含项目信息、关于数据来源的消息以及你在测试中之前从 Recipe Puppy API 获取的相同食谱

之前的测试将通过,但这种方法存在几个缺陷。

许多 API 会根据请求次数向你收费,并在一定时间窗口内限制它们将响应的请求数量。例如,如果你使用 API 向用户发送短信,它可能会对你发送的消息收费。因为即使是免费的 API 也有维护成本,如果你不是付费客户,它们可能会限制你可以发送的请求数量。

如果你和你的应用程序都必须向第三方 API 发送真实请求,你的成本可能会轻易飙升。测试应该频繁运行,如果你每次运行测试都要付费,它们将变得昂贵,开发者将受到激励减少运行它们的频率。

如果你需要通过身份验证才能向 API 发送请求,管理其访问令牌也可能很棘手。你需要确保每个开发者都将这些令牌保存在他们的机器上。以这种方式管理凭证可能会很麻烦,并给测试过程增加开销,因为人们将不得不询问他人凭证信息或手动生成凭证。

运行测试所需的额外努力可能会增加维护应用程序的成本,因为人们需要花费更多的时间来设置环境,如果你正在处理付费 API,还需要支付获取令牌的费用——这还不包括在传递凭证时的安全风险。

如果你的应用程序需要向第三方 API 发起请求,它的测试只有在有可用互联网连接的情况下才会运行。网络问题可能会导致测试失败,即使代码是正确的。

最后,通过测试真实 API,模拟错误场景可能会很困难。例如,你将如何检查当第三方 API 不可用时,你的应用程序是否表现适当?

避免发出请求 altogether 但仍然检查你的应用程序是否尝试过发出请求,这是解决这些问题的绝佳方案。

根据我们之前已经讨论的内容,可能第一个出现在你脑海中的解决方案是自行模拟fetch包。你将不会使用“真实”的 fetch 函数,而是使用存根,然后检查它是否以正确的参数被调用。这个存根也会解析为适当的响应,以便测试可以继续进行。

尝试这样做,看看效果如何。首先,如这里所示,在测试文件顶部添加对jest.mock的调用,并使其创建一个isomorphic-fetch的模拟。

列表 4.57 server.test.js

// ...

jest.mock("isomorphic-fetch");        ❶

// Your tests go here...

❶ 使导入 isomorphic-fetch 解析为 Jest 模拟

在调用jest.mock之后,你的测试中的isomorphic-fetch导入将解析为模拟。

现在你需要在测试中模拟fetch函数,并使其解析为一个硬编码的响应。为了能够模拟一个假响应,你需要仔细查看以下应用期望isomorphic-fetch如何表现的方式:

  1. fetch函数返回一个 promise,该 promise 解析为一个对象。

  2. 一旦fetch函数的 promise 解析完成,你将调用它的json方法。

  3. json方法返回一个 promise,该 promise 将解析为包含响应体的对象。

现在继续为isomorphic-fetch创建一个模拟,以模拟我刚才描述的行为。

列表 4.58 server.test.js

// ...

describe("fetch inventory items", () => {
  // ...

  test("can fetch an item from the inventory", async () => {
    const fakeApiResponse = {                                 ❶
      title: "FakeAPI",
      href: "example.org",
      results: [{ name: "Omelette du Fromage" }]
    };

    fetch.mockResolvedValue({                                 ❷
      json: jest.fn().mockResolvedValue(fakeApiResponse)
    });

    const response = await request(app)                       ❸
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual({                           ❹
      ...eggs,
      info: `Data obtained from ${fakeApiResponse.title} - 
             ${fakeApiResponse.href}`,
      recipes: fakeApiResponse.results
    });
  });

  // ...
});

❶ 定义一个静态对象,模拟 Recipe Puppy API 的响应

❷ 使 isomorphic-fetch 的 fetch 函数始终解析为测试中定义的静态对象

❸ 向你的服务器/inventory/eggs 路由发送 GET 请求,并期望它成功

❹ 检查服务器的响应。这个断言期望响应中包含数据库中找到的项目信息,并使用测试中之前指定的静态数据来验证其他字段。

这个模拟将导致正在测试的应用程序获取你定义的fakeApiResponse,而不是进行实际的 HTTP 请求。

你编写的测试仍然没有检查请求的目标 URL,这意味着即使应用程序正在向错误的地方发送请求,这个测试也会通过。

为了确保你的 API 正在将请求发送到正确的 URL,你可以检查fetch是否以预期的参数被调用,如下所示。

列表 4.59 server.test.js

// ...

describe("fetch inventory items", () => {
  // ...

  test("can fetch an item from the inventory", async () => {
    const fakeApiResponse = {
      title: "FakeAPI",
      href: "example.org",

      results: [{ name: "Omelette du Fromage" }]
    };

    fetch.mockResolvedValue({                                       ❶
      json: jest.fn().mockResolvedValue(fakeApiResponse)
    });

    const response = await request(app)                             ❷
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(fetch.mock.calls).toHaveLength(1);                       ❸
    expect(fetch.mock.calls[0]).toEqual([
      `http://recipepuppy.com/api?i=eggs`]);                        ❹

    expect(response.body).toEqual({ 
      ...eggs,
      info: `Data obtained from ${fakeApiResponse.title} - 
             ${fakeApiResponse.href}`,
      recipes: fakeApiResponse.results
    });
  });
});

❶ 使 isomorphic-fetch 的 fetch 函数始终解析到测试中之前定义的静态对象

❷ 向你自己的服务器的/inventory/eggs 路由发送 GET 请求,并期望它成功

❸ 期望 isomorphic-fetch 的 fetch 函数被调用了一次

❹ 检查第一次调用 fetch 是否使用了预期的 URL

或者,为了避免对传递给fetch的参数进行断言,你可以限制它响应的值。例如,如果你让fetch仅在传递正确的 URL 时返回成功的响应,你就可以避免对 URL 本身进行断言。

小贴士:你可以通过使用针对它们的特定断言来避免在测试中访问测试替身的内部属性。

例如,要检查一个测试替身是否被调用了一次,你可以使用expect(testDouble).toHaveBeenCalled()。如果你想断言一个调用的参数,你可以使用expect(testDouble).toHaveBeenCalledWith(arg1, arg2, ...).

为了根据传递给模拟的参数定义不同的响应,你可以使用jest-when包,如下一个列表所示。这个包使得根据输入确定模拟应该做什么变得更加容易。

列表 4.60 server.test.js

const { when } = require("jest-when");

// ...

describe("fetch inventory items", () => {
  // ...

  test("can fetch an item from the inventory", async () => {
    const eggsResponse = {
      title: "FakeAPI",
      href: "example.org",
      results: [{ name: "Omelette du Fromage" }]
    };

    fetch.mockRejectedValue("Not used as expected!");      ❶
    when(fetch)                                            ❷
      .calledWith("http://recipepuppy.com/api?i=eggs")
      .mockResolvedValue({
        json: jest.fn().mockResolvedValue(eggsResponse)
      });

    const response = await request(app)
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual({
      ...eggs,
      info: `Data obtained from ${eggsResponse.title} - 
             ${eggsResponse.href}`,
      recipes: eggsResponse.results
    });
  });
});

❶ 使 isomorphic-fetch 的 fetch 函数被拒绝

❷ 当使用正确的 URL 调用时,仅使 isomorphic-fetch 的 fetch 函数解析到测试中之前定义的静态对象

手动设置模拟的问题在于,正如你所看到的,你必须严格复制你的应用程序期望 HTTP 请求库执行的操作。使用模拟来复制复杂的行为会使测试与你的应用程序紧密耦合,因此增加了维护成本,因为你将不得不更频繁地更新它们,即使应用程序仍然可以正常工作。

当你与其他协议(如MQTTCoAP)交互时,模拟可以是一个绝佳的解决方案,但对于 HTTP,你可以使用名为nock的模块。

与之前的模拟不同,nock要求你确定服务器的响应,而不是要求你确定发送库的行为。通过使用nock,你的测试不会依赖于你用来发送请求的库的行为。因为你将模拟服务器的响应,所以你的测试将更加松散耦合,因此成本更低。

nock作为开发依赖项安装,这样你就可以在测试中使用它。nock包将允许你指定特定 HTTP 动词、域名、路径和查询字符串的状态和响应。

列表 4.61 server.test.js

const nock = require("nock");

// Don't forget to remove the mock you've done for `isomorphic-fetch`!

// ...

beforeEach(() => nock.cleanAll());                     ❶

describe("fetch inventory items", () => {
  // ...

  test("can fetch an item from the inventory", async () => {
    const eggsResponse = {
      title: "FakeAPI",
      href: "example.org",
      results: [{ name: "Omelette du Fromage" }]
    };

    nock("http://recipepuppy.com")                     ❷
      .get("/api")
      .query({ i: "eggs" })
      .reply(200, eggsResponse);

    const response = await request(app)                ❸
      .get(`/inventory/eggs`)
      .expect(200)
      .expect("Content-Type", /json/);

    expect(response.body).toEqual({                    ❹
      ...eggs,
      info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`,
      recipes: eggsResponse.results
    });
  });
});

❶ 确保没有模拟会从一个测试持续到另一个测试

❷ 当向 Recipe Puppy API 的/api 端点发送请求时,如果查询字符串的 i 属性值为 eggs,则此拦截器将被触发,并且请求将解析为测试中之前定义的静态对象。

❸ 向您自己的服务器的/inventory/eggs 路由发送 GET 请求,并期望其成功。

❹ 检查服务器的响应。此断言期望响应包含数据库中找到的项目信息,并使用 nock 拦截器响应的静态数据。

使用nock,您不需要手动编写断言来检查fetch函数是否向其他不适当的端点发送了请求。发送到除您使用nock模拟的 URL 之外的其他 URL 的请求将导致测试失败。

注意:每次 HTTP 请求击中端点时,nock将销毁处理该请求的拦截器。因为该拦截器将不再活跃,下一个请求将击中下一个匹配的拦截器或根本没有任何拦截器。

为了避免在使用后删除拦截器,您必须在设置拦截器时调用nock.persist方法。

最后,您可以使用nock.isDone来检查所有端点,而不是为每个模拟端点编写断言以确保它收到了请求。如果nock.isDone返回false,则意味着一个或多个模拟路由没有被击中。

在您的测试文件中添加一个afterEach钩子,使用nock.isDone,如下所示,以确保在每次测试之后所有模拟的路由都被击中。

列表 4.62 server.test.js

describe("fetch inventory items", () => {
  // ...

  beforeEach(() => nock.cleanAll());

  afterEach(() => {                   ❶
    if (!nock.isDone()) {
      nock.cleanAll();
      throw new Error("Not all mocked endpoints received requests.");
    }
  });

  test("can fetch an item from the inventory", async () => { /* */ })
});

❶ 在每个测试之前,如果并非所有拦截器都已到达,则删除所有拦截器并抛出错误。清除未使用的拦截器将防止进一步的测试因旧拦截器被触发而失败。

您可以使用nock做更多的事情。例如,您可以使用正则表达式模拟端点,并使用函数来匹配请求的主体,甚至其头部。您可以在github.com/nock/nock找到其完整的文档。

注意:nock与几乎任何 HTTP 请求库都配合得很好,因为它覆盖了 Node.js 的http.requesthttp.ClientRequest函数,这些库在幕后使用这些函数。

在我们之前使用nock的代码片段中,如果您将isomorphic-fetch替换为request之类的包,例如,您的测试将继续工作。

通过移除处理网络请求的复杂性,您可以保证测试将在离线状态下工作,不需要身份验证令牌,并且不会消耗您宝贵的资源或达到第三方速率限制。

在这些示例中,我们使用了一个简单的 API 来演示如何模拟请求。现在,尝试使用更复杂的 API,如 Mailgun 或 Twilio,并在编写测试时使用本节中学到的技术。

摘要

  • 为了使应用程序可测试,你必须设计时考虑到测试。它需要由小的独立部分组成,这些部分可以单独暴露和测试。

  • 测试必须能够访问它们需要执行的功能和它们需要检查的输出。仅为了测试而暴露特定的代码块并不是问题。如果该输出是一个全局状态,你必须将其暴露给测试。如果它是一个数据库,测试必须能够访问它。

  • 我们在讨论测试金字塔时看到的相同原则也适用于后端应用程序。你应该将你的测试细分为端到端测试,这些测试向你的路由发送请求;集成测试,这些测试直接调用与软件的各个部分交互的功能;以及单元测试,这些测试涉及独立的功能。

  • 为了简化测试 HTTP 端点并避免冗余,你可以使用supertest,它捆绑了一个灵活的 API,用于执行请求并对其内容进行断言。使用supertest,你可以避免执行复杂和重复的断言,并且你不需要在 HTTP 请求库之上编写自己的包装器。

  • 当处理数据库时,你的测试管道必须确保它们将可用。通过使用全局设置和清理脚本,你可以保证它们的模式将是最新的,并且必要的种子数据将存在。

5 高级后端测试技术

本章涵盖

  • 消除非确定性

  • 运行后端测试的并发技术

  • 如何在保持质量的同时降低成本

即使是世界上最美味的芝士蛋糕,如果其利润率太小,对面包店来说也是不可行的。如果你每片蛋糕只能赚一美分,那么经营成功将是一项挑战。

此外,为了建立一个成功的面包店,你必须能够持续地烘焙出无瑕疵的食谱。否则,如果你的杏仁小圆饼批次有一半无法出售,你唯一的结果就是经济损失。

同样,如果你的测试成本过高,因为它们运行时间过长或维护难度过大,那么建立一家成功的软件公司将会很具挑战性。

然而,为了使这些快速且易于更新的测试变得有用,它们必须可靠且稳健。如果你不能信任你的测试,那么它们运行得多快或更新得多容易都没有关系。

在本章中,我将教你测试技术,帮助你使你的后端测试快速、可靠且易于更新,同时仍然保持你的错误检测机制的质量。

你将通过改进上一章中构建的测试来了解这些技术。当我演示如何应用这些技术时,我还会解释为什么它们很重要。

因为服务器可能依赖于你无法控制的因素,如异步性、时间或并行性以及共享资源,所以本章的第一部分重点介绍如何使你的测试具有确定性。在本节中,你将学习如何使你的测试能够在任何地方快速且可靠地运行,即使它们依赖于外部资源,如数据库或时间。

采用目标导向的方法,在第 5.2 节中,我将解释你如何在保持严格的质量控制的同时降低测试的成本。本节教你关于你将不得不做出的决策,即选择什么要测试以及如何测试,考虑到你的时间和资源。

5.1 消除非确定性

因为路易斯在完善每一道食谱上投入了大量的努力,所以他希望每个人都严格遵循这些食谱。他训练每位糕点师像飞机驾驶员一样一丝不苟。路易斯知道,如果每位厨师每次都遵循相同的步骤,从烤箱中出来的每一块蛋糕都会和之前的一样美味。

可重复的食谱对于他面包店的成功至关重要,就像可重复的测试对于项目成功至关重要一样。

可重复的测试被认为是确定性的

确定性测试 确定性测试是指在给定相同输入的情况下,总是产生相同结果的测试。

在本节中,我将讨论测试中可能出现的非确定性行为的来源,并探讨解决方法。

测试应该始终是确定的。否则,你很难判断是测试中存在问题还是应用中存在问题。它们会逐渐削弱你对测试套件的信心,并允许错误悄悄溜入。有些人,比如马丁·福勒,甚至会说非确定性的测试是无用的(martinfowler.com/articles/nonDeterminism.html)。

确定性测试可以增强你的信心,因此让你进步更快。对测试的信心使你能够一次更改更大的代码块,因为你相信它们只有在应用程序不工作时才会失败。

在上一章中,当我们讨论如何处理第三方 API 时,我们实际上是在使我们的测试确定。因为我们消除了对他人服务的依赖,我们完全控制了测试何时通过。为了使这些测试工作,你不会依赖于互联网连接或他人的服务可用并提供一致的响应。

但第三方 API 并不是非确定性的唯一来源。在并行运行测试或处理共享资源、时间依赖性代码和其他你无法控制的因素时,你经常会创建非确定性测试。特别是在编写后端应用程序时,你经常会不得不处理这些元素,因此你必须准备好编写覆盖它们的确定性测试。

通常,最好的解决方案是创建测试替身来使非确定性代码变得确定性。例如,当与物联网(IoT)设备交互时,你不想你的测试依赖于这些设备可用。相反,你应该使用 Jest 的模拟来模拟这些设备会产生的响应。对于处理随机性也是如此。如果你必须使用随机数生成器,比如Math.random,就模拟它,以便从源头消除随机性。

作为一个经验法则,你应该模拟你无法控制的一切

我们之前用来使测试确定的一种技术是确保它们都从相同的初始状态开始。

为了使测试确定,它们始终需要给出相同的初始状态。否则,即使正在操作该状态的单元测试是确定的,最终结果也会不同。

备注:通过状态,我指的是与单元测试相关的状态。例如,对一个应用 50%折扣的用户购物车函数,当给定的用户名不同时,不会生成不同的结果。在折扣函数的情况下,当提到给它的状态时,我指的是购物车的内容,而不是用户的名字。

模拟你无法控制的一切,并且始终提供具有相同初始状态的测试,这应该能让你走得很远,但一些特殊情况有更好的解决方案。

当处理依赖于时间的代码或可能相互干扰的并发测试时,你并不一定需要使用 Jest 的模拟。对于这些情况,我将详细介绍如何找到一个解决方案,它以更少的妥协带来更好的结果。

5.1.1 并行性和共享资源

同时在同一个烤箱里烤胡萝卜蛋糕、马卡龙和面包似乎不是一个好主意。因为每种糕点都需要在不同的温度下烤不同长度的时间,所以路易斯的厨师要么为不同的食谱使用不同的烤箱,要么一次只烤一种甜点。

同样,当你对相同的资源进行并发测试时,结果可能是灾难性的。

在上一节中,你必须通过使用 --runInBand 选项来按顺序运行你的测试,以避免不可靠性。并行化会导致这些测试变得不可靠,因为它们正在操作相同的数据库,因此会相互干扰。

让我们用一个更简单的例子来可视化这种情况是如何发生的,然后看看我们如何能够解决它。鉴于我们可以在较小的样本中准确复制这个问题,我们可以在处理更复杂的应用时应用相同的原理。

我们不创建一个针对数据库操作的后端,而是创建一个小的模块,它更新文件的内容。这两个模块都在多个并行测试共享的资源上工作。因此,我们可以将一个问题的解决方案适应到另一个问题中。

首先,编写一个小程序,我们将用它作为示例。创建一个模块,它改变包含计数的文件的内容。

列表 5.1 countModule.js

const fs = require("fs");
const filepath = "./state.txt";

const getState = () => parseInt(fs.readFileSync(filepath), 10);          ❶
const setState = n => fs.writeFileSync(filepath, n);                     ❷
const increment = () => fs.writeFileSync(filepath, getState() + 1);      ❸
const decrement = () => fs.writeFileSync(filepath, getState() - 1);      ❹

module.exports = { getState, setState, increment, decrement };

❶ 同步地从文件中读取计数,将原本是字符串的内容转换为数字

❷ 同步地将传递的参数写入文件

❸ 同步地增加文件的计数

❹ 同步地减少文件的计数

为了验证这个模块,编写两个测试:一个用于增加文件的计数,另一个用于减少它。将每个测试分别放入一个单独的文件中,以便它们可以并行运行。每个测试都应该重置计数,通过暴露的函数进行操作,并检查最终的计数。

列表 5.2 increment.test.js

const { getState, setState, increment } = require("./countModule");

test("incrementing the state 10 times", () => {
  setState(0);                                           ❶
  for (let i = 0; i < 10; i++) {                         ❷
    increment();
  }

  expect(getState()).toBe(10);                           ❸
});

❶ 安排:将文件的内容设置为 0

❷ 行动:调用 increment 函数 10 次

❸ 断言:期望文件的内容为 10

列表 5.3 decrement.test.js

const { getState, setState, decrement } = require("./countModule");

test("decrementing the state 10 times", () => {
  setState(0);                                      ❶
  for (let i = 0; i < 10; i++) {                    ❷
    decrement();
  }

  expect(getState()).toBe(-10);                     ❸
});

❶ 安排:将文件的 内容设置为 0

❷ 行动:调用 decrement 函数 10 次

❸ 断言:期望文件的内容为 -10

重复进行大约 10 或 20 次这些测试应该足以让你看到它们表现出不可靠的行为。最终,它们要么找到一个与预期不同的计数,要么在尝试同时读取和写入 state.txt 时遇到问题,这将导致模块将 NaN 写入文件。

提示:如果您使用的是 UNIX 系统,可以使用while npm test; do :; done来运行这些测试,直到 Jest 失败。

并行运行的测试,但共享相同的底层资源,如图 5.1 所示的测试,可能会发现每次运行时该资源处于不同的状态,因此失败。在先前的例子中,我们正在使用一个文件,但当我们看到测试共享相同的数据库时,同样的事情也可能发生。

图片

图 5.1 使用相同模块和相同底层资源的两个测试

解决这个问题的最明显的方法是按顺序运行测试,就像我们之前通过使用--runInBand选项所做的那样。这种解决方案的问题是它可能会使您的测试运行时间更长。大多数时候,对于许多项目来说,按顺序运行测试已经足够好了。但是,当处理庞大的测试套件时,它可能会对团队的速度产生不利影响,使开发过程更加繁琐。

我们不按顺序运行测试,而是探索一个不同的解决方案,这样我们仍然可以在它们共享资源的情况下并行运行它们。

我推荐的一种策略是对您的模块的不同实例运行测试,每个实例都在不同的资源上操作。

要实现这种方法,更新您的模块,以便您可以初始化多个countModule实例,并告诉它们使用哪个文件,如下所示。

列表 5.4 countModule.js

const fs = require("fs");

const init = filepath => {               ❶
  const getState = () => {
    return parseInt(fs.readFileSync(filepath, "utf-8"), 10);
  };
  const setState = n => fs.writeFileSync(filepath, n);
  const increment = () => fs.writeFileSync(filepath, getState() + 1);
  const decrement = () => fs.writeFileSync(filepath, getState() - 1);

  return { getState, setState, increment, decrement };
};

module.exports = { init };

❶ 返回一个具有相同的 setState、increment 和 decrement 文件的对象,但与始终写入同一文件不同,这些函数将写入作为参数传递的路径

然后,创建一个名为instancePool.js的文件,该文件能够根据 Jest 为每个工作者生成的id提供模块的不同实例。

列表 5.5 instancePool.js

const { init } = require("./countModule");

const instancePool = {};

const getInstance = workerId => {                                          ❶
  if (!instancePool[workerId]) {
    instancePool[workerId] = init(`/tmp/test_state_${workerId}.txt`);      ❷
  }

  return instancePool[workerId];                                           ❸
};

module.exports = { getInstance };

❶ 给定一个工作者的 ID,返回一个仅写入该特定工作者使用的文件的 countModule 实例

❷ 如果该实例尚不存在,则为传递的工作者创建一个新的 countModule 实例

❸ 返回一个仅由传递的工作者使用的 countModule 实例

现在,在您的每个测试文件中,您都可以获得一个仅对该工作者专用的模块实例。

使用process.env.JEST_WORKER_ID来获取工作者的唯一 ID,并将其传递给getInstance,以便它可以给每个工作者提供不同的资源。

列表 5.6 increment.test.js 和.decrement.test.js

const pool = require("./instancePool");
const instance = pool.getInstance(process.env.JEST_WORKER_ID);         ❶
const { setState, getState, increment } = instance;

// The code for each test file goes here

❶ 使每个测试文件获得一个仅由执行它的工作者使用的 countModule 实例

在这些更改之后,每个测试都将使用countModule的不同实例(图 5.2),因此您可以安全地并行运行尽可能多的测试。

图片

图 5.2 每个测试通过getInstance获取模块的不同实例,并检查不同的底层资源。

为了使示例简短,我使用了一个写入文件的模块,但对于使用数据库的应用程序测试,同样的原则适用。如果你想要为这类应用程序并行运行测试,你可以在不同的端口上运行应用程序的不同实例,每个实例连接到不同的数据库。然后你可以让工作进程向不同的端口发送请求,以与应用程序的不同实例进行交互。

5.1.2 处理时间

对于路易斯来说,不幸的是,许多将商品添加到购物车的顾客从未回来结账。因为商品在添加到某人的购物车时就会从库存中移除,面包店的网站经常列出商品为不可用,而实际上它们正坐在面包店的货架上等待饥饿的顾客。

在路易斯的面包店中,每一块蛋糕的最终目的地应该是顾客的手中,而不是垃圾桶。

为了解决这个问题,路易斯想要删除那些在用户购物车中超过一小时的商品,并将它们返回到库存中。

这个功能将需要一个新列来指示客户上次将商品添加到购物车的时间。为了创建这个列,通过运行 ./node_ modules/.bin/knex migrate:make --env development updatedAt_field 添加一个新的迁移。然后,在 migrations 文件夹中应该创建的迁移文件中,向 carts_items 表添加一个 updatedAt 列。

列表 5.7 DATESTRING_updatedAt_field.js

exports.up = knex => {                     ❶
  return knex.schema.alterTable(
    "carts_items",
    table => {
      table.timestamp("updatedAt");
    });
};

exports.down = knex => {                   ❷
  return knex.schema.alterTable(
    "carts_items",
    table => {
      table.dropColumn("updatedAt");
    });
};

❶ 导出的 up 函数将数据库迁移到下一个状态,在 carts_items 表中添加 updatedAt 列。

❷ 导出的 down 函数将数据库迁移到之前的状态,从 carts_items 表中删除 updatedAt 列。

现在,更新 cartController 以便在向购物车添加商品时更新 updatedAt 字段。

列表 5.8 cartController.js

const addItemToCart = async (username, itemName) => {
  // ...

  if (itemEntry) {
    await db("carts_items")                               ❶
      .increment("quantity")
      .update({ updatedAt: new Date().toISOString() })
      .where({
        userId: itemEntry.userId,
        itemName
      });
  } else {
    await db("carts_items").insert({
      userId: user.id,
      itemName,
      quantity: 1,
      updatedAt: new Date().toISOString()
    });
  }

  // ...
};

// ...

❶ 当向购物车添加商品时,如果购物车中已经存在该商品的条目,则使用当前时间更新其 updatedAt

最后,创建一个删除四小时前添加的商品的函数,并使用 setInterval 来安排每两小时运行一次。

列表 5.9 cartController.js

// ...

const hoursInMs = n => 1000 * 60 * 60 * n;

const removeStaleItems = async () => {
  const fourHoursAgo = new Date(                                           ❶
    Date.now() - hoursInMs(4)
  ).toISOString();

  const staleItems = await db                                              ❷
    .select()
    .from("carts_items")
    .where("updatedAt", "<", fourHoursAgo);

  if (staleItems.length === 0) return;

  const inventoryUpdates = staleItems.map(staleItem =>                     ❸
    db("inventory")
      .increment("quantity", staleItem.quantity)
      .where({ itemName: staleItem.itemName })
  );
  await Promise.all(inventoryUpdates);

  const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]);
  await db("carts_items")                                                  ❹
    .del()
    .whereIn(["itemName", "userId"], staleItemTuples);
};

const monitorStaleItems = () => setInterval(                               ❺
  removeStaleItems,
  hoursInMs(2)
);

module.exports = { addItemToCart, monitorStaleItems };

❶ 创建一个比当前时间早四小时的日期

❷ 查找所有四小时前已更新的购物车商品

❸ 将过期的商品放回库存中

❹ 从购物车中删除过期的商品

❺ 当被调用时,安排每两小时运行一次 removeStaleItems 函数

一旦调用 monitorStaleItems,每两小时它将删除那些四小时前添加到购物车的商品,并将它们放回库存中。

继续为 monitorStaleItems 添加一个简单的测试。

  1. 在库存中插入一个商品。

  2. 将该商品添加到购物车中。

  3. 等待商品足够陈旧。

  4. 启动 monitorStaleItems

  5. 等待 monitorStaleItems 运行。

  6. 检查 monitorStaleItems 是否将旧商品重新添加到库存中。

  7. 检查 monitorStaleItems 是否已从购物车中删除了商品。

  8. 停止 monitorStaleItems 以防止测试挂起。

您的测试应该看起来像这样。

列表 5.10 cartController.test.js

const { addItemToCart, monitorStaleItems } = require("./cartController");

// ...

describe("timers", () => {
  const waitMs = ms => {                                           ❶
    return new Promise(resolve => setTimeout(resolve, ms));
  };
  const hoursInMs = n => 1000 * 60 * 60 * n;

  let timer;
  afterEach(() => {                                                ❷
    if (timer) clearTimeout(timer);
  });

  test("removing stale items", async () => {                       ❸
    await db("inventory").insert({                                 ❸
      itemName: "cheesecake",
      quantity: 1
    });

    await addItemToCart(                                           ❹
      globalUser.username,
      "cheesecake"
    );

    await waitMs(hoursInMs(4));                                    ❺

    timer = monitorStaleItems();                                   ❻

    await waitMs(hoursInMs(2));                                    ❼

    const finalCartContent = await db
      .select()
      .from("carts_items")
      .join("users", "users.id", "carts_items.userId")
      .where("users.username", globalUser.username);
    expect(finalCartContent).toEqual([]);                          ❽

    const inventoryContent = await db
      .select("itemName", "quantity")
      .from("inventory");
    expect(inventoryContent).toEqual([                             ❾
      { itemName: "cheesecake", quantity: 1 }
    ]);
  });
});

❶ 返回一个在传递的毫秒数后解决的承诺

❷ 每次测试后,如果存在计时器,则取消它

❸ 安排:将一个芝士蛋糕插入库存中

❹ 安排:向测试用户的购物车添加一个项目

❺ 等待四小时,以便项目变得足够旧

❻ 行动:调用 monitorStaleItems 以安排清除过时项目的函数

❻ 等待两小时,以便计时器运行

❽ 断言:期望购物车为空

❾ 断言:期望之前在购物车中的芝士蛋糕已被放回库存中

这个测试的问题在于它至少需要六小时才能完成:四小时用于项目变得足够旧,两小时用于计时器运行。因为 Jest 默认的最大超时时间是五秒,所以这个测试甚至不会终止。

即使您增加了测试的超时时间,一旦测试完成,测试仍然可能失败。可能是数据库花费了额外的几毫秒来删除过时的项目,因此,当测试运行其验证时,这些项目仍然存在。在这种情况下,非确定性会使您的测试变得不可靠并且缓慢。

时间,就像第三方 API 一样,是一个您无法控制的因素。因此,您应该用确定性替代方案来替换它。

现在,你可能正在考虑使用 Jest 的模拟,就像我们之前做的那样,但创建自己的模拟不会是一件容易的工作。

即使您模拟了 setInterval,这仍然不足以解决问题。因为您的测试使用 setTimeout,而 addItemToCart 函数使用 Date,您也必须模拟这些。

然后,在模拟了所有这些不同的函数之后,您仍然需要确保它们保持同步。否则,您可能会遇到例如,在推进时钟时执行 setTimeout,但 Date 仍然在过去的情形。

为了持续模拟所有与时间相关的函数,我们将使用 sinonjs/fake-timerssinonjs/fake-timers 包允许您模拟所有与时间相关的函数并保持它们同步使用模拟计时器,您可以向前推进时间或运行任何挂起的计时器

备注:在撰写本文时,Jest 有两个模拟计时器的实现:legacy,这是默认的,和 modern,它使用 sinonjs/fake-timers 包(以前称为 lolex)作为模拟计时器的底层实现。

我在这本书中直接使用 sinonjs/fake-timers,因为它允许我配置我使用的模拟计时器。如果您通过 Jest 使用此包,您不能向要安装的模拟计时器传递选项。

通过直接使用此包,我对计时器有了更多的控制。例如,我可以确定要模拟哪些计时器函数。如果您认为此功能对您不是必需的,您可以使用 Jest 的 useFakeTimers 方法代替,并避免安装此额外包。

sinonjs/fake-timers 作为开发依赖项安装,这样你就可以开始为 monitorStaleItems 创建确定性测试。

一旦安装,将 sinonjs/fake-timers 导入到你的测试文件中,并使用它的 install 方法。此方法将全局替换真实的时间相关函数为模拟函数,并返回一个 clock 对象,你可以用它来控制时间。

列表 5.11 cartController.test.js

const FakeTimers = require("@sinonjs/fake-timers");

describe("timers", () => {
  const waitMs = ms => new Promise(resolve => setTimeout(resolve, ms));
  const hoursInMs = n => 1000 * 60 * 60 * n;

  let clock;
  beforeEach(() => {
    clock = FakeTimers.install();                                          ❶
  });

  // Don't forget to restore the original timer methods after tests!
  afterEach(() => {
    clock = clock.uninstall();                                             ❷
  });

  test("removing stale items", async () => {
    // ...
  });
});

❶ 在每个测试之前,用模拟计时器替换原始计时器方法,你可以控制这些模拟计时器

❷ 在每个测试之后,恢复原始计时器方法

现在,你不再需要等待时间流逝,而是可以通过调用其 tick 方法来将时钟向前调整。tick 方法接受你想要时钟前进的毫秒数,如下所示。

列表 5.12 cartController.test.js

describe("timers", () => {
  const hoursInMs = n => 1000 * 60 * 60 * n;

  let clock;
  beforeEach(() => {
    clock = FakeTimers.install();
  });

  afterEach(() => {
    clock = clock.uninstall();
  });

  test("removing stale items", async () => {
    await db("inventory").insert({ itemName: "cheesecake", quantity: 1 });
    await addItemToCart(globalUser.username, "cheesecake");

    clock.tick(hoursInMs(4));                ❶

    timer = monitorStaleItems();             ❷

    clock.tick(hoursInMs(2));                ❸

    const finalCartContent = await db
      .select()
      .from("carts_items")
      .join("users", "users.id", "carts_items.userId")
      .where("users.username", globalUser.username);
    expect(finalCartContent).toEqual([]);

    const inventoryContent = await db
      .select("itemName", "quantity")
      .from("inventory");
    expect(inventoryContent).toEqual([{ itemName: "cheesecake", quantity: 1 }]);
  });
});

❶ 将时钟向前调整四小时,使项目变得足够旧

❷ 调用 monitorStaleItems 来安排清除过时项目的函数

❸ 将时钟向前调整两小时以触发计划中的 removeStaleItems 函数,从而从购物车中删除过时项目

即使你已经模拟了所有必要的计时器,之前的测试仍然可能失败。尽管调用了计划中的 removeStaleItems,但有时它运行不够快,无法在测试检查内容之前更新数据库。

当你没有等待承诺的情况时,例如在这个案例中,数据库更新发生在计时器内部,我建议你重试你的断言。

与等待固定时间相比,重试提供了两个优势:速度和健壮性,如下所述。

  • 重试使测试更快,因为它们允许测试在满足特定条件后立即进行,而不是等待固定的时间窗口。

  • 重试使测试更加健壮,因为它们保证了无论操作持续多长时间,只要不超过测试的总超时时间,测试仍然会工作。

要实现重试机制,将断言和获取断言主题的代码包装到一个函数中。然后,每当断言抛出错误(失败)时,让该函数调用自己。

具有重试功能的测试看起来是这样的。

列表 5.13 cartController.test.js

describe("timers", () => {
  // ...

  test("removing stale items", async () => {
    await db("inventory").insert({ itemName: "cheesecake", quantity: 1 });
    await addItemToCart(globalUser.username, "cheesecake");

    clock.tick(hoursInMs(4));
    timer = monitorStaleItems();
    clock.tick(hoursInMs(2));

    const checkFinalCartContent = async () => {            ❶
      const finalCartContent = await db
        .select()
        .from("carts_items")
        .join("users", "users.id", "carts_items.userId")
        .where("users.username", globalUser.username);

        try {                                              ❷
          expect(finalCartContent).toEqual([]);
        } catch (e) {
          await checkFinalCartContent();
        }
    };
    await checkFinalCartContent()

    const checkInventoryContent = async () => {            ❸
      const inventoryContent = await db
        .select("itemName", "quantity")
        .from("inventory");

      try {                                                ❹
        expect(inventoryContent)
          .toEqual([{ itemName: "cheesecake", quantity: 1 }]);
      } catch (e) {
        await checkInventoryContent()
      }
    };
    await checkInventoryContent();
  });
});

❶ 不断检查最终购物车的内容,直到断言通过

❷ 断言购物车为空;如果不为空,则重新运行包含此断言的函数

❸ 不断检查库存的内容,直到断言通过

❹ 断言芝士蛋糕已放回库存中;如果没有,则重新运行包含此断言的函数

为了让它看起来更美观,你可以将重试行为提取到单独的函数中。你可以创建一个 withRetries 函数,它接受应该重试的函数,并在它抛出 JestAssertionError 时不断重新运行它。

列表 5.14 cartController.test.js

const withRetries = async fn => {
  const JestAssertionError = (() => {             ❶
    try {
      expect(false).toBe(true);
    } catch (e) {
      return e.constructor;
    }
  })();

  try {                                           ❷
    await fn();
  } catch (e) {
    if (e.constructor === JestAssertionError) {
      await withRetries(fn);
    } else {
      throw e;
    }
  }
};

❶ 捕获断言错误,Jest 没有导出这个错误

❷ 重复执行传递的函数,直到它不再抛出断言错误

然后,将你想要重试的函数传递给 withRetries

列表 5.15 cartController.test.js

describe("timers", () => {
  // ...

  test("removing stale items", async () => {
    await db("inventory").insert({ itemName: "cheesecake", quantity: 1 });
    await addItemToCart(globalUser.username, "cheesecake");

    clock.tick(hoursInMs(4));
    timer = monitorStaleItems();
    clock.tick(hoursInMs(2));

    await withRetries(async () => {                             ❶
      const finalCartContent = await db
        .select()
        .from("carts_items")
        .join("users", "users.id", "carts_items.userId")
        .where("users.username", globalUser.username);

      expect(finalCartContent).toEqual([]);
    });

    await withRetries(async () => {                             ❷
      const inventoryContent = await db
        .select("itemName", "quantity")
        .from("inventory");

      expect(inventoryContent).toEqual([
        { itemName: "cheesecake", quantity: 1 }
      ]);
    });
  });
});

❶ 持续检查购物车的最终内容,直到它为空

❷ 持续检查库存内容,直到芝士蛋糕放回其中

现在,如果你想在每次重试前添加几毫秒的间隔,因为计时器被模拟了,使用 setTimeout 来等待承诺解决就不会起作用。

为了避免模拟 setTimeout 函数,你可以在调用模拟计时器的 install 方法时指定你想要模拟的计时器,如下所示。

列表 5.16 cartController.test.js

describe("timers", () => {
  // ...

  let clock;
  beforeEach(() => {
    clock = FakeTimers.install({              ❶
      toFake: ["Date", "setInterval"]
    });
  });

  // ...
});

❶ 仅使用模拟计时器来处理 Date 和 setInterval 函数

通过指定你只想模拟 DatesetInterval,你可以在其他任何地方使用真实的 setTimeout,包括在你的 withRetries 函数内部。

列表 5.17 cartController.test.js

// ...

const withRetries = async fn => {
  const JestAssertionError = (() => {
    try {
      expect(false).toBe(true);
    } catch (e) {
      return e.constructor;
    }
  })();

  try {
    await fn();
  } catch (e) {
    if (e.constructor === JestAssertionError) {
      await new Promise(resolve => {                ❶
        return setTimeout(resolve, 100)
      });
      await withRetries(fn);
    } else {
      throw e;
    }
  }
};

// ...

❶ 在重试前等待 100 毫秒

你刚刚构建的测试现在尽可能的确定。

由于时间不再在你的控制之下,你用模拟计时器替换了它,你可以随意处理。通过推进时间,你可以触发计划好的计时器并更新由 Date 生成的值。

使用这些模拟计时器,你不再需要让你的测试等待。你可以完全控制发生的事情以及何时发生。为了避免使用计划操作使测试失败,因为它们运行时间较长,你还添加了一个 retry 机制,这样你就可以多次重新运行断言,直到测试本身超时。

警告 使用这些重试机制会使测试失败的时间变长。你应该仅在绝对必要时重试断言。否则,它们可能会显著影响你的测试套件运行的时间。

正如你在第九章中将要看到的,我们将讨论测试驱动开发,拥有可以快速运行的测试对于高效迭代至关重要。

如果你感到好奇,可以尝试编写一个依赖于 setIntervalsetTimeout 的新功能并进行测试。例如,你可以尝试定期向所有用户发送电子邮件。

提示 @sinonjs/fake-timers 包除了使用 tick 来推进时间之外,还可以以许多其他有用的方式操作计时器。例如,你可以使用 runNext 来仅运行下一个计划好的计时器,或者使用 runAll 来运行所有挂起的计时器。有关 @sinonjs/fake-timers 的完整文档,请访问 github.com/sinonjs/fake-timers

5.2 在保持质量的同时降低成本

快速制作一千个糟糕的蛋糕和花一整天时间制作一个完美的蛋糕一样糟糕。路易斯的面包店的成功不仅取决于他的甜点有多美味,还取决于他能够生产多少产品来应对天文数字的需求。为了在短时间内制作美味的甜点,他必须弄清楚哪些过程的部分需要做得更仔细,哪些可以稍微宽松一些。

编写后台应用程序可能不如烘焙蛋糕那样令人愉悦,但说到快速交付工作的软件,降低成本和保持质量仍然至关重要。

在本节中,我们将探讨哪些细节提供了最大的好处,并将它们应用于我们已学到的几个概念在后台应用程序的上下文中。

我将本节分为以下三个最相关的技术,以尽可能少的成本保持和获得可靠的质量保证:

  • 减少测试之间的重叠

  • 创建传递性保证

  • 将断言转换为前置条件

我将详细介绍每种技术,并讨论如何根据你拥有的时间和资源,将这些技术适应到你的项目环境中。

5.2.1 减少测试之间的重叠

当测试运行相同的代码片段并重复彼此的验证时,测试会重叠。例如,图 5.3 中的测试触及了你的应用程序的许多部分,因此它可能与许多其他测试重叠。尽管你的测试可能从不同的角度与你的应用程序交互,但通过消除这种重叠,你通常可以减少你必须维护的代码量。删除重叠的棘手之处在于,在删除测试的同时保持高质量。更多的测试并不一定意味着更高的质量,但它们确实会增加你必须维护的代码量。

图片

图 5.3 发送请求到该路由的端到端测试将导致addItemToCartcompliesToItemLimitremoveFromInventorylog等函数被执行。

为了决定为特定功能编写哪些测试,考虑你运行每个测试时应用程序的哪些部分被执行。例如,让我们看看向添加到购物车路由发送请求时会发生什么。

TIP 将其视为一个依赖树,其中最顶层的节点是包含所有路由的server.js文件,这有助于可视化。

现在,将之前端到端测试的范围与图 5.4 所示的集成测试的范围进行比较。

由于addItemToCart函数在其依赖树下的节点较少,因此它执行的代码比之前的端到端测试少。因此,尽管其焦点不同,但其范围较小。

集成测试仅覆盖控制器内的业务逻辑,而端到端测试覆盖从中间件到路由规范本身的一切。尽管两者都能检测业务逻辑是否有错误,但只有端到端测试可以确保应用程序符合 HTTP 端点的设计。

图 5.4 对addItemToCart的集成测试也将导致compliesToItemLimitremoveFromInventorylog被调用。

最后,让我们比较这两个测试与直接调用compliesToItemLimit的单元测试,如图 5.5 所示。

图 5.5 对compliesToItemLimit的单元测试仅覆盖该函数。

前面的测试不仅可以捕获compliesToItemLimit中的错误,还可以捕获其上方的其他依赖项中的错误。因为compliesToItemLimit不依赖于任何其他软件组件,所以测试它执行非常少的代码行。

考虑到你预算紧张和截止日期紧迫,你如何以尽可能少的测试来最大化编写测试的好处?

要回答这个问题,考虑一下每次测试可以覆盖你代码的多少。假设你希望尽可能多地以尽可能少的努力断言,你应该选择运行你应用程序最大部分但代码最少的测试。在这种情况下,那将是端到端测试。

备注:通常,端到端测试是编写和运行时间最长的测试。在大多数情况下,你会选择实现集成测试,考虑到这将容易得多。但是,由于测试 Node.js 后端非常快速且相对直接,通常更好的选择是进行端到端测试。

只编写一个测试的问题在于,通过省略其他测试,你可能会错过关键的断言。

例如,假设你的端到端测试只断言应用程序的响应,而你的addItemToCart集成测试也断言数据库的内容。如果你简单地选择不编写集成测试,例如,你可能不知道你的应用程序在数据库中插入了错误的项目,即使它返回了有效的响应。

为了编写更少的代码但保持相同的保证,你应该将这些断言移动到你的端到端测试中,该测试也覆盖addItemToCart。在这种情况下,你仍然运行相同的验证,但将它们集中在以最高节点为入口点的测试中,而不是像图 5.6 所示的那样分散在多个测试中。

图 5.6 当减少测试代码量时,将断言集中在覆盖最高节点的测试中。

这种方法的缺点是,有时你将无法覆盖某些边缘情况。例如,你不能将null值传递给addItemToCart,因为路由参数始终是字符串。因此,addItemToCart在传递null值时可能会意外失败。另一方面,也有人可能会争辩说,如果这个函数从未传递null值,那么测试这个情况就没有价值。

通过编写具有更多断言的更粗粒度的测试,你选择通过消除重复来降低成本,以换取更不精确的反馈。

你拥有的时间和资源越多,你就可以将断言分配到更细粒度的测试中,通过不同的端点来锻炼你的应用程序,并生成更精确的反馈。记住,当涉及到测试时,没有一种适合所有情况的解决方案。它总是“取决于”。

5.2.2 创建传递性保证

在第三章中引入的传递性保证的概念在测试后端应用程序时特别有用。

每当你对应用程序的某个特定方面进行断言的成本很高时,传递性保证可以帮助你减少获取相同可靠性所需的代码量。

例如,考虑cartController中可能函数的测试。假设这些函数在你添加或从卡片中删除项目时都会进行日志记录,无论这些操作是否成功。因为日志记录发生得如此频繁,你必须编写大量的重复断言来覆盖它,如图 5.7 所示。

图 5.7

图 5.7 在每个依赖于日志记录器的测试中,你将重复相同的代码来读取日志文件并执行类似的断言。

重复断言你的日志记录器的行为是耗时的,因此成本高昂。每次你必须断言函数的日志方面时,你必须导入fs模块,打开文件,并断言其内容。在测试之间,你还必须记得清除日志文件。如果日志记录器将额外的元数据,如时间戳,添加到日志内容中,你必须在断言时考虑它。你可能必须忽略时间戳或模拟其来源,这将给测试增加额外的复杂性。

为了减少重复并确保你的应用程序正确地进行日志记录,你可以创建一个传递性保证。首先,你将为log函数编写一个单独的测试,确保记录器写入正确的文件并添加所有必要的元数据。然后,在测试每个需要logger的函数时,你不必重复那些昂贵的检查,只需验证log是否以正确的参数被调用,因为你已经知道它工作正常。

这样做就像是在说,“我已经知道我的日志记录器工作正常,因为我已经测试过它了。现在,我只想确保它使用正确的参数被调用。”这种做法如图 5.8 所示。

图 5.8 购物车功能的测试依赖于日志记录器测试创建的保证。

由于后端通常处理许多与应用程序正交的依赖项,如日志记录,昂贵的断言非常频繁。每当这种情况发生时,封装那些依赖项的测试,并仅检查它们是否被适当地调用。

TIP 相比于编写执行断言的函数,使用传递性保证来封装它们。

5.2.3 将断言转换为先决条件

您可以通过将断言转换为先决条件来减少您需要编写的测试数量。您不需要为特定的行为编写单独的测试,而是依赖于该行为使其他测试通过。

例如,考虑一下您为应用程序构建的认证中间件。我们不需要为每个路由编写特定的测试来检查它是否允许经过身份验证的请求通过。相反,我们通过发送经过身份验证的请求来测试路由的其他方面。如果认证中间件不允许经过身份验证的请求通过,那么其他测试将失败。

由于那些其他测试已经依赖于认证,这就像您已经将断言嵌入到您如何执行应用程序的方式中,如图 5.9 所示。通过以这种方式编写测试,对于您拥有的每个路由,您可以节省一个额外的测试。

图 5.9 通过依赖于其他测试中发生的行为,您可以避免为它编写特定的测试。

另一方面,您无法避免的认证中间件测试是确保您的应用程序拒绝未经身份验证的请求的测试。因为测试路由内的其他功能需要您传递有效的身份验证头,所以您不能将此验证嵌入到其他测试中。

在您的测试中嵌入断言的另一种常见方式是通过改变创建模拟的方式。

例如,当我们使用 nock 来模拟端点时,我们实际上是在那些模拟中构建了断言。我们使那些模拟仅对正确的请求做出响应,并在它们未被调用时失败。

以我们为获取库存项的路由编写的测试为例,该测试依赖于第三方 API。多亏了 nock,该测试只有在请求以正确的参数发送到正确的 URL 时才会通过。因此,您不需要断言 fetch 函数的使用方式。

每当您使模拟仅对期望的参数做出响应时,您就不需要断言该模拟是如何被调用的。

如果您不处理端点,另一种选择是使用 jest-when 来实现相同的目标。

在这种情况下,你又一次是在以更少的重复为代价换取更细粒度的反馈。当这些测试失败时,找出错误的根本原因将需要更长的时间,但你也会在编写测试上花费更少的资源。

摘要

  • 测试必须始终从相同的初始状态开始。否则,它们可能会变得不可靠。在每个测试之间,不要忘记清理使用的每个资源。为了定义将执行此定期清理的钩子,你可以使用 Jest 的setupFilesAfterEnv选项。

  • 当你依赖于任何不在你控制范围内的事物,如时间或第三方 API 时,你应该模拟它。否则,你的测试将不会是确定性的,因此会削弱你对测试套件的信心,并且可能变得难以调试。

  • 要模拟第三方 API,你可以使用nock。这将使你能够指定端点的响应,而不是必须模拟 HTTP 库。因为nock可以与任何你正在使用的请求库一起工作,这将使测试更加解耦,更容易阅读、编写和更新。

  • 你的测试不应该依赖于时间。无论何时你依赖于日期或计时器被触发,请使用模拟计时器而不是等待固定的时间。模拟计时器使你能够触发任何计时器并向前移动时钟,使测试更快、更健壮和确定性更高。

  • 如果你有的测试共享资源,当它们并行运行时可能会相互干扰。为了隔离你的测试,你可以针对你的应用程序的不同实例运行它们,每个实例使用单独的资源。

  • 通过编写执行你应用程序更大部分的测试并将断言向上移动,你可以减少需要编写的测试数量,但仍然可以执行相同的检查。即使你的反馈将不那么细粒度,这种策略可以帮助你在保持测试全面性的同时减少成本。

  • 为了避免重复的断言,你可以创建传递性保证。而不是在每个测试中重新测试依赖项,你可以确保该依赖项独立工作,然后只检查它是否被正确使用。这种技术在编写后端应用程序时特别有用,因为后端应用程序往往具有更多的正交依赖项。

  • 你并不总是需要一个单独的测试来检查特定的行为。如果其他测试依赖于该行为通过,那么你就可以认为它已经被覆盖了。

6 测试前端应用程序

本章涵盖

  • 在测试中复制浏览器的 JavaScript 环境

  • 对 DOM 元素进行断言

  • 处理和测试事件

  • 编写涉及浏览器 API 的测试

  • 处理 HTTP 请求和 WebSocket 连接

尝试编写一个不使用 JavaScript 的客户端应用程序,就像在没有碳水化合物的情况下烘焙甜点一样困难。JavaScript 是为了征服网络而生的,在浏览器中,它闪耀着光芒。

在本章中,我们将介绍测试前端应用程序的基础知识。我们将一起构建一个用于第四章中编写后端的小型网络客户端,并学习如何测试它。

在构建和测试此应用程序的过程中,我将解释在浏览器中运行 JavaScript 的特殊性,由于 Jest不能在浏览器中运行,我将教您如何在 Node.js 中模拟该环境。如果不能在 Jest 中模拟浏览器环境,您就无法使用它来测试您的前端应用程序。

在测试前端应用程序时,断言可能会变得更加困难,因为现在您不仅要处理函数的返回值,还要处理它与 DOM 的交互。因此,您将学习如何在测试中找到元素并对它们的内 容进行断言。

用户与前端应用程序的交互方式也与他们与后端应用程序的交互方式显著不同。服务器通过例如 HTTP、CoAP 或 MQTT 接收输入,而网络客户端必须处理用户的滚动、点击、输入和拖动,这些更难准确模拟。

为了了解您如何处理这些复杂的交互,我将解释事件是如何工作的,以及您如何像浏览器一样触发它们。适当地模拟用户行为对于使您的测试尽可能接近运行时发生的情况至关重要。这种相似性将使您能够从测试中获得尽可能多的价值,增加在达到生产之前可以捕获的错误数量。

假设,例如,你有一个输入字段,其内容在用户输入每个字符时都会被验证。如果在你的测试中你一次性更改输入的内容,你将不会触发在运行时发生的多次验证。因为你的测试会模拟一个与生产环境中发生的情况不同的场景,所以你的测试将不可靠。例如,你不会捕获到仅在用户输入时发生的错误。

除了能够处理复杂的交互外,浏览器还提供了许多您可以使用来存储数据或操作导航历史记录等功能的令人兴奋的 API。即使您不需要测试这些 API 本身,验证您的代码是否充分与它们接口也是至关重要的。否则,您的应用程序可能无法按预期工作。

通过使用历史和 Web 存储 API,你将了解如何测试涉及浏览器 API 的功能。你将学习你应该测试什么,不应该测试什么,最重要的是,如何进行测试。

最后,在本章的结尾,你将了解到如何通过 HTTP 请求或 WebSocket 连接与第三方进行交互,这两种方式是网络上收集数据最常见的方法之一。就像你在测试后端应用程序时所做的那样,你将学习如何可靠地处理这些交互,同时不产生维护开销。

本章的主要目标是教你测试任何前端应用程序所需的基础。因为你将学习每个工具的作用以及它们在幕后是如何工作的,所以无论你是在测试用 Vue.js 编写的应用程序还是 React 编写的应用程序,这些概念都将是有用的。

对如何测试“原始”前端应用程序有扎实的理解,将使你更容易测试你未来可能使用的任何其他库或框架。

你将通过为路易斯的员工构建一个前端应用程序来管理他们的库存来学习这些概念。一开始,你只允许用户将奶酪蛋糕添加到库存中,并学习如何在 Node.js 中运行你的应用程序代码,以便你可以使用 Jest 进行测试。

随着你对这些章节的深入,你将添加新的功能,并学习如何测试它们以及使用哪些工具。例如,你将允许路易斯的员工添加他们想要的任何数量的任何甜点,并验证他们的输入以防止他们犯错。然后,如果他们确实犯了错误,你将使他们能够通过一个撤销按钮来恢复,这个按钮与浏览器的历史 API 进行交互。

最后,在本章的最后部分,你将使应用程序能够自动更新,而无需用户刷新页面。当操作员添加项目时,任何员工都将能够实时看到厨师正在消耗哪些原料,以及顾客正在购买哪些甜点。

通过测试这些功能,这些功能涵盖了编写前端应用程序所涉及的不同方面,你将能够测试路易斯未来可能要求你实现的所有功能。

希望员工们对你的软件工作得有多好,就像顾客对糕点店的奶酪蛋糕味道有多满意一样。

6.1 介绍 JSDOM

在专业厨房烘焙与在家烘焙大不相同。在家烘焙时,你不会总是拥有厨师架子上所有独特的原料。你可能不会有同样复杂的设备或同样完美的厨房。尽管如此,这并不意味着你不能烘焙出美味的甜点。你只需要适应。

类似地,在浏览器中运行 JavaScript 与在 Node.js 中运行 JavaScript 有显著不同。根据情况,在浏览器中运行的 JavaScript 代码根本不能在 Node.js 中运行,反之亦然。因此,为了测试你的前端应用程序,你可能需要走一些额外的步骤,但这并不意味着你不能做到。通过一些调整,你可以使用 Node.js 以与路易在面包店使用他拥有的花哨的法式厨具在家烘焙令人垂涎的奶酪蛋糕相同的方式运行为浏览器编写的 JavaScript。

在本节中,你将学习如何使用 Node.js 和 Jest 来测试为在浏览器中运行而编写的代码。

在浏览器中,JavaScript 可以访问不同的 API,因此具有不同的功能。

在浏览器中,JavaScript 可以访问一个名为window的全局变量。通过window对象,你可以更改页面的内容,在用户的浏览器中触发操作,并对事件做出反应,如点击和按键。

通过window,例如,你可以将监听器附加到按钮上,以便每次用户点击它时,你的应用程序都会更新面包店库存中商品的数量。

尝试创建一个执行此操作的应用程序。编写一个包含按钮和计数的 HTML 文件,并加载一个名为main.js的脚本,如下所示。

列表 6.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Inventory Manager</title>
</head>
<body>
    <h1>Cheesecakes: <span id="count">0</span></h1>
    <button id="increment-button">Add cheesecake</button>
    <script src="main.js"></script>                         ❶
</body>
</html>

❶ 我们将用来使页面交互的脚本

main.js中,通过其 ID 找到按钮,并将其监听器附加到它上。每次用户点击此按钮时,监听器都会被触发,应用程序将增加奶酪蛋糕的数量。

列表 6.2 main.js

let data = { count: 0 };

const incrementCount = () => {                                             ❶
  data.cheesecakes++;
  window.document.getElementById("count")
    .innerHTML = data.cheesecakes;
};

const incrementButton = window.document.getElementById("increment-button");
incrementButton.addEventListener("click", incrementCount);                 ❷

❶ 更新应用程序状态的函数

❷ 当按钮被点击时,将调用 incrementCount 的事件监听器

要查看此页面的实际效果,请在与你的index.html相同的文件夹中执行npx http-server ./,然后访问localhost:8080

因为这个脚本在浏览器中运行,所以它能够访问window,因此它可以操作浏览器和页面中的元素,如图 6.1 所示。

与浏览器不同,Node.js 不能运行该脚本。如果你尝试使用node main.js执行它,Node.js 会立即告诉你它发现了一个ReferenceError,因为"window is not defined"。

图 6.1 浏览器中的 JavaScript 环境

那个错误发生是因为 Node.js 没有window。相反,因为它被设计为运行不同类型的应用程序,所以它为你提供了访问process等 API 的权限,其中包含有关当前 Node.js 进程的信息,以及require,它允许你导入不同的 JavaScript 文件。

目前,如果你要为incrementCount函数编写测试,你必须运行它们在浏览器中。因为你的脚本依赖于 DOM API,你无法在 Node.js 中运行这些测试。如果你尝试这样做,你会遇到你在执行node main.js时看到的相同的ReferenceError。鉴于 Jest 依赖于 Node.js 特定的 API,因此它只能在 Node.js 中运行,你也不能使用 Jest。

要能够在 Jest 中运行你的测试,而不是在浏览器中运行测试,你可以通过使用 JSDOM 将浏览器 API 带到 Node.js 中。你可以将 JSDOM 视为一个可以在 Node.js 中运行的浏览器环境实现。它使用纯 JavaScript 实现网络标准。例如,使用 JSDOM,你可以模拟操作 DOM 并为元素附加事件监听器。

JSDOM JSDOM 是纯 JavaScript 编写的网络标准实现,你可以在 Node.js 中使用。

要了解 JSDOM 是如何工作的,让我们使用它来创建一个表示index.html的对象,我们可以在 Node.js 中使用它。

首先,使用npm init -y创建一个package.json文件,然后使用npm install --save jsdom安装 JSDOM。

通过使用fs,你可以读取index.html文件并将其内容传递给 JSDOM,以便它能够创建该页面的表示。

列表 6.3 page.js

const fs = require("fs");
const { JSDOM } = require("jsdom");

const html = fs.readFileSync("./index.html");
const page = new JSDOM(html);

module.exports = page;

page表示包含你在浏览器中会找到的属性,例如window。因为你现在正在处理纯 JavaScript,你可以在 Node.js 中使用page

尝试在脚本中导入page并像在浏览器中一样与之交互。例如,你可以尝试将一个新段落附加到page上,如下所示。

列表 6.4 example.js

const page = require("./page");                                 ❶

console.log("Initial page body:");
console.log(page.window.document.body.innerHTML);

const paragraph = page.window.document.createElement("p");      ❷
paragraph.innerHTML = "Look, I'm a new paragraph";              ❸
page.window.document.body.appendChild(paragraph);               ❹

console.log("Final page body:");
console.log(page.window.document.body.innerHTML);

❶ 导入页面的 JSDOM 表示

❷ 创建一个段落元素

❸ 更新段落的文本内容

❹ 将段落附加到页面上

要在 Node.js 中执行前面的脚本,请运行node example.js

使用 JSDOM,你可以做几乎在浏览器中能做的任何事情,包括更新 DOM 元素,如count

列表 6.5 example.js

const page = require("./page");

// ...

console.log("Initial contents of the count element:");
console.log(page.window.document.getElementById("count").innerHTML);

page.window.document.getElementById("count").innerHTML = 1337;          ❶
console.log("Updated contents of the count element:");
console.log(page.window.document.getElementById("count").innerHTML);

// ...

❶ 更新计数元素的文本内容

多亏了 JSDOM,你可以在 Jest 中运行你的测试,正如我之前提到的,Jest 只能在 Node.js 中运行。

通过将值"jsdom"传递给 Jest 的testEnvironment选项,你可以让它设置一个全局的 JSDOM 实例,你可以在运行测试时使用它。

要在 Jest 中设置 JSDOM 环境,如图 6.2 所示,首先创建一个名为jest.config.js的新 Jest 配置文件。在这个文件中,导出一个对象,其testEnvironment属性的值是"jsdom"

图 6.2 Node.js 中的 JavaScript 环境

列表 6.6 jest.config.js

module.exports = {
  testEnvironment: "jsdom",
};

注意:在撰写本文时,Jest 的当前版本是 26.6。在这个版本中,jsdom是 Jest 的testEnvironment的默认值,因此你不必指定它。

如果您不想手动创建 jest.config.js 文件,可以使用 ./node_modules/.bin/jest --init来自动化此过程。Jest 的自动初始化将提示您选择测试环境,并为您提供 jsdom 选项。

现在尝试创建一个 main.test.js 文件并将 main.js 导入以查看会发生什么。

列表 6.7 main.test.js

require("./main");

如果您尝试使用 Jest 运行此测试,您仍然会得到一个错误。

FAIL  ./main.test.js
● Test suite failed to run

  TypeError: Cannot read property 'addEventListener' of null

    10 |
    11 | const incrementButton = window.document.getElementById("increment-button");
  > 12 | incrementButton.addEventListener("click", incrementCount);

即使 window 现在存在,多亏了 Jest 设置的 JSDOM,其 DOM 并不是从 index.html 构建的。相反,它是由一个空的 HTML 文档构建的,因此没有 increment-button。因为按钮不存在,所以您不能调用它的 addEventListener 方法。

要使用 index.html 作为 JSDOM 实例将使用的页面,您需要读取 index.html 并将其内容分配给 window.document.body.innerHTML,然后再导入 main.js,如下所示。

列表 6.8 main.test.js

const fs = require("fs");
window.document.body.innerHTML = fs.readFileSync("./index.html");       ❶

require("./main");

❶ 分配 index.html 文件的内容到页面的主体

因为您现在已经配置了全局 window 使用 index.html 的内容,Jest 将能够成功执行 main.test.js

要能够为 incrementCount 编写测试,您需要采取的最后一步是暴露它。因为 main.js 没有暴露 incrementCountdata,所以您不能执行该函数或检查其结果。通过以下方式使用 module.exports 来导出 dataincrementCount 函数来解决这个问题。

列表 6.9 main.js

// ...

module.exports = { incrementCount, data };

最后,您可以继续创建一个 main.test.js 文件,该文件设置初始计数,执行 incrementCount 并在 data 中检查新的 count。这仍然是三个 A 模式——安排、行动、断言——就像我们之前做的那样。

列表 6.10 main.test.js

const fs = require("fs");
window.document.body.innerHTML = fs.readFileSync("./index.html");

const { incrementCount, data } = require("./main");

describe("incrementCount", () => {
  test("incrementing the count", () => {
    data.cheesecakes = 0;                          ❶
    incrementCount();                              ❷
    expect(data.cheesecakes).toBe(1);              ❸
  });
});

❶ 安排:设置芝士蛋糕的初始数量

❷ 行动:执行待测试的 incrementCount 函数

❸ 断言:检查 data.cheesecakes 是否包含正确的芝士蛋糕数量

注意:目前我们不会担心检查页面内容。在下一节中,您将学习如何断言 DOM 并处理由用户交互触发的事件。

一旦您庆祝看到这个测试通过,就是时候解决最后一个问题。

因为您已经使用 module.exports 来暴露 incrementCountdata,所以 main.js 在浏览器中运行时会抛出错误。要查看错误,请尝试再次使用 npx http-server ./ 提供您的应用程序,并使用浏览器开发者工具打开 localhost: 8080

Uncaught ReferenceError: module is not defined
    at main.js:14

您的浏览器抛出此错误是因为它没有全局提供 module。再次,您遇到了与浏览器和 Node.js 之间差异相关的问题。

在浏览器中运行使用 Node.js 模块系统的文件的一种常见策略是使用一个工具,该工具将依赖项捆绑到一个浏览器可以执行的单一文件中。像 Webpack 和 Browserify 这样的工具的主要目标之一就是进行这种捆绑。

browserify 安装为开发依赖项,并运行 ./node_modules/.bin/browserify main.js -o bundle.js 以将您的 main.js 文件转换为浏览器友好的 bundle.js

NOTE 您可以在 browserify.org 找到 Browserify 的完整文档。

一旦运行了 Browserify,更新 index.html 以使用 bundle.js 而不是 main.js

列表 6.11 index.html

<!DOCTYPE html>
<html lang="en">
  <!-- ... -->
  <body>
    <!-- ... -->
    <script src="bundle.js"></script>         ❶
  </body>
</html>

❶ bundle.js 将由 main.js 生成。它是一个包含 main.js 的所有直接和间接依赖项的单个文件。

TIP 每当 main.js 发生变化时,您都需要重新构建 bundle.js

由于您需要频繁运行它,创建一个运行 Browserify 的 NPM 脚本会是一个明智的选择。

要创建一个运行 Browserify 的 NPM 脚本,更新您的 package.json 以包括以下行。

列表 6.12 package.json

{
  // ...
  "scripts": {
    // ...
    "build": "browserify main.js -o bundle.js"         ❶
  },
  // ...
}

❶ 遍历 main.js 文件的依赖树,并将所有依赖项打包成一个单一的 bundle.js 文件

通过使用像 Browserify 或 Webpack 这样的工具,您可以将您编写的可测试代码转换为在 Node.js 中运行,以便它可以在浏览器中运行。

使用打包器使您能够单独测试您的模块,并使它们在浏览器中管理起来更加容易。当您将应用程序打包成一个单一文件时,您不需要在您的 HTML 页面中管理多个 script 标签。

在本节中,您学习了如何使用 Node.js 和 Jest 来测试设计在浏览器中运行的 JavaScript。您看到了这两个平台之间的区别,并学习了如何使用 JSDOM 将浏览器 API 带到 Node.js。

您还看到了 Browserify 如何通过允许您将应用程序分割成单独的模块来帮助您测试应用程序,这些模块可以在 Node.js 中测试,然后打包在浏览器中运行。

通过使用这些工具,您能够使用 Jest 在 Node.js 中测试您的浏览器应用程序。

6.2 在 DOM 上断言

最好的厨师知道,甜点不仅应该 味道 正确;它还必须 看起来 很好。

在上一节中,您学习了如何设置 Jest 以测试您的脚本,但您还没有检查页面是否向用户显示了正确的输出。在本节中,您将了解您的脚本如何与页面的标记交互,并学习如何断言 DOM。

在我们编写测试之前,重构上一节的应用程序,使其能够管理多个库存项目,而不仅仅是芝士蛋糕。

因为您正在使用 Browserify 打包您的应用程序,所以您可以创建一个单独的 inventoryController.js 文件来管理库存中的项目,这些项目您将存储在内存中。

NOTE 目前,我们将所有数据存储在内存中,并专注于测试我们的网络客户端。在本章的最后部分,您将学习如何将前端应用程序连接到第四章中的服务器,并测试其后端集成。

列表 6.13 inventoryController.js

const data = { inventory: {} };

const addItem = (itemName, quantity) => {
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;
};

module.exports = { data, addItem };

正如我们在上一节中所做的那样,你可以通过导入 inventoryController.js,将一个空对象分配给 inventory 属性,执行 addItem 函数,并检查库存的内容——这是通常的三个 A 模式。

列表 6.14 inventoryController.test.js

const { addItem, data } = require("./inventoryController");

describe("addItem", () => {
  test("adding new items to the inventory", () => {
    data.inventory = {};                                    ❶
    addItem("cheesecake", 5);                               ❷
    expect(data.inventory.cheesecake).toBe(5);              ❸
  });
});

❶ 安排:将一个空对象分配给库存,代表其初始状态

❷ 行动:执行 addItem 函数,向库存中添加五个芝士蛋糕

❸ 断言:检查库存中是否包含正确数量的芝士蛋糕

运行 Jest 应该表明你的测试通过了,但即使 addItem 正常工作,它也没有更新页面以显示库存的内容。要更新页面以显示库存项目列表,更新你的 index.html 文件,使其包括一个无序列表,我们将向其中追加项目,如下所示。

列表 6.15 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Inventory Manager</title>
  </head>
  <body>
    <h1>Inventory Contents</h1>
    <ul id="item-list"></ul>
    <script src="bundle.js"></script>
  </body>
</html>

创建这个无序列表后,创建一个名为 domController.js 的文件,并编写一个 updateItemList 函数。这个函数应该接收 inventory 并相应地更新 item-list

列表 6.16 domController.js

const updateItemList = inventory => {
  const inventoryList = window.document.getElementById("item-list");

  inventoryList.innerHTML = "";                                         ❶

  Object.entries(inventory).forEach(([itemName, quantity]) => {         ❷
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
    inventoryList.appendChild(listItem);
  });
};

module.exports = { updateItemList };

❶ 清除列表

❷ 对于库存中的每一项,创建一个 li 元素,将其内容设置为包括项目的名称和数量,并将其追加到项目列表中

最后,你可以将这些内容全部放入你的 main.js 文件中。尝试使用 addItem 并调用 updateItemList 来添加一些项目到库存中,将新的库存传递给它。

列表 6.17 main.js

const { addItem, data } = require("./inventoryController");
const { updateItemList } = require("./domController");

addItem("cheesecake", 3);
addItem("apple pie", 8);
addItem("carrot cake", 7);

updateItemList(data.inventory);

注意:因为你应该已经完全重写了 main.js,所以 main.test.js 中的测试不再适用,因此可以删除。

不要忘记,因为我们使用 Node.js 的模块系统来启用测试,我们必须通过 Browserify 运行 main.js,以便它能够生成一个能够在浏览器中运行的 bundle.js 文件。bundle.js 包括 inventoryController.jsdomController.js 的代码,而不是依赖于 requiremodule 这样的 API。

一旦你使用 ./node_modules/.bin/browserify main.js -o bundle.js 建立了 bundle.js,你就可以使用 npx http-server ./ 来提供你的应用程序,并通过访问 localhost:8080 来查看库存项目列表。

到目前为止,你只测试了 addItem 是否充分更新了应用程序的状态,但你根本就没有检查 updateItemList。尽管 addItem 的单元测试通过了,但这并不能保证当给 updateItemList 提供当前 inventory 时,它能够更新页面。

因为 updateItemList 依赖于页面的标记,你必须设置 Jest 的 JSDOM 所使用的文档的 innerHTML,就像我们在上一节中所做的那样。

列表 6.18 domController.test.js

const fs = require("fs");
document.body.innerHTML = fs.readFileSync("./index.html");

提示:除了 window 之外,document 在你的测试中也是全局的。你可以通过访问 document 而不是 window.document 来节省一些按键。

在使用你的 index.html 页面的内容设置 JSDOM 实例后,再次使用三个 A 模式测试 updateItemList:通过创建包含几个项目的库存来设置场景,将其传递给 updateItemList,并检查它是否适当地更新了 DOM。

由于 Jest 和 JSDOM 的存在,全局 document 在浏览器中工作得就像它应该那样,你可以使用浏览器 API 来查找 DOM 节点并对它们进行断言。

例如,尝试使用 querySelector 找到一个 body 的直接子元素的未有序表,并断言它包含的 childNodes 的数量。

列表 6.19 domController.test.js

// ...

document.body.innerHTML = fs.readFileSync("./index.html");         ❶

const { updateItemList } = require("./domController");

describe("updateItemList", () => {
  test("updates the DOM with the inventory items", () => {
    const inventory = {                                            ❷
      cheesecake: 5,
      "apple pie": 2,
      "carrot cake": 6
    };
    updateItemList(inventory);                                     ❸

    const itemList = document.querySelector("body > ul");          ❹
    expect(itemList.childNodes).toHaveLength(3);                   ❺
  });
});

❶ 因为你在将 index.html 文件的内容分配给 body 的 innerHTML,所以当测试运行时,页面将处于其初始状态。

❷ 创建包含几个不同项目的库存表示

❸ 行动:练习 updateItemList 函数

❹ 通过其在 DOM 中的位置找到列表

❺ 断言:检查列表是否包含正确的子节点数量

JSDOM 中的 DOM 元素包含与浏览器中相同的属性,因此你可以通过断言列表中每个项目的 innerHTML 来使你的测试更加严格。

列表 6.20 domController.test.js

// ...

test("updates the DOM with the inventory items", () => {
  const inventory = { /* ... */ };

  updateItemList(inventory);

  const itemList = document.getElementById("item-list");
  expect(itemList.childNodes).toHaveLength(3);

  // The `childNodes` property has a `length`, but it's _not_ an Array
  const nodesText = Array.from(itemList.childNodes).map(                  ❶
    node => node.innerHTML
  );
  expect(nodesText).toContain("cheesecake - Quantity: 5");
  expect(nodesText).toContain("apple pie - Quantity: 2");
  expect(nodesText).toContain("carrot cake - Quantity: 6");
});

// ...

❶ 从 itemList 中的每个节点提取 innerHTML,创建一个字符串数组。

因为你会直接调用 updateItemList 但检查 DOM 来断言函数是否产生了正确的输出,所以我将这个 updateItemList 的测试归类为集成测试。它专门测试 updateItemList 是否正确更新了页面的标记。

你可以在图 6.3 中看到这个测试如何与其他模块交互。

图 6.3 测试和被测试单元与文档的交互方式

注意测试金字塔如何渗透到你的所有测试中。你用来测试后端应用程序的原则同样适用于前端应用程序。

之前测试的问题在于它与页面的标记紧密耦合。它依赖于 DOM 的结构来查找节点。如果你的页面标记以这种方式改变,以至于节点不在完全相同的位置,测试将失败,即使从用户的角度来看,应用程序仍然完美无瑕。

假设,例如,你想要为了样式目的将你的无序列表包裹在一个 div 中,如下所示。

列表 6.21 index.html

< !-- ... -->

<body>
  <h1>Inventory Contents</h1>
  <div class="beautiful-styles">
    <ul id="item-list"></ul>
  </div>
  <script src="bundle.js"></script>
</body>

< !-- ... -->

这个更改将使你的 domController 中的测试失败,因为它将找不到无序列表。因为测试依赖于列表是 body 的直接后代,所以一旦你将 item-list 包裹在任何其他元素中,它就会失败。

在这种情况下,你不必担心列表是否是 body 的直接后代。相反,你需要保证它存在并且包含正确的项目。这个查询只有在你的目标是确保 ulbody 的直接后代时才足够。

你应该将测试中的查询视为内置断言。例如,如果你想断言一个元素是另一个元素的直接后代,你应该编写一个依赖于其 DOM 位置的查询。

注意:我们已经在第五章的最后部分讨论了如何将断言转换为先决条件。依赖于元素特定特征的查询遵循相同的原理。

在编写前端时,你很快就会注意到 DOM 结构会频繁变化,而不会影响应用程序的整体功能。因此,在绝大多数情况下,你应该避免将测试耦合到 DOM 结构。否则,即使应用程序仍然工作,你也必须频繁更新测试,这将产生额外的成本。

为了避免依赖于 DOM 的结构,更新你的 domController 中的测试,使其通过 id 找到列表,如下一代码所示。

列表 6.22 domController.test.js

// ...

test("updates the DOM with the inventory items", () => {
  const inventory = { /* ... */ };
  updateItemList(inventory);

  const itemList = document.getElementById("item-list");       ❶
  expect(itemList.childNodes).toHaveLength(3);

  // ...
});

// ...

❶ 通过 ID 查找列表

通过通过 id 查找列表,你可以自由地在 DOM 中移动它,并包裹你想要的任何元素。只要它有相同的 id,你的测试就会通过。

提示:你想要断言的元素不一定已经有了 id 属性。可能你的应用程序不使用 id 属性来查找元素,例如。

在那种情况下,将具有如此强语义的属性 id 附着到你的元素上并不是最佳选择。相反,你可以添加一个唯一的 data-testid 属性,并使用它通过 document.querySelector ('[data-testid="your-element-testid"]') 来查找你的元素。

现在,为了指示自页面首次加载以来发生了哪些操作,更新你的 updateItemList 函数,使其在每次运行时都向文档的主体附加一个新的段落。

列表 6.23 domController.js

// ...

const updateItemList = inventory => {
  // ...

  const inventoryContents = JSON.stringify(inventory);
  const p = window.document.createElement("p");                            ❶
  p.innerHTML = `The inventory has been updated - ${inventoryContents}`;   ❷
  window.document.body.appendChild(p);                                     ❸
};

module.exports = { updateItemList };

❶ 创建一个段落元素

❷ 设置段落的文本内容

❸ 将段落附加到文档的主体

一旦你更新了 updateItemList,使用 Browserify 通过运行 browserify main.js -o bundle.js 来重建 bundle.js,然后使用 npx http-server ./ 来提供应用程序。当访问 localhost:8080 时,你应该在页面底部看到一个段落,指示最后一次更新是什么。

现在是时候添加一个测试来覆盖这个功能了。因为附加到主体的段落没有 iddata-testid,你必须添加这些属性之一,或者发现另一种找到这个元素的方法。

在这种情况下,给段落添加一个标识符属性似乎是个坏主意。为了确保这些标识符是唯一的,你必须使 domController 状态化,以便每次都能生成一个新的 ID。通过这样做,你将添加大量的代码来仅使这个功能可测试。除了添加更多的代码,这需要更多的维护外,你还会将你的实现与测试紧密耦合。

为了避免这种开销,而不是通过唯一标识符查找段落,而是通过您想要断言的特征来查找段落:其内容。

domController.test.js 中添加一个新的测试,用于查找页面中的所有段落并根据其内容进行过滤。

警告:现在您在同一 document 上运行多个测试,因此您必须在每个测试之间重置其内容。不要忘记在 beforeEach 钩子中封装对 document.body.innerHTML 的赋值。

列表 6.24 domController.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");

// ...

beforeEach(() => {
  document.body.innerHTML = initialHtml;                                   ❶
});

describe("updateItemList", () => {
  // ...

  test("adding a paragraph indicating what was the update", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);                                             ❷
    const paragraphs = Array.from(document.querySelector("p"));            ❸
    const updateParagraphs = paragraphs.filter(p => {                      ❹
      return p.includes("The inventory has been updated");
    });

    expect(updateParagraphs).toHaveLength(1);                              ❺
    expect(updateParagraphs[0].innerHTML).toBe(                            ❻
      `The inventory has been updated - ${JSON.stringify(inventory)}`
    );
  });
});

❶ 在每个测试之前,您将通过重新分配 index.html 的内容来将文档的 body 重置为其初始状态。

❷ 练习 updateItemList 函数

❸ 查找页面中的所有段落

❹ 通过文本过滤页面的所有段落以找到包含所需文本的段落

❺ 检查是否存在一个包含预期文本的段落

❻ 检查段落的全部内容

通过内容查找元素比依赖于 DOM 的结构或唯一的 ids 更好。尽管所有这些技术都是有效的,并且适用于不同的场景,但通过内容查找元素是避免应用程序和测试之间耦合的最佳方式。或者,您可以通过其他属性来查找元素,这些属性不仅能够唯一标识它,而且也是元素应该具有的固有部分。例如,您可以通过 role 属性来查找元素,因此可以将无障碍性检查集成到选择器中。

在测试您的前端应用程序时,请记住不仅要断言您的函数是否工作,还要断言您的页面是否显示了正确的内容。为此,请在 DOM 中查找元素,并确保编写断言来验证它们。在编写这些断言时,请注意您是如何查找这些元素的。尽量始终断言元素应该具有的固有特征。通过断言这些特征,您将使您的测试更加健壮,并在重构应用程序时不会创建额外的维护开销,即使一切仍然正常工作。

6.2.1 使查找元素更加容易

如果路易斯每次想要烤蛋糕时都要花一个小时来找到正确的烤盘,他早就放弃烘焙了。为了避免您每次添加新功能时都放弃编写有价值的测试,使查找元素变得像路易斯找到他的烤盘一样轻松是一个好主意。

到目前为止,我们一直在使用原生 API 来查找元素。有时,这可能会变得相当繁琐。

如果您通过 test-id 来查找元素,例如,您必须重写许多类似的选择器。在之前的测试中,为了通过文本查找一个段落,我们不仅必须使用选择器,还必须编写大量的代码来过滤页面的 p 元素。如果您尝试通过 valuelabel 来查找 input,类似的情况也可能发生。

为了使查找元素更加直接,你可以使用像 dom-testing-library 这样的库,它提供了使你能够轻松找到 DOM 节点的函数。

现在你已经了解了如何对 DOM 进行断言,你将通过运行 npm install --save-dev @testing-library/domdom-testing-library 安装为 dev-dependency,并重构你的测试,以便它们使用这个库的查询。

从检查页面项目列表的测试开始。在那个测试中,你将使用由 dom-testing-library 导出的 getByText 函数。使用 getByText,你不需要创建一个包含每个项目 innerHTML 的数组并检查该数组是否包含你想要的文本。相反,你将告诉 getByText 在列表中查找所需的文本。getByText 函数接受两个参数:你想要在其中搜索的 HTMLElement 和要查找的文本。

因为如果 getByText 没有找到元素,它将返回一个 falsy 结果,所以你可以使用 toBeTruthy 来断言它确实找到了匹配的节点。目前,toBeTruthy 足够了,但在下一个子节中,你将学习如何编写更精确的断言。

列表 6.25 domController.test.js

const { getByText } = require("@testing-library/dom");

// ...

describe("updateItemList", () => {
  // ...

  test("updates the DOM with the inventory items", () => {
    const inventory = {
      cheesecake: 5,
      "apple pie": 2,
      "carrot cake": 6
    };
    updateItemList(inventory);

    const itemList = document.getElementById("item-list");
    expect(itemList.childNodes).toHaveLength(3);

    expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeTruthy();  ❶
    expect(getByText(itemList, "apple pie - Quantity: 2")).toBeTruthy();   ❶
    expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeTruthy(); ❶
  });

  // ...
});

❶ 在这些断言中,你使用 getByText 来更容易地找到所需的元素。

现在,你不再需要编写根据文本查找元素的逻辑,你可以将这项任务委托给 dom-testing-library

为了使选择更加彻底,你也可以向 getByText 传递第三个参数,告诉它只考虑 li 元素。尝试将 { selector: "li" } 作为第三个参数传递给 getByText,你会发现测试仍然通过。

接下来,对 domController.test.js 中的另一个测试也做同样的操作。这次,你不需要传递一个 getByText 应该在其中搜索的元素,你可以使用 dom-testing-library 导出的 screen 命名空间中的 getByText 方法。与直接导出的 getByText 不同,screen.getByText 默认在全局 document 中查找项目。

列表 6.26 domController.test.js

const { screen, getByText } = require("@testing-library/dom");

// ...

describe("updateItemList", () => {
  // ...

  test("adding a paragraph indicating what was the update", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);

    expect(
      screen.getByText(                                                   ❶
        `The inventory has been updated - ${JSON.stringify(inventory)}`
      )
    ).toBeTruthy();
  });
});

❶ 代替使用 getByText,使用 screen.getByText 在全局文档中搜索元素,从而避免在之前必须找到 itemList

dom-testing-library 包还包含许多其他有用的查询,例如 getByAltTextgetByRolegetByLabelText。作为一个练习,尝试向页面添加新元素,例如在 input 字段中的图像,并使用这些查询在你要编写的测试中找到它们。

注意:你可以在 testing-library.com/docs/dom-testing-library/api-queries 找到 dom-testing-library 查询的完整文档。

你的选择器,就像你的断言一样,应该基于构成元素本质的部分。例如,id 是任意的,因此通过 ids 找到元素将使你的测试与标记紧密耦合。你应该通过对用户有意义的内容来查找元素,比如它们的文本或角色。通过使用强大且易于编写的选择器,你的测试将更快编写,并且对不影响应用程序正常工作的更改更具弹性。

6.2.2 编写更好的断言

在上一节中,你使用了 toBeTruthy 来断言 dom-testing-library 能够找到你想要的元素。尽管对于这些示例来说效果足够好,但像 toBeTruthy 这样的断言太宽松,可能会使测试更难理解。

就像我们在第三章中使用 jest-extended 库扩展 Jest 以添加新的匹配器一样,我们也可以使用 jest-dom 来扩展它,添加专门用于测试 DOM 的新匹配器。这些匹配器可以帮助你减少测试中需要编写的代码量,并使测试更易读。

要使用 jest-dom,首先,通过运行 npm install --save-dev @testing-library/jest-dom 将其作为开发依赖项安装。安装后,在你的应用程序目录中添加一个 jest.config.js 文件,并配置 Jest 以运行一个名为 setupJestDom.js 的设置文件。

列表 6.27 jest.config.js

module.exports = {
  setupFilesAfterEnv: ['<rootDir>/setupJestDom.js'],
};

setupJestDom.js 中,调用 expect.extend 并传递 jest-dom 的主要导出。

列表 6.28 setupJestDom.js

const jestDom = require("@testing-library/jest-dom");

expect.extend(jestDom);

setupJestDom.js 添加到 setupFilesAfterEnv 配置中,将在 Jest 初始化后运行,并将 jest-dom 的匹配器添加到 expect 中。

更新 Jest 配置后,你可以用 jest-domtoBeInTheDocument 断言替换 toBeTruthy。这个更改会使你的测试更易读且更精确。如果 dom-testing-library 找到的元素不再附加到文档上,例如,toBeInTheDocument 将会失败,而 toBeTruthy 则会通过。

列表 6.29 domController.test.js

// ...

describe("updateItemList", () => {
  // ...

  test("updates the DOM with the inventory items", () => {
    // ...

    expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument();
    expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument();
    expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeInTheDocument();
  });

  // ...
});

要尝试不同的断言,更新你的应用程序,使其高亮显示 红色 的名称,这些名称的 quantity 小于五。

列表 6.30 domController.js

const updateItemList = inventory => {
  // ...

  Object.entries(inventory).forEach(([itemName, quantity]) => {       ❶
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;

    if (quantity < 5) {                                               ❷
      listItem.style.color = "red";
    }

    inventoryList.appendChild(listItem);
  });
  // ...
};

// ...

❶ 遍历库存中的每个条目

❷ 如果一个项目的数量少于五,将其颜色设置为红色

要断言一个元素的样式,而不是手动访问其 style 属性并检查 color 的值,你可以使用 toHaveStyle

继续添加一个新的测试,检查你的应用程序是否高亮显示红色,以突出显示 quantity 小于五的元素,如下所示。

列表 6.31 domController.test.js

describe("updateItemList", () => {
  // ...

  test("highlighting in red elements whose quantity is below five", () => {
    const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 };
    updateItemList(inventory);

    expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({
      color: "red"
    });
  });
});

使用 toHaveStyle,你也可以断言通过样式表应用的风格。例如,尝试向你的 index.html 添加一个包含 almost-soldout 类的 style 标签,该类将元素的色彩设置为红色。

列表 6.32 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    < !-- ... -->
    <style>
      .almost-soldout {
        color: red;
      }
    </style>
  </head>
  < !-- ... -->
</html>

然后,当物品的数量少于五时,不要手动设置其style.color属性,而是将其className属性设置为almost-soldout

列表 6.33 domController.js

const updateItemList = inventory => {
  // ...
  Object.entries(inventory).forEach(([itemName, quantity]) => {
    const listItem = window.document.createElement("li");
    listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;

    if (quantity < 5) {
      listItem.className = "almost-soldout";        ❶
    }

    inventoryList.appendChild(listItem);
  });

  // ...
};

// ...

❶ 不要直接设置元素的色彩,而是将其类设置为 almost-soldout,这将导致元素的色彩变为红色

即使样式不是由你的脚本应用的,你的测试仍然应该通过。为了在不使用jest-dom的情况下达到相同的目标,你需要在测试中编写更多的代码。

作为练习,尝试向应用程序添加新功能,例如将售罄物品的visibility设置为hidden或添加一个当库存已空时保持禁用的button。然后,使用toBeVisibletoBeEnabledtoBeDisabled等断言来测试这些新功能。

注意:您可以在github.com/testing-library/jest-dom找到jest-dom的完整文档,包括可用匹配器的完整列表。

在本节中,你应该已经学会了如何在测试中找到 DOM 元素,无论是使用原生的浏览器 API 还是使用dom-testing-library提供的工具,这些工具使你的测试更具可读性。到如今,你也应该了解应该使用哪些技术来避免维护开销。例如,你应该知道基于其层次链查找元素不是一个好主意,而且最好通过它们的标签来查找元素,这样你就可以将验证集成到选择器中。此外,你应该能够借助jest-dom编写精确且易于阅读的断言来为你的测试服务。

6.3 处理事件

要制作人们想要的产品,你必须倾听你的客户有什么要说的。客户可能并不总是对的,但在路易斯的面包店,每个员工都知道他们必须始终倾听客户的声音——至少,要让客户感到被倾听。

从商业角度来看,客户的反馈驱动着产品决策。例如,这有助于面包店生产更多客户想要的产品,而减少他们不想要的产品。从软件角度来看,用户输入导致应用程序做出反应,改变其状态并显示新的结果。

在浏览器中运行的应用程序不会直接接收像数字或字符串这样的输入。相反,它们处理事件。当用户点击、键入和滚动时,它们会触发事件。这些事件包括有关用户交互的详细信息,例如他们提交的表单内容或他们点击了哪个按钮。

在本节中,你将学习如何在测试中处理事件,并准确模拟用户与你的应用程序的交互方式。通过精确地表示用户的输入,你将拥有更可靠的测试,因为它们将更接近运行时发生的情况。

要了解事件是如何工作的以及如何测试它们,你将在你的应用程序中添加一个新的 form,允许用户向库存中添加项目。然后你将使你的应用程序在用户与之交互时验证表单,并为这些交互编写一些额外的测试。

首先,在 index.html 中添加一个包含两个字段的 form:一个用于项目名称,另一个用于数量。

列表 6.34 index.html

<!DOCTYPE html>
<html lang="en">
  < !-- ... -->

  <body>
    < !-- ... -->

    <form id="add-item-form">
      <input
        type="text"
        name="name"
        placeholder="Item name"
      >
      <input
        type="number"
        name="quantity"
        placeholder="Quantity"
      >
      <button type="submit">Add to inventory</button>         ❶
    </form>
    <script src="bundle.js"></script>
  </body>
</html>

❶ 导致表单提交,触发提交事件

在你的 domController.js 文件中,创建一个名为 handleAddItem 的函数。这个函数将接收一个 event 作为其第一个参数,检索提交的值,调用 addItem 来更新库存,然后调用 updateItemList 来更新 DOM。

列表 6.35 domController.js

// ...

const handleAddItem = event => {
  event.preventDefault();                                   ❶

  const { name, quantity } = event.target.elements;

  addItem(name.value, parseInt(quantity.value, 10));        ❷

  updateItemList(data.inventory);
};

❶ 阻止页面默认重新加载

❷ 因为数量字段值是一个字符串,我们需要使用 parseInt 来将其转换为数字。

注意:默认情况下,当用户提交表单时,浏览器会重新加载页面。调用事件的 preventDefault 方法将取消默认行为,导致浏览器不重新加载页面。

最后,为了使 handleAddItem 在用户提交新项目时被调用,你需要将一个 submit 事件监听器附加到表单上。

现在你有了提交项目的表单,你不再需要在 main.js 文件中手动调用 addItemupdateItemList。相反,你可以替换这个文件的整个内容,并使其仅附加一个事件监听器到表单上。

列表 6.36 main.js

const { handleAddItem } = require("./domController");

const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem);           ❶

❶ 当用户提交表单时调用 handleAddItem

在这些更改之后,你应该有一个能够动态添加项目到库存中的应用程序。要看到它运行,执行 npm run build 来重新生成 bundle.js,然后执行 npx http-server ./ 来提供 index.html,并访问 localhost:8080,就像你之前做的那样。

现在,考虑你会如何测试你刚刚添加的代码。

一种可能性是为 handleAddItem 函数本身添加一个测试。这个测试将创建一个类似事件的对象,并将其作为参数传递给 handleAddItem,如下所示。

列表 6.37 domController.test.js

const { updateItemList, handleAddItem } = require("./domController");

// ...

describe("handleAddItem", () => {
  test("adding items to the page", () => {
    const event = {                                                ❶
      preventDefault: jest.fn(),
      target: {
        elements: {
          name: { value: "cheesecake" },
          quantity: { value: "6" }
        }
      }
    };

    handleAddItem(event);                                          ❷

    expect(event.preventDefault.mock.calls).toHaveLength(1);       ❸

    const itemList = document.getElementById("item-list");
    expect(getByText(itemList, "cheesecake - Quantity: 6"))        ❹
      .toBeInTheDocument();
  });
});

❶ 创建一个对象,它复制了事件接口

❷ 练习 handleAddItem 函数

❸ 检查表单的默认重新加载是否已被阻止

❹ 检查 itemList 是否包含具有预期文本的节点

为了使之前的测试通过,你必须逆向工程 event 的属性,从头开始构建它。

这种技术的缺点是它没有考虑到页面中的任何实际 input 元素。因为你自己构建了 event,所以你能够为 namequantity 包含任意值。例如,如果你尝试从 index.html 中移除 input 元素,这个测试仍然会通过,尽管你的应用程序可能无法工作。

因为这个测试直接调用了 handleAddItem,如图 6.4 所示,它不关心它是否作为 submit 事件的监听器附加到 form 上。例如,如果您尝试从 main.js 中移除 addEventListener 的调用,这个测试将继续通过。再次,您又发现了一个应用程序不会工作但测试会通过的情况。

图 6.4 handleAddItem 的测试直接调用它,导致文档更新。

如您刚刚所做的那样手动构造事件,可以快速迭代并测试您构建时的监听器。但是,当涉及到创建可靠的保证时,这种技术是不够的。这个单元测试仅涵盖 handleAddItem 函数本身,因此不能保证当用户触发真实事件时应用程序将正常工作。

为了创建更可靠的保证,最好创建一个真实的事件实例,并通过使用节点的 dispatchEvent 方法通过 DOM 节点派发它。

准确重现运行时发生情况的第一步是更新文档的 body,使其包含我们之前所做的 index.html 的标记。然后,最好使用 require("./main") 执行 main.js,以便它可以将 eventListener 附加到 form 上。如果您在用 initialHTML 再次更新文档的 body 后不运行 main.js,它的表单将不会附加事件监听器。

此外,您必须在引入 main.js 之前调用 jest.resetModules。否则,Jest 将从其缓存中获取 ./main.js,从而阻止它再次执行。

列表 6.38 main.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");

beforeEach(() => {
  document.body.innerHTML = initialHtml;

  jest.resetModules();                    ❶
  require("./main");                      ❷
});

❶ 在这里您必须使用 jest.resetModules,否则 Jest 将缓存 main.js,它将不会再次运行。

❷ 您必须再次执行 main.js,以便每次文档内容发生变化时,它都能将事件监听器附加到表单上。

现在您的文档已经包含了 index.html 的内容,并且 main.js 已经将监听器附加到了 form 上,您就可以编写测试了。这个测试将填充页面的输入框,创建一个类型为 submitEvent 对象,找到 form,并调用其 dispatchEvent 方法。在派发事件后,它将检查列表是否包含刚刚添加的项目条目。

列表 6.39 main.test.js

const { screen, getByText } = require("@testing-library/dom");

// ...

test("adding items through the form", () => {
  screen.getByPlaceholderText("Item name").value = "cheesecake";
  screen.getByPlaceholderText("Quantity").value = "6";

  const event = new Event("submit");                               ❶
  const form = document.getElementById("add-item-form");           ❷
  form.dispatchEvent(event);

  const itemList = document.getElementById("item-list");
  expect(getByText(itemList, "cheesecake - Quantity: 6"))          ❸
    .toBeInTheDocument();
});

❶ 创建一个类型为 submit 的“原生”Event 实例

❷ 通过页面的表单派发事件

❸ 检查派发的事件是否导致页面包含具有预期文本的元素

这个测试(如图 6.5 所示)更准确地代表了运行时发生的情况。因为它的范围比之前的测试更广,所以这个测试在测试金字塔中位置更高,因此它的保证更可靠。例如,如果您尝试从 index.html 中移除 input 元素或从 main.js 中移除 addEventListener 的调用,这个测试将失败,而之前的测试不会。

图 6.5 通过通过表单派发事件,应用程序触发main.js中附加的事件监听器。

接下来,你将使你的应用程序在用户输入时验证项目名称字段。每次用户在项目名称输入框中输入时,你将确认其值是否有效,通过检查它是否存在于预定义的配料列表中。

通过向domController添加一个新函数来开始实现这个功能。这个函数将接受一个事件并检查事件的目标是否在配料列表中。如果项目存在,它将显示一个成功消息。否则,它将显示一个错误。

列表 6.40 domController.js

// ...

const validItems = ["cheesecake", "apple pie", "carrot cake"];
const handleItemName = event => {
  const itemName = event.target.value;

  const errorMsg = window.document.getElementById("error-msg");

  if (itemName === "") {
    errorMsg.innerHTML = "";
  } else if (!validItems.includes(itemName)) {
    errorMsg.innerHTML = `${itemName} is not a valid item.`;
  } else {
    errorMsg.innerHTML = `${itemName} is valid!`;
  }
};

// Don't forget to export `handleItemName`
module.exports = { updateItemList, handleAddItem, handleItemName };

现在,为了让handleItemName能够显示其消息,向index.html添加一个新的p标签,其iderror-msg

列表 6.41 index.html

<!DOCTYPE html>
<html lang="en">
  < !-- ... -->
  <body>
    < !-- ... -->
    <p id="error-msg"></p>               ❶

    <form id="add-item-form">
      < !-- ... -->
    </form>
    <script src="bundle.js"></script>
  </body>
</html>

❶ 根据项目名称是否有效,向用户显示反馈的元素

如果你想要单独测试handleItemName函数,你可以作为一个练习,尝试为它编写一个单元测试,就像我们之前为handleAddItem函数所做的那样。你可以在本书 GitHub 仓库的chapter6/3_handling_events/1_handling_raw_events文件夹中找到一个如何编写此测试的完整示例,网址为github.com/lucasfcosta/testing-javascript-applications

注意:如前所述,在迭代过程中对函数进行单元测试可能很有用,但派发实际事件的测试要可靠得多。鉴于这两种测试具有高度的重叠和需要相似数量的代码,如果你必须选择一种,我建议你坚持使用使用元素的dispatchEvent的测试。

如果你习惯于在整个过程中不单独测试你的处理函数,那么可能更好的是编写只使用dispatchEvent的测试。

验证功能正常工作的最后一步是附加一个处理input事件的监听器,这些事件发生在项目名称的input中。更新你的main.js,并添加以下代码。

列表 6.42 main.js

const { handleAddItem, handleItemName } = require("./domController");

// ...

const itemInput = document.querySelector(`input[name="name"]`);
itemInput.addEventListener("input", handleItemName);               ❶

❶ 使用 handleItemName 处理来自 itemInput 的输入事件

提示:为了看到这个新功能,在通过npx http-server ./提供服务之前,不要忘记通过运行npm run build来重建bundle.js

现在你已经实现了验证功能,为它编写一个测试。这个测试必须设置输入的值并通过input节点派发一个input事件。在派发事件后,它应该检查文档是否包含成功消息。

列表 6.43 main.test.js

// ...

describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");
    itemField.value = "cheesecake";
    const inputEvent = new Event("input");                ❶

    itemField.dispatchEvent(inputEvent);                  ❷

    expect(screen.getByText("cheesecake is valid!"))      ❸
      .toBeInTheDocument();
  });
});

❶ 创建一个类型为 input 的“原生”Event 实例

❷ 通过字段名称派发事件

❸ 检查页面是否包含预期的反馈消息

作为练习,尝试编写一个测试来测试不愉快的路径。这个测试应该输入一个无效的项目名称,通过项目名称字段派发一个事件,并检查文档是否包含错误消息。

回到我们的应用程序需求——当项目的名称无效时显示错误消息是很好的,但如果我们不禁用用户提交表单,他们仍然可以向库存中添加无效的项目。我们也没有任何验证来防止用户在未指定数量时提交表单,这会导致显示 NaN

为了防止这些无效操作发生,你需要重构你的处理程序。而不是只监听在项目名称字段上发生的 input 事件,你将监听在表单子元素上发生的所有 input 事件。然后,表单将检查其子元素的值,并决定是否应该禁用提交按钮。

首先,将 handleItemName 重命名为 checkFormValues,并使其验证表单的两个字段中的值。

列表 6.44 domController.js

// ...

const validItems = ["cheesecake", "apple pie", "carrot cake"];
const checkFormValues = () => {
  const itemName = document.querySelector(`input[name="name"]`).value;
  const quantity = document.querySelector(`input[name="quantity"]`).value;

  const itemNameIsEmpty = itemName === "";
  const itemNameIsInvalid = !validItems.includes(itemName);
  const quantityIsEmpty = quantity === "";

  const errorMsg = window.document.getElementById("error-msg");
  if (itemNameIsEmpty) {
    errorMsg.innerHTML = "";
  } else if (itemNameIsInvalid) {
    errorMsg.innerHTML = `${itemName} is not a valid item.`;
  } else {
    errorMsg.innerHTML = `${itemName} is valid!`;
  }

  const submitButton = document.querySelector(`button[type="submit"]`);
  if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) {           ❶
    submitButton.disabled = true;
  } else {
    submitButton.disabled = false;
  }
};

// Don't forget to update your exports!
module.exports = { updateItemList, handleAddItem, checkFormValues };

❶ 根据表单字段中的值是否有效,禁用或启用表单的提交输入

现在更新 main.js,使其不再将 handleItemName 绑定到名称输入,而是将新的 checkFormValues 绑定到你的表单上。这个新的监听器将响应来自表单子元素的任何 input 事件。

列表 6.45 main.js

const { handleAddItem, checkFormValues } = require("./domController");

const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem);
form.addEventListener("input", checkFormValues);                 ❶

// Run `checkFormValues` once to see if the initial state is valid
checkFormValues();

checkFormValues 函数现在将处理表单中触发的任何输入事件,包括从表单子元素冒泡上来的输入事件。

注意:为了看到应用程序的工作情况,在提供之前,使用 npm run build 重新构建它,就像我们在本章中多次做的那样。

由于你保留了当用户输入无效项目名称时出现的错误消息,之前的针对项目名称验证的测试应该继续通过。但是,如果你尝试重新运行它们,你会看到它们失败了。

提示:要仅运行 main.test.js 中的测试,可以将 main.test.js 作为 jest 命令的第一个参数传递。

如果你从 node_modules 文件夹运行 jest,你的命令应该看起来像 ./node_modules/.bin/jest main.test.js

如果你已经添加了一个名为 test 的 NPM 脚本来运行 Jest,你应该运行 npm run test -- main.test.js

这些测试失败是因为你发出的事件将不会冒泡。例如,通过项目名称字段发出 input 事件时,它将不会触发其父元素(包括 form)上的任何监听器。因为 form 监听器没有执行,它不会向页面添加任何错误消息,导致你的测试失败。

为了通过使事件冒泡来修复你的测试,你必须在使用事件时传递一个额外的参数。这个额外的参数应该包含一个名为 bubbles 的属性,其值为 true。使用此选项创建的事件冒泡并触发元素父元素上的监听器。

列表 6.46 main.test.js

// ...

describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");
    itemField.value = "cheesecake";
    const inputEvent = new Event("input", { bubbles: true });    ❶

    itemField.dispatchEvent(inputEvent);                         ❷

    expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
  });
});

// ...

❶ 创建一个具有输入类型的“原生”事件实例,该实例可以通过发出它的元素的父母冒泡到元素的父母。

❷ 通过项目的名称字段分发事件。因为事件的冒泡属性被设置为 true,它将冒泡到表单,触发其监听器。

为了避免手动实例化和分发事件,dom-testing-library包含了一个名为fireEvent的实用工具。

使用fireEvent,你可以精确地模拟许多不同类型的事件,包括提交表单、按键和更新字段。因为fireEvent处理在特定组件上触发事件时你需要做的所有事情,它帮助你编写更少的代码,并且不必担心事件触发时发生的所有事情。

通过使用fireEvent而不是手动创建input事件,例如,你可以避免设置项目名称字段的value属性。fireEvent函数知道input事件通过它分发的方式改变了组件的值。因此,它会为你处理更改value

更新你的表单验证测试,以便使用来自dom-testing-libraryfireEvent实用工具。

列表 6.47 main.test.js

// ...

const { screen, getByText, fireEvent } = require("@testing-library/dom");

// ...

describe("item name validation", () => {
  test("entering valid item names ", () => {
    const itemField = screen.getByPlaceholderText("Item name");

    fireEvent.input(itemField, {                  ❶
      target: { value: "cheesecake" },
      bubbles: true
    });

    expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
  });
});

❶ 不要创建事件然后分发它,而是使用fireEvent.input在项目的名称字段上触发输入事件。

小贴士:如果你需要更准确地模拟用户事件,例如以特定速度输入,你可以使用由testing-library组织制作的user-event库。

当例如你有使用防抖验证的字段时,这个库特别有用:验证只在用户停止输入一段时间后才会触发。

你可以在github.com/testing-library/user-event查看@testing-library/user-event的完整文档。

作为练习,尝试更新所有其他测试,使它们使用fireEvent。我还建议处理与库存管理器的不同类型的交互并对其进行测试。例如,你可以尝试在用户双击项目列表中的名称时移除项目。

在本节之后,你应该能够编写测试来验证用户与你的页面交互的交互。尽管在迭代过程中手动构建事件以获得快速反馈是可以的,但这种测试并不能提供最可靠的质量保证。相反,为了更准确地模拟用户的行为——因此,创建更可靠的质量保证——你可以使用dispatchEvent来分发原生事件,或者使用第三方库来使这个过程更加方便。当涉及到捕获错误时,这种相似性将使你的测试更有价值,而且因为你不是试图手动重现事件的接口,所以它们将导致更少的维护开销。

6.4 测试和浏览器 API

一个设备齐全的厨房并不一定意味着能做出美味的甜点。当谈到其在烘焙美味蛋糕中的作用时,厨房的好坏仅取决于里面的糕点师。同样,在不太美味但同样有趣的网页开发世界中,浏览器提供的出色 API 只有在您的应用程序正确与之接口时才有帮助。

正如我在本章前面提到的,由于浏览器提供给您的代码的方法,您可以构建功能丰富的应用程序。例如,您可以获取用户的位置,发送通知,浏览应用程序的历史记录,或者在浏览器中存储将在各个部分之间持久化的数据。现代浏览器甚至允许您与蓝牙设备交互并进行语音识别。

在本章中,您将学习如何测试涉及这些 API 的功能。您将了解它们的来源,如何检查它们,以及如何编写适当的测试替身来帮助您处理事件处理器,而不会干扰您的应用程序代码。

您将学习如何通过将两个 DOM API 集成到您的前端应用程序中来测试这些 DOM API:localStoragehistory。通过使用 localStorage,您将使您的应用程序在浏览器中持久化其数据,并在页面加载时恢复它。然后,使用历史 API,您将允许用户撤销向库存中添加项目。

6.4.1 测试 localStorage 集成

localStorage 是 Web Storage API 的一部分机制。它允许应用程序在浏览器中存储键值对,并在稍后日期检索它们。您可以在 developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Local_storage 找到关于 localStorage 的文档。

通过学习如何测试像 localStorage 这样的 API,您将了解它们在测试环境中的工作方式以及如何验证您的应用程序与它们的集成。

在这些示例中,您将持久化存储用于更新页面的库存到 localStorage。然后,当页面加载时,您将从 localStorage 中检索库存,并使用它再次填充列表。这个功能将使您的应用程序在会话之间不会丢失数据。

首先,更新 updateItemList 以使其在 localStorage 中的 inventory 键下存储传递给它的对象。因为 localStorage 不能存储对象,所以在持久化数据之前,您需要使用 JSON.stringify 序列化 inventory

列表 6.48 domController.js

// ...

const updateItemList = inventory => {
  if (!inventory === null) return;

  localStorage.setItem("inventory", JSON.stringify(inventory));      ❶

  // ...
}

❶ 在浏览器本地存储中存储序列化的库存

现在您正在将用于填充页面的项目列表保存到 localStorage 中,更新 main.js,使其在页面加载时检索 inventory 键下的数据。然后,用这个数据调用 updateItemList

列表 6.49 main.js

// ...

const storedInventory = JSON.parse(localStorage.getItem("inventory"));     ❶

if (storedInventory) {
  data.inventory = storedInventory;                                        ❷
  updateItemList(data.inventory);                                          ❸
}

❶ 在页面加载时从本地存储检索并反序列化库存

❷ 使用先前存储的数据更新应用程序的状态

❸ 使用恢复的库存更新项目列表

在此更改之后,当你重新构建你的应用程序并刷新你正在服务的页面时,你会看到数据在会话之间保持持久。如果你向库存中添加一些项目并再次刷新页面,你会看到之前会话中的项目将保留在列表中。

为了测试这些功能,我们将再次依赖 JSDOM。与在浏览器中localStorage是全局的且在window下可用一样,在 JSDOM 中,它也在你的 JSDOM 实例的window属性下可用。多亏了 Jest 的环境设置,这个实例在每个测试文件的全球命名空间中都是可用的。

由于这个基础设施,你可以使用与在浏览器控制台中相同的代码行来测试你的应用程序与localStorage的集成。通过使用 JSDOM 的实现而不是存根,你的测试将更接近浏览器的运行时,因此将更有价值。

TIP 作为一条经验法则,每当 JSDOM 实现了你想要集成的浏览器 API 时,就使用它。通过避免测试替身,你的测试将更接近运行时发生的情况,因此将变得更加可靠。

继续添加一个验证updateItemList及其与localStorage集成的测试。这个测试将遵循三个 A 模式。它将创建一个库存,执行updateItemList函数,并检查localStorageinventory键是否包含预期的值。

此外,你应该添加一个beforeEach钩子,在每个测试运行之前清除localStorage。这个钩子将确保任何其他使用localStorage的测试不会干扰这个测试的执行。

列表 6.50 domController.test.js

// ...

describe("updateItemList", () => {
  beforeEach(() => localStorage.clear());

  // ...

  test("updates the localStorage with the inventory", () => {
    const inventory = { cheesecake: 5, "apple pie": 2 };
    updateItemList(inventory);

    expect(localStorage.getItem("inventory")).toEqual(
      JSON.stringify(inventory)
    );
  });
});

// ...

正如我之前提到的,多亏了 JSDOM 和 Jest 的环境设置,你可以在你的测试和被测试的单元中同时使用全局命名空间中的localStorage,如图 6.6 所示。

图片

图 6.6 无论是你的测试还是被测试的单元,都将能够访问由 JSDOM 提供的相同全局localStorage

注意,这个测试并没有提供一个非常可靠的质量保证。它并没有检查应用程序是否使用updateItemList作为任何事件的处理器,或者当页面重新加载时是否恢复库存。尽管它并没有告诉你关于应用程序整体功能太多信息,但它是一个快速迭代或获取细粒度反馈的好测试,尤其是在编写起来如此容易的情况下。

从现在开始,您可以在各种隔离级别上编写许多不同类型的测试。例如,您可以编写一个测试,填写表单,点击提交按钮,并检查localStorage是否已更新。这个测试的范围比之前的更广,因此它在测试金字塔中位置更高,但它仍然不会告诉您应用程序在用户刷新页面后是否会重新加载数据。

或者,您可以直接进行更复杂的端到端测试,该测试将填写表单,点击提交按钮,检查localStorage中的内容,并且刷新页面以查看项目列表是否在会话之间保持填充。因为这个端到端测试与运行时发生的情况非常相似,它提供了更可靠的保证。这个测试与之前提到的测试完全重叠,因此它为您节省了重复测试代码的精力。本质上,它只是将更多的操作打包到一个测试中,并帮助您保持测试代码库小且易于维护。

因为您不会重新加载页面的脚本,所以您可以重新分配 HTML 的内容到document.body.innerHTML,并再次执行main.js,就像您在main.test.js中的beforeEach钩子中做的那样。

尽管目前这个测试将是这个文件中唯一使用localStorage的测试,但添加一个在每次测试之前清除localStoragebeforeEach钩子是个好主意。通过现在添加这个钩子,您将来就不会浪费时间在思考为什么涉及此 API 的其他测试失败。

下面是这个测试应该看起来像什么。

列表 6.51 main.test.js

// ...

beforeEach(() => localStorage.clear());

test("persists items between sessions", () => {
  const itemField = screen.getByPlaceholderText("Item name");
  fireEvent.input(itemField, {
    target: { value: "cheesecake" },
    bubbles: true
  });

  const quantityField = screen.getByPlaceholderText("Quantity");
  fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true });

  const submitBtn = screen.getByText("Add to inventory");
  fireEvent.click(submitBtn);                                       ❶

  const itemListBefore = document.getElementById("item-list");
  expect(itemListBefore.childNodes).toHaveLength(1);
  expect(
    getByText(itemListBefore, "cheesecake - Quantity: 6")
  ).toBeInTheDocument();

  document.body.innerHTML = initialHtml;                            ❷
  jest.resetModules();                                              ❸
  require("./main");                                                ❹

  const itemListAfter = document.getElementById("item-list");       ❺
  expect(itemListAfter.childNodes).toHaveLength(1);                 ❺
  expect(                                                           ❺
    getByText(itemListAfter, "cheesecake - Quantity: 6")
  ).toBeInTheDocument();
});

// ...

❶ 在填写完表单后,提交它以便应用程序可以存储库存的状态

❷ 在这种情况下,这个重新分配相当于重新加载页面。

❸ 为了在再次导入时运行 main.js,别忘了您必须清除 Jest 的缓存。

❹ 再次执行 main.js 以便应用程序恢复存储的状态

❺ 检查页面的状态是否与重新加载之前存储的状态相匹配

现在您已经了解了浏览器 API 的来源,它们是如何提供给您的测试的,以及您如何使用它们来模拟浏览器行为,尝试添加一个类似的功能并自行测试。作为一个练习,您还可以尝试持久化操作日志,以便在会话之间保持完整。

6.4.2 测试 History API 集成

History API 允许开发者与特定标签页或框架中的用户导航历史进行交互。应用程序可以将新状态推入history,并回滚或重放它。您可以在developer.mozilla.org/en-US/docs/Web/API/History找到关于 History API 的文档。

通过学习如何测试 History API,你将学习如何使用测试替身操作事件监听器,以及如何执行依赖于异步触发的事件的断言。这种知识不仅对测试涉及 History API 的功能很有用,而且在你需要与默认情况下可能无法访问的监听器交互时也很有用。

在进行测试之前,你将实现“撤销”功能。

为了允许用户将项目撤销到库存中,更新 handleAddItem 以便在用户添加项目时将新的状态推入库存。

列表 6.52 domController.js

// ...

const handleAddItem = event => {
  event.preventDefault();

  const { name, quantity } = event.target.elements;
  addItem(name.value, parseInt(quantity.value, 10));

  history.pushState(                                   ❶
    { inventory: { ...data.inventory } },
    document.title
  );

  updateItemList(data.inventory);
};

// ...

❶ 将包含库存内容的新的框架推入历史记录

注意:JSDOM 的 history 实现存在一个错误,即在将状态分配之前,不会对推入的状态进行克隆。相反,JSDOM 的 history 将保留传递给它的对象的引用。

因为你在用户添加项目时修改了 inventory,JSDOM 的 history 中的上一个框架将包含库存的最新版本,而不是上一个版本。因此,回滚到以前的状态将不会按预期工作。

为了避免这个问题,你可以通过使用 { ... data.inventory } 来自己创建一个新的 data.inventory

JSDOM 的 DOM API 实现永远不应该与浏览器中的实现不同,但由于它是一个完全不同的软件,这种情况可能会发生。

此问题已在 github.com/jsdom/jsdom/issues/2970 上进行调查,但如果你偶然发现类似这样的 JSDOM 错误,最快速的解决方案是自行修复,通过更新你的代码以在 JSDOM 中表现得像在浏览器中一样。如果你有时间,我强烈建议你也在上游 jsdom 存储库中提交一个问题,如果可能的话,创建一个拉取请求来修复它,这样其他人就不会在未来遇到相同的问题。

现在,创建一个当用户点击“撤销”按钮时将被触发的函数。如果用户尚未处于历史记录中的第一个项目,此函数应通过调用 history.back 来回退。

列表 6.53 domController.js

// ...

const handleUndo = () => {
  if (history.state === null) return;        ❶
  history.back();                            ❷
};

module.exports = {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo                                 ❸
};

❶ 如果 history.state 为 null,则意味着我们已经在历史记录的非常开始处。

❷ 如果 history.state 不为 null,则使用 history.back 弹出历史记录的最后一个框架

❸ 你将不得不使用 handleUndo 来处理事件。别忘了导出它。

因为 history.back 是异步发生的,你还必须创建一个用于窗口 popstate 事件的处理器,该事件在 history.back 完成时被触发。

列表 6.54 domController.js

const handlePopstate = () => {
  data.inventory = history.state ? history.state.inventory : {};
  updateItemList(data.inventory);
};

// Don't forget to update your exports.
module.exports = {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate           ❶
};

❶ 也导出 handlePopstate,这样你就可以在 main.js 中稍后将其附加到窗口的 popstate 事件。

index.html 中添加一个撤销按钮,我们将使用它来触发 handleUndo

列表 6.55 index.html

<!DOCTYPE html>
<html lang="en">
  <!-- ... -->
  <body>
    <!-- ... -->

    <button id="undo-button">Undo</button>        ❶

    <script src="bundle.js"></script>
  </body>
</html>

❶ 将触发“撤销”操作的按钮

最后,让我们把所有东西放在一起,更新 main.js,以便当用户点击撤销按钮时调用 handleUndo,并且当触发 popstate 事件时列表得到更新。

注意:关于 popstate 事件有趣的是,当用户按下浏览器的后退按钮时,它们也会被触发。因为你的 popstate 处理器与 handleUndo 分开,所以当用户按下浏览器的后退按钮时,撤销功能也将工作。

列表 6.56 main.js

const {
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate
} = require("./domController");

// ...

const undoButton = document.getElementById("undo-button");
undoButton.addEventListener("click", handleUndo);               ❶

window.addEventListener("popstate", handlePopstate);

// ...

❶ 当用户点击撤销按钮时调用 handleUndo

就像你之前做的那样,通过运行 Browserify 重新构建 bundle.js,并用 http-server 提供服务,这样你就可以在 localhost:8080 上看到它的工作情况。

实现了这个功能后,是时候对其进行测试了。因为这个功能涉及多个函数,所以我们将它的测试分成几个不同的部分。首先,你将学习如何测试 handleUndo 函数,检查它在被调用时是否返回历史。然后,你将编写一个测试来检查 handlePopstate 是否与 updateItemList 充分集成。最后,你将编写一个端到端测试,填写表单,提交一个项目,点击撤销按钮,并检查列表是否按预期更新。

从对 handleUndo 的单元测试开始。它应该遵循三个 A 的模式:arrange(准备)、act(行动)、assert(断言)。它将状态推入全局的 history(由于 JSDOM 而可用),调用 handleUndo,并检查 history 是否回到了初始状态。

注意:因为 history.back 是异步的,正如我已经提到的,你必须在 popstate 事件触发后执行你的断言。

在这种情况下,使用 done 回调来指示测试何时完成可能更简单、更清晰,而不是像我们到目前为止大多数时候所做的那样使用异步测试回调。

如果你忘记了 done 的工作原理以及它与使用承诺的比较,请再次查看第二章中“集成测试”部分的示例。

列表 6.57 domController.test.js

const {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo
} = require("./domController");

// ...

describe("tests with history", () => {
  describe("handleUndo", () => {
    test("going back from a non-initial state", done => {
      window.addEventListener("popstate", () => {               ❶
        expect(history.state).toEqual(null);
        done();
      });

      history.pushState(                                        ❷
        { inventory: { cheesecake: 5 } },
        "title"
      );
      handleUndo();                                             ❸
    });
  });
});

// ...

❶ 检查历史是否回到初始状态,并在触发 popstate 事件时结束测试

❷ 将新的框架推入历史记录

❸ 练习 handleUndo 函数以触发 popstate 事件

当单独运行这个测试时,它会通过,但如果它在同一文件中的其他测试之后运行,它将会失败。因为其他测试之前已经使用了 handleAddItem,它们已经干扰了 handleUndo 测试开始的初始状态。为了解决这个问题,你必须在每次测试之前重置历史记录。

继续创建一个 beforeEach 钩子,不断调用 history.back 直到它回到初始状态。一旦它达到初始状态,它应该断开自己的监听器,这样它就不会干扰测试。

列表 6.58 domController.test.js

// ...

describe("tests with history", () => {
  beforeEach(done => {
    const clearHistory = () => {
      if (history.state === null) {                                   ❶
        window.removeEventListener("popstate", clearHistory);
        return done();
      }

      history.back();                                                 ❷
    };

    window.addEventListener("popstate", clearHistory);                ❸

    clearHistory();                                                   ❹
  });

  describe("handleUndo", () => { /* ... */ });
});

❶ 如果你已经在历史的初始状态,则从监听 popstate 事件中断开连接并完成钩子

❷ 如果历史状态尚未处于初始状态,通过调用history.back函数触发另一个popstate事件

❸ 使用clearHistory函数处理popstate事件

❹ 首次调用clearHistory,导致历史回滚

你刚刚编写的测试中还存在另一个问题,那就是它将一个监听器附加到全局window上,在测试完成后并没有移除它。由于监听器没有被移除,每次发生popstate事件时,它仍然会被触发,即使那个测试已经完成。这些激活可能会使其他测试失败,因为完成测试的断言会再次运行。

为了在每次测试后断开所有popstate事件的监听器,我们必须监视windowaddEventListener方法,以便我们可以检索测试期间添加的监听器并将它们移除,如图 6.7 所示。

图 6.7 要查找事件处理器,你可以监视windowaddEventListener函数,并通过传递给第一个参数的事件名称过滤其调用。

要查找和断开事件监听器,请将以下代码添加到你的测试中。

列表 6.59 domController.test.js

// ...

describe("tests with history", () => {
  beforeEach(() => jest.spyOn(window, "addEventListener"));      ❶

  afterEach(() => {
    const popstateListeners = window                             ❷
      .addEventListener
      .mock
      .calls
      .filter(([ eventName ]) => {
        return eventName === "popstate"
      });

    popstateListeners.forEach(([eventName, handlerFn]) => {      ❸
      window.removeEventListener(eventName, handlerFn);
    });

    jest.restoreAllMocks();
  });

  describe("handleUndo", () => { /* ... */ });
});

❶ 使用间谍来跟踪添加到window上的每个监听器

❷ 查找所有popstate事件的监听器

❸ 从window中移除所有popstate事件的监听器

接下来,我们需要确保如果用户已经处于初始状态,handleUndo将不会调用history.back。在这个测试中,你无法在执行断言之前等待popstate事件,因为如果handleUndo没有调用history.back(如预期的那样),它将永远不会发生。你也不能在调用handleUndo后立即编写断言,因为当你的断言运行时,history.back可能已经被调用,但可能尚未完成。为了充分执行这个断言,我们将监视history.back并断言它没有被调用——这是我们讨论的第三章中讨论的少数几种否定断言足够的情况之一。

列表 6.60 domController.test.js

// ...

describe("tests with history", () => {
  // ...

  describe("handleUndo", () => {
    // ...

    test("going back from an initial state", () => {
      jest.spyOn(history, "back");
      handleUndo();

      expect(history.back.mock.calls).toHaveLength(0);       ❶
    });
  });
});

❶ 这个断言并不关心history.back是否已经完成历史栈的回滚。它只检查history.back是否被调用过。

你刚刚编写的测试只覆盖了handleUndo及其与history.back的交互。在测试金字塔中,它们位于单元测试和集成测试之间。

现在,编写覆盖handlePopstate的测试,它也使用了handleAddItem。这个测试的范围更广,因此它在测试金字塔中的位置比之前的更高。

这些测试应该将状态推入历史记录,调用handlePopstate,并检查应用程序是否适当地更新了项目列表。在这种情况下,你需要编写 DOM 断言,就像我们在上一节中所做的那样。

列表 6.61 domController.test.js

const {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate
} = require("./domController");

// ...

describe("tests with history", () => {
  // ...

  describe("handlePopstate", () => {
    test("updating the item list with the current state", () => {
      history.pushState(                                                 ❶
        { inventory: { cheesecake: 5, "carrot cake": 2 } },
        "title"
      );

      handlePopstate();                                                  ❷

      const itemList = document.getElementById("item-list");
      expect(itemList.childNodes).toHaveLength(2);                       ❸
      expect(getByText(itemList, "cheesecake - Quantity: 5"))            ❹
        .toBeInTheDocument();
      expect(
        getByText(itemList, "carrot cake - Quantity: 2")                 ❺
      ).toBeInTheDocument();
    });
  });
});

❶ 将包含库存内容的新的帧推入历史记录

❷ 调用 handlePopstate 以使应用程序使用当前历史帧中的状态更新自己

❸ 断言项目列表恰好有两个项目

❹ 找到一个元素表明库存中有 5 个芝士蛋糕,然后断言它在文档中

❺ 找到一个元素表明库存中有 2 个胡萝卜蛋糕,然后断言它在文档中

注意:如果您想完全隔离地测试 handlePopstate,您可以找到一种为 updateItemList 创建存根的方法,但正如我们之前讨论的,您使用的测试替身越多,您的测试就越不像运行时的情况,因此它们就越不可靠。

当运行您刚才编写的测试及其钩子时,会发生以下情况:

  1. 最顶层的 beforeEach 钩子将 initialHtml 赋值给文档的 body innerHTML

  2. 在此测试的 describe 块内的第一个 beforeEach 钩子监视 windowaddEventListener 方法,以便它可以跟踪将要附加到它上的所有监听器。

  3. 在此测试的 describe 块内的第二个 beforeEach 钩子将浏览器的历史记录重置为其初始状态。它是通过将一个事件监听器附加到 window 上来实现的,该监听器在状态为 null 之前为每个 popstate 事件调用 history.back。一旦历史记录被清除,它就断开监听器,从而清除 history

  4. 测试本身运行。它将一个状态推送到历史记录中,执行 handlePopstate,并检查页面是否包含预期的元素。

  5. 测试的 afterEach 钩子运行。它使用 window.addEventListener.mock.calls 中的记录来发现响应 windowpopstate 事件的监听器,并将它们断开连接。

作为练习,尝试编写一个测试,覆盖 handleAddItem 和 History API 之间的集成。创建一个调用 handleAddItem 并检查状态是否已更新为库存中添加的项目。

现在您已经学会了如何测试 handleUndo 的隔离和 handlePopstate 以及它与 updateItemList 的集成,您将编写一个端到端测试,将所有内容组合在一起。这个端到端测试是您可以创建的最可靠的保证。它将以用户的方式与应用程序交互,通过页面元素触发事件,并检查 DOM 的最终状态。

要运行此端到端测试,您还需要清除全局 history 栈。否则,可能已导致历史记录发生变化的其他测试可能会使其失败。为了避免在多个测试中复制和粘贴相同的代码,创建一个包含清除 history 的函数的单独文件,如下所示。

列表 6.62 testUtils.js

const clearHistoryHook = done => {
  const clearHistory = () => {
    if (history.state === null) {
      window.removeEventListener("popstate", clearHistory);
      return done();
    }

    history.back();
  };

  window.addEventListener("popstate", clearHistory);

  clearHistory();
};

module.exports = { clearHistoryHook };

现在你已经将清除 history 栈的函数移动到单独的文件中,你可以在钩子中导入和使用它,而不是每次都重写相同的内联函数。例如,你可以回到 domController.test.js 并使用 clearHistoryHook 来替换你那里编写的冗长的内联钩子。

列表 6.63 domController.test.js

// ...

const { clearHistoryHook } = require("./testUtils");

// ...

describe("tests with history", () => {
   // ...

  beforeEach(clearHistoryHook);             ❶

  // ...
});

❶ 代替内联函数,使用单独的 clearHistoryHook 来重置历史状态到初始状态

最后,将相同的钩子添加到 main.test.js 中,并编写一个测试,通过表单添加项目,点击撤销按钮,并检查列表的内容,就像用户一样。

列表 6.64 main.test.js

const { clearHistoryHook } = require("./testUtils.js");

describe("adding items", () => {
  beforeEach(clearHistoryHook);

  // ...

  test("undo to empty list", done => {
    const itemField = screen.getByPlaceholderText("Item name");
    const submitBtn = screen.getByText("Add to inventory");
    fireEvent.input(itemField, {                                           ❶
      target: { value: "cheesecake" },
      bubbles: true
    });

    const quantityField = screen.getByPlaceholderText("Quantity");
    fireEvent.input(quantityField, {                                       ❷
      target: { value: "6" },
      bubbles: true
    });

    fireEvent.click(submitBtn);                                            ❸

    expect(history.state).toEqual({ inventory: { cheesecake: 6 } });       ❹

    window.addEventListener("popstate", () => {                            ❺
      const itemList = document.getElementById("item-list");
      expect(itemList).toBeEmpty();
      done();
    });

    fireEvent.click(screen.getByText("Undo"));                             ❻
  });
});

❶ 填写项目名称的字段

❷ 填写项目数量的字段

❸ 提交表单

❹ 检查历史状态是否在期望的状态

❺ 当发生 popstate 事件时,检查项目列表是否为空,并完成测试

❻ 通过点击撤销按钮触发 popstate 事件

如之前发生的那样,当单独执行时,此测试将始终通过,但如果它与同一文件中的其他测试一起运行,这些测试会触发 popstate 事件,它可能会导致它们失败。这种失败发生是因为它将断言监听器附加到 window 上,即使在测试完成后,这些断言也会继续运行,就像之前一样。

如果你想要看到它失败,尝试添加一个测试,在当前测试之前也触发一个 popstate 事件。例如,你可以编写一个新的测试,将多个项目添加到库存中,并只点击一次撤销按钮,如下所示。

列表 6.65 main.test.js

// ...
describe("adding items", () => {
  // ...

  test("undo to one item", done => {
    const itemField = screen.getByPlaceholderText("Item name");
    const quantityField = screen.getByPlaceholderText("Quantity");
    const submitBtn = screen.getByText("Add to inventory");

    // Adding a cheesecake
    fireEvent.input(itemField, {
      target: { value: "cheesecake" },
      bubbles: true
    });
    fireEvent.input(quantityField, {
      target: { value: "6" },
      bubbles: true
    });
    fireEvent.click(submitBtn);                                     ❶

    // Adding a carrot cake
    fireEvent.input(itemField, {
      target: { value: "carrot cake" },
      bubbles: true
    });
    fireEvent.input(quantityField, {
      target: { value: "5" },
      bubbles: true
    });
    fireEvent.click(submitBtn);                                     ❷

    window.addEventListener("popstate", () => {                     ❸
      const itemList = document.getElementById("item-list");
      expect(itemList.children).toHaveLength(1);
      expect(
        getByText(itemList, "cheesecake - Quantity: 6")
      ).toBeInTheDocument();
      done();
    });

    fireEvent.click(screen.getByText("Undo"));                      ❹
  });

  test("undo to empty list", done => { /* ... */ });
});

// ...

❶ 提交表单,将 6 个芝士蛋糕添加到库存中

❷ 再次提交表单,将 5 个胡萝卜蛋糕添加到库存中

❸ 当发生 popstate 事件时,检查项目列表是否包含你期望的元素,并完成测试

❹ 通过点击撤销按钮触发 popstate 事件

当运行你的测试时,你会看到它们失败,因为所有之前附加到窗口 popstate 事件的处理器都会执行,无论之前的测试是否完成。

你可以用与在 domController.test.js 中测试相同的方式解决这个问题:通过跟踪对 window.addEventListener 的调用,并在每个测试后断开处理程序。

因为你会重用你在 domController.test.js 中编写的钩子,所以也要将它移动到 testUtils.js 中,如下所示。

列表 6.66 testUtils.js

// ...

const detachPopstateHandlers = () => {
  const popstateListeners = window.addEventListener.mock.calls      ❶
    .filter(([eventName]) => {
      return eventName === "popstate";
    });

  popstateListeners.forEach(([eventName, handlerFn]) => {           ❷
    window.removeEventListener(eventName, handlerFn);
  });

  jest.restoreAllMocks();
}

module.exports = { clearHistoryHook, detachPopstateHandlers };

❶ 查找 popstate 事件的全部监听器

❷ 断开所有 popstate 监听器

现在,你可以在 domController.test.js 中使用 detachPopstateHandlers 而不是编写内联函数。

列表 6.67 domController.test.js

const {
  clearHistoryHook,
  detachPopstateHandlers
} = require("./testUtils");

// ...

describe("tests with history", () => {
  beforeEach(() => jest.spyOn(window, "addEventListener"));      ❶
  afterEach(detachPopstateHandlers);                             ❷

  // ...
});

❶ 使用 spy 跟踪添加到窗口的每个事件监听器

❷ 代替使用内联函数来断开 popstate 事件的监听器,使用 detachPopstateHandlers

当在main.test.js中使用detachPopstateHandlers时,你必须小心在每个测试后断开窗口的所有监听器,否则,main.js附加的监听器可能会意外地被断开。为了避免移除main.js附加的监听器,确保你在执行main.js之后才对window.addEventListener进行间谍操作,如图 6.8 所示。

图 6.8

图 6.8 你的间谍应该捕获在执行main.js之后发生的调用。

然后,添加带有detachPopstateHandlersafterEach钩子。

列表 6.68 main.test.js

// ...

const {
  clearHistoryHook,
  detachPopstateHandlers
} = require("./testUtils");

beforeEach(clearHistoryHook);

beforeEach(() => {
  document.body.innerHTML = initialHtml;
  jest.resetModules();
  require("./main");

  jest.spyOn(window, "addEventListener");          ❶
});

afterEach(detachPopstateHandlers);

describe("adding items", () => { /* ... */ });

❶ 你只能在 main.js 执行之后对 window.add-EventListener 进行间谍操作。否则,detachPopstateHandlers 也会断开 main.js 附加到页面上的处理程序。

注意:重要的是要注意这些测试有很高的重叠度。

由于你已经为这个功能的一部分的各个函数以及包括与 DOM 交互在内的整个功能编写了测试,你将有一些冗余的检查。

根据你希望反馈有多细粒度以及你有多少可用时间,你应该考虑只编写端到端测试,这提供了对所有测试的最全面覆盖。另一方面,如果你有时间,并且希望在编写代码时有一个更快的反馈循环,编写细粒度测试也将是有用的。

作为练习,尝试添加一个“重做”功能并为其编写测试。

现在你已经测试了与localStorage和 History API 的集成,你应该知道 JSDOM 负责在测试环境中模拟它们。多亏了 Jest,这些 JSDOM 在其实例的window属性中存储的值将通过全局命名空间对测试可用。你可以像在浏览器中一样使用它们,无需使用存根。避免这些存根可以增加你的测试创建的可靠性保证,因为它们的实现应该反映浏览器运行时发生的情况。

正如我们在本章中一直所做的那样,在测试你的前端应用程序时,请注意你的测试重叠程度以及你想要达到的反馈粒度。考虑这些因素来决定你应该编写哪些测试,以及不应该编写哪些测试,就像我们在上一章中讨论的那样。

6.5 处理 WebSocket 和 HTTP 请求

在本章的前几节中,你已经构建了一个前端应用程序,该程序将数据本地存储。由于你的客户端没有共享后端,当多个用户更新库存时,每个人都会看到不同的项目列表。

在本节中,为了在客户端之间同步项目,您将集成前端应用程序与第四章中的后端,并学习如何测试该集成。到本节结束时,您将拥有一个可以读取、插入和更新数据库项的应用程序。为了避免用户需要刷新页面才能看到他人所做的更改,您还将实现实时更新,这些更新将通过 WebSocket 完成。

注意:您可以在 github.com/lucasfcosta/testing-javascript-applications 找到上一章后端代码的完整代码。

这个后端将处理来自网络客户端的请求,向其提供数据并更新数据库条目。

为了使本章专注于测试并确保服务器将支持我们正在构建的客户端,我强烈建议您使用我推送到 GitHub 的后端应用程序。它已经包含了一些更新,以更好地支持以下示例,这样您就不必自己更改后端。

要运行它,请导航到 chapter6/5_web_ sockets_and_http_requests 中的 server 文件夹,使用 npm install 安装其依赖项,运行 npm run migrate:dev 以确保您的数据库具有最新的模式,然后使用 npm start 启动它。

如果您想自己更新后端,在 server 文件夹中有一个 README.md 文件,其中详细说明了我必须对我们在第四章中构建的应用程序所做的所有更改。

6.5.1 涉及 HTTP 请求的测试

开始您的后端集成,通过将用户添加到库存中的项目保存到数据库中。为了实现此功能,每当用户添加项目时,向第四章中添加到服务器的新的 POST /inventory/:itemName 路由发送请求。此请求的主体应包含添加的 quantity

更新 addItem 函数,使其在用户添加项目时向后端发送请求,如下所示。

列表 6.69 inventoryController.js

const data = { inventory: {} };

const API_ADDR = "http://localhost:3000";

const addItem = (itemName, quantity) => {
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;

  fetch(`${API_ADDR}/inventory/${itemName}`, {               ❶
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ quantity })
  });

  return data.inventory;
};

module.exports = { API_ADDR, data, addItem };

❶ 在添加项目时向库存发送 POST 请求

在您编写检索库存中项目的请求之前,让我们讨论一下测试您刚刚实现的功能的最佳方法。您会如何测试 addItem 函数是否正确与后端接口?

测试这种集成的一个次优方法是在服务器启动并允许请求到达它。起初,这可能看起来是最直接的选择,但实际上,这是需要更多工作且收益更少的方法。

由于必须运行后端以通过客户端的测试,这给测试过程增加了复杂性,因为它涉及太多步骤,并创造了太多人为错误的空间。开发者很容易忘记他们必须运行服务器,而且他们更容易忘记服务器应该监听哪个端口,或者数据库应该处于哪种状态。

即使你可以自动化这些步骤,但最好避免这样做。更好的做法是将这种集成留给端到端 UI 测试,你将在第十章中学习到。通过避免使用后端来运行客户端测试,你也会使设置持续集成服务变得更容易,这些服务将在远程环境中执行你的测试,我将在第十二章中介绍。

考虑到你不想将这些测试与后端关联起来,你只有一个选择:使用测试替身来控制对 fetch 的响应。你可以通过两种方式做到这一点:你可以模拟 fetch 本身,编写断言来检查它是否被充分使用,并指定一个硬编码的响应。或者,你可以使用 nock 来替代对服务器的需求。使用 nock,你会确定要匹配哪些路由以及提供哪些响应,使你的测试与实现细节(例如你传递给 fetch 的参数或你用于执行请求的库)的耦合度更低。由于这些优势,我在第四章中提到过,我建议你选择第二种方案。

由于 nock 依赖于请求到达你的拦截器,首先,确保你的测试可以在 node 中运行,并且可以发出请求。为此,运行你的测试,看看会发生什么。在运行时,你会注意到所有调用 handleAddItem 的测试都会失败,因为 "fetch is not defined"

即使 fetch 在浏览器中全局可用,它通过 JSDOM 还不可用,因此你需要找到一种方法来用等效的实现来替换它。要覆盖它,你可以使用一个设置文件,该文件将 isomorphic-fetch(一个可以在 Node.js 中运行的 fetch 实现)附加到全局命名空间。

使用 npm install --save-dev isomorphic-fetchisomorphic-fetch 安装为开发依赖项,并创建一个 setupGlobalFetch.js 文件,它将将其附加到全局命名空间。

列表 6.70 setupGlobalFetch.js

const fetch = require("isomorphic-fetch");

global.window.fetch = fetch;           ❶

❶ 用 isomorphic-fetch 的 fetch 函数替换 window 的原始 fetch

一旦创建了此文件,将其添加到 jest.config.js 文件的 setupFilesAfterEnv 属性中的脚本列表中,如下所示代码所示,这样 Jest 就可以在测试之前运行它,使 fetch 对测试可用。

列表 6.71 jest.config.js

module.exports = {
  setupFilesAfterEnv: [
    "<rootDir>/setupGlobalFetch.js",
    "<rootDir>/setupJestDom.js"
  ]
};

这些更改之后,如果你没有可用的服务器,你的测试应该会失败,因为 fetch 发出的请求无法得到响应。

最后,是时候使用 nock 来拦截这些请求的响应了。

nock 作为开发依赖项安装(npm install --save-dev nock),并更新你的测试,以便它们有 /inventory 路由的拦截器。

列表 6.72 inventoryController.test.js

const nock = require("nock");
const { API_ADDR, addItem, data } = require("./inventoryController");

describe("addItem", () => {
  test("adding new items to the inventory", () => {
    nock(API_ADDR)                                      ❶
      .post(/inventory\/.*$/)
      .reply(200);

    addItem("cheesecake", 5);
    expect(data.inventory.cheesecake).toBe(5);
  });
});

❶ 响应所有对 POST /inventory/:itemName 的 POST 请求

尝试只为此文件运行测试。要做到这一点,将文件名作为 Jest 的第一个参数传递。你会看到测试通过了。

现在,添加一个测试来确保 POST /inventory/:itemName 的拦截器已被触发。

列表 6.73 inventoryController.test.js

// ...

afterEach(() => {
  if (!nock.isDone()) {                     ❶
    nock.cleanAll();
    throw new Error("Not all mocked endpoints received requests.");
  }
});

describe("addItem", () => {
  // ...

  test("sending requests when adding new items", () => {
    nock(API_ADDR)
      .post("/inventory/cheesecake", JSON.stringify({ quantity: 5 }))
      .reply(200);

    addItem("cheesecake", 5);
  });
});

❶ 如果测试后,并非所有拦截器都已触发,则清除它们并抛出错误

作为练习,请继续使用 nock 来拦截所有到达此路由的其他测试中对 /inventory/:itemNamePOST 请求。如果你需要帮助,请查看此书的 GitHub 仓库,网址为 github.com/lucasfcosta/testing-javascript-applications

当你更新其他测试时,不要忘记在多个集成级别上检查特定操作是否调用此路由。例如,我建议向 main.test.js 添加一个测试,以确保通过 UI 添加项目时能够到达正确的路由。

提示:拦截器一旦被触发就会移除。为了避免测试因为 fetch 无法获取响应而失败,你必须在每次测试之前创建一个新的拦截器,或者使用第四章中提到的 nock 的 persist 方法。

为了使此功能完整,你的前端必须在加载时向服务器请求库存项目。在此更改之后,它应该只在无法到达服务器时才从 localStorage 加载数据。

列表 6.74 main.js

// ...
const { API_ADDR, data } = require("./inventoryController");

// ...

const loadInitialData = async () => {
  try {
    const inventoryResponse = await fetch(`${API_ADDR}/inventory`);
    if (inventoryResponse.status === 500) throw new Error();

    data.inventory = await inventoryResponse.json();
    return updateItemList(data.inventory);                        ❶
  } catch (e) {
    const storedInventory = JSON.parse(                           ❷
      localStorage.getItem("inventory")
    );

    if (storedInventory) {
      data.inventory = storedInventory;
      updateItemList(data.inventory);
    }
  }
};

module.exports = loadInitialData();

❶ 如果请求成功,则使用服务器的响应更新项目列表

❷ 如果请求失败,则从 localStorage 中恢复库存

即使你的应用程序正在运行,检查会话之间项目是否持久化的 main.test.js 中的测试应该失败。它失败是因为它需要在尝试从 localStorage 加载数据之前,使对 /inventoryGET 请求失败。

要使测试通过,你需要进行两个更改:你必须使用 nock 来使 GET /inventory 响应错误,并且你必须等待初始数据加载完成。

列表 6.75 main.test.js

// ...

afterEach(nock.cleanAll);

test("persists items between sessions", async () => {
  nock(API_ADDR)                                             ❶
    .post(/inventory\/.*$/)
    .reply(200);

  nock(API_ADDR)                                             ❷
    .get("/inventory")
    .twice()
    .replyWithError({ code: 500 });

  // ...

  document.body.innerHTML = initialHtml;                     ❸
  jest.resetModules();

  await require("./main");                                   ❹

  // Assertions...
});

// ...

❶ 成功响应对 POST /inventory/:itemName 的请求

❷ 对 GET /inventory 的请求两次返回错误

❸ 这相当于重新加载页面。

❹ 等待初始数据加载

不要忘记,那些测试包括一个 beforeEach 钩子,因此,在它里面,你也必须等待 loadInitialData 完成。

列表 6.76 main.test.js

// ...

beforeEach(async () => {
  document.body.innerHTML = initialHtml;

  jest.resetModules();

  nock(API_ADDR)                               ❶
    .get("/inventory")
    .replyWithError({ code: 500 });
  await require("./main");

  jest.spyOn(window, "addEventListener");
});

// ...

❶ 对 GET /inventory 的请求返回错误

注意:在这里,你正在暴露一个将在应用程序加载初始数据后解决的承诺,因为你需要知道等待什么。

或者,你可以在测试中等待一个固定的超时,或者持续重试,直到成功或超时。这些替代方案不需要你导出 loadInitialData 返回的承诺,但它们可以使测试变得不可靠或比应有的速度慢。

你不必担心main.js中的module.exports赋值,因为当你使用 Browserify 构建该文件并在浏览器中运行时,它将没有任何效果。Browserify 会为你处理所有的module.exports赋值,将所有依赖项打包成一个单一的bundle.js

现在你已经学会了如何使用nock拦截器测试涉及 HTTP 请求的功能,如果需要,还可以覆盖fetch,我将通过一个挑战来结束本节。

目前,当你撤销操作时,你的应用程序不会向服务器发送请求来更新库存内容。作为一个练习,尝试使undo功能与服务器同步,并测试这个集成。为了让你能够实现这个功能,我在 GitHub 上本章的server文件夹中添加了一个新的DELETE /inventory/:itemName路由,它接受包含用户想要删除的quantity的正文。

到本节结束时,你应该能够通过使用nock准确模拟其行为来将你的客户端测试从后端分离出来。多亏了nock,你可以在不需要启动整个后端的情况下,专注于指定服务器在何种情况下会产生的响应。创建这样的隔离测试可以让你的团队中的每个人都能更快、更轻松地运行测试。这种改进加速了开发者收到的反馈循环,因此激励他们编写更好的测试,并且更频繁地编写测试,这反过来又往往会导致更可靠的软件。

6.5.2 涉及 WebSockets 的测试

到目前为止,如果你的应用程序一次只有一个用户,它将无缝工作。但如果有多个操作员需要同时管理库存怎么办?如果是这种情况,库存很容易就会不同步,导致每个操作员看到不同的项目和数量。

要解决这个问题,你将实现通过 WebSockets 进行实时更新的支持。这些 WebSockets 将负责在库存数据更改时更新每个客户端,以确保客户端之间始终同步。

因为这本书是关于测试的,所以我已经在后端实现了这个功能。如果你不想自己实现,你可以使用这本书的 GitHub 仓库中chapter6文件夹内的server。该仓库的网址为github.com/lucasfcosta/testing-javascript-applications

当客户端添加项目时,我对服务器所做的更改将导致它向所有连接的客户端(不包括发送请求的那个客户端)发出add_item事件。

要连接到服务器,你将使用socket.io-client模块,因此你必须使用npm install socket.io-client将其作为依赖项安装。

通过创建一个将连接到服务器并在连接后保存客户端 ID 的模块来开始实现实时更新功能。

列表 6.77 socket.js

const { API_ADDR } = require("./inventoryController");

const client = { id: null };

const io = require("socket.io-client");

const connect = () => {
  return new Promise(resolve => {
    const socket = io(API_ADDR);         ❶

    socket.on("connect", () => {         ❷
      client.id = socket.id;
      resolve(socket);
    });
  });
}

module.exports = { client, connect };

❶ 创建一个客户端实例,连接到 API_ADDR

❷ 一旦客户端连接,就存储其 ID 并解析承诺

对于每个连接到服务器的客户端,你必须在 main.js 中调用由 socket.js 导出的 connect 函数。

列表 6.78 main.js

const { connect } = require("./socket");

// ...

connect();                             ❶

module.exports = loadInitialData();

❶ 当应用程序加载时连接到 Socket.io 服务器

客户端连接到服务器后,每当用户添加一个新项目时,客户端必须通过 x-socket-client-id 标头将它的 Socket.io 客户端 ID 发送到服务器。服务器将使用此标头来识别哪个客户端添加了项目,以便在已知此客户端已经更新了自己的情况下跳过它。

注意:允许客户端向库存添加项目的路由将提取 x-socket-client-id 标头中的值,以确定哪个客户端发送了请求。然后,一旦它向库存添加了一个项目,它将遍历所有连接的套接字,并向 id 不匹配 x-socket-client-id 的客户端发出 add_item 事件。

列表 6.79 server.js

router.post("/inventory/:itemName", async ctx => {
  const clientId = ctx.request.headers["x-socket-client-id"];

  // ...

  Object.entries(io.socket.sockets.connected)
    .forEach(([id, socket]) => {
      if (id === clientId) return;
      socket.emit("add_item", { itemName, quantity });
    });

  // ...
});

更新 inventoryController.js,以便它将客户端的 ID 发送到服务器,如下所示。

列表 6.80 inventoryController.js

// ...

const addItem = (itemName, quantity) => {
  const { client } = require("./socket");

  // ...

  fetch(`${API_ADDR}/inventory/${itemName}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-socket-client-id": client.id             ❶
    },
    body: JSON.stringify({ quantity })
  });

  return data.inventory;
};

❶ 在发送添加项目请求时包含一个包含 Socket.io 客户端 ID 的 x-socket-client-id

现在服务器可以识别发送者,最后一步是更新 socket.js 文件,以便客户端在接收到服务器发送的 add_item 消息(当其他人添加项目时)时可以更新自己。这些消息包含 itemNamequantity 属性,你将使用这些属性来更新库存数据。一旦本地状态更新,你将使用它来更新 DOM。

列表 6.81 socket.js

const { API_ADDR, data } = require("./inventoryController");
const { updateItemList } = require("./domController");

// ...

const handleAddItemMsg = ({ itemName, quantity }) => {             ❶
  const currentQuantity = data.inventory[itemName] || 0;
  data.inventory[itemName] = currentQuantity + quantity;
  return updateItemList(data.inventory);
};

const connect = () => {
  return new Promise(resolve => {
    // ...

    socket.on("add_item", handleAddItemMsg);                       ❷
  });
};

module.exports = { client, connect };

❶ 一个函数,根据包含项目名称和数量的对象更新应用程序的状态和项目列表

❷ 当服务器触发 add_item 事件时调用 handleAddItemMsg

通过 npm run build 使用 Browserify 重建你的 bundle.js 并通过 npx http-server ./ 提供服务来尝试这些更改。别忘了你的服务器必须运行在 API_ADDR 中指定的地址上。

可以在多个集成级别测试此功能。例如,你可以单独检查你的 handleAddItemMsg 函数,而完全不接触 WebSocket。

要单独测试 handleAddItemMsg,首先在 socket.js 中导出它。

列表 6.82 socket.js

// ...

module.exports = { client, connect, handleAddItemMsg };

然后,在新的 socket.test.js 中导入它,并直接调用它,传递一个包含 itemNamequantity 的对象。别忘了你需要钩子来确保在每个测试之前重置文档和库存状态。

列表 6.83 socket.test.js

const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
const { getByText } = require("@testing-library/dom");
const { data } = require("./inventoryController");

const { handleAddItemMsg } = require("./socket");

beforeEach(() => {
  document.body.innerHTML = initialHtml;
});

beforeEach(() => {
  data.inventory = {};
});

describe("handleAddItemMsg", () => {
  test("updating the inventory and the item list", () => {
    handleAddItemMsg({ itemName: "cheesecake", quantity: 6 });       ❶

    expect(data.inventory).toEqual({ cheesecake: 6 });
    const itemList = document.getElementById("item-list");
    expect(itemList.childNodes).toHaveLength(1);
    expect(getByText(itemList, "cheesecake - Quantity: 6"))
      .toBeInTheDocument();
  });
});

❶ 直接通过调用它测试 handleAddItemMsg 函数

TIP 尽管这个测试在你迭代时获取反馈可能很有用,但它与通过 WebSockets 发送add_item消息而不是直接调用handleAddItemMsg的测试有很高的重叠度。因此,在实际场景中,在选择是否保留它之前,请考虑你的时间和成本限制。

正如我之前提到的,准确复制运行时场景会导致你的测试生成更可靠的保证。在这种情况下,你能够最接近模拟后端发送的更新的方法就是创建一个 Socket.io 服务器并自行派发更新。然后你可以检查这些更新是否在客户端触发了预期的效果。

因为在运行测试时你需要一个 Socket.io 服务器,所以使用npm install --save-dev socket.io将其作为开发依赖项安装。

安装 Socket.io 后,创建一个名为testSocketServer.js的文件,你将在其中创建自己的 Socket.io 服务器。这个文件应该导出启动和停止服务器以及向客户端发送消息的函数。

列表 6.84 testSocketServer.js

const server = require("http").createServer();
const io = require("socket.io")(server);             ❶

const sendMsg = (msgType, content) => {              ❷
  io.sockets.emit(msgType, content);
};

const start = () =>                                  ❸
  new Promise(resolve => {
    server.listen(3000, resolve);
  });

const stop = () =>                                   ❹
  new Promise(resolve => {
    server.close(resolve);
  });

module.exports = { start, stop, sendMsg };

❶ 创建一个 socket.io 服务器

❷ 一个将消息发送到连接到 socket.io 服务器的客户端的函数

❸ 在端口 3000 上启动 socket.io 服务器,并在启动后解决一个承诺

❹ 关闭 socket.io 服务器,并在停止后解决一个承诺

NOTE 理想情况下,你应该有一个单独的常量来决定你的服务器应该监听的端口。如果你想,你可以将API_ADDR拆分为API_HOSTAPI_PORT。因为这本书专注于测试,所以我在这里硬编码为3000

此外,为了避免因为服务器已经绑定到端口3000而无法运行测试,允许用户通过环境变量配置这个端口可能是有用的。

返回当startstop完成时解决的承诺非常重要,这样你就可以在钩子中使用它们时等待它们完成。否则,由于资源悬挂,你的测试可能会挂起。

最后,是时候编写一个通过 Socket.io 服务器发送消息并检查你的应用程序是否适当处理它们的测试了。

从启动服务器的钩子开始,连接你的客户端到它,然后在测试完成后关闭服务器。

列表 6.85 testSocketServer.js

const nock = require("nock");

// ...

const { start, stop } = require("./testSocketServer");

// ...

describe("handling real messages", () => {
  beforeAll(start);                             ❶

  beforeAll(async () => {
    nock.cleanAll();                            ❷
    await connect();                            ❸
  });

  afterAll(stop);                               ❹
});

❶ 在测试运行之前,启动你的 Socket.io 测试服务器

❷ 为了避免 nock 干扰你的 Socket.io 服务器连接,在尝试连接之前清除所有模拟

❸ 在所有测试之前,连接到 Socket.io 测试服务器

❹ 在测试完成后,停止 Socket.io 测试服务器

最后,编写一个发送add_item消息的测试,等待一秒钟以便客户端可以接收和处理它,并检查新的应用程序状态是否符合你的预期。

列表 6.86 testSocketServer.js

const { start, stop, sendMsg } = require("./testSocketServer");

// ...

describe("handling real messages", () => {

  // ...

  test("handling add_item messages", async () => {
    sendMsg("add_item", { itemName: "cheesecake", quantity: 6 });          ❶

    await new Promise(resolve => setTimeout(resolve, 1000));               ❷

    expect(data.inventory).toEqual({ cheesecake: 6 });                     ❸
    const itemList = document.getElementById("item-list");                 ❸
    expect(itemList.childNodes).toHaveLength(1);                           ❸
    expect(getByText(itemList, "cheesecake - Quantity: 6"))                ❸
      .toBeInTheDocument();                                                ❸
  });                     
});

❶ 通过 Socket.io 测试服务器发送消息

❷ 等待消息被处理

❸ 检查页面状态是否与预期状态相符

注意这个测试与handleAddItemMsg的单元测试重叠了多少。拥有两者的优点是,如果连接设置有问题,使用真实套接字的测试会失败,但单元测试不会。因此,你可以快速检测问题是否出在你的逻辑或服务器连接上。拥有两者的缺点是,它们会增加维护测试套件的成本,特别是考虑到你在两个测试中都执行了相同的断言。

现在你已经检查了你的应用程序在接收到消息时是否可以更新,请编写一个测试来检查inventoryController.js中的handleAddItem函数是否将套接字客户端的 ID 包含在它发送给服务器的POST请求中。本测试的不同部分之间的通信在图 6.9 中展示。

图 6.9 你的测试如何与你的 Socket.io 服务器通信,导致它更新文档以便它们可以执行断言

为了做到这一点,你必须启动你的测试服务器,连接到它,并使用一个 nock 拦截器来执行handleAddItem函数,该拦截器只会匹配包含适当的x-socket-client-id头部的请求。

列表 6.87 inventoryController.test.js

// ...

const { start, stop } = require("./testSocketServer");
const { client, connect } = require("./socket");

// ...

describe("live-updates", () => {
  beforeAll(start);

  beforeAll(async () => {
    nock.cleanAll();
    await connect();
  });

  afterAll(stop);

  test("sending a x-socket-client-id header", () => {
    const clientId = client.id;

    nock(API_ADDR, {                                              ❶
        reqheaders: { "x-socket-client-id": clientId }
    })
      .post(/inventory\/.*$/)
      .reply(200);

    addItem("cheesecake", 5);
  });
});

❶ 只对包含 x-socket-client-id 头部的 POST /inventory/:itemName 请求成功响应

在这些示例中,我们并不是试图在我们的测试中复制后端的行为。我们分别检查我们发送的请求以及我们是否可以处理接收到的消息。检查后端是否向正确的客户端发送正确的消息是应该在服务器测试中进行的验证,而不是客户端的。

现在你已经学会了如何设置一个可以在测试中使用并验证你的 WebSocket 集成的 Socket.io 服务器,尝试通过添加新功能并对其进行测试来扩展这个应用程序。记住,你可以在多个不同的集成级别上编写这些测试,无论是通过单独检查处理函数,还是通过通过测试服务器推送真实消息。例如,尝试在客户端点击撤销按钮时推送实时更新,或者尝试添加一个测试来检查当页面加载时main.js是否连接到服务器。

以 WebSockets 为例,你必须已经学会了如何模拟你的前端可能与其他应用程序交互的其他类型的交互。如果你有依赖项,使用存根会导致过多的维护开销,那么实现自己的依赖项实例可能更好——这样你可以完全控制它。例如,在这种情况下,手动操作多个不同的间谍来访问监听器和触发事件将导致过多的维护开销。除了使测试更难阅读和维护外,这也会使测试与运行时发生的情况差异很大,从而大大削弱你的可靠性保证。这种方法的缺点是,你的测试范围会增加,这会使你获得反馈的时间更长,并且使测试更加粗糙。因此,在决定最适合 的情况的最佳技术时必须谨慎。

摘要

  • JavaScript 在浏览器中可以访问的值和 API 与在 Node.js 中可以访问的值和 API 不同。因为 Jest 只能在 Node.js 中运行,所以使用 Jest 运行测试时,你必须准确复制浏览器的环境。

  • 为了模拟浏览器的环境,Jest 使用 JSDOM,这是一个完全用 JavaScript 编写的 Web 标准实现。JSDOM 允许你在其他运行时环境中访问浏览器 API,例如 Node.js。

  • 在多个集成级别编写测试需要你将代码组织成独立的部分。为了使测试中管理不同的模块变得容易,你仍然可以使用 require,但此时你必须使用打包器如 Browserify 或 Webpack 将依赖项打包成一个可以在浏览器中运行的文件。

  • 在你的测试中,得益于 JSDOM,你可以访问 document.querySelectordocument.getElementById 等 API。一旦你测试了想要测试的功能,就可以使用这些 API 在页面中查找并断言 DOM 节点。

  • 通过 ID 或 DOM 中的位置查找元素可能会导致你的测试变得脆弱,并且与你的标记紧密耦合。为了避免这些问题,可以使用像 dom-testing-library 这样的工具通过内容或其他属性来查找元素,这些属性是元素应该具有的组成部分,例如其 rolelabel

  • 为了编写更准确和可读的断言,而不是手动访问 DOM 元素的属性或编写复杂的代码来执行某些检查,可以使用像 jest-dom 这样的库来扩展 Jest,并添加专门针对 DOM 的新断言。

  • 浏览器会响应复杂的用户交互,例如输入、点击和滚动。为了处理这些交互,浏览器依赖于事件。由于测试在准确模拟运行时发生的情况时更为可靠,因此你的测试应该尽可能精确地模拟事件。

  • 准确重现事件的一种方法是通过使用来自 dom-testing-libraryfireEvent 函数或 user-event 库提供的工具,该库是 testing-library 组织下的另一个库。

  • 你可以在不同级别的集成中测试事件及其处理程序。如果你在编写代码时想要更细粒度的反馈,可以直接调用处理程序来测试你的处理程序。如果你愿意以更可靠的保证来交换细粒度的反馈,可以发送真实的事件。

  • 如果你的应用程序使用 Web API,如 History 或 Web Storage API,你可以在测试中使用它们的 JSDOM 实现。记住,你不应该测试这些 API 本身;你应该测试你的应用程序是否与它们充分交互。

  • 为了避免使你的测试设置过程更加复杂,并消除启动后端以运行前端测试的必要性,使用 nock 来拦截请求。使用 nock,你可以确定要拦截哪些路由以及这些拦截器将产生哪些响应。

  • 与我们见过的所有其他类型的测试类似,WebSocket 可以在不同程度的集成中进行测试。你可以编写直接调用处理函数的测试,或者你可以创建一个服务器,通过该服务器发送真实消息。

7 React 测试生态系统

本章涵盖

  • 为 React 应用设置测试环境

  • 不同 React 测试工具概述

  • 为 React 应用编写第一个测试

当你拥有一个顶级的搅拌器时,你不必浪费时间搅拌鸡蛋和糖,你可以专注于提高你工艺的其他方面,比如完善你的食谱或装饰你的蛋糕。

类似于优秀设备使糕点师能够专注于他们工艺的更重要的方面,如 React 这样的前端框架和库,让你能够专注于编写网络应用的更重要的方面。你不必专注于操作 DOM——自己删除、插入和更新元素——你可以专注于你应用的可用性、可访问性和业务逻辑。

在这个章节中,你将学习如何测试 React 应用,以及它与你在测试前端应用中学到的知识如何相关。你将了解像 JSDOM 这样的工具在不同环境中仍然有用,你将学习有助于测试你可能使用的任何其他前端框架的概念。

我选择用 React 编写这些示例主要是因为它的流行。我相信它是最多人已经熟悉的工具。

如果你还不了解 React,通过它的“入门”指南应该足以让你理解我将使用的示例。我写这一章的主要目标不是教你 React,而是展示一些无论你使用什么工具都会有用的原则。

我将从这个章节开始解释 React 应用如何在浏览器中运行,然后教你如何在测试环境中模拟这些条件,这样你就可以有可靠的质量保证。

在设置测试环境的过程中,我会介绍多个工具。我会解释它们的作用以及它们是如何工作的,这样你可以更容易地修复测试和测试基础设施中的问题。

一旦你的 React 测试环境运行起来,你将亲身体验到你在测试纯 JavaScript 应用时学到的许多概念仍然适用。

然后,我将为你概述 React 测试生态系统中的工具,重点关注我选择的库react-testing-library,我将用它来展示如何为 React 应用编写第一个测试。

尽管我会专注于一个库,但我将解释许多其他库是如何工作的,它们的优缺点,以及选择工具时需要考虑的因素,这样你可以根据自己的项目做出自己的决定。

7.1 为 React 设置测试环境

关于厨房,最重要的学习之一是了解一切的位置。在路易的厨房里,他给每个抽屉都贴上了标签,并制定了严格的规则来存储原料和设备。他的员工可能认为他太有条理了,但路易更愿意称自己为实用主义者。他知道,如果找不到烤盘,就无法烘焙蛋糕。

在本节中,你将设置一个 React 应用程序及其测试环境。在这个过程中,你将了解 React 的工作原理以及如何在测试环境中重现它。

本章中你将要编写的应用程序将与上一章中你构建的应用程序类似。不同之处在于这次你将使用 React 而不是自己操作 DOM。

由于我无法描述 Jest 如何与你的应用程序接口,而不解释浏览器如何运行 React 应用程序,所以我将本节分为两部分。在第一部分,你将了解使 React 应用程序在浏览器中运行的工具。在第二部分,你将设置你的应用程序的测试环境,并了解你用来使应用程序在浏览器中运行的工具如何影响测试环境的设置。

7.1.1 设置 React 应用程序

要了解如何测试一个 React 应用程序,你必须学习使其在浏览器中运行所必需的内容。只有在你准确复制浏览器环境之后,你才能在测试中准确复制。

在本小节中,你将学习如何配置一个 React 应用程序,使其能够在浏览器中运行。在这个过程中,我会解释你需要哪些工具以及每个工具的作用。

首先,创建一个包含一个节点(你将在其中渲染你的应用程序)的 index.html 文件。此文件还应加载一个名为 bundle.js 的脚本,该脚本将包含你的应用程序的 JavaScript 代码。

列表 7.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Inventory</title>
</head>
<body>
    <div id="app" />
    <script src="bundle.js"></script>           ❶
</body>
</html>

❶ 加载包含整个捆绑应用程序的 bundle.js 文件

在开始使用任何 JavaScript 包之前,使用 npm init -y 创建一个 package.json 文件。你将使用这个文件来跟踪你的依赖项并编写 NPM 脚本。

一旦创建了 package.json 文件,安装你编写可以在浏览器中运行的 React 应用程序所需的两个主要依赖项:reactreact-domreact 是处理组件本身的库,而 react-dom 允许你将这些组件渲染到 DOM 中。请记住,使用 npm install --save react react-dom 将这些包作为依赖项安装。

作为你应用程序的入口点,创建一个 bundle.js 文件。在其中,你将使用 React 定义一个 App 组件,并使用 react-dom 将该组件的一个实例渲染到 index.html 中的 app 节点,如下所示。

列表 7.2 index.js

const ReactDOM = require("react-dom");
const React = require("react");

const header = React.createElement(                        ❶
  "h1",
  null,
  "Inventory Contents"
);

const App = React.createElement("div", null, header);      ❷

ReactDOM.render(App, document.getElementById("app"));      ❸

❶ 创建一个 h1 元素作为页面的标题

❷ 创建一个 div 元素,其唯一子元素是标题元素

❸ 将 App 元素渲染到 id 为 app 的节点

如你从上一章所记得,因为我们正在导入其他库,我们必须将它们捆绑到 index.html 将要加载的 bundle.js 文件中。为了执行捆绑,使用 npmbrowserify 作为开发依赖项安装,命令为 install --save-dev browserify。然后,将以下脚本添加到你的 package.json 文件中,以便你可以使用 npm run build 生成捆绑包。

列表 7.3 package.json

{
  "name": "my_react_app",
  // ...
  "scripts": {
    "build": "browserify index.js -o bundle.js"       ❶
  }
  // ...
}

❶ 一个脚本,当运行 npm run build 时将你的应用程序打包成一个单独的 bundle.js 文件

生成一个 bundle.js 文件,并使用 npx http-server ./localhost:8080 上提供你的应用程序。

如果你已经熟悉 React,到现在你可能已经在想,“但是,嘿,这并不是我写 React 应用程序的方式!”你完全正确。编写 React 应用程序的大多数人都在他们的 JavaScript 代码中使用标记。他们使用的是所谓的 JSX,这是一种混合了 JavaScript 和 HTML 的格式。你习惯看到的 React 代码可能看起来更像是这样。

列表 7.4 index.jsx

const ReactDOM = require("react-dom");
const React = require("react");

const App = () => {                                            ❶
  return (
    <div>
      <h1>Inventory Contents</h1>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("app"));      ❷

❶ 一个将渲染一个包含标题的 div 的 App 组件

❷ 将 App 组件渲染到 id 为 app 的 DOM 节点

能够在 JavaScript 代码中嵌入标记可以使组件更易于阅读且更简洁,但值得注意的是,浏览器并不知道如何运行 JSX。因此,为了使这些文件能在浏览器中运行,你必须使用工具将 JSX 转换为纯 JavaScript。

JSX 是编写组件的一种更 方便 的方式,但它 不是 React 的一部分。它扩展了 JavaScript 的语法,并且在将 JSX 转换为纯 JavaScript 时,它会被转换为函数调用。在 React 的情况下,这些函数调用恰好是 React.createElement 调用——与我们在之前的 index.js 文件中使用的相同函数调用。

JSX 并非仅限于 React。例如,像 Preact 这样的其他库也可以利用 JSX。区别在于,为 Preact 应用程序编写的 JSX 需要转换为不同的函数调用,而不是 React 特有的 React.createElement

一旦我将上面的 index.jsx 文件转换为纯 JavaScript 文件,其输出应该与直接使用 React.createElementindex.js 版本的输出相似。

理解 JSX 和 React 的工作原理至关重要,因为这将帮助你设置测试环境。这种知识将使你能够理解 Jest 在处理 JSX 文件时正在做什么。

重要 JSX 只是一种更方便的编写组件的方式。浏览器 不能 运行 JSX 文件。要能够运行使用 JSX 编写的应用程序,你需要将 JSX 转换为纯 JavaScript。

JSX 不是 React 的专属特性;它是 JavaScript 语法的扩展,在 React 的情况下,它会被转换为 React.createElement 调用。

记住,当你的应用程序到达浏览器时,它就变成了“仅仅是 JavaScript。”

现在你已经了解了 JSX 的工作原理,是时候看到它在实际中的应用了。将你的 index.js 文件重命名为 index.jsx,并更新其代码,使其使用 JSX 而不是我之前使用的 React.createElement

要将你的代码转换为能在浏览器中运行的代码,你将使用 babelify 和 Babel。babelify 包使 Browserify 能够使用 Babel,一个 JavaScript 编译器,来编译你的代码。然后,你可以使用像 preset-react 这样的包将 JSX 转换为纯 JavaScript。鉴于你只需要在开发环境中使用这些包,请使用 npm install --save-dev babelify @babel/core @babel/preset-react 命令将它们作为开发依赖项安装。

注意:我选择使用 Browserify 来演示这些示例,因为它使它们更加简洁。目前,许多读者可能正在使用 Webpack。

如果你正在使用 Webpack,同样的原则适用。使用 Webpack 时,你仍然会使用 Babel 及其预设来将你的 JSX 代码转换为纯 JavaScript,使其能在浏览器中运行。

为了帮助你理解这些工具之间的关系,可以将 Webpack 视为与 Browserify 等效,将 babel-loader 视为与 babelify 等效。当然,这些比较是简化的,但在本章的上下文中,它们将帮助你理解示例的工作原理。

更新你的 package.json,使你的 build 命令使用 index.jsx 而不是 index.js 作为应用程序的入口点,并添加 Browserify 的配置,以便在构建应用程序时使用 babelify@babel/preset-react

列表 7.5 package.json

{
  "name": "2_transforming_jsx",
  "scripts": {
    "build": "browserify index.jsx -o bundle.js"
  },
  // ...
  "browserify": {
    "transform": [
      [
        "babelify",
        { "presets": [ "@babel/preset-react" ] }       ❶
      ]
    ]
  }
}

❶ 配置 Browserify 的 babelify 插件以将 JSX 转换为纯 JavaScript

在此更改之后,你的应用程序将准备好在浏览器中运行。当你运行 npm run build 命令时,Browserify 将将你的应用程序打包成一个单一的纯 JavaScript 文件。在打包过程中,它将通过 babelify 与 Babel 交互,将 JSX 转换为纯 JavaScript,如图 7.1 所示。

最后,当你使用 npx http-server ./ 命令提供应用程序服务时,index.html 将加载 bundle.js 文件,并将 App 组件挂载到页面上。

图 7.1 DaCosta 的配置

图 7.1 你的应用程序的构建过程,从打包和转换代码到在浏览器中执行

要看到你的应用程序运行效果,请使用 npm run build 命令构建它,然后使用 npx http-server ./ 命令来提供服务,以便在 localhost:8080 上访问它。

你可能还注意到的一个细节是,到目前为止,我在本书的示例中一直使用 Node.js 的 require 来导入 JavaScript 模块。然而,这并不是在 JavaScript 中导入模块的标准方式。在本章中,我将不使用 require,而是使用 ES 导入。

列表 7.6 index.jsx

import ReactDOM from "react-dom";
import React from "react";

// ...

要使用 ES 导入,你必须使用 Babel 的 preset-env 将 ES 导入转换为 require 调用——也称为 CommonJS 导入。

注意:在撰写本文时,Node.js 的最新版本已经支持 ES 导入。我选择使用 Babel 来演示如何做这件事,以便使用先前版本的 Node.js 的读者也可以跟随。你可以在 nodejs.org/api/esm.html 上了解更多关于 Node.js 对 ES 模块的支持。

使用 npm install --save-dev @babel/preset-env 将 Babel 的 preset-env 作为开发依赖项安装,并更新你的 package.json 以便在构建应用程序时使用此包。

列表 7.7 package.json

{
  "name": "2_transforming_jsx",
  // ...
  "browserify": {
    "transform": [
      [
        "babelify",
        {
          "presets": [
            "@babel/preset-env",         ❶
            "@babel/preset-react"
          ]
        }
      ]
    ]
  }
}

❶ 配置 Browserify 的 babelify 插件,以便以这种方式转换你的代码,这样你就可以针对特定环境进行操作,而无需对特定环境的更改进行微管理

现在你已经完成了 babelify 的设置,你可以像以前一样构建和访问你的应用程序。首先,运行 npm run build,然后使用 npx http-server ./localhost:8080 上提供服务。

7.1.2 设置测试环境

现在你已经了解了使 React 应用程序在浏览器中运行所涉及的内容,现在是时候了解使其在 Node.js 中运行所涉及的内容了,这样你就可以使用 Jest 来测试它。

通过使用 npm install --save-dev jest 将 Jest 作为开发依赖项安装,开始设置测试环境。

因为你要开始测试你的 App 组件,所以将其单独分离到自己的 App.jsx 文件中,并更新 index.jsx 以便从 App.jsx 中导入 App

列表 7.8 App.jsx

import React from "react";

export const App = () => {             ❶
  return (
    <div>
      <h1>Inventory Contents</h1>
    </div>
  );
};

❶ 创建一个 App 组件,并导出它

列表 7.9 index.jsx

import ReactDOM from "react-dom";
import React from "react";
import { App } from "./App.jsx";                                ❶

ReactDOM.render(<App />, document.getElementById("app"));       ❷

❶ 从 App.jsx 导入 App 组件

❷ 将 App 的实例渲染到 ID 为 app 的 DOM 节点中

现在,在你甚至尝试渲染 App 并测试它——我们将在下一节中这样做——之前,你必须能够执行其 App.jsx 文件中的代码。

创建你的第一个测试文件,并将其命名为 App.test.js。目前,只需尝试使用 ES 模块语法导入 App

列表 7.10 App.test.js

import { App } from "./App.jsx";

当尝试使用 Jest 运行此测试文件时,你会得到一个语法错误。

提示:为了运行你的测试,更新你的 package.json,并添加一个名为 test 的 NPM 脚本,该脚本调用 jest,就像我们以前做的那样。这个 NPM 脚本将允许你使用 npm test 运行你的测试。

在撰写本文时,我正在使用 Node.js v12。在这个版本中,即使只是使用具有 .js 扩展名的文件中的 ES 模块语法导入 App,也会导致你的测试失败。

为了解决这个问题,你必须使用 Babel 和 preset-env 包转换 App.test.js,就像你以前使用 Browserify 打包代码时做的那样。不同之处在于这次你不需要 Browserify 作为中间件。相反,你将指示 Jest 本身使用 Babel。

要告诉 Jest 转换您的文件以便您可以在 Node.js 中运行它们,您可以将 Babel 的配置移动到它自己的 babel.config.js 文件中。在我撰写此内容时,Jest 的版本中,仅此配置文件就足以让 Jest 知道它应该在运行文件之前进行转换。

继续创建一个使用 preset-env 转换源代码以便它们可以在 Node.js 中运行的 babel.config.js 文件。

列表 7.11 babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current"          ❶
        }
      }
    ]
  ]
};

❶ 配置 Babel 将您的文件转换为与 Node.js 兼容,因此可以由在 Node.js 中运行的 Jest 执行

这个更改使得导入本身成功,但它仍然不会导致 Jest 无错误退出。如果您尝试重新运行 Jest,您会看到它现在抱怨在您的 App.jsx 文件中找到了意外的标记。

这个错误发生是因为,就像浏览器一样,Node 也不知道如何执行 JSX。因此,在您可以使用 Jest 在 Node.js 中运行它之前,您必须将 JSX 转换为纯 JavaScript。

更新您的 babel.config.js 文件,使其使用 preset-react 将 JSX 转换为 React.createElement 调用,就像您之前所做的那样。

列表 7.12 babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current"
        }
      }
    ],
    "@babel/preset-react"          ❶
  ]
};

❶ 配置 Babel 将 JSX 转换为纯 JavaScript,以便您可以使用 Node.js 执行它们

现在您已经创建了一个 .babel.config.js 配置文件,Jest 将在运行文件之前使用 Babel 进行转换,如图 7.2 所示。Jest 需要这样做,因为它在 Node.js 中运行,Node.js 不了解 JSX,并且其当前版本还无法处理 ES 模块。为了将 JSX 转换为纯 JavaScript,它使用 preset-react,并将 ES 导入转换为 CommonJS 导入(require 调用),它使用 preset-env

图 7.2 Jest 在运行测试之前如何转换您的文件

最后,在使用了 preset-react 之后,Jest 将能够运行 App.test.js。它仍然以错误结束,但现在它是一个更容易解决的错误:您还没有编写任何测试。

重要提示:理解您所使用的每个工具的作用至关重要。在本章的第一部分,您使用 Browserify 将您的应用程序打包成一个单一的 JavaScript 文件。babelify 包使您能够使用 Babel,即编译器本身,来 转换 您的文件。preset-envpreset-react 包负责告诉 Babel 如何 执行转换。

在最后一部分,您已配置 Jest 使用 Babel 在运行文件之前将它们进行 转换preset-envpreset-react 的作用保持不变:它们告诉 Babel 如何 进行转换。

测试 React 应用程序与测试纯前端应用程序并没有很大区别。在这两种情况下,你都想尽可能精确地在 Node.js 中复制浏览器的环境。为此,你可以使用像 JSDOM 这样的工具,它模拟浏览器的 API,以及 Babel,它将你的代码转换成可以在浏览器中运行的格式。

如果你使用的是 React 之外的库或框架,为了了解如何测试它,我建议你遵循我在本节中展示的相同思维过程。首先,检查你需要做什么才能让你的应用程序在浏览器中运行。理解每个工具的作用及其工作原理。然后,当你对应用程序在浏览器中的工作方式有了很好的理解后,以这种方式修改这些步骤,以便你可以将代码运行在 Node.js 中,从而可以使用 Jest。

7.2 React 测试库概述

一台一流的烤箱,一些高端的法国厨具,以及一个全新的搅拌器不会为你烘焙蛋糕,但选择适合工作的工具会让你成功一半。

在本节中,我将简要概述 React 测试生态系统中的工具。我会解释它们的工作原理,以及它们的优缺点,以便你可以选择你认为适合你项目的工具。

通过众多示例,我将教你如何使用 React 自带的工具。我会解释这些工具如何与你的测试环境交互,以及与测试 React 应用程序相关的根本概念。

由于大多数 React 测试库都是对 React 自带测试工具中已存在的功能的便捷包装,因此对这些工具有一个扎实的理解将使你更容易理解幕后发生的事情。

一旦你对 React 自带的测试工具和 React 在你的测试环境中的工作方式有了很好的理解,你就会看到哪些库你可以使用来减少你在测试中需要编写的代码量。

尽管我会解释多个库的优缺点,但本节中我将重点关注的库是 react-testing-library。它是我在大多数项目中的首选测试库,在本节中,你将学习如何使用它,并理解为什么我推荐它在大多数情况下使用。

7.2.1 渲染组件和 DOM

在本节中,你的第一个任务将是编写 App 组件的测试。你将要编写的测试将遵循三个 A 的模式:arrange(准备)、act(执行)、assert(断言)。因为 Jest 已经为你设置了一个 JSDOM 实例,所以你可以直接跳到执行和断言。你将在 JSDOM 实例中渲染 App 并检查它是否显示了正确的标题。

要能够为 App 组件编写这个测试,你必须解决两个问题。首先,你必须能够渲染 App 本身。然后,你必须能够检查 DOM 中是否存在正确的标题。

首先,将一个 div 添加到 JSDOM 实例的 document 中。稍后,你将在这个 div 中渲染 App 组件,就像你在应用程序代码中将 App 渲染到 index.html 中的 div 时所做的那样。

列表 7.13 App.test.js

import { App } from "./App.jsx";

const root = document.createElement("div");     ❶
document.body.appendChild(container);           ❷

❶ 创建一个 div 元素

❷ 将 div 附加到文档体

现在,继续编写一个测试,将 App 渲染到你刚刚创建的 root 节点。要渲染 App,你将使用 react-dom

与你在应用程序中所做的不一样,在你的测试中,你必须将每个交互用 React 测试工具包中的 act 组件包裹起来,actreact-dom 包的一部分。act 函数确保与你的交互相关的更新已经被处理并应用到 DOM 上,在这种情况下,DOM 是由 JSDOM 实现的。

列表 7.14 App.test.jsx

import React from "react";
import { App } from "./App.jsx";
import { render } from "react-dom";
import { act } from "react-dom/test-utils";

const root = document.createElement("div");
document.body.appendChild(root);                        ❶

test("renders the appropriate header", () => {
  act(() => {
    render(<App />, root);                              ❷
  });
});

❶ 将 div 附加到文档体

❷ 将 App 实例渲染到你附加到文档体中的 div

注意:由于你的测试文件现在使用 JSX 创建 App,我建议你将其扩展名更改为 .jsx,以指示它包含的代码类型。

在你刚刚编写的测试中,你准确地模拟了 App 组件是如何由浏览器渲染的。你并没有用测试替身替换 React 的任何部分,而是利用 JSDOM 的 DOM 实现来渲染组件,就像浏览器会做的那样。

除了使测试更加可靠外,将组件渲染到 DOM 中还使你能够使用任何与纯 JavaScript 应用程序一起工作的测试工具和技术。只要你在 DOM 中渲染 HTML 元素,你就可以像在其他任何情况下一样与这些元素交互。

例如,尝试使用文档的 querySelector 函数来查找渲染的标题,并断言其 textContent 属性,就像你为任何其他 DOM 节点所做的那样。

列表 7.15 App.test.jsx

// ...

test("renders the appropriate header", () => {
  act(() => {
    render(<App />, root);                                   ❶
  });
  const header = document.querySelector("h1");               ❷
  expect(header.textContent).toBe("Inventory Contents");     ❸
});

❶ 将 App 实例渲染到文档体内的 div

❷ 在文档中找到一个 h1 元素

❸ 断言标题的内容是“库存内容”

你刚刚编写的测试使用 react-dom/test-utilsApp 渲染到 JSDOM 实例,然后使用 Web API 来查找和检查一个 h1 元素,以便你可以对其断言。这些测试步骤在图 7.3 中展示。

图 7.3 使用 react-dom/test-utils 将组件渲染到 DOM 并使用 Web API 查找和检查渲染元素来测试组件

你使用 reactreact-dom 的实际情况对刚刚使用的 document.querySelector 函数来说是完全透明的。这个函数在文档的元素上操作,无论它们是如何渲染的。

同样的原则也适用于其他测试工具。鉴于你将 App 组件渲染到 DOM 中,你可以使用像 dom-testing-library 这样的 DOM 测试工具,使你的测试更加可读和健壮。

备注:一般来说,在处理 React 时,由于集成层很薄,我认为我的组件是原子的“单元”。因此,我将针对隔离组件的测试归类为“单元测试”。当一个测试涉及多个组件时,我更喜欢将其称为“集成测试”。

尽管我会将你刚刚编写的最后一个测试归类为单元测试,但也有人可能会争论它应该被标记为集成测试,因为你不仅测试了你的代码,还测试了它是否与 React 正确接口。

尤其是在这个案例中,将测试金字塔视为一个连续的谱系,而不是一组离散的分类,是很有趣的。尽管我会把这个测试放在金字塔的底部,但它仍然高于调用函数并检查返回值的测试,例如。

在测试组件时使用 React不要将你的组件从 React 中隔离出来,只是为了将测试标记为“单元测试”。

在隔离的情况下测试你的组件会使你的测试几乎无用,因为 React 的渲染和更新组件的逻辑是使你的应用程序工作的一个重要部分。

记住你的目标不是标记测试。你的目标是编写每个不同集成级别所需数量的测试。

请使用 npm install --save-dev @testing-library/domdom-testing-library 作为开发依赖项安装。然后,尝试使用这个库的 screen 对象的 getByText 方法来查找页面的标题,而不是使用文档的 querySelector 函数。

列表 7.16 App.test.jsx

// ...

import { screen } from "@testing-library/dom";

// ...

test("renders the appropriate header", () => {
  act(() => {
    render(<App />, root);                              ❶
  });
  expect(screen.getByText("Inventory Contents"))        ❷
    .toBeTruthy();
});

❶ 将 App 渲染到你附加到文档体的 div 中

❷ 使用 @testing-library/dom 的 getByText 函数查找内容为“Inventory Contents”的元素,然后断言该元素已被找到

现在你已经安装了 dom-testing-library,你还可以使用它的 fireEvent API。就 fireEvent 而言,它像处理其他 DOM 节点一样处理 DOM 节点,因此它可以点击按钮、填充输入框和提交表单,就像在任何其他情况下一样。

dom-testing-library 不关心 React 一样,React 和 react-dom 也不关心它们是在浏览器中渲染还是在 JSDOM 中渲染。只要 JSDOM API 与浏览器匹配,React 就会以相同的方式响应用件。

要了解如何使用 dom-testing-library 与你的组件交互,首先,在你的 App 组件中添加一个按钮,该按钮会增加可用的芝士蛋糕数量。

列表 7.17 App.jsx。

import React from "react";

export const App = () => {
  const [cheesecakes, setCheesecake] = React.useState(0);           ❶

  return (
    <div>
      <h1>Inventory Contents</h1>
      <p>Cheesecakes: {cheesecakes}</p>
      <button onClick={() => setCheesecake(cheesecakes + 1)}>       ❷
        Add cheesecake
      </button>
    </div>
  );
};

❶ 创建一个表示库存芝士蛋糕的状态

❷ 当用户点击按钮时增加芝士蛋糕的数量

在测试此功能时,请记住你必须将你的组件交互包裹在 react-dom 提供的 act 函数中。这个函数确保交互已经被处理,并且必要的更新已经应用到 DOM 上。

列表 7.18 App.test.jsx

// ...

import { screen, fireEvent } from "@testing-library/dom";

// ...

test("increments the number of cheesecakes", () => {
  act(() => {
    render(<App />, root);                                              ❶
  });

  expect(screen.getByText("Cheesecakes: 0")).toBeInTheDocument();       ❷

  const addCheesecakeBtn = screen.getByText("Add cheesecake");          ❸

  act(() => {                                                           ❹
    fireEvent.click(addCheesecakeBtn);
  });

  expect(screen.getByText("Cheesecakes: 1")).toBeInTheDocument();       ❺
});

❶ 渲染 App 实例

❷ 使用来自 @testing-library/dom 的 getByText 方法来查找表示库存中没有奶酪蛋糕的元素,然后断言它存在

❸ 通过文本找到添加奶酪蛋糕的按钮

❹ 使用来自 @testing-library/dom 的 fireEvent 来点击添加奶酪蛋糕到库存的按钮,并确保更新被处理并应用到 DOM 上

❺ 使用 getByText 来查找表示库存中有一个奶酪蛋糕的元素,然后断言它存在

在先前的示例中,如图 7.4 所示,你使用了来自 react-dom/utilsrender 方法将 App 组件渲染到你的 JSDOM 实例中,并使用来自 dom-testing-librarygetByText 查询来查找页面中的元素并与它们交互。

图片

图 7.4 使用 react-dom/test-utils 渲染组件但使用 dom-testing-library 来查找渲染的元素并与它们交互时会发生什么

现在你已经看到 dom-testing-library 正确地与你的 React 应用程序交互,尝试像测试纯 JavaScript 项目一样使用 jest-dom。因为 jest-dom 在 DOM 之上运行,它将与你渲染的 React 组件无缝工作,就像 dom-testing-library 一样。

要使用 jest-dom,使用 npm install --save-dev @testing-library/jest-dom 安装它,创建一个脚本以扩展 Jest 以使用 jest-dom 提供的断言,并更新 jest.config.js 以在运行测试文件之前执行该脚本。

列表 7.19 jest.config.js

module.exports = {
  setupFilesAfterEnv: ["<rootDir>/setupJestDom.js"]         ❶
};

❶ 在每个测试之前,Jest 执行扩展 Jest 以使用 jest-dom 断言的脚本。

列表 7.20 setupJestDom.js

const jestDom = require("@testing-library/jest-dom");
expect.extend(jestDom);                                 ❶

❶ 扩展 Jest 以使用 jest-dom 的断言

一旦设置了此库,使用它来断言标题当前在文档中。

列表 7.21 App.test.jsx

// ...

test("renders the appropriate header", () => {
  act(() => {
    render(<App />, root);
  });
  expect(screen.getByText("Inventory Contents"))
    .toBeInTheDocument();                              ❶
});

❶ 使用 jest-dom 的断言来断言某个元素在文档中

这些只是你可以用于测试纯 JavaScript 应用程序和 React 应用程序的许多工具中的两个。

重要提示:只要你在 DOM 上渲染组件并准确复制浏览器的行为,任何适用于纯 JavaScript 应用程序的工具都将适用于 React 应用程序。

作为一般建议,当研究如何测试使用特定库或框架的应用程序时,我建议你首先了解该库或框架本身在浏览器中的工作方式。无论你使用什么库或框架,通过像浏览器一样渲染你的应用程序,你可以使你的测试更加可靠,并扩大你可以使用的工具范围。

除了编译和渲染步骤之外,测试 React 应用程序与测试纯 JavaScript 应用程序类似。在测试你的 React 应用程序时,请记住你主要处理的是 DOM 节点,并且编写有效测试的原则仍然适用。例如,你应该编写紧凑且精确的断言,并使用构成其本质部分的属性来查找元素。

7.2.2 React 测试库

到目前为止,因为你正在处理 React,你的测试涉及大量的 React 特定问题。由于你需要将组件渲染到 DOM 中,你手动将一个div附加到 JSDOM 实例上,并使用react-dom自行渲染组件。除了这项额外的工作之外,当你的测试完成后,你没有一个拆解钩子来从 DOM 中移除渲染的节点。这种缺乏清理例程可能会导致一个测试干扰另一个测试,如果你要实现它,你必须手动完成。

此外,为了确保更新将被处理并应用到 DOM 中,你将你的组件交互包裹在react-dom提供的act函数中。

为了有效地解决这些问题,你可以用react-testing-library替换dom-testing-library。与dom-testing-library的方法不同,react-testing-library的方法已经考虑了 React 特定的关注点,例如将交互包裹到act中或在测试完成后自动卸载组件。

在本节中,你将学习如何使用react-testing-library来测试你的组件。你将编写一个包含用于向库存中添加新项目的表单的组件,另一个包含服务器库存内容的列表。然后,你将学习如何使用react-testing-library测试这些组件。

通过使用react-testing-library及其方法,你将了解它如何使你的测试更加简洁和易于理解,通过隐藏你之前看到的测试 React 应用程序的复杂性和特定性。

注意:react-testing-library包建立在dom-testing-library之上,两者都属于同一个“家族”的工具。因此,它们的 API 故意几乎相同。

渲染组件和查找元素

你使用react-testing-library的第一个任务将是使用它将组件渲染到 DOM 中。在整个过程中,我将解释使用react-testing-libraryreact-dom之间的区别。

使用npm install --save-dev @testing-library/react安装react-testing-library作为开发依赖项,这样你就可以开始使用它的函数而不是dom-testing-library中的函数。

一旦你安装了react-testing-library,就开始使用它的render方法而不是react-dom中的方法来渲染你的组件。

因为 react-testing-library 在 DOM 中添加了一个自己的容器,并在其中渲染元素,所以你不需要自己创建任何节点。

列表 7.22 App.test.jsx

// ...

import { render } from "@testing-library/react";

// ...

// Don't forget to delete the lines that
// append a `div` to the document's body.

test("renders the appropriate header", () => {
  render(<App />);                                          ❶

  // ...
});

test("increments the number of cheesecakes", () => {
  render(<App />);                                          ❷

  // ...
});

❶ 使用来自 react-testing-library 的 render 函数将 App 实例渲染到文档中

❷ 使用来自 react-testing-library 的 render 函数将 App 实例渲染到文档中

在前面的例子中,你使用了 react-testing-libraryrender 方法来替换 react-dom/test-utilsrender 方法。与 react-dom/test-utils 不同,react-testing-library 会自动配置一个钩子,在每个测试之后清理你的 JSDOM 实例。

除了不需要为你的 JSDOM 实例设置或清理例程之外,react-testing-libraryrender 函数返回一个包含与 dom-testing-library 包含的相同查询的对象。它们之间的区别在于,react-testing-libraryrender 方法的查询会自动绑定在渲染组件内部运行,而不是在整个 JSDOM 实例内部。因为这些查询的范围有限,你不需要使用 screen 或传递容器作为参数。

列表 7.23 App.test.jsx

// ...

test("renders the appropriate header", () => {
  const { getByText } = render(<App />);                           ❶
  expect(getByText("Inventory Contents")).toBeInTheDocument();     ❷
});

test("increments the number of cheesecakes", () => {
  const { getByText } = render(<App />);                           ❸

  expect(getByText("Cheesecakes: 0")).toBeInTheDocument();         ❹

  const addCheesecakeBtn = getByText("Add cheesecake");            ❺
  act(() => {                                                      ❻
    fireEvent.click(addCheesecakeBtn);
  });

  expect(getByText("Cheesecakes: 1")).toBeInTheDocument();         ❼
});

❶ 使用来自 react-testing-library 的 render 函数将 App 实例渲染到文档中,并获取一个限定在 render 结果的 getByText 查询

❷ 使用 scoped getByText 函数通过文本查找元素,然后断言该元素在文档中

❸ 再次使用来自 react-testing-library 的 render 函数渲染 App 实例,并获取一个限定在 render 结果的 getByText 查询

❹ 使用 scoped getByText 函数查找表示库存中没有芝士蛋糕的元素,然后断言该元素在文档中

❺ 使用 scoped getByText 函数查找添加芝士蛋糕到库存的按钮

❻ 使用 dom-testing-library 的 fireEvent 函数点击添加芝士蛋糕到库存的按钮

❼ 再次使用 scoped getByText 函数查找表示库存现在包含一个芝士蛋糕的元素,然后断言该元素在文档中

多亏了 react-testing-library,渲染组件变得更加简洁。因为 react-testing-library 处理了组件的挂载和卸载,你不需要手动创建特殊节点,也不需要设置任何清理钩子。

此外,你的查询变得更加安全,因为 react-testing-libraryrender 方法将你的查询范围限定在渲染组件的根容器内。因此,在执行断言时,你可以确保断言的是测试组件内的元素。

与组件交互

之前,为了与您的应用程序交互,您已经使用了来自 dom-testing-libraryfireEvent 工具以及 React 的 act 函数的调用。尽管这两个工具使您能够以编程方式执行丰富的交互,但 react-testing-library 提供了一种更简单的方法来实现这一点。

在本小节中,您将创建一个包含 Louis 员工添加新项目到面包店库存的表单的组件。然后,您将学习如何使用 react-testing-library 与此表单交互,以便您可以编写简洁、可靠且易于阅读的测试。

使用 react-testing-library 与组件交互时,您将使用其 fireEvent 工具而不是来自 dom-testing-library 的工具。这两个工具之间的区别在于,react-testing-library 中的 fireEvent 工具已经将交互封装到 act 调用中。因为 react-testing-library 负责使用 act,所以您不必自己担心这一点。

dom-testing-libraryfireEvent 函数替换为 react-testing-libraryfireEvent 函数,这样您就可以停止自己使用 act

列表 7.24 App.test.jsx

// ...

// At this stage, you won't need any imports
// from `@testing-library/dom` anymore.

import { render, fireEvent } from "@testing-library/react";     ❶

// ...

test("increments the number of cheesecakes", () => {
  const { getByText } = render(<App />);                        ❷

  expect(getByText("Cheesecakes: 0")).toBeInTheDocument();      ❸

  const addCheesecakeBtn = getByText("Add cheesecake");         ❸

  fireEvent.click(addCheesecakeBtn);                            ❹

  expect(getByText("Cheesecakes: 1")).toBeInTheDocument();      ❸
});

❶ 从 react-testing-library 导入 renderfireEvent

❷ 将 App 实例渲染到文档中,并获取一个作用域为渲染结果的 getByText 函数

❸ 使用作用域内的 getByText 函数在 DOM 中查找元素,并断言和与之交互

❹ 使用来自 react-testing-libraryfireEvent 工具点击添加奶酪蛋糕到库存的按钮,这样您就不必将您的交互封装到 act 调用中

通过使用来自 react-testing-libraryqueriesrenderfireEvent 方法,您已经完全消除了使用 dom-testing-library 的必要性。在此更改之后,react-testing-library 是您唯一需要与之交互的库,用于渲染组件、查找元素以及与之交互,如图 7.5 所示。

图片

图 7.5 当您仅使用 react-testing-library 时,您的测试如何与组件交互。这个库能够渲染组件、查找元素以及与之交互。

提示:要卸载 dom-testing-library 并从依赖列表中移除它,请使用 npm uninstall dom-testing library

现在您已经了解了 react-testing-libraryfireEvent 方法是如何工作的,您将创建一个更复杂的组件,并学习如何测试它。这个新组件将被命名为 ItemForm,它将替换当前增加奶酪蛋糕数量的按钮。

与上一章应用程序中的表单类似,当提交时,它将向服务器发送请求。因为它将包含两个字段——一个用于物品的名称,另一个用于要添加的数量——表单将允许库存经理添加任何数量的任何产品。

注意:由于本章重点介绍测试 React 应用程序,而不是后端,因此我将基于您在第六章中使用的相同服务器构建下一个示例。

您可以在本书 GitHub 仓库中找到服务器的代码以及本章的示例,网址为github.com/lucasfcosta/testing-javascript-applications

在第七章的文件夹中,您将找到一个名为server的目录,其中包含一个能够处理您的 React 应用程序将发出的请求的 HTTP 服务器。

要运行该服务器,导航到其文件夹,使用npm install安装其依赖项,使用npm run migrate:dev确保您的数据库模式是最新的,然后使用npm start启动服务器。默认情况下,您的 HTTP 服务器将绑定到端口3000

通过创建一个只能管理自身状态的ItemForm组件来开始此表单的工作。现在不必担心向服务器发送请求。

列表 7.25 ItemForm.jsx

export const ItemForm = () => {
  const [itemName, setItemName] = React.useState("");       ❶
  const [quantity, setQuantity] = React.useState(0);        ❷

  const onSubmit = (e) => {
    e.preventDefault();
    // Don't do anything else for now
  }

  return (
    <form onSubmit={onSubmit}>                              ❸
      <input
        onChange={e => setItemName(e.target.value)}
        placeholder="Item name"
      />
      <input
        onChange={e => setQuantity(parseInt(e.target.value, 10))}
        placeholder="Quantity"
      />
      <button type="submit">Add item</button>
    </form>
  );
};

❶ 创建一个状态片段,用于存储表单的itemName

❷ 创建一个状态片段,用于存储表单的quantity

❸ 创建一个包含两个字段和提交按钮的表单。此表单在提交时会调用onSubmit函数。

作为练习,为了练习您之前关于react-testing-library查询学到的知识,创建一个名为ItemForm.test.jsx的文件,并编写一个单元测试来验证此组件是否渲染了正确的元素。此测试应渲染ItemForm并使用render函数返回的查询来查找您想要的元素。然后您应该断言这些元素存在于 DOM 中,就像您之前在App中查找标题时做的那样。

注意:您可以在本书 GitHub 仓库的chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library文件夹中找到一个如何编写此测试的完整示例,网址为github.com/lucasfcosta/testing-javascript-applications

现在,由于ItemForm渲染了一个包含两个字段和提交按钮的form,您将使其在用户提交新项目时向服务器发送请求。

为了确保服务器地址在您的项目中保持一致,创建一个constants.js文件,在其中创建一个包含服务器地址的常量并将其导出。

列表 7.26 constants.js

export const API_ADDR = "http://localhost:3000";

最后,更新ItemForm.js,以便在用户提交表单时向服务器发送请求。

列表 7.27 ItemForm.jsx

// ...

import { API_ADDR } from "./constants"

const addItemRequest = (itemName, quantity) => {               ❶
  fetch(`${API_ADDR}/inventory/${itemName}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ quantity })
  });
}

export const ItemForm = () => {
  const [itemName, setItemName] = React.useState("");
  const [quantity, setQuantity] = React.useState(0);

  const onSubmit = (e) => {                                    ❷
    e.preventDefault();
    addItemRequest(itemName, quantity)
  }

  return (
    <form onSubmit={onSubmit}>                                 ❸
      // ...
    </form>
  );
};

❶ 一个向服务器路由发送请求的函数,该路由处理向库存中添加新项目

❷ 一个onSubmit函数,该函数在表单提交时防止页面重新加载并向服务器发送请求

❸ 一个在提交时调用onSubmit的表单元素

在你能够与你的组件交互并检查它是否向服务器发送适当的请求之前,你必须记住将全局的 fetch 替换为 isomorphic-fetch,就像你在上一章中所做的那样。否则,由于运行 Jest 的 Node.js 没有全局的 fetch 函数,你将遇到错误。

为了在运行测试时替换全局的 fetch,使用 npm install --save-dev isomorphic-fetchisomorphic-fetch 安装为开发依赖项。然后,创建一个名为 setupGlobalFetch.js 的文件,该文件将 isomorphic-fetchfetch 函数分配给 JSDOM 的 window 中的 fetch 属性。

列表 7.28 setupGlobalFetch.js

const fetch = require("isomorphic-fetch");

global.window.fetch = fetch;                ❶

❶ 将 isomorphic-fetch 的 fetch 函数分配给全局 window 的 fetch 属性

一旦你创建了此文件,通过更新 jest.config.js 中的 setupFilesAfterEnv 选项,告诉 Jest 在每个测试文件之前运行它。

列表 7.29 setupGlobalFetch.js

module.exports = {
  setupFilesAfterEnv: [
    "<rootDir>/setupJestDom.js",
    "<rootDir>/setupGlobalFetch.js"       ❶
  ]
};

❶ 在执行每个测试文件之前,Jest 将运行一个脚本,将来自 isomorphic-fetch 的 fetch 函数分配给全局 window 的 fetch 属性。

现在由于你的组件在测试期间可以访问 fetch,你将测试表单是否向你的后端发送适当的请求。在这个测试中,你将使用来自 react-testing-libraryfireEvent 函数填写和提交表单,并使用 nock 拦截请求并对其做出响应。由于你正在处理 JSDOM 中的 DOM 节点,并且 fireEvent 已经在 act 函数内执行交互,这个测试将类似于一个纯 JavaScript 应用程序的测试。

列表 7.30 ItemForm.test.jsx

// ...
import nock from "nock";
import { render, fireEvent } from "@testing-library/react";

const API_ADDR = "http://localhost:3000";

// ...

test("sending requests", () => {
  const { getByText, getByPlaceholderText } = render(<ItemForm />);

  nock(API_ADDR)                                                         ❶
    .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 }))
    .reply(200);

  fireEvent.change(                                                      ❷
    getByPlaceholderText("Item name"),
    { target: {value: "cheesecake"} }
  );
  fireEvent.change(                                                      ❸
    getByPlaceholderText("Quantity"),
    { target: {value: "2"} }
  );
  fireEvent.click(getByText("Add item"));                                ❹

  expect(nock.isDone()).toBe(true);                                      ❺
});

❶ 创建一个响应状态为 200 的拦截器,对发送到 /inventory/cheesecake 的 POST 请求做出响应,其 body 的数量属性为 2

❷ 更新为“芝士蛋糕”,项目名称的表单字段

❸ 更新为“2”,项目数量的表单字段

❹ 点击提交表单的按钮

❺ 期望所有拦截器都已到达

一旦你完成了 ItemForm 的实现,你将在 App 组件中使用它。在此更改之后,用户将能够将任何数量的任何项目添加到库存中——而不仅仅是芝士蛋糕。

列表 7.31 App.jsx

import React from "react";
import { ItemForm } from "./ItemForm.jsx";

export const App = () => {
  return (
    <div>
      <h1>Inventory Contents</h1>
      <ItemForm />                     ❶
    </div>
  );
};

❶ 在 App 中渲染 ItemForm 的实例

为了确保所有测试仍然通过,请记住从 App.test.jsx 中移除验证负责将芝士蛋糕添加到库存的按钮的测试。

为了让你看到表单的工作情况,使用 npm run build 构建你的应用程序,并通过 npx http-server ./localhost:8080 上提供服务。在你的开发者工具的网络标签页打开的情况下,填写表单并提交新项目,以便你可以看到发送到服务器的请求。

等待事件

当你编写 React 应用程序时,你最终会发现你依赖于外部源,这会导致你的组件更新。例如,你可能有一个依赖于生成随机值或响应请求的服务器的计时器的组件。

在那些情况下,您需要等待这些事件发生,并让 React 处理更新并将最新的组件渲染到 DOM 中。

在本节中,您将实现并测试一个 ItemList 组件,该组件从服务器获取项目列表并更新自身以显示库存情况。没有这个列表,员工将无法管理面包店的库存。

通过创建一个名为 ItemList.jsx 的文件并编写将列出库存的组件来开始实现此功能。ItemList 组件应接收一个 itemsprop 并使用它来渲染项目列表。

列表 7.32 ItemList.jsx

import React from "react";

export const ItemList = ({ items }) => {                         ❶
  return (
    <ul>
      {Object.entries(items).map(([itemName, quantity]) => {     ❷
        return (
          <li key={itemName} >
            {itemName} - Quantity: {quantity}
          </li>
        );
      })}
    </ul>
  );
};

❶ 创建一个可以接收 items 属性的 ItemList 组件

❷ 遍历 items 中的每个属性,并为每个属性渲染一个带有其名称和数量的 li 元素

要验证此组件是否充分渲染传递给它的项目列表,您将在 ItemList.test.jsx 中编写一个测试。此测试应将包含几个项目的对象传递给 ItemList,使用来自 react-testing-libraryrender 函数将组件渲染到 DOM 中,并检查列表是否包含正确的内容。

列表 7.33 ItemList.spec.jsx

import React from "react";
import { ItemList } from "./ItemList.jsx";
import { render } from "@testing-library/react";

test("list items", () => {
  const items = { cheesecake: 2, croissant: 5, macaroon: 96 };     ❶
  const { getByText } = render(<ItemList items={items} />);        ❷

  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);                   ❸
  expect(getByText("cheesecake - Quantity: 2"))                    ❹
    .toBeInTheDocument();
  expect(getByText("croissant - Quantity: 5"))                     ❺
    .toBeInTheDocument();
  expect(getByText("macaroon - Quantity: 96"))                     ❻
    .toBeInTheDocument();
});

❶ 创建一个静态项目列表

❷ 使用静态项目列表渲染 ItemList 元素

❸ 期望渲染的 ul 元素有三个子元素

❹ 查找表示库存中有 2 个芝士蛋糕的元素

❺ 查找表示库存中有 5 个可颂的元素

❻ 查找表示库存中有 96 个马卡龙的元素

现在您知道 ItemList 可以充分渲染库存的项目,您将使用服务器提供的内容填充它。

要首次填充 ItemList,让 App 在渲染时通过向 GET /inventory 发送请求来获取库存的内容。一旦客户端收到响应,它应更新其状态并将项目列表传递给 ItemList

列表 7.34 App.jsx

import React, { useEffect, useState } from "react";
import { API_ADDR } from "./constants"

// ...

export const App = () => {
  const [items, setItems] = useState({});
  useEffect(() => {                                                ❶
    const loadItems = async () => {
      const response = await fetch(`${API_ADDR}/inventory`)
      setItems(await response.json());                             ❷
    };
    loadItems();
  }, []);

  return (
    <div>
      <h1>Inventory Contents</h1>
      <ItemList items={items} />                                   ❸
      <ItemForm />
    </div>
  );
};

❶ 当 App 组件渲染时,使其向服务器发送请求以获取项目列表

❷ 当 App 接收到来自服务器的项目列表时,更新其状态

❸ 使用从服务器获取的项目列表渲染 ItemList

注意:在打包应用程序时,由于在 useEffect 钩子中使用了 async/await,您必须配置 Babel 的 preset-env 以使用名为 core-js 的包中的 polyfills。否则,即使构建后,您的应用程序在浏览器中也无法工作。

要这样做,使用 npm install --save-dev core-js@2 安装 core-js,并在 package.json 中更新 Browserify 的 transform 设置。

core-js 包包含对更近期的 ECMAScript 版本的 polyfills,这些 polyfills 将包含在您的包中,以便您可以使用现代功能,如 async/await

列表 7.35 package.json

  "name": "2_react-testing-library",
  // ...
  "browserify": {
    "transform": [
      [
        "babelify",
        {
          "presets": [
            [
              "@babel/preset-env",
              {
                "useBuiltIns": "usage",
                "corejs": 2
              }
            ],
            "@babel/preset-react"
          ]
        }
      ]
    ]
  }
}

在调用fetch解决后使你的组件更新自身将导致App.test.js中的测试失败。它会失败,因为它在测试完成之前没有等待fetch调用解决。

因为react-testing-library在测试完成后卸载组件,所以在fetch解决之前,组件将不再挂载,但仍会尝试设置其状态。这就是 React 引发错误的原因。

通过使App.jsx在组件未挂载时避免更新其状态来修复该测试。

列表 7.36 App.jsx

import React, { useEffect, useState, useRef } from "react";

// ...

export const App = () => {
  const [items, setItems] = useState({});
  const isMounted = useRef(null);                                ❶

  useEffect(() => {
    isMounted.current = true;                                    ❷
    const loadItems = async () => {
      const response = await fetch(`${API_ADDR}/inventory`)
      const responseBody = await response.json();
      if (isMounted.current) setItems(responseBody);             ❸
    };
    loadItems();
    return () => isMounted.current = false;                      ❹
  }, []);

  // ...
};

❶ 创建一个引用,其值将指示 App 是否已挂载

❷ 当 App 挂载时将 isMounted 设置为 true

❸ 避免在 App 未挂载时更新其状态

❹ 当 App 卸载时将被调用的函数,并将 isMounted 设置为 false

当这个测试通过后,你现在必须测试你的应用程序是否会在服务器响应由App组件发送的请求后显示库存内容。否则,物品列表将始终为空。

要测试App是否适当地填充ItemList,你应该编写一个能够使fetch解决到一个静态物品列表的测试,渲染App,等待App使用请求的响应更新,并检查列表中的每个项目。

为了让App组件从服务器获取一个物品列表,请向App.test.jsx添加一个beforeEach钩子,该钩子将使用nock拦截对/inventoryGET请求。然后,通过添加一个清除所有拦截器的afterEach钩子,确保每个测试后没有未使用的拦截器。此外,如果nock.isDone方法返回false,此钩子应抛出错误。

列表 7.37 App.test.jsx

import nock from "nock";

beforeEach(() => {                                                         ❶
  nock(API_ADDR)
    .get("/inventory")
    .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 });
});

afterEach(() => {                                                          ❷
  if (!nock.isDone()) {
    nock.cleanAll();
    throw new Error("Not all mocked endpoints received requests.");
  }
});

// ...

❶ 在每个测试之前,创建一个拦截器,该拦截器将对/inventory的 GET 请求响应一个物品列表

❷ 在每个测试之后,检查是否所有拦截器都已到达,如果没有,则清除未使用的拦截器并抛出错误

在创建这些钩子之后,编写一个测试,该测试渲染App组件,等待列表有三个子项,并检查是否出现了预期的列表项。

要等待列表被填充,你可以使用来自react-testing-librarywaitFor方法。这个方法将重新运行传递给它的函数,直到该函数不抛出任何错误。因为当你的断言失败时,它们将抛出AssertionError,所以你可以将waitFor用作它们的重试机制。

列表 7.38 App.test.jsx

import { render, waitFor } from "@testing-library/react";

// ...

test("rendering the server's list of items", async () => {
  const { getByText } = render(<App />);                        ❶

  await waitFor(() => {                                         ❷
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });

  expect(getByText("cheesecake - Quantity: 2"))                 ❸
    .toBeInTheDocument();
  expect(getByText("croissant - Quantity: 5"))                  ❹
    .toBeInTheDocument();
  expect(getByText("macaroon - Quantity: 96"))                  ❺
    .toBeInTheDocument();
});

❶ 渲染 App 的一个实例

❷ 等待渲染的 ul 元素有三个子项

❸ 找到一个表示库存包含 2 个芝士蛋糕的元素

❹ 找到一个表示库存包含 5 个羊角面包的元素

❺ 找到一个表示库存包含 96 个马卡龙的元素

在这种情况下,因为你只想等待列表中有项目,所以你将包装进 waitFor 的唯一断言是检查列表中元素数量的断言。

如果你将其他断言也包装进 waitFor,这些断言可能会因为列表的内容不正确而失败,但 react-testing-library 会不断重试,直到测试超时。

提示:为了避免每次需要等待元素时都必须使用 waitFor,你也可以使用 findBy* 而不是 getBy* 查询。

findBy* 查询是异步执行的。此类查询返回的承诺要么在找到匹配的元素时解决,要么在 1 秒后拒绝,如果没有找到匹配的元素。

你可以用它来替换 waitFor,这会导致你的测试等待列表有三个子元素。

而不是使用 waitFor 函数在运行断言之前等待列表包含三个子元素,你可以做相反的事情。你可以使用 findByText 等待具有预期文本的元素首先变得可见,然后才对列表的大小进行断言。

列表 7.39 App.test.jsx

test("rendering the server's list of items", async () => {
  const { findByText } = render(<App />);                     ❶

  expect(await findByText("cheesecake - Quantity: 2"))        ❷
    .toBeInTheDocument();
  expect(await findByText("croissant - Quantity: 5"))         ❸
    .toBeInTheDocument();
  expect(await findByText("macaroon - Quantity: 96"))         ❹
    .toBeInTheDocument();

  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);              ❺
});

❶ 渲染 App 实例

❷ 等待一个元素指示库存中有 2 个芝士蛋糕

❸ 等待一个元素指示库存中有 5 个可颂

❹ 等待一个元素指示库存中有 96 个马卡龙

❺ 断言渲染的 ul 元素有三个子元素

总是尽量使你的 waitFor 回调尽可能简洁。否则,它可能会导致你的测试运行时间更长。就像你在这次测试中所做的那样,写下尽可能少的断言来验证特定事件是否发生。

注意:在测试 React 应用程序时,我认为组件是原子单元。因此,与之前的测试不同,我会将这个测试归类为集成测试,因为它涉及多个组件。

7.2.3 酶

酶是一个与 react-testing-library 相似的 React 测试工具。它具有将组件渲染到 DOM、查找元素以及与之交互的方法。

酶与 react-testing-library 之间最显著的区别在于它们对工具化的方法。酶让你对组件的内部有非常细粒度的控制。它允许你以编程方式设置其状态、更新其属性,并访问传递给每个组件子元素的值。另一方面,react-testing-library 专注于尽可能少地进行内省来测试组件。它允许你像用户一样与组件交互:通过在 DOM 中查找节点并通过它们分发事件。

此外,Enzyme 还包含了一些实用工具,可以帮助你执行shallow渲染,这仅渲染传递给它的顶级组件。换句话说,Enzyme 的shallow渲染不会渲染目标组件的任何子组件。相比之下,在react-testing-library中实现这一点的唯一方法是通过 Jest 的测试替身手动替换组件。

考虑到其广泛且灵活的 API,Enzyme 可以是一个在编写小型测试和获取代码编写过程中的快速且细粒度反馈的有吸引力的工具。使用 Enzyme,你可以轻松地将组件彼此隔离,甚至在各种测试中隔离组件的不同部分。然而,这种灵活性是以可靠性为代价的,可能会使你的测试套件难以维护且成本高昂。

由于 Enzyme 使得测试组件的实现细节变得过于容易,它很容易将测试紧密耦合到组件的实现上。这种紧密耦合导致你必须更频繁地更新测试,从而产生更多成本。此外,在shallow渲染组件时,你实际上是用测试替身替换了子组件,这使得你的测试无法代表运行时发生的情况,因此可靠性降低。

个人而言,react-testing-library是我首选的 React 测试工具。我同意这种做法,因为使用更少的测试替身确实可以使测试更加可靠,尽管有时我认为该库可以更容易地创建测试替身。此外,它的方法允许我快速准确地模拟运行时发生的情况,这为我提供了更强的可靠性保证。

备注:在本章的下一段中,我将更详细地解释如何使用测试替身、何时使用以及使用测试替身的优缺点。

为了简洁起见,我不会详细介绍如何使用 Enzyme,因为在绝大多数情况下,我更推荐使用react-testing-library。除了react-testing-library的 API 更加简洁,鼓励产生更可靠保证的模式之外,在撰写本文时,Enzyme 的shallow渲染也无法正确处理许多不同类型的 React 钩子。因此,如果你想采用 React 钩子,你将无法使用shallow,这是使用 Enzyme 的主要原因之一。

由于它仍然是一个流行的工具,并且你可能在现有的项目中找到它,我认为提一下它是值得的。

如果你确实决定使用 Enzyme,请记住,与将组件渲染到 DOM 以及将 JSX 转换为 DOM 的整体结构相关的相同原则仍然适用。因此,学习如何使用它将相对直接。

备注:如果你感兴趣,可以在enzymejs.github.io/enzyme/找到 Enzyme 的文档。

7.2.4 React 测试渲染器

React 自己的测试渲染器是另一个渲染 React 组件的工具。与 Enzyme 或react-testing-library不同,它将组件渲染为简单的 JavaScript 对象,而不是将它们渲染到 DOM 中,如图 7.6 所示。

例如,如果您不使用 JSDOM,或者无法使用它,这可能是有用的。因为 React 的测试渲染器不会将您的组件转换为完整的 DOM 节点,所以您不需要任何 DOM 实现来渲染组件并检查其内容。

图片

图 7.6 React 的测试渲染器不会将组件渲染到 JSDOM 实例中。相反,它创建了一个包含一些您可以使用以查找和检查渲染元素的简单 JavaScript 对象。

如果您正在测试一个 Web 应用程序,我认为使用 React 的测试渲染器没有好处,因此我反对使用它。设置 JSDOM 相对较快,并且它使您的测试更加可靠,因为它使您的代码像在浏览器中运行一样,更准确地复制运行时环境条件。

react-test-renderer的主要用例是当您不将组件渲染到 DOM 中,但仍想检查其内容时。

例如,如果您有一个react-native应用程序,其组件依赖于移动设备的运行时环境。因此,您无法在 JSDOM 环境中渲染它们。

请记住,React 允许您定义组件及其行为。实际上在不同平台上渲染这些组件的任务是其他工具的责任,您将根据目标环境选择这些工具。例如,react-dom包负责将组件渲染到 DOM 中,而与react-native不同,后者在移动环境中处理组件。

注意:您可以在reactjs.org/docs/test-renderer.html找到 React 测试渲染器的完整文档。

摘要

  • 要在 Node.js 中测试您的 React 组件,使其类似于在浏览器中的工作方式,您可以使用 JSDOM。类似于测试纯 JavaScript 客户端,当测试 React 应用程序时,您可以将组件渲染到 JSDOM 实例中,然后使用查询来查找元素,以便对它们进行断言。

  • JSX 扩展了 JavaScript 语法,但浏览器或 Node.js 无法理解它。与您必须将 JSX 代码编译为纯 JavaScript 才能在浏览器中运行一样,您需要配置Jest在运行测试之前将 JSX 转换为纯 JavaScript。

  • 当使用无法在您使用的 Node.js 版本中运行的代码时,您需要在运行 Jest 测试之前将其编译为纯支持的 JavaScript。例如,如果您使用 ES 导入,您可能需要转换您的文件。

  • 要测试你的 React 应用程序,你可以使用react-testing-library,其 API 与你在上一章中看到的dom-testing-library包类似。这两个库之间的区别在于react-testing-library直接解决了 React 特定的关注点。这些关注点包括自动卸载组件、返回针对组件包装器的查询,以及将交互包装到act中以确保更新被处理并应用到 DOM 上。

  • 为了处理你的 React 应用程序测试中的 HTTP 请求,你可以像测试纯 JavaScript 应用程序时那样使用nock。如果你需要在请求解决或外部数据源提供数据时等待组件更新,你可以使用react-testing-library中的waitFor函数。使用waitFor,你可以重试断言直到成功,然后才继续执行其他操作或验证。

  • Enzyme 是react-testing-library的一个流行替代品。与react-testing-library不同,Enzyme 允许你直接与组件的内部方面进行交互,如它们的propsstate。此外,它的shallow渲染功能使得隔离测试更加容易。由于这些特性使得你的测试与运行时发生的情况不同,它们是以可靠性为代价的。

  • 如果你的 React 应用程序将组件渲染到除了 DOM 之外的目标,就像 React Native 那样,你可以使用 React 的测试渲染器将组件渲染到普通的 JavaScript 对象上。

  • 尤其是在测试 React 应用程序时,将测试金字塔视为一个连续的谱系,而不是一组离散的分类,是非常有趣的。由于 React 与你的测试之间的集成层非常薄,我会把只涉及单个组件的测试放入金字塔的底部,即使它们并没有模拟 React 本身。测试涉及的组件和不同代码片段越多,它在金字塔中的位置就越高。

8 测试 React 应用程序

本章涵盖

  • 如何测试相互交互的组件

  • 快照测试

  • 测试组件的样式

  • 故事和组件级验收测试

在熟悉了专业厨房的操作并学会了一些技巧,比如使用裱花袋后,某个时候,一位 糕点师 必须敲碎几个鸡蛋,准备一些面团,并进行一些 真正的 烘焙。

在本章中,我们将采用与测试 React 应用程序相似的方法。现在你已经熟悉了 React 测试生态系统,并理解了其工具的作用,我们将更深入地探讨如何编写有效、健壮和可维护的测试来测试你的 React 应用程序。

要学习如何编写这类测试,你需要扩展上一章中构建的应用程序,并学习如何使用高级技术对其进行测试。

首先,你将在多个隔离级别上学习如何验证相互交互的组件。在整个过程中,我会解释如何以易于理解和维护的方式进行。为此,你将学习如何模拟组件,这些模拟如何影响你的测试,以及如何在测试 React 应用程序时应用测试金字塔概念。

在本章的第二部分,我将解释快照测试是什么,如何进行,最重要的是,何时 进行。在本节中,你将学习在决定是否应该使用快照测试来测试应用程序的特定部分时需要考虑哪些因素。当我解释快照测试的优缺点时,我会坚持本书的价值驱动方法,这样你将能够做出自己的决定。

然后,鉴于 CSS 在你的软件开发中的重要作用,以及对于客户端应用程序不仅需要 表现 良好,而且还需要 外观 优美的重要性,你将了解如何测试你的应用程序的样式规则。你将学习哪些组件样式的方面值得测试,以及你可以和不能实现什么。

在本章的最后部分,我将解释组件故事是什么以及如何编写它们。当你使用 Storybook 编写故事时,我会阐明它们如何改进你的开发过程,并帮助你生产可靠且文档齐全的组件。

你将理解故事对反馈循环速度的影响,以及它们如何简化 UI 开发,改善不同团队之间的沟通,并使你和你同事能够在组件级别进行验收测试。

8.1 测试组件集成

当处理像工业烤箱这样的昂贵设备时,至关重要的是要检查每一件设备是否都位于手册所说的位置。但仅仅这样做是不够的。面包师越早转动烤箱的旋钮、按下按钮和翻转开关,就越早能够激活保修并订购替换品,如果发生任何问题。然而,路易斯从未在没有尝过在烤箱中烤制的一批酸面包之前认为烤箱是完美的。

类似地,在测试组件时,你可以检查所有元素是否都位于正确的位置。然而,如果不填写一些字段并按下几个按钮,你无法判断组件是否能够充分响应用户的输入。此外,如果不测试组件的集成,很难创建可靠的保证。

在本节中,你将学习如何测试相互交互的组件。首先,你将使应用程序在操作员向库存中添加产品时更新项目列表。然后,你将编写不同类型的测试来测试该功能,在多个不同级别的集成中进行。我将解释每种方法的优缺点。

注意:本章基于上一章中编写的应用程序以及当时使用的服务器。

你可以在本书 GitHub 仓库中找到本章示例中使用的客户端和服务器代码,网址为github.com/lucasfcosta/testing-javascript-applications

在第八章的文件夹中,你会找到一个名为server的目录,其中包含一个能够处理你的 React 应用程序将发出的请求的 HTTP 服务器。

正如你之前所做的那样,要运行该服务器,导航到其文件夹,使用npm install安装其依赖项,并确保你的数据库模式与npm run migrate:dev保持最新。在安装依赖项并准备数据库后,使用npm start启动服务器。默认情况下,你的 HTTP 服务器将绑定到端口3000

为了使App能够更新其状态以及因此其子组件的状态,它将为ItemForm创建一个回调函数,当用户添加项目时调用。这个回调函数应该接受项目的名称、添加的数量,并在App内部更新状态。

在开始修改App之前,按照下一个列表所示更新ItemForm,使其接受一个onItemAdded函数作为prop。如果这个prop被定义,ItemForm应该在表单提交时调用它,并将项目的名称和添加的数量作为参数传递。

列表 8.1 ItemForm.jsx

import React from "react";

// ...

export const ItemForm = ({ onItemAdded }) => {
  const [itemName, setItemName] = React.useState("");
  const [quantity, setQuantity] = React.useState(0);

  const onSubmit = async e => {
    e.preventDefault();
    await addItemRequest(itemName, quantity);
    if (onItemAdded) onItemAdded(itemName, quantity);       ❶
  };

  return (
    <form onSubmit={onSubmit}>
      { /* ... */ }
    </form>
  );
};

❶ 当表单提交时调用传递的 onItemAdded 回调

现在,为了验证 ItemForm 组件在存在时是否调用传递的 onItemAdded 函数,你将创建一个单元测试,如以下所示。你的测试应该渲染 ItemForm,通过 onItemAdded 属性传递一个占位符,提交表单,等待请求解决,并检查这个组件是否调用了传递的占位符。

列表 8.2 ItemForm.test.jsx

// ...

test("invoking the onItemAdded callback", async () => {
  const onItemAdded = jest.fn();
  const { getByText, getByPlaceholderText } = render(                      ❶
    <ItemForm onItemAdded={onItemAdded} />
  );

  nock(API_ADDR)                                                           ❷
    .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 }))
    .reply(200);

  fireEvent.change(getByPlaceholderText("Item name"), {                    ❸
    target: { value: "cheesecake" }
  });
  fireEvent.change(getByPlaceholderText("Quantity"), {                     ❹
    target: { value: "2" }
  });
  fireEvent.click(getByText("Add item"));                                  ❺

  await waitFor(() => expect(nock.isDone()).toBe(true));                   ❻

  expect(onItemAdded).toHaveBeenCalledTimes(1);                            ❼
  expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2);               ❽
});

❶ 渲染一个 ItemForm 组件,其 onItem-Added 属性是一个使用 Jest 创建的占位符

❷ 创建一个拦截器来响应表单提交时发送的 POST 请求

❸ 使用“cheesecake”更新项目名称的表单字段

❹ 使用“2”更新项目数量的表单字段

❺ 点击提交表单的按钮

❻ 等待拦截器到达

❼ 期望 onItemAdded 回调只被调用一次

❽ 期望 onItemAdded 回调以“cheesecake”作为其第一个参数和“2”作为其第二个参数被调用

这个测试,其覆盖范围和交互显示在图 8.1 中,可以验证 ItemForm 是否会调用通过 App 传递的函数,但它不能检查 App 是否将函数传递给 ItemForm,或者传递的函数是否正确。

图片

图 8.1 这个针对 ItemForm 的测试只能验证组件本身。它不能检查其父组件或兄弟组件是否给它提供了正确的 props,或者它们是否适当地更新。

为了确保当用户添加新项目时 App 能够充分更新其状态,你需要一个单独的测试来验证 App 及其与 ItemForm 的集成。

好吧,更新你的 App 组件,以便当用户通过表单提交新项目时,项目列表会更新。

为了实现这个功能,你需要编写一个函数,该函数能够接受项目的名称和数量,并更新 App 中的状态。然后,你将通过 onItemAddedprop 将这个函数传递给 ItemForm

列表 8.3 ItemForm.test.jsx

// ...

export const App = () => {
  const [items, setItems] = useState({});                                  ❶

  // ...

  const updateItems = (itemAdded, addedQuantity) => {                      ❷
    const currentQuantity = items[itemAdded] || 0;
    setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity });
  };

  return (
    <div>
      <h1>Inventory Contents</h1>
      <ItemList itemList={items} />                                        ❸
      <ItemForm onItemAdded={updateItems} />
    </div>
  );
};

❶ 创建一个表示库存项目列表的状态

❷ 创建一个函数,该函数将添加的项目名称和数量合并到表示库存项目列表的状态中

❸ 渲染一个项目列表,其 itemList 属性是 App 中的 items 状态

ItemForm 组件会在用户提交新项目时调用 updateItems 方法。这个方法将接收项目名称和增加的数量,并使用这些信息来更新 App 中的状态,该状态被传递给 ItemList。因为提交表单会更新 ItemList 所使用的状态,所以会导致项目列表更新,反映所添加的项目。

在为这种行为编写测试之前,先快速尝试一下。使用 npm run build 构建你的应用程序,用 npx http-server ./ 提供服务,并访问 localhost:8080。当你向库存中添加项目时,你会看到项目列表会自动更新。

因为你还未添加测试来检查App及其子组件之间的集成,所以即使你的应用程序不工作,测试也可能通过。

你当前的测试检查列表是否正确显示库存中的内容,以及项目表单是否调用了传递的onItemAdded函数。然而,它们并没有验证App是否与这些组件充分集成。目前,如果你的测试通过,例如,你忘记了为ItemForm提供updateItems函数,或者该函数不正确,那么测试仍然会通过。

在开发过程中,单独测试组件是一种快速获得反馈的好方法,但它在创建可靠保证方面并不那么有价值。

为了验证当用户添加新项目时App是否能够适当地更新自身,你将编写一个渲染App、提交表单并期望ItemList更新的测试。

在这个测试中,必须考虑到ItemForm在添加项目时会向POST发送请求,并且它只有在那个请求解决后才会调用传递的onItemAdded函数。因此,为了能够编写一个通过测试,你必须使请求成功,并在运行断言之前使测试等待请求解决。

为了使测试成功,你将为添加项目到库存的路由创建一个拦截器。然后,你将使测试等待请求解决,通过将断言包装在waitFor中来实现。

列表 8.4 App.test.jsx

import { render, fireEvent, waitFor } from "@testing-library/react";

// ...

test("updating the list of items with new items", async () => {
  nock(API_ADDR)                                                           ❶
    .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 }))
    .reply(200);

  const { getByText, getByPlaceholderText } = render(<App />);             ❷

  await waitFor(() => {                                                    ❸
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });

  fireEvent.change(getByPlaceholderText("Item name"), {                    ❹
    target: { value: "cheesecake" }
  });
  fireEvent.change(getByPlaceholderText("Quantity"), {                     ❺
    target: { value: "6" }
  });
  fireEvent.click(getByText("Add item"))

  await waitFor(() => {                                                    ❻
    expect(getByText("cheesecake - Quantity: 8")).toBeInTheDocument();
  });

  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);                           ❼

  expect(getByText("croissant - Quantity: 5")).toBeInTheDocument();        ❽
  expect(getByText("macaroon - Quantity: 96")).toBeInTheDocument();        ❾
});

❶ 创建一个拦截器,当表单添加 6 个 cheesecakes 时将响应请求

❷ 渲染App的一个实例

❸ 等待项目列表有三个子元素

❹ 更新项目名称表单字段为“cheesecake”

❺ 更新项目数量表单字段为“6”

❻ 等待一个元素指示库存包含 8 个 cheesecakes

❼ 断言项目列表有三个子元素

❽ 等待一个元素指示库存包含 5 个羊角面包

❾ 等待一个元素指示库存包含 96 个 macarons

你刚刚编写的测试为你提供了可靠的保证,表明你的组件在集成中可以正常工作,因为它涵盖了App组件及其所有子组件。它通过使用ItemForm添加产品到库存来涵盖ItemForm,并通过检查项目列表是否包含具有预期文本的元素来涵盖ItemList

如图 8.2 所示,此测试也涵盖了App,因为它只有在AppItemForm提供足够更新传递给ItemList的状态的回调时才会通过。

图 8.2 渲染App、与其子表单交互并验证项目列表元素的测试覆盖率

这个测试的唯一缺点是,如果您更改任何底层组件,您将多一个测试需要修复。例如,如果您更改ItemList渲染的文本格式,您将不得不更新ItemList的测试以及您刚刚为App组件编写的测试,如图 8.3 所示。

图 8.3 如果您决定更改ItemList渲染的文本格式,则会中断的测试

当您进行更改时,需要更新的测试越多,更改的成本就越高,因为您需要更多的时间来使测试套件通过。

另一方面,通过在App中验证列表项,您为您的应用程序创建了一个可靠的保证,即一旦它从服务器收到响应,它将渲染正确的元素。

关于决定在哪个集成级别测试组件,我的个人观点与react-testing-library文档中的建议一致,该文档建议您将测试编写得尽可能高,以获得可靠的保证。您的测试目标在组件树中的位置越高,您的保证就越可靠,因为它们与您的应用程序的运行时场景更接近。

尽管渲染多个组件会产生更可靠的质量保证,但在现实世界中,这并不总是可能的,并且可能会在您的测试之间产生显著的重复。

在保持测试可靠性的同时,使测试更易于维护的一个替代方案是将列表每个元素的文本生成集中到一个单独的函数中,并为该函数编写单独的测试。然后,您可以在App应用的测试和ItemList的测试中使用该函数。通过这样做,当您更改文本格式时,您只需更新您的文本生成函数及其自身的测试。

通过在ItemList中创建一个新函数并将该函数导出为generateItemText来集中这个依赖项。这个函数接受一个项目的名称和数量,并返回每个元素应显示的适当文本片段。

列表 8.5 ItemList.jsx

// ...

export const generateItemText = (itemName, quantity) => {
  return `${itemName} - Quantity: ${quantity}`;
};

一旦您实现了这个函数,就为它编写一个测试。为了更好地在ItemList.test.jsx中组织您的测试,我建议您将文本生成函数的测试和ItemList本身的测试分别放入两个独立的describe块中。

列表 8.6 ItemList.test.jsx

// ...

import { ItemList, generateItemText } from "./ItemList.jsx";

describe("generateItemText", () => {
  test("generating an item's text", () => {          ❶
    expect(generateItemText("cheesecake", 3))
      .toBe("cheeseceake - Quantity: 3");
    expect(generateItemText("apple pie", 22))
      .toBe("apple pie - Quantity: 22");
  });
});

describe("ItemList Component", () => {
  // ...
});

❶ 将项目名称和数量传递给generateItemText函数,并检查它是否产生正确的结果

现在您已经测试了generateItemText,更新ItemList组件本身,使其使用这个新函数为列表的每个项创建文本。

列表 8.7 ItemList.jsx

// ...

export const ItemList = ({ itemList }) => {
  return (
    <ul>
      {Object.entries(itemList).map(([itemName, quantity]) => {
        return (
          <li key={itemName}>
            { generateItemText(itemName, quantity) }           ❶
          </li>
        );
      })}
    </ul>
  );
};

// ...

❶ 使用generateItemText生成ItemList中每个项目的文本

由于你已经通过测试 generateItemText 函数创建了可靠的保证,因此你可以在整个测试套件中自信地使用它。如果 generateItemText 函数失败,即使使用它的测试会通过,generateItemText 本身的测试也会失败。这样的测试是你可以如何利用传递性保证的一个极好例子。

请继续更新 ItemListApp 的测试,以便它们使用这个新函数。

列表 8.8 ItemList.test.jsx

// ...

describe("ItemList Component", () => {
  test("list items", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };
    const { getByText } = render(<ItemList itemList={itemList} />);

    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
    expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument();❶
    expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); ❷
    expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); ❸
  });
});

❶ 使用 generateItemText 函数创建字符串,以便测试可以找到表示库存有 2 个芝士蛋糕的元素

❷ 使用 generateItemText 函数创建字符串,以便测试可以找到表示库存有 5 个羊角面包的元素

❸ 使用 generateItemText 函数创建字符串,以便测试可以找到表示库存有 96 个马卡龙的元素

列表 8.9 App.test.jsx

import { generateItemText } from "./ItemList.jsx";

// ...

test("rendering the server's list of items", async () => {
  // ...

  expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument();
  expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument();
  expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument();
});

test("updating the list of items with new items", async () => {
  // ...

  await waitFor(() => {
    expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument();
  });

  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);

  expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument();
  expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument();
});

如果你在这之后运行测试,你会看到它们仍然通过。唯一的区别是它们现在更加经济。

通过为 generateItemText 创建单独的测试并在其他测试中使用此函数,你已经创建了一个传递性保证。你的组件的两个测试都信任 generateItemText 工作得当,但还有一个专门针对 generateItemText 的测试来确保这个特定函数工作(图 8.4)。

图片

图 8.4 集中依赖 generateItemText 如何创建传递性保证

为了看到你的测试生成的维护开销减少了多少,尝试更改 ItemList 中文本的格式,使得每个项目的名称的首字母总是大写,然后重新运行你的测试。

列表 8.10 ItemList.test.jsx

// ...

export const generateItemText = (itemName, quantity) => {
  const capitalizedItemName =                                ❶
    itemName.charAt(0).toUpperCase() +
    itemName.slice(1);
  return `${capitalizedItemName} - Quantity: ${quantity}`;
};

// ...

❶ 将项目名称的首字符大写

重新运行测试后,你应该看到只有 generateItemText 本身的测试会失败,但所有其他测试都通过了。要使所有测试再次通过,你只需要更新一个测试:generateItemText 的测试。

列表 8.11 ItemList.test.jsx

describe("generateItemText", () => {
  test("generating an item's text", () => {          ❶
    expect(generateItemText("cheesecake", 3))
      .toBe("Cheesecake - Quantity: 3");
    expect(generateItemText("apple pie", 22))
      .toBe("Apple pie - Quantity: 22");
  });
});

❶ 使用几个项目名称和数量调用 generateItemText,并检查结果是否正确,包括项目名称的首字符是否已大写

当你有太多依赖于单一故障点的测试时,将这个故障点集中到一个你将在整个测试中使用的单一组件中,就像你为 generateItemText 所做的那样。模块化可以使你的应用程序代码和测试更加健壮。

8.1.1 组件模拟

你并不总是能够通过将组件渲染到 DOM 中来测试多个组件。有时,你将不得不将你的组件包裹在其他具有不良副作用或第三方库提供的组件中,正如我之前提到的,你不应该自己测试这些组件。

在本节中,你将集成react-spring到你的应用程序中,以便你可以添加动画来突出显示进入或离开库存的新类型的项目。然后,你将学习如何使用存根来测试你的组件,而无需测试react-spring本身。

首先,使用npm install react-spring安装react-spring,以便你可以在ItemList.jsx中使用它。

注意:由于react-spring使用的导出类型,在打包应用程序时,你将不得不使用一个名为esmify的 Browserify 插件。

要使用esmify,使用npm install --save-dev esmify安装它,然后更新package.json中的build脚本,使其使用esmify作为插件。

列表 8.12 package.json

//...
{
  "name": "my-application-name",
  "scripts": {
    "build": "browserify index.jsx -p esmify -o bundle.js",      ❶
   // ...
  }
  // ...
}

❶ 配置 Browserify 在生成你的包时使用 esmify

一旦安装了react-spring,使用react-spring中的Transition组件来动画化列表中进入或离开的每个项目。

列表 8.13 ItemList.jsx

// ...

import { Transition } from "react-spring/renderprops";

// ...

export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);

  return (
    <ul>
      <Transition                                        ❶
        items={items}
        initial={null}
        keys={([itemName]) => itemName}
        from={{ fontSize: 0, opacity: 0 }}
        enter={{ fontSize: 18, opacity: 1 }}
        leave={{ fontSize: 0, opacity: 0 }}
      >
        {([itemName, quantity]) => styleProps => (       ❷
          <li key={itemName} style={styleProps}>         ❸
            {generateItemText(itemName, quantity)}
          </li>
        )}
      </Transition>
    </ul>
  );
};

❶ 一个将动画化进入或离开项目列表的每个项目的Transition组件

❷ 一个将被列表的每个项目调用并返回另一个函数的函数,该函数接受一个表示动画当前状态的样式的属性

❸ 一个li元素,其样式将与动画的当前状态相对应,使每个项目都能进行动画

注意:你可以在www.react-spring.io/docs找到react-spring的完整文档。

要尝试你的应用程序并查看它如何动画化项目列表的元素,使用npm run build运行你的应用程序,然后使用npx http-server ./提供服务,访问localhost:8080。在测试你的应用程序时,你的后端必须在端口3000上可用并正在运行。

现在,当测试ItemList时,你应该小心不要测试Transition组件本身。测试react-spring库是维护者的责任,并且可能会给你的测试增加额外的复杂性。如果你认为增加的复杂性不会显著影响你的测试,你始终可以选择不使用任何测试替身。然而,鉴于你最终必须这样做,学习如何使用本节中的示例来这样做将是有用的。

要充分模拟Transition组件,你需要首先观察其工作方式,以便能够准确地重现其行为。在这种情况下,Transition组件将使用items属性接收到的每个项目调用其子组件,然后使用表示转换状态的样式调用生成的函数。这个最后的函数调用将返回一个包含项目文本的li元素,其中包含生成的styles

要在测试中一致地模拟 Transition 组件,首先在项目根目录中创建一个 *mocks* 文件夹,然后在那个文件夹中创建一个名为 react-spring 的另一个文件夹。在那个文件夹中,您将创建一个名为 renderprops.jsx 的文件,该文件将包含 react-spring 库的 renderprops 命名空间中的模拟。

react-spring.jsx 文件中,创建一个 FakeReactSpringTransition 组件并将其导出为 Transition。这个组件应该接受 itemschildren 作为属性。它将遍历其项目,通过 children 传递的函数调用。每个这样的调用将返回一个函数,该函数接受样式并返回一个组件实例。然后,该函数将使用表示假样式集的对象调用,导致子组件渲染。

列表 8.14 renderprops.jsx

const FakeReactSpringTransition = jest.fn(                 ❶
  ({ items, children }) => {
    return items.map(item => {
      return children(item)({ fakeStyles: "fake " });
    });
  }
);

export { FakeReactSpringTransition as Transition };

❶ 一个假的 Transition 组件,它将列表项传递给每个子组件,然后使用表示假样式集的对象调用返回的函数。这个最后的调用导致应该被动画化的子组件渲染。

react-spring 中的 Transition 组件替换为这个测试双胞胎将导致它仅仅渲染每个子组件,就像没有 Transition 组件包裹它们一样。

要在测试中使用这个存根,请在每个您想使用 FakeReactSpringTransition 的测试文件顶部调用 jest.mock("react-spring/renderprops")

目前,您正在使用 ItemList,它在 App.test.jsxItemList.test.jsx 中都依赖于 Transition,所以请继续在每个这些文件顶部添加对 mock react-spring/renderprops 的调用。

列表 8.15 App.test.jsx

import React from "react";

// ...

jest.mock("react-spring/renderprops");       ❶

beforeEach(() => { /* ... */ });

// ...

❶ 导致 react-spring/renderprops 解析到在 mocks/react-spring 中创建的存根

列表 8.16 ItemList.test.jsx

import React from "react";

// ...

jest.mock("react-spring/renderprops");               ❶

describe("generateItemText", () => { /* ... */ });

// ...

❶ 导致 react-spring/renderprops 解析到在 mocks/react-spring 中创建的存根

通过在 *mocks* 文件夹中创建一个测试双胞胎并调用 jest.mock,您将导致 ItemList 中对 react-spring/renderprops 的导入解析到您的模拟,如图 8.5 所示。

图片

图 8.5 当运行测试时,ItemList 如何使用您的测试双胞胎

使用这个测试双胞胎后,您的组件将表现得与引入 react-spring 之前的行为相似,因此所有测试都应该仍然通过。

多亏了这个测试双胞胎,您能够避免与测试 react-spring 相关的所有可能的复杂性。您不需要自己测试 react-spring,而是依赖于其维护者已经这样做的事实,因此避免了测试第三方代码。

如果您想检查传递给 Transition 的属性,您也可以检查您的测试双胞胎的调用,因为您已经使用 jest.fn 包装了您的假组件。

在决定是否要模拟组件时,考虑你是否将测试第三方代码,这将增加多少测试复杂性,以及你希望验证组件之间集成哪些方面。

在测试我的 React 应用程序时,我尽量避免尽可能多地用测试替身替换组件。如果我能控制组件,我会选择扩展 react-testing-library 或创建测试工具,这些工具可以帮助我使我的测试更具可维护性。我只对触发我无法控制的副作用(例如,动画)的组件进行存根。在没有存根的情况下,测试会变得太长或太复杂时,使用测试替身也是一个好主意。

注意:在上一章中提到的 Enzyme,作为 react-testing-library 的替代品,使得在不存根子组件的情况下测试组件变得更容易。当使用 Enzyme 时,你不需要手动创建测试替身,可以使用它的 shallow 方法来避免渲染子组件。

浅渲染 组件的缺点是,它们减少了你的运行时环境与测试之间的相似性,就像存根一样。因此,浅渲染 使得你的测试保证更弱。

正如我在书中多次提到的,说“总是这样做”或“永远不要这样做”是危险的。相反,在决定采用哪种方法之前,了解每种方法的优缺点更为有益。

8.2 快照测试

到今天为止,路易斯更喜欢通过观看他人烘焙来学习食谱,而不是阅读食谱。当你能够将你的面糊的稠度和你的巧克力奶油霜的外观与他人进行比较时,烘焙美味的蛋糕要容易得多。

当测试组件时,你可以遵循类似的方法。每当一个组件的标记与你的预期相匹配时,你可以对其进行快照。然后,随着你对组件进行迭代,你可以将其与所拍摄的快照进行比较,以检查它是否仍然渲染了正确的内容。

在本节中,你将实现一个记录会话中发生的所有事件的组件,并使用 Jest 的快照来测试该组件。在整个过程中,我将解释使用快照的优势和劣势,以及何时决定使用它。然后,我将解释快照在测试 React 组件之外的用例。

在你开始编写涉及快照的测试之前,创建一个新的组件名为 ActionLog。这个组件将接受一个对象数组并渲染一个动作列表。数组中的每个对象都将包含一个 message 属性,告知发生了什么,以及一个 time 属性,告知发生的时间。此外,这些对象中的每一个都可以有一个 data 属性,包含任何其他可能有用的任意信息,例如,当加载第一次的项目时,你的应用程序从服务器收到的响应。

列表 8.17 ActionLog.jsx

import React from "react";

export const ActionLog = ({ actions }) => {                   ❶
  return (
    <div>
      <h2>Action Log</h2>
      <ul>
        {actions.map(({ time, message, data }, i) => {        ❷
          const date = new Date(time).toUTCString();
          return (
            <li key={i}>
              {date} - {message} - {JSON.stringify(data)}
            </li>
          );
        })}
      </ul>
    </div>
  );
};

❶ 一个 ActionLog 组件,它接受一个 actions 属性,表示应用程序内部发生的事情

❷ 遍历 actions 中的每个项目并生成一个 li 元素,告知用户发生了什么以及何时发生

现在,创建一个名为 ActionLog.test.jsx 的文件,您将在其中编写 ActionLog 组件的测试。

在此文件中,您将使用 Jest 的 toMatchSnapshot 匹配器编写您的第一个快照测试。

此测试将渲染 ActionLog 并检查渲染的组件是否与特定的快照匹配。为了获取渲染的组件并将其与快照进行比较,您将使用 render 结果中的 container 属性和 toMatchSnapshot 匹配器。

列表 8.18 ActionLog.test.jsx

import React from "react";
import { ActionLog } from "./ActionLog";
import { render } from "@testing-library/react";

const daysToMs = days => days * 24 * 60 * 60 * 1000;                       ❶

test("logging actions", () => {
  const actions = [                                                        ❷
    {
      time: new Date(daysToMs(1)),
      message: "Loaded item list",
      data: { cheesecake: 2, macaroon: 5 }
    },
    {
      time: new Date(daysToMs(2)),
      message: "Item added",
      data: { cheesecake: 2 }
    },
    {
      time: new Date(daysToMs(3)),
      message: "Item removed",
      data: { cheesecake: 1 }
    },
    {
      time: new Date(daysToMs(4)),
      message: "Something weird happened",
      data: { error: "The cheesecake is a lie" }
    }
  ];

  const { container } = render(<ActionLog actions={actions} />);           ❸
  expect(container).toMatchSnapshot();                                     ❹
});

❶ 将天数数量转换为毫秒

❷ 创建一个静态操作列表

❸ 在测试中创建一个包含静态操作的 ActionLog 实例

❹ 期望渲染的元素与快照匹配

当您第一次执行此测试时,您会看到,除了测试通过之外,Jest 还会告诉您它已写入一个快照。

PASS  ./ActionLog.test.jsx
 › 1 snapshot written.

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Jest 创建此文件是因为您第一次运行使用快照的测试时,Jest 没有之前的快照可以与之比较。因此,它会对组件进行快照,以便在您下次运行测试时使用,如图 8.6 所示。在 Jest 保存快照后,在您测试的每次后续执行中,Jest 都会将断言目标与存储的快照进行比较。

图 8.6 当 Jest 找不到测试的快照时,它会使用断言目标的内 容创建一个快照。

如果您重新运行测试,您会看到 Jest 不会说它已写入快照。相反,它将现有的快照与断言目标进行比较,并检查它们是否匹配,如图 8.7 所示。如果它们匹配,则断言通过。否则,它会抛出一个错误。

图 8.7 当存在快照时,Jest 将断言目标的内 容与快照的内 容进行比较。

Jest 将这些快照写入与测试文件相邻的文件夹中。这个文件夹被称为 snapshots

现在,打开您项目根目录下的 snapshots 文件夹,查看其中的 ActionLog.test.jsx.snap 文件。

您会看到这个文件包含您的测试名称和 ActionLog 组件在测试期间渲染的标记。

列表 8.19 ActionLog.test.jsx.snap

exports[`logging actions 1`] = `
<div>
  <div>
    <h2>
      Action Log
    </h2>
    <ul>
    // ...
    </ul>
  </div>
</div>
`;

注意:您快照中的这个额外的 divreact-testing-library 渲染您的组件的 div。为了避免在快照中包含这个额外的 div,请使用 container.firstChild 而不是 container 作为您的断言目标。

如果您仔细观察,您会注意到这个文件只是一个导出包含测试名称后跟一个数字的 JavaScript 文件,该数字代表与它关联的 toMatchSnapshot 断言。

当运行测试时,Jest 使用测试的名称和 toMatchSnapshot 断言的顺序从 .snap 文件中获取字符串,该字符串将用于与断言的目标进行比较。

重要提示:生成快照时要小心。如果你的组件渲染了错误的内容,Jest 将比较你的组件的标记也将是错误的。

你应该在确定断言的目标正确后生成快照。或者,在第一次运行测试后,你也可以阅读快照文件的內容,以确保其正确性。

在相同的测试中,尝试创建一个新的动作日志,渲染另一个 ActionLog 组件,并再次使用 toMatchSnapshot

列表 8.20 ActionLog.test.jsx

// ...

test("logging actions", () => {
  // ...

  const { container: containerOne } = render(         ❶
    <ActionLog actions={actions} />
  );
  expect(containerOne).toMatchSnapshot();             ❷

  const newActions = actions.concat({                 ❸
    time: new Date(daysToMs(5)),
    message: "You were given lemons",
    data: { lemons: 1337 }
  });

  const { container: containerTwo } = render(         ❹
    <ActionLog actions={newActions} />
  );
  expect(containerTwo).toMatchSnapshot();             ❺
});

❶ 渲染一个带有静态项目列表的 ActionLog 组件

❷ 期望 ActionLog 匹配一个快照

❸ 创建一个带有额外项目的项目列表

❹ 渲染另一个带有新动作列表的 ActionLog 组件

❺ 期望第二个 ActionLog 匹配另一个快照

再次运行此测试时,你会看到 Jest 会告诉你它已经编写了一个快照。

Jest 必须为这个测试编写一个新的快照,因为它在 ActionLog.test.jsx.snap 中找不到与断言的目标进行比较的字符串。

如果你再次打开 ActionLog.test.jsx.snap 文件,你会看到它现在导出两个不同的字符串,每个 toMatchSnapshot 断言一个。

列表 8.21 ActionLog.test.jsx.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`logging actions 1`] = `
  // ...
`;

exports[`logging actions 2`] = `
  // ...
`

现在尝试更改每个日志条目的格式,并重新运行你的测试。

列表 8.22 ActionLog.jsx

import React from "react";

export const ActionLog = ({ actions }) => {
  return (
    <div>
      <h2>Action Log</h2>
      <ul>
        {actions.map(({ time, message, data }, i) => {
          const date = new Date(time).toUTCString();
          return (
            <li key={i}>
              Date: {date} -{" "}
              Message: {message} -{" "}
              Data: {JSON.stringify(data)}
            </li>
          );
        })}
      </ul>
    </div>
  );
};

在此更改之后,因为渲染的组件将不再匹配快照的内容,你的测试将失败。

为了让测试再次通过,使用带有 -u 选项的测试运行,该选项是 --updateSnapshot 的简称。此选项将导致 Jest 更新那些 toMatchSnapshot 匹配器失败的快照。

PASS  ./ActionLog.test.jsx
 › 1 snapshot updated.

Snapshot Summary
 › 1 snapshot updated from 1 test suite.

TIP 如果你使用 NPM 脚本来运行测试,请向其追加 -- 以添加选项到脚本中。如果你使用 NPM 的 test 脚本来运行测试,你可以尝试使用 npm run test -- --updateSnapshot

重要提示:只有当你确定断言的目标是正确的时才更新快照。当你使用 --updateSnapshot 选项时,与第一次生成快照时类似,如果测试不匹配快照,Jest 不会导致任何测试失败。

当你完成组件的开发后,如果你使用像 git 这样的版本控制系统,确保将快照包含在你的提交中。否则,在其他机器上,你的测试将 总是 通过,因为 Jest 将编写新的快照文件,即使组件渲染了错误的内容。

感谢 Jest 的快照功能,你能够用更简洁的测试来测试你的 ActionLog 组件。你不需要编写包含长字符串的多个断言,只需编写一个能够验证组件整个内容的单个断言。

快照可以特别有用,用于替换一组复杂的断言。具有固定标记的复杂组件的日志——长文本片段——是 Jest 快照最闪耀的使用案例之一。

由于创建和更新快照非常容易,你不需要频繁地更新测试中的断言。当测试涉及多个低价值、昂贵的更改时,避免手动更新测试特别有用,例如更新大量相似的字符串。

到目前为止,鉴于编写和更新涉及快照的测试既快又简单,它们可能看起来像是单元测试的万能药,但它们并不总是适用于所有类型的测试。

快照的一个最明显的问题是错误很容易被忽视。由于快照是自动生成的,你可能会错误地更新快照,导致测试通过,尽管断言的目标是不正确的。

即使你有代码审查流程,在一次性更新多个快照时,很容易错过更改,尤其是如果你的快照太大,或者你一次性更改了太多代码。

注意:在第十三章中,我将更详细地讨论如何进行有用的代码审查。

为了避免意外更新快照,在运行多个测试时,请避免使用 --updateSnapshot 标志。要谨慎使用,并且仅在每次运行单个测试文件时使用,这样你才能确切知道 Jest 更新了哪些快照。

提示:Jest 有一种交互式使用模式,允许你交互式地更新快照。在交互式快照模式下,Jest 将显示你测试执行期间每个更改的快照的 diff,并允许你选择新的快照是否正确。

要进入交互式快照模式,使用 --watch 选项运行 Jest,并按 i 键。

此外,为了使你的同事更容易发现错误的快照,避免生成过长的快照。

提示:如果你正在使用 eslint,你可以通过 eslint-plugin-jest 中的 no-large-snapshots 选项来禁止使用大快照,关于这个选项的更多详细信息,你可以在github.com/jest-community/eslint-plugin-jest找到。我将在第十三章深入探讨像 eslint 这样的代码检查器。

使用快照的另一个缺点是它会锁定测试的行为到特定的输出。

例如,如果你为 ActionLog 有多个测试,并且所有这些测试都使用快照,那么如果你决定更改操作日志的标题,所有这些测试都会失败。相比之下,如果你为 ActionLog 组件的不同部分编写多个小型测试,你会得到更细粒度的反馈。

为了避免粗略的反馈,同时仍然获得快照测试的好处,你可以缩小你想要快照测试的组件部分。除了使你的测试更细粒度外,这种技术还可以减小快照的大小。

如果你只想检查你刚刚编写的测试中的 ActionLog 列表内容,例如,你可以在使用 toMatchSnapshot 的断言中仅使用 ul 元素作为断言目标。

列表 8.23 ActionLog.test.jsx

// ...

test("logging actions", () => {
  // ...

  const { container } = render(<ActionLog actions={actions} />);
  const logList = document.querySelector("ul")
  expect(logList).toMatchSnapshot();
});

现在你已经知道了如何进行快照测试,你将更新 App 以使其向 ActionLog 传递一个动作列表。

首先,更新 App.jsx 以使其包含一个状态,它将存储一个动作数组。然后,App 组件将把这个状态传递给它将要渲染的 ActionLog 组件作为其子项之一。

列表 8.24 App.jsx

// ...

import { ActionLog } from "./ActionLog.jsx";

export const App = () => {
  const [items, setItems] = useState({});
  const [actions, setActions] = useState([]);

  // ...

  return (
    <div>
      <h1>Inventory Contents</h1>
      <ItemList itemList={items} />
      <ItemForm onItemAdded={updateItems} />
      <ActionLog actions={actions} />
    </div>
  );
};

为了使 ActionLog 组件有一些初始内容来显示,当 App 收到包含初始库存项目的服务器响应时,它应该更新 actions,如下所示。

列表 8.25 App.jsx

// ...

export const App = () => {
  const [items, setItems] = useState({});
  const [actions, setActions] = useState([]);                         ❶
  const isMounted = useRef(null);

  useEffect(() => {
    isMounted.current = true;
    const loadItems = async () => {
      const response = await fetch(`${API_ADDR}/inventory`);
      const responseBody = await response.json();

      if (isMounted.current) {
        setItems(responseBody);
        setActions(actions.concat({                                   ❷
          time: new Date().toISOString(),
          message: "Loaded items from the server",
          data: { status: response.status, body: responseBody }
        }));
      }
    };
    loadItems();
    return () => (isMounted.current = false);
  }, []);

  // ...

  return (
    <div>
      <h1>Inventory Contents</h1>
      <ItemList itemList={items} />
      <ItemForm onItemAdded={updateItems} />
      <ActionLog actions={actions} />
    </div>
  );
};

❶ 创建一个状态片段来表示应用程序的动作

❷ 更新 App 中的状态,使其动作列表包括一个通知从服务器加载项目列表的动作

现在你已经编写了 ActionLog 组件的标记,并给它提供了一些数据,构建你的应用程序,提供服务,并检查动作日志的内容。一旦客户端从服务器接收到初始项目,你的动作日志应该包含响应体和状态。

要测试由 App 渲染的 ActionLog,再次使用快照测试。

首先,为了将快照测试限制在 ActionLog 组件上,给其最外层的 div 添加一个 data-testid 属性,这样你就可以在测试中找到它。

列表 8.26 ActionLog.jsx

import React from "react";

export const ActionLog = ({ actions }) => {
  return (
    <div data-testid="action-log">
      { /* ... */ }
    </div>
  );
};

在设置了这个属性之后,编写一个测试,渲染 App 并等待加载项目的请求解决,然后使用 toMatchSnapshotActionLog 的内容生成一个快照。

列表 8.27 App.test.jsx

test("updating the action log when loading items", async () => {
  const { getByTestId } = render(<App />);                              ❶

  await waitFor(() => {                                                 ❷
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });

  const actionLog = getByTestId("action-log");                          ❸
  expect(actionLog).toMatchSnapshot();                                  ❹
});

❶ 渲染 App 实例

❷ 等待渲染的项目列表有三个子项

❸ 查找动作日志容器

❹ 期望动作日志匹配快照

这个测试第一次运行时会通过,但在所有后续执行中都会失败。这些失败发生是因为 Jest 为动作列表生成的快照包括了当前时间,这会每次你重新运行测试时都会改变。

为了使那个测试具有确定性,你可以使用一个模拟计时器,就像你在第五章中所做的那样,或者你可以直接模拟 toISOString,使其始终返回相同的值。

列表 8.28 App.test.jsx

test("updating the action log when loading items", async () => {
  jest.spyOn(Date.prototype, "toISOString")
    .mockReturnValue("2020-06-20T13:37:00.000Z");                   ❶

  const { getByTestId } = render(<App />);                          ❷
  await waitFor(() => {                                             ❸
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });

  const actionLog = getByTestId("action-log");                      ❹
  expect(actionLog).toMatchSnapshot();                              ❺
});

❶ 在 Date.prototype 中模拟 toIsoString 方法,使其始终返回相同的日期

❷ 渲染 App 实例

❸ 等待渲染的项目列表有三个子项

❹ 查找动作日志容器

❺ 期望动作日志匹配快照

在此更改后,使用 --updateSnapshot 选项重新运行你的测试。然后,在 Jest 更新此测试的快照之后,多次重新运行你的测试,你会发现它们总是会通过。

当在测试中使用快照时,请确保您的测试是确定的。否则,它们在第一次执行后总是会失败。

作为练习,更新 App 以便每次用户向库存添加项目时,它都会在操作日志中添加一个新条目。然后,使用 toMatchSnapshot 测试它。

注意:您可以在本书 GitHub 仓库的 chapter8/2_snapshot _testing/1_component_snapshots 目录中找到此练习的解决方案:github.com/lucasfcosta/testing-javascript-applications

8.2.1 组件之外的快照

快照测试不仅限于测试 React 组件。您可以使用它来测试任何类型的数据,从 React 组件到简单的对象,或原始值,如字符串。

想象一下,你已经构建了一个小工具,如下所示,它接受一个包含项目、数量和价格的列表,并将报告写入一个 .txt 文件。

列表 8.29 generate_report.js

const fs = require("fs");

module.exports.generateReport = items => {
  const lines = items.map(({ item, quantity, price }) => {                 ❶
    return `${item} - Quantity: ${quantity} - Value: ${price * quantity}`;
  });
  const totalValue = items.reduce((sum, { price }) => {                    ❷
    return sum + price;
  }, 0);

  const content = lines.concat(`Total value: ${totalValue}`).join("\n");   ❸
  fs.writeFileSync("/tmp/report.txt", content);                            ❹
};

❶ 生成每项数量的行以及每种物品的总价值

❷ 计算库存的总价值

❸ 生成文件的最终内容

❹ 同步将报告写入文件

要测试此实用程序,您可以在断言中而不是写一段长文本,使用 Jest 的快照测试功能将生成的值与快照进行比较。

尝试创建一个名为 generate_report.test.js 的文件,并编写一个调用 generateReport 并带有项目列表的测试,从 /tmp/report.txt 读取,并将该文件的内容与快照进行比较。

列表 8.30 generate_report.test.js

const fs = require("fs");
const { generateReport } = require("./generate_report");

test("generating a .txt report", () => {
  const inventory = [                                              ❶
    { item: "cheesecake", quantity: 8, price: 22 },
    { item: "carrot cake", quantity: 3, price: 18 },
    { item: "macaroon", quantity: 40, price: 6 },
    { item: "chocolate cake", quantity: 12, price: 17 }
  ];

  generateReport(inventory);                                       ❷
  const report = fs.readFileSync("/tmp/report.txt", "utf-8");      ❸
  expect(report).toMatchSnapshot();                                ❹
});

❶ 安排:创建一个静态的项目列表

❷ 执行:调用 generateReport 函数

❸ 读取生成的文件

❹ 断言:期望文件内容与快照匹配

一旦编写了此测试,运行它并检查 snapshots 文件夹中的 generate_report.test.js.snap 文件的内容。在该文件中,您将找到一个包含文件内容的字符串。

列表 8.31 generate_report.test.js.snap

exports[`generating a .txt report 1`] = `
"cheesecake - Quantity: 8 - Value: 176
carrot cake - Quantity: 3 - Value: 54
macaroon - Quantity: 40 - Value: 240
chocolate cake - Quantity: 12 - Value: 204
Total value: 63"
`;

现在,每次你重新运行测试时,Jest 都会将 /tmp/report.txt 文件的内容与快照中的内容进行比较,就像在测试 React 组件时一样,如图 8.8 所示。

图 8.8

图 8.8 Jest 首次运行时创建包含报告内容的快照。第二次运行时,它将比较报告的实际内容与它保存在快照文件中的内容。

这种技术在测试转换代码或写入终端的程序时很有用。

例如,Jest 项目使用它自己和其快照测试功能来验证它生成的测试摘要。当 Jest 的贡献者编写一个新功能时,他们会编写执行 Jest 并将写入终端的 stdout 内容与快照进行比较的测试。

8.2.2 序列化器

为了让 Jest 能够将数据写入快照,它需要知道如何正确地序列化它。

当你在测试 React 组件时,例如,Jest 知道如何以使快照可读的方式序列化这些组件。如图 8.9 所示的这种专门用于 React 组件的序列化器,是为什么你在快照中看到的是漂亮的 HTML 标签,而不是一大堆令人困惑的对象。

图 8.9 Jest 的序列化器决定了如何将数据序列化到快照文件中。

可理解的快照通过使你更容易发现错误,以及其他人一旦将代码推送到远程仓库后更容易审查你的快照,从而提高了测试的质量。

在撰写本文时,Jest(26.6)的当前版本已经包含了所有 JavaScript 原始类型、HTML 元素、React 组件和 ImmutableJS 数据结构的序列化器,但你也可以构建自己的。

例如,你可以使用自定义序列化器来比较组件的样式,正如你将在下一节中看到的。

8.3 测试样式

路易斯知道,大多数时候,蛋糕上的樱桃不仅仅是细节。它实际上是顾客决定是否带回家那块甜美甜点的关键。当芝士蛋糕看起来很棒时,它肯定能卖得更好。

同样,组件的样式是决定你是否可以发布它的一个重要部分。例如,如果你的组件的根元素有一个永久的 visibility: hidden 规则,那么它可能对你的用户来说并不太有用。

在本节中,你将学习如何测试组件的样式,以及你可以和不能通过测试实现什么。

要了解如何测试组件的样式,你将使应用程序动画化,并将即将售罄的物品以 红色 突出显示。在实现这些更改后,我将介绍测试过程,并解释你可以和不能测试的内容,以及哪些工具可以帮助你产生更好的自动化测试。

首先,创建一个 styles.css 文件,在其中编写一个类来样式化即将售罄的物品。

列表 8.32 styles.css

.almost-out-of-stock {
  font-weight: bold;
  color: red;
}

一旦创建了该文件,请向 index.html 添加一个 style 标签以加载它。

列表 8.33 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Inventory</title>
    <link rel="stylesheet" href="./styles.css">      ❶
  </head>
  <!-- ... -->
</html>

❶ 加载 styles.css

现在你可以将这个类的规则应用到页面中的元素上,更新 ItemList 以使用 almost-out-of-stock 来样式化数量少于五的元素。

列表 8.34 ItemList.jsx

// ...

export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);

  return (
    <ul>
      <Transition
        { /* ... */ }
      >
        {([itemName, quantity]) => styleProps => (
          <li
            key={itemName}
            className={quantity < 5 ? "almost-out-of-stock" : null}        ❶
            style={styleProps}
          >
            {generateItemText(itemName, quantity)}
          </li>
        )}
      </Transition>
    </ul>
  );
};

❶ 将 almost-out-of-stock 类应用到代表数量少于 5 的物品的 li 元素上

要看到数量少于五的物品以红色突出显示,重新构建你的应用程序,并在浏览器中手动尝试。

最后,是时候为它编写自动化测试了。你将要编写的测试应该将 itemListprop 传递给 ItemList 组件,并检查数量少于五的物品是否应用了 almost-out-of-stock 类。

列表 8.35 ItemList.test.jsx

describe("ItemList Component", () => {
  // ...

  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶

    const { getByText } = render(<ItemList itemList={itemList} />);        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveClass("almost-out-of-stock");             ❹
  });
});

❶ 安排:创建静态项目列表

❷ 行动:渲染一个包含静态项目列表的 ItemList 实例

❸ 找到一个表示库存中有 2 个芝士蛋糕的元素

❹ 断言:期望渲染的 li 元素具有 almost-out-of-stock 类

一旦你运行了你的测试,它们都应该通过,但这并不一定意味着它们是可靠的。例如,你刚刚编写的测试在更改almost-out-of-stock类的名称或其规则,以便不再突出显示项目时,将不会失败。

例如,尝试从almost-out-of-stock中移除将color设置为red的 CSS 规则。如果你这样做并重新运行测试,你会发现它仍然会通过,即使应用程序不会用红色突出显示即将变得不可用的项目。

当测试你的样式时,如果你使用外部 CSS 文件,你将无法检查类中特定的样式规则是否应用。你只能检查组件的classname属性是否正确。

如果你正在使用外部 CSS 文件,我甚至会建议你不要断言那些不会改变的类。例如,如果你总是将一个名为item-list的类应用到ItemList中的ul元素上,测试ul是否有特定的className不会有很大的价值。这样的测试不会确保组件应用了正确的样式规则,或者它看起来应该是怎样的。相反,这个测试会产生更多的工作,因为它会频繁地因为一个完全随机的字符串而中断,这在你的测试上下文中并没有什么意义。在这种情况下,你最好写一个快照测试。

使你的样式测试更有价值的一个替代方案是在你的组件中编写内联样式。因为这些样式将包含强制组件以某种方式显示的规则,你可以编写更具体的断言,这提供了更可靠的保证。

例如,尝试将almost-out-of-stock中的规则封装到ItemList.jsx中的单独对象中。然后,在渲染你的li元素时,使用该对象而不是类。

列表 8.36 ItemList.jsx

// ...

const almostOutOfStock = {                                    ❶
  fontWeight: "bold",
  color: "red"
};

export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);

  return (
    <ul>
      <Transition
        { /* ... */ }
      >
        {([itemName, quantity]) => styleProps => (
          <li
            key={itemName}
            style={                                           ❷
              quantity < 5
                ? { ...styleProps, ...almostOutOfStock }
                : styleProps
            }
          >
            {generateItemText(itemName, quantity)}
          </li>
        )}
      </Transition>
    </ul>
  );
};

❶ 表示要应用于即将缺货的项目的一组样式的对象

❷ 如果一个项目的数量少于 5,则将 almostOutOfStock 对象中的样式与 Transition 提供的动画生成的样式合并;否则,只需使用动画的样式

在此更改之后,你将能够在测试中使用toHaveStyle断言对特定的样式进行断言。

列表 8.37 ItemList.test.jsx

describe("ItemList Component", () => {
  // ...

  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶

    const { getByText } = render(<ItemList itemList={itemList} />);        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveStyle({ color: "red" });                  ❹
  });
});

❶ 安排:创建一个静态项目列表

❷ 行动:渲染一个包含静态项目列表的 ItemList 实例

❸ 找到一个表示库存中有 2 个芝士蛋糕的元素

❹ 断言:期望渲染的 li 元素在其样式中具有值为红色的颜色属性

多亏了这个断言,你可以验证当项目即将变得不可用时,你的列表会以红色渲染项目。

这种策略在大多数情况下效果不错,但它有局限性。尽管你可以断言单个样式规则,但你不能确保你的应用程序看起来应该是那样的。例如,组件可能会相互重叠,某个特定的规则可能不支持在某个浏览器上,或者另一个样式表可能会干扰你的组件样式。

验证应用程序实际外观的唯一方法是通过使用将浏览器渲染结果与之前的快照进行比较的图像的工具。这种技术被称为视觉回归测试,你将在第十章中了解更多关于它的内容。

如果你使用内联样式,同时断言多个样式可能会变得重复,甚至可能无法执行动画。例如,如果你想使即将变得不可用的项目产生脉冲效果,使其更加显眼怎么办?

为了更容易地处理这些情况,你现在将采用我最喜欢的策略来设置 React 组件的样式。你将使用 css-in-js——也就是说,你将使用允许你在组件文件中使用 CSS 语法的工具。

除了使你在组件内管理样式更加容易外,许多 CSS-in-JS 库还允许你扩展工具,如 linters,以使你的自动化质量保证过程更加可靠。

我认为 CSS-in-JS 是设置 React 组件的最佳方式,因为它解决了与在兼容 React 采用的哲学的方式管理 CSS 相关的许多作用域问题。它使你的组件封装了它们正确工作所需的一切。

要使用 CSS-in-JS,你需要安装专门为此目的制作的库。你将使用的库名为 emotion,你可以使用 npm install @emotion/core 来安装它。

注意:由于你使用 React,emotion 库的文档建议你使用 @emotion/core 包。

在实现我提到的动画之前,安装 emotion 后,更新 ItemList 组件,使其使用 emotion 定义即将变得不可用的列表项的样式。

列表 8.38 ItemList.jsx

/* @jsx jsx */

// ...

import { css, jsx } from "@emotion/core"

// ...

const almostOutOfStock = css`                                      ❶
  font-weight: bold;
  color: red;
`;

export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);

  return (
    <ul>
      <Transition
        { /* ... */ }
      >
        {([itemName, quantity]) => styleProps => (
          <li
            key={itemName}
            style={styleProps}
            css={quantity < 5 ? almostOutOfStock : null}           ❷
          >
            {generateItemText(itemName, quantity)}
          </li>
        )}
      </Transition>
    </ul>
  );
};

❶ 使用 @emotion/core 中的 css 创建一组应用于数量少于 5 的项目的样式

❷ 将使用 emotion 创建的样式应用于表示数量少于 5 的 li 元素

在运行或更新任何测试之前,重新构建应用程序,并手动测试以确保你的项目列表仍然可以突出显示即将缺货的项目。

即使你的应用程序运行正常,你的测试现在也会失败,因为你的组件不再使用内联样式属性以红色突出显示项目。相反,由于emotion的工作方式,你的应用程序将自动生成你使用emotion创建的规则对应的类,并将这些类应用到你的元素上。

提示:要查看在浏览器中查看您的应用程序时 emotion 生成的类,您可以使用检查器检查每个列表项应用了哪些类名和规则。

为了解决类名自动生成的事实,同时保持您的断言简洁、严格和精确,您将使用 jest-emotion 包。此包允许您通过 toHaveStyleRule 匹配器扩展 Jest,以验证 emotion 应用了哪些样式规则。

使用 npm install --save-dev jest-emotionjest-emotion 作为开发依赖项安装,然后创建一个名为 setupJestEmotion.js 的文件,该文件通过 jest-emotion 的匹配器扩展 jest

列表 8.39 setupJestEmotion.js

const { matchers } = require("jest-emotion");

expect.extend(matchers);                        ❶

❶ 使用 jest-emotion 的匹配器扩展 Jest

为了使 setupJestEmotion.js 在每个测试文件之前运行,将其添加到 jest.config.jssetupFilesAfterEnv 属性的脚本列表中。

列表 8.40 jest.config.js

module.exports = {
  setupFilesAfterEnv: [
    "<rootDir>/setupJestDom.js",
    "<rootDir>/setupGlobalFetch.js",
    "<rootDir>/setupJestEmotion.js"           ❶
  ]
};

❶ 在每个测试文件之前,使 Jest 执行 setupJestEmotion.js,这将使用 jest-emotion 的断言扩展 Jest

最后,在 ItemList 的测试中使用 toHaveStyleRule 匹配器。

列表 8.41 ItemList.test.jsx

describe("ItemList Component", () => {
  // ...

  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶

    const { getByText } = render(<ItemList itemList={itemList} />);        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveStyleRule("color", "red");                ❹
  });
});

❶ 安排:创建一个静态项目列表

❷ 行动:渲染一个包含静态项目列表的 ItemList 实例

❸ 查找一个元素,表示库存中有 2 个芝士蛋糕

❹ 断言:使用 jest-emotion 的断言断言找到的 li 具有名为 color 的样式规则,其值为红色

再次,所有测试都应该通过。

现在,您正在使用 jest-emotion,您仍然可以断言特定于组件的样式规则,并且您还可以执行更复杂的任务,例如动画。

现在您可以添加一个动画到即将变得不可用的项目应用的样式。

列表 8.42 ItemList.jsx

// ...

import { css, keyframes, jsx } from "@emotion/core"

const pulsate = keyframes`                                    ❶
  0% { opacity: .3; }
  50% { opacity: 1; }
  100% { opacity: .3; }
`;

const almostOutOfStock = css`                                 ❷
  font-weight: bold;
  color: red;
  animation: ${pulsate} 2s infinite;
`;

export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);

  return (
    <ul>
      <Transition
        { /* ... */ }
      >
        {([itemName, quantity]) => styleProps => (
          <li
            key={itemName}
            style={styleProps}
            css={quantity < 5 ? almostOutOfStock : null}      ❸
          >
            {generateItemText(itemName, quantity)}
          </li>
        )}
      </Transition>
    </ul>
  );
};

❶ 创建一个动画,使数量小于 5 的项目产生脉冲效果

❷ 为表示数量小于 5 的项目的 li 元素创建应用样式。这些样式包括脉冲动画。

❸ 将使用 emotion 创建的样式应用于表示数量小于 5 的项目的 li 元素

多亏了 emotion,即将售罄的项目现在应该包含一个脉冲动画。

在此更改之后,我强烈建议您使用 Jest 的快照功能,这样您就可以避免在断言中编写任何长而复杂的字符串。

更新您的测试,以便它们将列表元素的样式匹配到快照。

列表 8.43 ItemList.test.jsx

describe("ItemList Component", () => {
  // ...

  test("highlighting items that are almost out of stock", () => {
    const itemList = { )                                             ❶
      cheesecake: 2,
      croissant: 5,
      macaroon: 96
    };

    const { getByText } = render(                                    ❷
      <ItemList itemList={itemList} />
    );

    const cheesecakeItem = getByText(                                ❸
      generateItemText("cheesecake", 2)
    );

    expect(cheesecakeItem).toMatchSnapshot();                        ❹
  });
});

❶ 创建一个静态项目列表

❷ 渲染一个包含静态项目列表的 ItemList 实例

❸ 查找一个元素,表示库存中有 2 个芝士蛋糕

❹ 期望找到的 li 与快照匹配

在运行此测试第一次以使 Jest 可以创建快照之后,再运行几次以查看它总是通过。

这个测试的问题在于其快照信息量不大,也不容易审查。如果您打开为这个测试创建的快照,您会看到它包含一个神秘的类名,而不是组件的实际样式。

列表 8.44 ItemList.test.jsx.snap

exports[`ItemList Component highlighting items that are almost out of stock 1`] = `
<li
  class="css-1q1nxwp"
>
  Cheesecake - Quantity: 2
</li>
`;

如果您还记得我在上一节中提到的话,没有信息量的快照会让您和审查您代码的人很容易错过重要的更改,错误也可能被忽视。

为了解决这个问题,使用jest-emotion提供的自定义序列化器扩展 Jest。如图 8.10 所示,这个序列化器将告诉 Jest 如何正确地序列化emotion样式,以便您的快照可读且易于理解。感谢jest-emotion中包含的序列化器,您的快照将包含实际的 CSS 规则,而不是神秘的类名。

图 8.10 jest-emotion中包含的序列化器导致 Jest 写入包含实际 CSS 规则而不是神秘类名的快照。

更新jest.config.js,并将包含jest-emotion的数组分配给snapshotSerializers属性。

列表 8.45 jest.config.js

module.exports = {
  snapshotSerializers: ["jest-emotion"],        ❶
  setupFilesAfterEnv: [
    // ...
  ]
};

❶ 使用来自 jest-emotion 的序列化器扩展 Jest,以便它知道如何正确地序列化样式,包括快照中的所有规则,而不是只包含神秘的类名

现在 Jest 知道如何序列化emotion创建的样式后,使用--updateSnapshot标志重新运行测试,并再次检查快照文件。

列表 8.46 ItemList.test.jsx.snap

exports[`ItemList Component highlighting items that are almost out of stock 1`] = `
@keyframes animation-0 {
  0% {
    opacity: .3;
  }

  50% {
    opacity: 1;
  }

  100% {
    opacity: .3;
  }
}

.emotion-0 {
  font-weight: bold;
  color: red;
  -webkit-animation: animation-0 2s infinite;
  animation: animation-0 2s infinite;
}

<li
  class="emotion-0"
>
  Cheesecake - Quantity: 2
</li>
`;

由于快照文件现在包含关于应用于您组件的样式的可读信息,您的快照更容易审查,这使得快速发现错误变得更快。

当您处理复杂样式时,尝试使用快照而不是手动编写多个繁琐且重复的断言。

作为练习,尝试将不同的样式和动画应用到库存中过多的项目上,然后,使用您在本节中学到的技术测试它们。

样式是一个例子,您的工具选择可以深刻影响您编写测试的方式。因此,它是一个很好的例子,说明在选择构建应用程序时使用的库和框架时,也应该考虑测试。

8.4 组件级验收测试和组件故事

在夫妇聘请路易斯为他们婚礼的甜点自助餐之前,他们总是会先安排一个品鉴会。在这些会上,路易斯准备了多种不同的甜点,从羊角面包和杏仁糖到蛋糕和派,以便他的客户可以看到并品尝他每一道天赐佳肴。

编写故事类似于准备一个组件的品尝自助餐。故事是组件特定用例或视觉状态的演示。故事展示了单个组件的功能,以便你和你的团队能够可视化并与之交互,而无需运行整个应用程序。

想想你需要做什么才能看到 ItemList 工作,例如。要看到 ItemList,你必须编写组件本身,在 App 中使用它,向数据库添加一些项目,并让应用程序从你的服务器获取库存项目。此外,你还必须构建你的前端应用程序,构建后端,迁移和初始化数据库。

使用故事,你可以编写包含 ItemList 不同实例和不同静态数据集的页面。这种技术的第一个优点是,你将能够在开发过程中更早地看到并交互 ItemList,甚至在开始在实际应用程序中使用它之前。

除了加速反馈循环外,故事还促进了团队之间的协作,因为它们允许任何人在任何时候实验组件并查看它们的样式。

通过编写故事,你使其他人能够在组件级别执行验收测试。而不是为 QA 或产品团队创建一个单独的环境来验证你的 UI 是否可接受,有了故事,这些团队可以单独测试每个组件,并且速度更快。

在本节中,你将学习如何使用 Storybook 编写组件故事并记录你的组件。你将从 ItemList 开始,然后继续编写除 App 之外的所有组件的故事。一旦你编写了多个故事,我将深入探讨它们在简化开发过程、促进协作和提高质量方面所起的作用。

编写故事后,我将教你如何记录你的组件,以及为什么这样做是有益的。

8.4.1 编写故事

要编写你的组件故事,你将使用一个名为 Storybook 的工具。Storybook 能够加载你的故事并通过一个有组织且易于理解的 UI 显示它们。

首先,使用 npm install --save-dev @storybook/react 将 Storybook for React 作为开发依赖项安装。然后,为了让 Storybook 能够打包你将用于导航故事的应用程序,你必须使用 npm install --save-dev babel-loader 安装 babel-loader

安装这两个包后,你需要在项目的根目录中创建一个 .storybook 文件夹来配置 Storybook。在该文件夹内,你将放置一个 main.js 配置文件,该文件确定 Storybook 将加载哪些文件作为故事。

列表 8.47 main.js

module.exports = {
  stories: ["../**/*.stories.jsx"],       ❶
};

❶ 通知 Storybook 哪些文件包含你的故事

注意:在撰写本文时,我正在使用 Storybook 的最新可用版本:版本 6。在这个版本中,Storybook 工具链中存在一个问题,导致它在构建过程中找不到它需要的某些文件。

如果您希望使用 Storybook 的版本 6,您可能需要更新您的 Storybook 配置,以便它告诉 Webpack 在构建过程中在哪里找到所需的文件,如下所示。

列表 8.48 main.js

module.exports = {
  // ...
  webpackFinal: async config => {
    return {
      ...config,
      resolve: {
        ...config.resolve,
        alias: {
          "core-js/modules": "@storybook/core/node_modules/core-js/modules",
          "core-js/features": "@storybook/core/node_modules/core-js/features"
        }
      }
    };
  }
};

创建此文件后,您可以通过运行 ./node_modules/.bin/start-storybook 来启动 Storybook。

提示:为了避免每次运行 Storybook 时都不得不输入完整的 Storybook 可执行文件路径,请将名为 storybook 的脚本添加到您的 package.json 文件中。

列表 8.49 package.json

{
  "name": "my-application",
  // ...
  "scripts": {
    "storybook": "start-storybook",       ❶
    // ...
  }
  // ...
}

❶ 创建一个 NPM 脚本,当您执行 npm run storybook 时启动 Storybook

现在,您可以使用 npm run storybook 运行 Storybook,而不是输入 start-storybook 可执行文件的完整路径。

当您启动 Storybook 时,它将创建一个允许您浏览组件故事的 Web 应用程序。一旦它打包了这个 Web 应用程序,Storybook 将提供它并在新浏览器标签页中打开。

提示:为了促进开发、设计和产品团队之间的信息交流,您可以将 Storybook 生成的应用程序部署到每个团队成员都可以访问的地方。

要为 ItemList 组件创建第一个故事,添加一个名为 ItemList.stories.jsx 的文件。在这个文件中,您将导出一个对象,其中包含您将要编写的故事的组元数据以及 Storybook 要显示的每个故事的名称。

要编写一个单独的故事,创建一个具有值的命名导出,该值是一个返回您想要展示的组件的函数。

列表 8.50 ItemList.stories.jsx

import React from "react";
import { ItemList } from "./ItemList";

export default {                                      ❶
  title: "ItemList",
  component: ItemList,
  includeStories: ["staticItemList"]
};

export const staticItemList = () => <ItemList         ❷
  itemList={{
    cheesecake: 2,
    croissant: 5,
    macaroon: 96
  }}
/>

❶ 配置 ItemList 的故事集,告知这些故事的标题、相关组件以及要包含的故事

❷ 创建一个渲染静态项目列表的 ItemList 实例的故事

一旦您编写了这个故事,您会看到您的 Storybook 实例渲染了一个 ItemList,就像 App 一样。因为您已经将静态数据写入 ItemList,所以您不需要运行任何服务器或从应用程序的其他部分获取数据。

一旦您的组件可以渲染,您就可以通过故事查看并与之交互。

现在您为 ItemList 创建了故事,您团队中的每个人都可以看到它的外观并原子化地与之交互。每当他们需要更改 ItemList 时,他们可以通过使用您的这个故事快速迭代,而不是处理整个应用程序。

尽管这个故事使人们更改和与 ItemList 交互更快、更便捷,但它尚未展示该组件的所有功能。

为了展示 ItemList 将如何动画化项目进入或离开库存,并因此覆盖该组件功能的全部范围,你需要编写一个新的故事。这个故事应该返回一个包含 ItemList 和两个按钮的有状态组件,这两个按钮会更新外部组件的状态。其中一个按钮将向列表添加项目,另一个将 ItemList 重置到其原始状态。

列表 8.51 ItemList.stories.jsx

import React, { useState } from "react";

// ...

export default {
  title: "ItemList",
  component: ItemList,
  includeStories: ["staticItemList", "animatedItems"]
};

// ...

export const animatedItems = () => {                                       ❶
  const initialList = { cheesecake: 2, croissant: 5 };                     ❷
  const StatefulItemList = () => {                                         ❸
    const [itemList, setItemList] = useState(initialList);                 ❹
    const add = () => setItemList({ ...initialList, macaroon: 96 });       ❺

    const reset = () => setItemList(initialList);                          ❻

    return (                                                               ❼
      <div>
        <ItemList itemList={itemList} />
        <button onClick={add}>Add item</button>
        <button onClick={reset}>Reset</button>
      </div>
    );
  };

  return <StatefulItemList />                                              ❽
};

❶ 一个演示 ItemList 如何动画化进入或离开它的故事

❷ 创建一个静态的项目列表

❸ 故事将渲染的有状态组件

❹ 包含项目列表的状态对象

❺ 一个向列表添加 96 个马卡龙的函数

❻ 一个将列表重置为其初始状态的功能

❼ 导致有状态的组件返回添加项目和重置项目列表的按钮,以及一个包含 ItemList 实例的 div,其 itemList prop 是有状态组件状态中的项目列表

❽ 渲染有状态组件的一个实例

每当你需要使你的组件能够进行交互时,你可以创建一个有状态的包装器,就像你刚刚做的那样。这些包装器的问题在于,它们为你的故事添加了一个额外的复杂层,并限制了观众的交互,使其仅限于你最初认为他们可能想要做的事情。

与使用有状态的包装器不同,你可以使用一个名为 @storybook/addon-knobs 的包来允许观众以他们想要的方式操纵传递给组件的 props

@storybook/addon-knobs 插件向 Storybook 底部面板添加了一个新标签页,观众可以在其中实时更改与你的故事相关联的任何 props 的值。

使用 npm install --save-dev @storybook/addon-knobs@storybook/addon-knobs 作为开发依赖项安装。然后,更新你的 .storybook/main.js 文件,并向其中添加一个 addons 属性。此属性的值将是一个数组,包含 Storybook 应加载的插件列表。

列表 8.52 main.js

module.exports = {
  stories: ["../**/*.stories.jsx"],
  addons: ["@storybook/addon-knobs/register"],          ❶
  // ...
};

❶ 配置 Storybook 使用 @storybook/addon-knobs 插件

使用这个插件,你可以更新你的故事,以便 @storybook/addon-knobs 将管理传递给组件的 props

列表 8.53 ItemList.stories.jsx

import React from "react";
import { withKnobs, object } from "@storybook/addon-knobs";

export default {
  title: "ItemList",
  component: ItemList,
  includeStories: ["staticItemList", "animatedItems"],
  decorators: [withKnobs]                                         ❶
};

// ...

export const animatedItems = () => {
  const knobLabel = "Contents";
  const knobDefaultValue = { cheesecake: 2, croissant: 5 };
  const itemList = object(knobLabel, knobDefaultValue);           ❷
  return <ItemList itemList={itemList} />                         ❸
};

❶ 配置 ItemList 的故事,以便它们可以使用 knobs 插件

❷ 创建一个将由 knobs 插件管理的 itemList 对象

❸ 渲染一个 ItemList 的实例,其 itemList prop 是由 knobs 管理的对象

一旦你使用你的新插件将管理的属性传递给 ItemList,打开 Storybook 并尝试通过故事底部的“动态项目”标签页更改 itemListprop。当你更改这些属性时,你会看到组件更新,动画化项目进入或离开列表。

@storybook/addon-knobs 提供的灵活性使得测试人员更容易审查你的组件,模拟边缘情况,并执行探索性测试。对于产品团队来说,这种灵活性将导致对你组件功能的更深入了解。

现在你已经为 ItemList 编写了故事,你也将为 ItemForm 编写一个。在你的项目根目录中创建一个名为 ItemForm.stories.jsx 的文件,并编写一个故事,渲染你的表单并在用户提交时显示警告。

列表 8.54 ItemForm.stories.jsx

import React from "react";
import { ItemForm } from "./ItemForm";

export default {                             ❶
  title: "ItemForm",
  component: ItemForm,
  includeStories: ["itemForm"]
};

export const itemForm = () => {              ❷
  return (
    <ItemForm
      onItemAdded={(...data) => {
        alert(JSON.stringify(data));
      }}
    />
  );
};

❶ 配置 ItemForm 的故事集,包括它们的标题、相关的组件以及要包含的故事

❷ 一个显示当添加项目时发出警告的 ItemForm 故事

尽管这个故事渲染了你的组件并显示了一个包含提交数据的警告,但 ItemForm 仍然在向你的后端发送请求。如果你在交互这个组件的故事时运行服务器,你会看到当提交 ItemForm 时,你的数据库确实被更新了。为了避免 ItemForm 向后端发送任何请求,你必须模拟该功能。

之前你使用 nock 来创建响应 HTTP 请求的拦截器,但在 Storybook 中你将无法使用它。因为 nock 依赖于 Node.js 特定的模块,如 fs,所以它不能在浏览器上运行。

你将不再使用 nock 来拦截和响应 HTTP 请求,而是使用一个名为 fetch-mock 的包。它的 API 与 nock 类似,并且可以在浏览器中工作。

使用 npm install --save-dev fetch-mock 安装 fetch-mock 作为开发依赖项,并更新 ItemForm.stories.jsx 以便你有一个拦截器来处理 ItemForm 执行的 POST 请求。

列表 8.55 ItemForm.stories.jsx

// ...

import fetchMock from "fetch-mock";
import { API_ADDR } from "./constants";

// ...

export const itemForm = () => {
  fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200);      ❶

  return (
    <ItemForm
      onItemAdded={(...data) => {
        alert(JSON.stringify(data));
      }}
    />
  );
};

❶ 创建一个响应任何对 /inventory/* 的 POST 请求并返回 200 状态的拦截器

在使用 fetch-mock 拦截请求后,ItemForm 将永远不会到达你的后端,你将始终得到成功的响应。为了确认 ItemForm 不会发出任何 HTTP 请求,尝试与你的表单故事进行交互,并在你的开发者工具的网络标签打开的情况下提交几个项目。

现在,使这个故事完整化的最后一步是清除你编写的拦截器,这样它就不会干扰其他故事。目前,当你打开你表单的故事时,它将创建一个拦截器,这个拦截器将一直持续到用户刷新故事查看器。如果,例如,你还有一个故事向与 ItemForm 相同的 URL 发送请求,这个拦截器可能会影响其他故事。

当用户离开这个故事时清除你的拦截器,你需要将 ItemForm 包裹在另一个组件中,这个组件在挂载时创建拦截器,在卸载时消除拦截器。

列表 8.56 ItemForm.stories.jsx

// ...

export const itemForm = () => {
  const ItemFormStory = () => {
    useEffect(() => {                                              ❶
      fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200)
      return () => fetchMock.restore();                            ❷
    }, []);

    return (
      <ItemForm
        onItemAdded={(...data) => {
          alert(JSON.stringify(data));
        }}
      />
    );
  }

  return <ItemFormStory />
};

❶ 当 ItemFormStory 挂载时,创建一个响应任何对 /inventory/* 的 POST 请求并返回 200 状态的拦截器

❷ 当故事卸载时导致销毁拦截器

在你的故事中使用存根时,记得清除任何悬挂的存根或间谍,就像你恢复拦截器时做的那样。为了执行存根和清理,你可以使用带有 hooks 或生命周期方法的包装组件。

最后,你将摆脱由 ItemForm 触发的警报。而不是显示一个干扰性的弹出窗口,你将使用 @storybook/addon-actions 包将操作记录到 Storybook UI 的单独标签页中。

要使用此插件,将其作为开发依赖项安装,使用 npm install --save-dev @storybook/addon-actions,并更新你的 Storybook 配置文件。在 .storybook/main.js 中,向导出对象添加一个 addons 属性,并将其分配给包含插件注册命名空间的数组。

列表 8.57 main.js

module.exports = {
  stories: ["../**/*.stories.jsx"],
  addons: [
    "@storybook/addon-knobs/register",
    "@storybook/addon-actions/register"        ❶
  ],
  // ...
};

❶ 配置 Storybook 使用 @storybook/addon-actions 插件

安装此插件并重新运行 Storybook 后,你将在每个故事的底部看到一个“操作”标签页。在此标签页中,Storybook 将记录由 addon-actions 创建的每个操作调用。

要开始记录操作,你需要更新 ItemForm.stories.js。在这个文件中,你将导入 action@storybook/addon-actions,并使用此函数创建你将传递给故事中 ItemForm 实例的回调函数。

列表 8.58 ItemForm.stories.jsx

// ...

import { action } from "@storybook/addon-actions";

// ...

export const itemForm = () => {
  const ItemFormStory = () => {
    // ...

    return <ItemForm
      onItemAdded={action("form-submission")}       ❶
    />;
  };

  return <ItemFormStory />;
};

❶ 当提交表单时导致表单在 Storybook 的“操作”标签页中记录操作

一旦你更新了表单的故事,打开 Storybook 并尝试提交表单几次。每次提交时,Storybook 应该将新的操作记录到故事的“操作”标签页。

使用操作而不是警报,可以更容易地理解你的组件正在做什么,并检查它调用传递的回调函数时使用的参数。

现在你已经知道了如何创建故事,作为练习,尝试为 ActionLog 组件创建一个故事。创建一个新的 .stories.jsx 文件,并编写一个故事来展示 ActionLog 的工作方式。

注意:你可以在本书 GitHub 仓库的 chapter8/4_component _stories/1_stories 目录中找到这个练习的解决方案:github.com/lucasfcosta/testing-javascript-applications

除了缩短反馈循环并为其他人手动测试组件创建一个更友好的环境外,这些故事还有助于开发者和其他团队成员之间的沟通。当设计师可以访问故事时,他们更容易准备符合现有 UI 模式的布局,因为他们知道应用程序当前组件的外观和行为。

最终,故事是 UI 工业化的一个步骤。通过尝试将新功能的开发限制在现有组件集内,你可以减少返工,并最终得到更可靠的应用程序。这种改进不仅是因为你有更多时间专注于测试,而且因为代码更少,隐藏错误的地方也更少。

在阅读上一段之后,许多人可能会争论 UI 的工业化将限制创造力——我完全同意这个观点。尽管如此,我还是要说,这种限制是一个特性,而不是一个错误

创造力是有代价的,这种代价往往因为产品团队经常寻求新组件而被忽视。试图将 UI 模式限制在现有组件的集合中,使得实现新功能所需的工作量对其他团队来说更加明显。

组件库的目标不是限制设计师的创造力以进行创作,而是相反,使创造力的成本明确化,这样企业才能繁荣发展。

这类代表一组 UI 组件、约束和最佳实践的库也被称为设计系统,近年来其受欢迎程度有所上升。

尽管这种流行度的上升,但故事并不总是是一个好主意。就像测试一样,故事是需要维护的代码片段。当你更新你的组件时,你需要确保你的故事仍然充分展示了组件的使用案例。

即使你有能力保持故事更新,你仍然会为维护支付更高的代价。承担这些维护成本的优势在于,你将减少概念化和实现新功能所涉及的成本。这种成本降低是因为故事促进了组件的重用,并限制了设计团队的创造力,使其局限于现有内容,使变更的成本更加明确。

8.4.2 编写文档

路易对他的能力充满信心,相信每位顾客品尝到的每一款甜点都能让他们眼睛发光。然而,他知道,为了让他的员工也能做到同样的事情,他的食谱必须被仔细解释,从最微妙的可可豆到最大胆的奶油部分。

通过编写故事,你可以展示组件的外观,并演示其行为,但为了让其他人理解他们应该如何使用它,你必须编写文档。

在本节中,你将学习如何使用 Storybook 编写和发布你的组件文档,从ItemList开始。

要编写文档,你将使用一种名为 MDX 的文件格式。MDX 文件支持 Markdown 和JSX代码的组合,这样你就可以用纯文本来解释你的组件是如何工作的,并在文档本身中包含组件的真实实例。

为了使 Storybook 支持 MDX 文件,您将使用 @storybook/addon-docs 插件。此插件会在每个故事中添加一个名为 Docs 的额外选项卡。在这个选项卡中,您将找到与当前故事对应的 MDX 文档。

在安装 @storybook/addon-docs 时,您还必须安装此插件所依赖的 react-is 包。要作为开发依赖项安装这两个包,请执行 npm install --save-dev react-is @storybook/addon-docs

一旦安装了 @storybook/addon-docs 及其依赖项,请更新 .storybook/main.js 中的配置,以便 Storybook 支持使用 MDX 编写的文档。

除了在您的配置中更新 addons 属性外,您还必须更新 stories 属性,以便 Storybook 能够包含具有 .mdx 扩展名的文件。

列表 8.59 main.js

module.exports = {
  stories: ["../**/*.stories.@(jsx|mdx)"],
  addons: [
    "@storybook/addon-knobs/register",
    "@storybook/addon-actions/register",
    {
      name: "@storybook/addon-docs",         ❶
      options: { configureJSX: true }        ❷
    }
  ],
  // ...
};

❶ 配置 Storybook 使用 @storybook/addon-docs 插件

❶ 根据您当前的 Babel 配置,此选项是必要的,以确保插件能够处理 JSX 文件。

更新此文件后,重新运行 Storybook,并访问您的一个故事,以查看顶部的 Docs 选项卡。

现在您已经配置了此插件,您将编写项目列表 Docs 选项卡的内容。

继续创建一个名为 ItemList.docs.mdx 的文件,在其中您将使用 markdown 来描述组件的工作方式,并使用 JSX 包含真实的 ItemList 实例以说明文档。

为了使 Storybook 能够充分渲染您的组件实例,别忘了将其包裹在由 @storybook/addon-docs 导出的 PreviewStory 组件中。此外,为了将必要的元数据链接到您的故事中,您还必须导入此插件的 Meta 组件并将其添加到文件的开头。

注意:您可以在 mdxjs.com 找到 MDX 格式的完整文档。

列表 8.60 ItemList.docs.mdx

import { Meta, Story, Preview } from '@storybook/addon-docs/blocks';
import { ItemList } from './ItemList';

<Meta title="ItemList" component={ItemList} />

# Item list

The `ItemList` component displays a list of inventory items.

It's capable of:

* Animating new items
* Highlighting items that are about to become unavailable

## Props

* An object in which each key represents an item's name and each value represents its quantity.

<Preview>
  <Story name="A list of items">
    <ItemList itemList={{
      cheesecake: 2,
      croissant: 5,
      macaroon: 96
    }} />
  </Story>
</Preview>

在为 ItemList 编写了一些文档之后,打开其在 Storybook 中的故事,并检查 Docs 选项卡,以便您可以看到您的 MDX 文件将如何显示。

良好的文档有助于测试人员确定组件的预期行为。通过在组件所在的 Storybook 中编写文档,您可以清楚地向测试人员传达组件的预期行为。反过来,清晰的沟通可以导致更快、更有效的测试,从而减少开销,因此可以降低成本。

适当的文档还有助于产品团队以减少实现时间的方式设计新功能,从而让工程师能够专注于可靠性和软件开发的其他重要方面。

摘要

  • 在测试您的组件时,请将集成测试编写在组件树中尽可能高的位置,以获得可靠的保证。测试目标在组件树中的位置越高,测试就越可靠。

  • 为了避免触发组件的副作用或测试第三方库,你可以模拟组件。要模拟一个组件,你可以使用 Jest 创建一个模拟,并使用 jest.mock 使导入解析到你的测试替身。

  • 快照测试是一种测试技术,其中你在测试首次运行时保存断言目标的快照。然后,在每次后续执行中,你将断言的目标与存储的快照进行比较。

  • 快照对于测试包含大量标记或大量文本的组件很有用。因为你可以自动创建和更新快照,所以你避免了花费时间进行像编写和重写长而复杂的字符串这样的繁琐活动。

  • 当测试除组件之外的目标时,请确保你正在使用适当的序列化器,以便 Jest 可以生成可读的快照,因此更容易审查。可理解的快照简化了代码审查,并使错误难以被忽视。

  • 当测试样式时,如果没有视觉测试,你无法保证组件看起来应该是的样子,但你可以确保正确的类或规则被应用到它上。要断言组件的样式,你可以断言其 classnamestyle 属性。

  • 因为样式通常可能变得很长且复杂,你可以将 Jest 的快照与 CSS-in-JS 结合使用,以便让开发者更快地更新测试。在这种情况下,请确保使用正确的序列化器,以便你的快照可读。

  • 故事是一段代码,用于展示不同单个组件的功能。要编写故事,你可以使用一个名为 Storybook 的工具。

  • 故事使测试人员更容易在组件级别执行验收测试,因为它们消除了在交互你的组件之前启动整个应用程序的必要性。

  • 故事是 UI 工业化的一个步骤。它们将新功能的发展限制在现有组件集内。这种鼓励重用组件的做法可以减少开发时间,从而降低成本。代码越少,隐藏错误的地方就越少,你就有更多时间专注于质量控制。

  • 要在 Storybook 中记录你的组件,你可以使用 @storybook/addon-docs 包。此插件允许你编写 MDX 文件来记录你的组件。这种文件格式接受 markdown 和 JSX 的混合,这样你既可以解释组件的工作原理,也可以在文档中包含实际的组件实例。

9 测试驱动开发

本章涵盖

  • 测试驱动开发(TDD)是什么以及如何应用它

  • 采用 TDD 的原因

  • TDD 如何促进团队内和团队间的协作

  • 应用 TDD 的不同方法

  • 何时应用 TDD 以及何时不应用

  • 如何为 TDD 的成功创造一个合适的环境

  • 行为驱动开发是什么,它与 TDD 的关系以及为什么应该采用它

想象一下,今天是你醒来想要烘焙伦敦东区有史以来最好吃的香蕉派的一天。你已经买好了所有的饼干、香蕉、大量的糖和最美味的炼乳。你的工业烤箱预热好了,准备进行一些严肃的烘焙。

准备好相当多的派之后,你将它们放入烤箱,当计时器响起时,你立刻跑回厨房尝一块。那香味如此美妙,让你的眼睛闪闪发光,但当你尝了一片后,它远不如你预期的那么好。

不幸的是,看起来你买错了种类的香蕉。除了种类不对,它们就像你的测试应该一样:太绿了。

想象一下,如果你烘焙了一整批香蕉派,最后发现它们没有一个达到你的预期,那会有多令人沮丧。一整批之后,纠正方向已经太晚了。

当谈到烘焙时,测试驱动开发本身可能帮不上太多忙,但它的原则可以。测试驱动开发全部关于缩短反馈循环,这样你可以更早地纠正方向,并对你所构建的内容更有信心。通过尽早测试、经常测试和保持纪律,你可以产生更好的结果,减少挫折,最重要的是,减少恐惧。

在本章中,你将通过实际示例了解什么是测试驱动开发,如何应用它,以及何时应用它。

首先,我将讨论测试驱动开发的哲学,并解释它是什么以及它如何能帮助你。

这一节对于理解整个章节至关重要,因为在互联网上,关于 TDD 真正含义的误解似乎有很多。你会了解到,与大多数人的想法相反——与名字可能让人相信的相反——测试驱动开发****不是关于测试的。

一旦我解释了 TDD 是什么以及它是如何工作的,我将带你了解编写代码的测试驱动方法。我们一起为你编写一个 JavaScript 模块,用于为面包店的员工生成报告,以便他们做出关于销售哪些甜点、烘焙多少以及如何优化库存管理的最佳决策。

本章的第三部分涵盖了应用测试驱动开发的不同方式。在其中,你将学习如何按顺序处理软件的不同部分,以及先处理某些部分或最后处理的优缺点。此外,我还会教你如何根据你对自己软件结构的信心和其他几个方面来调整你的步骤应该有多渐进。

就像我在整本书中一直做的那样,在第四章中,我将揭示 TDD(测试驱动开发)对项目成本和团队产出的影响。我会解释如何平衡交付速度和可靠性,以获得最佳结果,并保持在预算和进度范围内。

这第四章在测试驱动开发的技术方面更进一步。本节是关于如何为测试驱动开发的成功创造一个适当的环境。像许多其他软件开发技术一样,TDD 的成功依赖于人们采用类似的态度来编写测试和交付代码。

最后,我将谈谈行为驱动开发,这是一种类似于 TDD 且更敏捷的方法。我会解释它与测试驱动开发的关系,以及它如何促进开发者之间的协作并加快与其他团队交换信息。我会讨论它如何帮助你明确需求,以更有利于业务的方式构建测试,并确保你的软件做对你客户重要的事情。

测试驱动开发是我职业生涯中最有帮助的工具之一。它帮助我更快地编写更好的代码,更有效地沟通,并做出更好的决策。

由于测试驱动开发是一种如此强大且宝贵的解决问题的思维框架,我忍不住为它专门写了一章。

9.1 测试驱动开发的哲学

如果你在使用香蕉之前发现它们还没有完全成熟,那是一个容易修复的错误。另一方面,如果你在烘焙完一整批派之后才注意到这个错误,你将浪费很多原料和相当多的时间。

同样,在编写软件时,一旦某个功能完成,调试它会比你在过程中编写测试要困难得多。

正如你在本书的第一章中学到的,你越早意识到自己犯了错误,修复它就越便宜。通过编写测试,你缩短了反馈循环,使开发成本更低、更少令人沮丧,并更具可预测性。

本节的主要主题——测试驱动开发,进一步推进了缩短反馈循环的想法。因此,它放大了编写测试的好处。

在本节中,你将学习什么是测试驱动开发,如何应用它,以及为什么它改进了你的软件开发过程。

首先,我将介绍测试驱动开发周期中涉及的步骤,教你如何应用它们,并解释随着你的信心增强,周期是如何变化的。

一旦你了解了什么是测试驱动开发以及它是如何工作的,我将向你展示它为什么有效。我会解释 TDD 如何减少恐惧,改进你的代码设计,并使软件开发过程更加不令人沮丧且更有效率。

9.1.1 什么是测试驱动开发

名称有时可能会误导。例如,肉馅饼并不是由肉馅制成的。说实话,我不知道肉馅饼里到底有什么,但我愿意相信它是魔法。

同样,测试驱动开发实际上并不是真的关于测试。测试驱动开发是关于朝着解决方案采取小步骤的增量过程。当我听到“测试驱动开发”这个词时,我首先想到的是迭代软件开发

TDD 是一种软件开发技术,它将你的开发过程分解成由多个小步骤组成的周期。这些步骤如下:

  1. 创建一个小的失败的测试

  2. 编写使测试通过所需的任何代码

  3. 重构你的测试和你的代码

在执行 TDD 时,这些步骤的顺序至关重要。在进行测试驱动开发时,你应该在编写代码之前先有一个失败的测试

重要事项 在进行测试驱动开发时,始终从一个失败的测试开始。

在测试社区中,这些步骤普遍被称为“红、绿、重构”。

注意:“red”和“green”这两个名字指的是测试运行器在测试失败或成功时常用的输出颜色。

你可以根据需要重复这个周期(图 9.1)多次。只有当被测试的单元按照你的期望执行并且测试通过时,这个周期才会结束。

图片

图 9.1 测试驱动开发周期的三个步骤

为了展示如何在现实世界中应用 TDD,你将采用测试驱动的方法来编写一个计算客户购物车最终价格的函数。这个函数应该接受一个包含单个价格的数组和一个折扣百分比,并返回应支付的总价值。

在进行测试驱动开发时,你从测试本身开始,而不是从被测试的单元开始

初始时,你的测试不一定需要覆盖被测试单元的全部功能范围。它们可以验证你一次性可以舒适实现的最小范围。例如,对于你要实现的函数,你最初可能不会关心它是否能计算折扣百分比。

重要事项 编写一个失败的测试——无论多小——是测试驱动开发周期的第一步。

尝试编写一个测试,调用calculateCartPrice函数并使用几个商品的价格,并期望它返回 7。

列表 9.1 calculateCartPrice.test.js

const { calculateCartPrice } = require("./calculateCartPrice");     ❶

test("calculating total values", () => {                            ❷
  expect(calculateCartPrice([1, 1, 2, 3])).toBe(7);
});

❶ 导入 calculateCartPrice 函数

❷ 使用几个商品的价格调用 calculateCartPrice 函数,并期望它返回 7

因为还没有名为 calculateCartPrice.js 的文件,这个测试文件将会失败,因此会变成 红色

从一个小测试开始的优势是,你不需要一次性编写 calculateCartPrice。相反,你可以实现测试通过所需的最小代码量,使其变成 绿色

在这个情况下,为了使测试通过,你需要做的最基本的事情是创建一个 calculateCartPrice.js 文件,该文件导出一个总是返回 7 的 calculateCartPrice 函数。

列表 9.2 calculateCartPrice.js

const calculateCartPrice = () => 7;            ❶
module.exports = { calculateCartPrice };       ❷

❶ 创建一个总是返回 7 的 calculateCartPrice 函数

❷ 导出 calculateCartPrice 函数

一旦你的测试能够从 calculateCartPrice.js 中导入 calculateCartPrice 函数,它们应该变成 绿色,带你进入测试驱动开发周期的第二阶段。

重要提示:在测试驱动开发中,你应该只编写使测试通过所需的最小代码量。

尽管你的代码还没有实现 calculateCartPrice 所需的功能,但它已经给你带来了一些信心。通过编写这个小测试并使其通过,你确信你已经创建了正确的文件,在正确的位置,并导出了正确的函数。

尽管你的测试和代码是不完整的,但它们帮助你朝着正确的方向迈出了小小的一步。

在你收到对你所写的代码的反馈后,你可以继续扩大测试的范围,使其更加全面。

为了向 calculateCartPrice 的最终实现迈进,你将继续进行 TDD 周期的第三阶段,并重构你的测试以使其更加全面。

你的测试现在将多次调用 calculateCartPrice 并使用不同的数组,并期望它返回正确的值。

列表 9.3 calculateCartPrice.test.js

const { calculateCartPrice } = require("./calculateCartPrice");

test("calculating total values", () => {                        ❶
  expect(calculateCartPrice([1, 1, 2, 3])).toBe(7);
  expect(calculateCartPrice([3, 5, 8])).toBe(16);
  expect(calculateCartPrice([13, 21])).toBe(34);
  expect(calculateCartPrice([55])).toBe(55);
});

❶ 调用 calculateCartPrice 函数多次,并使用不同的商品价格,期望它返回正确的值

在这次更改之后,你回到了 TDD 周期的第一阶段:你的测试是 红色

一旦测试失败,就是朝着 calculateCartPrice 的最终实现迈出另一个小步骤的时候了。

为了使你的测试 通过,你将再次只编写必要的最小代码量。

现在你有了大量的断言,使测试再次通过的最简单方法是让 calculateCartPrice 计算数组中的所有价格。

列表 9.4 calculateCartPrice.js

const calculateCartPrice = (prices) => {
  let total = 0;
  for (let i = 0; i < prices.length; i++) {        ❶
    total += prices[i];                            ❶
  }                                                ❶

  return total;                                    ❷
};

module.exports = { calculateCartPrice };

❶ 将价格数组中的每个数字加到总和中

❷ 返回购物车的总价

在实现这一功能后,你的测试应该通过,表明 calculateCartPrice 能够正确计算给定购物车的总价。

尽管你的函数还不能将折扣百分比应用到总价上,但你已经朝着最终实现迈出了另一个小步骤。

这个过程将使你更有信心你走在正确的道路上,因为你正在迭代地实现 calculateCartPrice 并在过程中获得即时反馈。

现在你有一个彻底的测试来验证这个函数是否正确地计算了购物车的最终价格,你可以安全地重构 calculateCartPrice

如果你像我一样喜欢函数式编程,例如,你可以将 calculateCartPrice 重写为使用 reduce 而不是 for 循环,如下所示。

列表 9.5 calculateCartPrice.js

const calculateCartPrice = prices => {
  return prices.reduce((sum, price) => {          ❶
    return sum + price;
  }, 0);
};

module.exports = { calculateCartPrice };

❶ 将每个商品的价格添加到初始为零的累计总和中

在重构 calculateCartPrice 后,如果你犯了一个错误,导致 calculateCartPrice 为给定的购物车返回不正确的总价,你的测试将失败。

多亏了 TDD(测试驱动开发),你首先能够写出最简单的实现。只有在你确信你有彻底的测试和可工作的代码之后,你才重构了你的函数,使其成为一个你认为更好的版本。

最后,是时候让 calculateCartPrice 能够将折扣百分比应用到购物车的总价格上了。

再次强调,因为你正在采用测试驱动的方法来实现这个函数,所以你会从一个失败的测试开始。

继续编写一个小测试,将一个数字作为 calculateCartPrice 的第二个参数传递,并期望该函数应用正确的折扣。

列表 9.6 calculateCartPrice.test.js

// ...

test("calculating total values", () => { /* ... */ });

test("applying a discount", () => {
  expect(calculateCartPrice([1, 2, 3], 50)).toBe(3);         ❶
});

❶ 调用 calculateCartPrice,传递几个商品的价格和折扣百分比,并期望它返回 3

现在你有一个失败的测试,你可以继续实现这个测试的最小实现,使其通过。

再次,你需要更新 calculateCartPrice,以便在传递第二个参数时返回正确的硬编码值。

列表 9.7 calculateCartPrice.js

const calculateCartPrice = (prices, discountPercentage) => {
  const total = prices.reduce((sum, price) => {                    ❶
    return sum + price;
  }, 0);

  return discountPercentage ? 3 : total;                           ❷
};

module.exports = { calculateCartPrice };

❶ 计算购物车的总价

❷ 如果传递了折扣百分比,则返回 3;否则,返回总价。

尽管你的 calculateCartPrice 还没有计算折扣百分比,但你已经为这样做建立了必要的结构。当你需要计算实际的折扣价格时,你只需要更改一行代码。再次强调,你安全地迈出了正确方向的一小步。

为了能够实现实际的折扣计算并获得是否正确完成的即时反馈,通过向测试中添加更多断言来使你的最后一个测试更加彻底。

列表 9.8 calculateCartPrice.test.js

// ...

test("calculating total values", () => { /* ... */ });

test("applying a discount", () => {                          ❶
  expect(calculateCartPrice([1, 2, 3], 50)).toBe(3);
  expect(calculateCartPrice([2, 5, 5], 25)).toBe(9);
  expect(calculateCartPrice([9, 21], 10)).toBe(27);
  expect(calculateCartPrice([50, 50], 100)).toBe(0);
});

❶ 调用 calculateCartPrice 多次,传递几个商品的价格和折扣百分比,并期望它返回应用折扣后的正确最终价格

在看到这个测试失败后,你现在可以确信,如果 calculateCartPrice 错误地将折扣应用到给定的购物车之一,它会发出警报。

为了让测试再次通过,继续实现实际的折扣计算。

列表 9.9 calculateCartPrice.js

const calculateCartPrice = (prices, discountPercentage) => {
  const total = prices.reduce((sum, price) => {                  ❶
    return sum + price;
  }, 0);

  return discountPercentage                                      ❷
    ? ((100 - discountPercentage) / 100) * total
    : total;
};

module.exports = { calculateCartPrice };

❶ 计算购物车的总价

❷ 如果传递了折扣百分比,则将其应用于总价;否则,返回全额

再次,所有你的测试都应该通过,但 calculateCartPrice 还未完成。

目前,如果你将字符串作为此函数的第二个参数传递,它将返回 NaN,代表“不是一个数字”。calculateCartPrice 应该在接收到字符串作为其第二个参数时应用任何折扣。

在修复那个错误之前,你会编写一个失败的测试。

列表 9.10 calculateCartPrice.test.js

// ...

test("applying a discount", () => { /* ... */ });

test("handling strings", () => {
  expect(calculateCartPrice([1, 2, 3], "string")).toBe(6);      ❶
});

❶ 调用 calculateCartPrice,传递一些商品的价格和字符串作为折扣百分比,并期望结果为 6

通过在修复错误之前添加测试,你可以确保测试能够检测到是否存在这个特定的错误。如果你在解决问题之后编写测试,你就无法确定它是否通过,因为缺陷已经解决,或者是因为你的测试无法捕获它。

最后,你将更新 calculateCartPrice 以确保仅在第二个参数是数字时才将折扣百分比应用于购物车价格。

列表 9.11 calculateCartPrice.js

const calculateCartPrice = (prices, discountPercentage) => {
  // ...

  return typeof discountPercentage === "number"          ❶
    ? ((100 - discountPercentage) / 100) * total
    : total;
};

module.exports = { calculateCartPrice };

❶ 如果给定的折扣百分比是“数字”类型,则将其应用于总价;否则,返回全额

由于 JavaScript 有时可能有点奇怪,代表“不是一个数字”的 NaN 实际上具有“数字”类型。因此,当调用者将 NaN 本身作为第二个参数传递时,calculateCartPrice 仍然会返回 NaN

正如你在应用测试驱动开发时应该做的,你会在修复错误之前编写一个失败的测试。

列表 9.12 calculateCartPrice.test.js

// ...

test("handling strings", () => { /* ... */ });

test("handling NaN", () => {                              ❶
  expect(calculateCartPrice([1, 2, 3], NaN)).toBe(6);
});

❶ 调用 calculateCartPrice,传递一些商品的价格和 NaN 作为折扣百分比,并期望结果为 6

现在你已经编写了一个测试,并看到它失败以确保它可以捕获那个错误,更新代码以便它可以适当地处理 NaN

列表 9.13 calculateCartPrice.js

const calculateCartPrice = (prices, discountPercentage) => {
  // ...

  return typeof discountPercentage === "number"           ❶
    && !isNaN(discountPercentage)
      ? ((100 - discountPercentage) / 100) * total
      : total;
};

module.exports = { calculateCartPrice };

❶ 如果给定的折扣百分比是“数字”类型且不是 NaN,则将其应用于总价;否则,返回全额

calculateCartPrice 现在终于完成了。

即使到达最终实现可能花费了你更长的时间,但你整个过程中都得到了精确的反馈。如果你犯了任何错误,你会更快地发现并修复它们,因为你一次只编写了较小的代码块。

由于你在编写代码之前编写了测试,你确保了如果 calculateCartPrice 对于任何传递的参数产生了不正确的结果,你的测试会提醒你。从失败的测试开始可以帮助你避免总是通过测试,因此无法捕获错误。

当你接近 calculateCartPrice 的最终实现时,你没有像没有在过程中测试那样谨慎。通过验证每一步,你确保了整个过程中你都在正确的道路上。

与一次性实现一个大型功能不同,测试驱动开发也没有让你走错路。你的失败的测试始终在告诉你下一步该做什么。

最后,在修复剩余的错误时,你首先编写了测试,这样你就能看到它们失败,因此可以确信它们能够检测到你试图修复的错误。这些失败的测试让你有信心,如果这些错误再次出现,你会得到通知。

9.1.2 调整迭代的大小

就像路易斯这样的经验丰富的糕点师不需要经常品尝他的食谱。他多年的经验使他能够在饼干出炉前不用手指去尝就能做出完美的香蕉焦糖派。

类似地,当程序员有足够的自信一次性实现一个函数时,他们不需要在过程中编写许多测试。

记住,测试驱动开发不是关于测试;它是一种减少恐惧的工具

如果你即将实现一个复杂的函数,但还不知道如何实现,你可能选择编写较小的测试,这样你就可以一次编写一小段代码,就像在之前的例子中那样。

感觉越不自信,你应该采取的步骤越多,这样你就可以在接近单元测试的最终实现时逐渐建立信心,如图 9.2 所示的工作流程所示。

图 9.2 感觉越不自信,你的步骤应该越小,你将越多次地经历测试驱动开发周期。

随着自信心的增加,你可以开始采取更大的步骤,如图 9.3 所示,因此可以更快地迭代。

图 9.3 随着自信心的增加,你可以增加步骤的大小,以减少实现特定代码片段所需的迭代次数。

当你感到自信时,你不必逐步编写许多小测试,你可以创建一个更大的测试,并一次性编写你函数的最终实现,如图 9.4 所示。

图 9.4 如果你非常自信地知道如何实现特定的代码片段,你可以直接编写一个完整的测试和最终实现。

例如,想象你需要编写一个函数,该函数接受一个包含多个购物车商品的数组,并选择最昂贵的购物车。

如果你自信地知道如何编写这个函数,并想更快地完成它,你不必编写一个小型失败的测试,可以直接编写一个或多个完整的测试。

列表 9.14 pickMostExpensive.test.js

const { pickMostExpensive } = require("./pickMostExpensive");

test("picking the most expensive cart", () => {                  ❶
  expect(pickMostExpensive([[3, 2, 1, 4], [5], [50]]))
    .toEqual([50]);
  expect(pickMostExpensive([[2, 8, 9], [0], [20]]))
    .toEqual([20]);
  expect(pickMostExpensive([[0], [0], [0]]))
    .toEqual([0]);
  expect(pickMostExpensive([[], [5], []]))
    .toEqual([5]);
});

test("null for an empty cart array", () => {                     ❷
  expect(pickMostExpensive([])).toEqual(null);
});

❶ 几次调用 pickMostExpensive 函数,并使用不同购物车的商品内容,期望它返回最昂贵的购物车

❷ 验证当给 pickMost-Expensive 函数一个空数组时,它是否返回 null

使用这些彻底的测试,你可以直接进入 pickMostExpensive 的最终实现。

列表 9.15 pickMostExpensive.js

const { calculateCartPrice } = require("./calculateCartPrice");

const pickMostExpensive = carts => {
  let mostExpensivePrice = 0;
  let mostExpensiveCart = null;

  for (let i = 0; i < carts.length; i++) {                          ❶
    const currentCart = carts[i];
    const currentCartPrice = calculateCartPrice(currentCart);       ❷
    if (currentCartPrice >= mostExpensivePrice) {                   ❸
      mostExpensivePrice = currentCartPrice;                        ❹
      mostExpensiveCart = currentCart;                              ❺
    }
  }

  return mostExpensiveCart;                                         ❻
};

module.exports = { pickMostExpensive };

❶ 遍历给定的购物车

❷ 计算当前购物车的价格

❸ 检查当前购物车的价格是否高于之前最贵的购物车的价格

❹ 如果当前购物车是最贵的,则更新 most-ExpensivePrice 及其价格

❺ 如果当前购物车是最贵的,则更新 mostExpensiveCart 及其内容

❻ 返回最贵购物车的商品内容

在这个例子中,你仍然遵循了 TDD 周期,但你一次采取了更大的步骤。首先,你编写了一个测试,然后才让它通过。如果你愿意,你还可以在测试通过后重构pickMostExpensive

尽管你为这个例子和之前的例子采取了不同大小的步骤,但步骤的顺序和每个步骤的目标保持不变。

通过在实现pickMostExpensive时采取更大的步骤,你能够更快地得到最终结果。然而,这些快速的结果是以一个平稳且可预测的开发过程为代价的。如果你的测试失败了,你会收到粗糙的反馈,并且你将需要调查更多的代码行。

因为你对pickMostExpensive的实现已经确信无疑,你明确选择以速度换取细粒度反馈。

在应用测试驱动开发时,你感觉越自信,你采取的步骤就越大

你的步骤越小,它们的反馈就越细粒度,你一次需要编写的代码块就越小。

随着你采取的步骤变大,它们的反馈变得更加粗糙,你每次迭代中编写的代码块也更大。

通过根据你的信心调整步骤的大小,你可以将测试驱动开发适应你正在实现的任何代码片段。

当感到不安全时,通过逐步进行以获取细粒度反馈,并在向测试单元的最终实现迈进的过程中减少你的恐惧。

9.1.3 为什么采用测试驱动开发?

当我教人们如何应用测试驱动开发时,他们通常对结果感到满意。我经常听到他们说,这使他们的开发体验更加顺畅,并且提高了他们交付的代码质量。

然而,尽管这些人体验到了应用测试驱动开发的好处,有时他们很难向非技术经理解释这个选择,或者在与其他开发者交谈时为其辩护。

在本节的最后部分,我将阐述工程师应该应用测试驱动开发的原因。

测试驱动开发降低成本

你尝试你的香蕉布丁派的时间越长,你意识到你使用了错误类型的香蕉所需的时间就越长。此外,一旦你的派做好了,告诉你哪里出了问题将会更加困难。

如果你开始烘焙之前尝试了一块香蕉,你就不会不得不丢弃整个批次,并且你会知道需要替换哪种成分。

同样,你花费的时间越长去编写测试,你意识到代码错误所需的时间就越长,修复它所需的时间也就越长。当你纠正方向太晚时,错误就有更多的藏身之处,需要撤销的代码行数也就越多,如图 9.5 所示。

图 9.5 DaCosta

图 9.5 随着时间的推移,代码变更的数量增加。你花费的时间越长去寻找一个错误,你将需要调查的代码行数就越多。

通过在编写测试和代码时采取小步骤,你将能够获得更细粒度的反馈。这种精确的反馈将使你更快地识别错误来源并修复它们。

除了减少查找错误所需的时间外,更早地捕捉错误也会使修复它们变得更便宜。你生产环境中的缺陷可能导致你损失相当数量的金钱。同样,你仓库中的有缺陷的提交可能导致其他开发者浪费大量时间。然而,当你编写功能时捕捉到错误通常几乎不花费任何成本。

重要 测试驱动开发由于两个主要原因降低了成本:它使得查找和修复错误更快,并允许你更早地捕捉到它们。

测试驱动开发减少恐惧和焦虑

如果你以前从未准备过提拉米苏,明智的做法是将每个步骤与食谱书中的图片进行比较。如果你的提拉米苏一直看起来像书中的那样,那么它很可能非常出色。另一方面,如果你一次性做所有的事情,你可能会因为担心是否浪费了所有的马斯卡彭奶酪而失眠。

通过仔细验证每一步,你对最终结果会感到不那么恐惧。这无论是对于制作提拉米苏还是编写软件都是正确的。

起初,可能觉得花那么多时间进行测试是一种浪费时间。然而,烘焙一次甜点仍然比烘焙两次要快。

这同样适用于编写代码。如果你一次写出一个出色的软件作品,即使你可能花费了更多的时间来完成它,你也会确信它在提交代码时是能够正常工作的。

测试驱动开发让你不那么恐惧,因为它在整个过程中都给你提供反馈,而不是让你在完全完成程序之前一直猜测程序是否能够正常工作。

重要 如果你在编写代码时彻底测试它,你将确信它在完成时是能够正常工作的。

测试驱动开发导致代码设计更优

测试驱动的工作流程激励开发者使每一块代码尽可能容易进行测试。因为测试小型、专注的函数比测试大型多功能的函数要容易,开发者最终会创建出更模块化的代码,以及只做一件事并且做得很好的函数。

通过创建模块化的代码片段,软件变得更加灵活和健壮,因为它优先考虑了重用经过关注和良好测试的代码。

此外,开发者们不再关注函数是如何完成某事的,而是更加关注函数做什么

这种关注点的转变发生是因为从测试开始可以激励开发者从以用户为中心的角度编写 API。他们不再考虑什么更容易实现,而是思考测试单元的用户期望函数能做什么。在这种情况下,目标用户的消费者由目标测试来代表。

一旦开发者达到了满足目标用户需求的实现,他们就可以以任何他们想要的方式重构测试单元。尽管如此,这种重构将受到现有测试中定义的接口的限制。即使开发者可以改变函数如何做某事,他们也不能改变它做什么,除非他们也改变其测试。

通过编写测试,开发者正在创建一个他们必须遵守的契约。

从本质上讲,在编写代码之前编写测试鼓励开发者“编写接口,而不是实现”。

注意:面向接口而不是面向实现是来自优秀书籍《设计模式》的建议,作者为 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional,1994 年)。

需要创建高保真度测试替身是另一个因素,它鼓励开发者在使用测试驱动方法工作时编写面向接口的代码。因为开发者必须能够轻松地用测试替身替换某些软件片段,所以他们被鼓励保持接口干净、统一和可理解。

测试驱动开发使测试更加全面

最危险的不是那些失败的测试。最危险的是那些总是通过的测试。

通过在修复错误之前编写测试,就像你之前做的那样,你可以确保你编写的测试可以捕捉到你将要修复的缺陷。如果在修复错误之前测试就通过了,那么这是一个信号,表明你编写的测试是无用的。它不能告诉你错误是否存在。

在进行测试驱动开发时,因为我还没有编写任何代码,所以我往往会发现一些边缘情况,如果我已经编写了代码,我就不会想到这些情况。

当你在编写代码之前编写测试时,你关心的是编写全面的测试来指导你完成实现测试单元的过程。另一方面,如果你先编写代码,你通常更关心证明你的代码是有效的,而不是捕捉错误。

重要的测试驱动开发使测试更加全面,因为它允许你验证测试是否能够捕捉到错误。它帮助你思考边缘情况,证明你的软件不会工作,而不是试图证明它确实工作,这会导致不可靠的保证。

测试驱动开发引导你通过开发过程

你有多少次不得不丢弃一堆工作并从头开始,因为你甚至不知道自己在哪里了?你有没有在辛苦工作了一天后回到家,第二天却不知道自己在做什么?

测试驱动开发解决了这两个问题。通过首先编写失败的测试,你总是知道下一步该做什么。

失败的测试将帮助你一次只保留必要的知识。每次你不得不更改代码时,你只需要当前问题的上下文:让你的测试通过。

在离开工作之前,如果我在编写代码,我总会留下失败的测试作为给未来自己的礼物。第二天,当未来的卢卡斯开始工作时,他会立刻知道该做什么,并对过去的卢卡斯感到满意。

重要测试驱动开发引导你通过开发过程,因为它总是告诉你下一步该做什么。

9.1.4 不适用测试驱动开发的情形

任何人都可以通过阅读食谱学会如何烘焙蛋糕,但只有最好的糕点师才知道哪些步骤和成分需要改变,以便他们可以烘焙出正宗的甜点并为自己赢得声誉。

在烘焙以及软件工程中,最危险的两个字是总是永远。在我的整个职业生涯中,我注意到,一个专业人士越有经验,他们就越经常说视情况而定

尽管我会说测试驱动开发在大多数情况下是有帮助的,但它可能并不总是理想的选择。

当编写将长期存在的代码时,例如在你的团队代码库中实现功能,采用测试驱动的方法是有益的。这些测试将帮助你坚持单元测试的规范,并且它们将长期存在,帮助你的团队快速识别和修复错误。

如果你对自己的代码没有信心,测试驱动开发也可能很有用。通过采用测试驱动的方法编写代码,你将在过程中获得精确的反馈。这种紧密的反馈循环将使你的开发过程更加顺畅,并减少恐惧和焦虑。

即使在你正在处理遗留代码库或设计不良的项目的情况下,采用测试驱动的方法也可能是有益的。

通过在更改遗留代码或设计不良且未经测试的代码之前编写测试,你将更有信心在重构后单元测试的行为保持不变。此外,正如我之前提到的,这种态度将提高代码的设计。

然而,如果你正在学习一门新的语言、框架或构建一个可丢弃的原型,采用测试驱动的方法可能不会提高效率。

在这种情况下,你需要有自由去探索和犯错。因为这种代码通常生命周期很短,你的测试不会产生太多价值,因为你不会经常运行它们。

此外,您学习如何测试新工具所花费的时间可能会给您试图构建的内容增加不必要的额外成本:一个快速的概念验证。

当学习新技术或构建快速的概念验证时,首先了解工具的工作原理通常会更加高效,这样您以后编写测试会更加容易。

作为一条经验法则,如果您不是在学习或探索,那么您应该采用测试驱动的方法来编写代码。

9.2 使用 TDD 编写 JavaScript 模块

您可以通过烘焙一个单独的批次来学习如何制作马卡龙,但要掌握这门精细的艺术,即使是最好的糕点师也需要练习

故意练习正是本节的主题。在这里,您将应用您已经学到的关于测试驱动开发的知识到实际场景中。

为了让您练习测试驱动开发,我将引导您实现一个生成面包店销售和库存报告的 JavaScript 模块。其他开发者可以将此模块集成到面包店的在线商店代码库中,以便面包店的员工生成报告,这些报告可以产生关于业务表现和运营的有用见解。

如同往常,您将首先为这个模块创建一个新的文件夹并向其中添加一个 package.json 文件。要快速创建一个 package.json 文件,您可以使用 npm init -y

一旦您创建了此文件夹并向其中添加了 package.json 文件,请使用 npm install --save-dev jest 来将 Jest 安装为开发依赖项。

为了方便运行测试,您还将向 package.json 文件中添加一个 test 脚本,该脚本运行 jest

列表 9.16 package.json

{
  // ...
  "scripts": {
    "test": "jest"
  },
  // ...
  "devDependencies": {
    "jest": "²⁶.1.0"
  }
}

完成设置后,您将为生成报告项行的函数编写第一个失败的测试。这个函数将后来被用于生成完整报告的函数。

您的失败测试应该调用名为 generateItemRow 的函数,并期望它返回一个包含项名称、单价、数量和该种类所有产品总价的逗号分隔字符串。这个测试应该放入一个名为 inventoryReport.test.js 的文件中。

注意:这些报告将以 CSV(逗号分隔值)格式呈现。此格式使用逗号分隔单元格,使用换行符分隔行。

列表 9.17 inventoryReport.test.js

const { generateItemRow } = require("./inventoryReport");

test("generating an item's row", () => {
  const item = { name: "macaroon", quantity: 12, price: 3 };
  expect(generateItemRow(item)).toBe("macaroon,12,3,36");           ❶
});

❶ 调用 generateItemRow,并期望它生成代表 CSV 文件中项行的正确字符串

要查看此测试失败,请运行 npm test

因为您已经创建了一个失败的测试,您现在可以实施 generateItemRow 并立即获得关于它的反馈。

继续实现 generateItemRow 的初始版本,以便您的测试通过。

列表 9.18 inventoryReport.js

const generateItemRow = ({ name, quantity, price }) => {
  return `${name},${quantity},${price},${price * quantity}`;       ❶
};

module.exports = { generateItemRow };

❶ 使用插值生成包含每行值的字符串

再次运行 npm test,您应该会看到您的测试现在通过了。

仍然有一些事情需要改进。在这种情况下,例如,您可以测试多个输入以确保您没有错过任何边缘情况。

列表 9.19 inventoryReport.test.js

const { generateItemRow } = require("./inventoryReport");

test("generating an item's row", () => {                                   ❶
  expect(generateItemRow({ name: "macaroon", quantity: 12, price: 3 })).toBe(
    "macaroon,12,3,36"
  );
  expect(generateItemRow({ name: "cheesecake", quantity: 6, price: 12 })).toBe(
    "cheesecake,6,12,72"
  );
  expect(generateItemRow({ name: "apple pie", quantity: 5, price: 15 })).toBe(
    "apple pie,5,15,75"
  );
});

❶ 调用 generateItemRow 并传入多个项目,并期望生成的行字符串是正确的

在重新运行测试以确保没有这些断言导致测试失败后,你也可以根据需要重构 generateItemRow

在重构步骤中,你可以按任何方式更新 generateItemRow。只要它对每个给定的输入产生相同的输出,你的测试就会通过。

列表 9.20 inventoryReport.test.js

const generateItemRow = ({ name, quantity, price }) => {
  return [name, quantity, price, price * quantity].join(",");     ❶
};

❶ 创建一个包含每个单元格值的数组,并将这些值连接成一个逗号分隔的字符串

再次运行你的测试,并确保这次重构没有导致任何测试失败。

小贴士:到现在为止,你可能已经注意到了每次你进行更改时手动执行 npm test 的重复性。为了使更改文件时测试自动重新运行,你可以使用 Jest 的 --watch 选项。

如果你通过运行 npm test -- --watch 将此选项附加到你的 npm test 命令,Jest 将在检测到更改时自动重新运行你的测试。

Jest 的 watch 模式非常适合练习测试驱动开发。

watch 模式下,你也可以通过按 standby 模式下显示的键来过滤测试。

你可以对 generateItemRow 函数进行另一个改进,使其对于数量或价格为零的项返回 null,这样你的报告中就不会包含与库存总价值无关的行。

在实现该功能之前,你应该再次从失败的测试开始。因为这个特性需要少量的代码,而且因为你已经通过之前的测试建立了一些信心,你可以直接编写完整的测试。

你将要编写的测试之一应该使用数量为零的项目调用 generateItemRow。另一个测试将使用价格为零的项目调用此函数。

列表 9.21 inventoryReport.test.js

const { generateItemRow } = require("./inventoryReport");

test("generating an item's row", () => { /* ... */ });

test("ommitting soldout items", () => {                                    ❶
  expect(generateItemRow({ name: "macaroon", quantity: 0, price: 3 })).toBe(
    null
  );
  expect(generateItemRow({ name: "cheesecake", quantity: 0, price: 12 })).toBe(
    null
  );
});

test("ommitting free items", () => {                                       ❷
  expect(
    generateItemRow({ name: "plastic cups", quantity: 99, price: 0 })
  ).toBe(null);
  expect(generateItemRow({ name: "napkins", quantity: 200, price: 0 })).toBe(
    null
  );
});

❶ 调用 generateItemRow 并传入售罄的项目,并期望其结果为 null

❷ 调用 generateItemRow 并传入价格为零的项目,并期望其结果为 null

如果你正在使用 Jest 的 watch 模式,当你保存 inventoryReport.test.js 文件时,你的测试将立即重新运行,这表明你的代码尚未符合这些要求。

因为一次编写了更多的测试,你将不得不编写更多的代码来使它们通过。这次你写了更大的测试,因为你已经对实现所需的功能有信心,所以你用更快的迭代能力交换了更细粒度的反馈。

为了使测试通过,更新 generateItemRow 以使其对于价格或数量属性为零的项返回 null

列表 9.22 inventoryReport.js

const generateItemRow = ({ name, quantity, price }) => {
  if (quantity === 0 || price === 0) return null;                 ❶

  return `${name},${quantity},${price},${price * quantity}`;      ❷
};

module.exports = { generateItemRow };

❶ 如果项目的数量或价格为零,则返回 null

❷ 返回一个包含行值的逗号分隔字符串

在 Jest 的 watch 模式下保存此文件,你应该会看到所有测试都通过了。

与多次编写小的测试和进行微小的代码更改相比,你编写了更大的测试,并一次性更新了你的代码。更大的步骤导致了更快的迭代。

现在generateItemRow已经完成,你将实现generateTotalRow。这个函数将接受一个项目数组,并生成一个包含库存总价的行。

在实现generateTotalRow之前,编写一个失败的测试。如果你现在感觉自信,可以立即编写一个完整的测试。

列表 9.23 inventoryReport.test.js

const { generateItemRow, generateTotalRow } = require("./inventoryReport");

describe("generateItemRow", () => {
  test("generating an item's row", () => { /* ... */ });
  test("ommitting soldout items", () => { /* ... */ });
  test("ommitting free items", () => { /* ... */ });
});

describe("generateTotalRow", () => {
  test("generating a total row", () => {                     ❶
    const items = [
      { name: "apple pie", quantity: 3, price: 15 },
      { name: "plastic cups", quantity: 0, price: 55 },
      { name: "macaroon", quantity: 12, price: 3 },
      { name: "cheesecake", quantity: 0, price: 12 }
    ];

    expect(generateTotalRow(items)).toBe("Total,,,81");
    expect(generateTotalRow(items.slice(1))).toBe("Total,,,36");
    expect(generateTotalRow(items.slice(3))).toBe("Total,,,0");
  });
});

❶ 使用generateTotalRow生成包含项目最终价格的行,并期望生成的行是正确的

注意:这些额外的逗号在这里是为了确保单元格在即将生成的.csv 文件中正确定位。

在编写了这个失败的测试之后,继续实现generateTotalRow。因为你有一个可以帮助你安全重构这个函数的测试,你可以从一个简单或次优的实现开始。

列表 9.24 inventoryReport.test.js

// ...

const generateTotalRow = items => {
  let total = 0;
  items.forEach(item => {                         ❶
    total += item.price * item.quantity;
  });

  return "Total,,," + total;                      ❷
};

module.exports = { generateItemRow, generateTotalRow };

❶ 将每种项目的总价添加到总和中

❷ 返回表示给定项目集合的总价值的字符串

在实现了generateTotalRow的工作版本之后,如果你想的话,可以继续重构它。现在你有一个可以帮助你避免破坏函数的测试,以及一个可以启发重构的初始实现。

列表 9.25 inventoryReport.js

const generateTotalRow = items => {
  const total = items.reduce(                                ❶
    (t, { price, quantity }) => t + price * quantity,
    0
  );
  return `Total,,,${total}`;                                 ❷
};

❶ 计算一组项目的总价

❷ 返回表示给定项目集合的总价值的字符串

最后,你将创建一个createInventoryValuesReport函数,该函数使用前两个函数将.csv 报告保存到磁盘上。

由于这个函数的实现可能会更具挑战性,并涉及多个不同的部分,你将一次编写更小的测试,并且迭代速度会慢一些。通过采取更小的步骤,你将能够在实现createInventoryValuesReport时获得更细致的反馈。这种细致的反馈将使开发过程更加顺畅,帮助你更快地识别和修复错误,并减少你的焦虑。

对于createInventoryValuesReport的第一个失败的测试应该要求传递一个包含一个项目的数组,并期望createInventoryValuesReport返回一个包含该项目行的单行。

列表 9.26 inventoryReport.test.js

const {
  // ...
  createInventoryValuesReport
} = require("./inventoryReport");

// ...

describe("generateTotalRow", () => { /* ... */ });

describe("createInventoryValuesReport", () => {
  test("creating reports", () => {                                     ❶
    const items = [{ name: "apple pie", quantity: 3, price: 15 }];

    expect(createInventoryValuesReport(items)).toBe("apple pie,3,15,45");
  });
});

❶ 调用createInventoryValuesReport,并期望它返回一个包含库存唯一项目的单行字符串

因为已经编写了一个小的测试,所以只需要编写一小块createInventoryValuesReport代码,以便测试通过。

为了让测试通过,你需要编写最少的代码。你将让createInventoryValuesReport函数选择数组中的第一个项目,并返回该项目的generateTotalRow函数的结果。

列表 9.27 inventoryReport.js

// ...

const createInventoryValuesReport = items => {
  return generateItemRow(items[0]);                       ❶
};

module.exports = { generateItemRow, generateTotalRow, createInventoryValuesReport };

❶ 返回表示给定数组中第一个项目的逗号分隔字符串

一旦你看到测试通过,你可以使测试更加彻底,以便它再次失败。

列表 9.28 inventoryReport.test.js

// ...

describe("createInventoryValuesReport", () => {
  test("creating reports", () => {                         ❶
    const items = [
      { name: "apple pie", quantity: 3, price: 15 },
      { name: "cheesecake", quantity: 2, price: 12 }
    ];

    expect(createInventoryValuesReport(items)).toBe(
      "apple pie,3,15,45\ncheesecake,2,12,24"
    );
  });
});

❶ 使用多个项目调用 createInventoryValuesReport,并期望它返回正确表示项目行的字符串

现在,你需要在 createInventoryValuesReport 中实现一些更多逻辑,以便测试通过。你必须为每个项目生成行,并在连接这些行时,用换行符分隔它们。

由于你有一个失败的测试来帮助你稍后重构,你可以首先尝试最简单实现。

列表 9.29 inventoryReport.js

// ...

const createInventoryValuesReport = items => {
  let lines = "";
  for (let i = 0; i < items.length; i++) {            ❶
    lines += generateItemRow(items[i]);               ❷
    if (i !== items.length - 1) lines += "\n";        ❸
  }

  return lines;
};

❶ 遍历所有传递的项目

❷ 对于每个传递的项目,生成一个表示它的逗号分隔字符串

❸ 如果当前项目不是给定数组中的最后一个,则将换行符追加到累积的内容中

在实现此功能并使测试通过后,你可以在重构 createInventoryValuesReport 之前使测试更加严格。这种详尽的测试将在稍后重构时为你提供额外的安全性。

列表 9.30 inventoryReport.test.js

// ...

describe("createInventoryValuesReport", () => {
  test("creating reports", () => {                          ❶
    const items = [
      { name: "apple pie", quantity: 3, price: 15 },
      { name: "cheesecake", quantity: 2, price: 12 },
      { name: "macaroon", quantity: 20, price: 3 }
    ];

    expect(createInventoryValuesReport(items)).toBe(
      "apple pie,3,15,45\ncheesecake,2,12,24\nmacaroon,20,3,60"
    );

    expect(createInventoryValuesReport(items.slice(1))).toBe(
      "cheesecake,2,12,24\nmacaroon,20,3,60"
    );

    expect(createInventoryValuesReport(items.slice(2))).toBe(
      "macaroon,20,3,60"
    );
  });
});

❶ 使用多个项目集调用 createInventoryValues-Report,并期望它返回正确表示每个集行的字符串

现在你有了详尽的测试来防止你破坏 createInventoryValuesReport,并且有一个可以在此基础上进行重构的初始实现,继续更新 createInventoryValuesReport 以使其更加简洁。

列表 9.31 inventoryReport.js

// ...

const createInventoryValuesReport = items => {
  return items
    .map(generateItemRow)                         ❶
    .join("\n");                                  ❷
};

❶ 创建一个表示每个项目行的逗号分隔字符串数组

❷ 将每个项目的行连接成字符串,每行之间用换行符分隔

多亏了详尽的测试,你以更高的安全性和没有意外的过程中实现了 createInventoryValuesReport 应有的部分功能。

你的下一个任务将是生成包含所有项目累积价值的最后一行。

如你至今所做的那样,你只需稍微更改测试,使其验证返回的字符串是否包含最后一行。

列表 9.32 inventoryReport.test.js

// ...
describe("createInventoryValuesReport", () => {
  test("creating reports", () => {                      ❶
    // ...

    expect(createInventoryValuesReport(items)).toBe(
      "apple pie,3,15,45\ncheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,129"
    );

    expect(createInventoryValuesReport(items.slice(1))).toBe(
      "cheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,84"
    );

    expect(createInventoryValuesReport(items.slice(2))).toBe(
      "macaroon,20,3,60\nTotal,,,60"
    );
  });
});

❶ 使用多个项目集调用 createInventoryValuesReport,并期望它返回代表每个集报告预期值的字符串

这次测试的更新将提供精确的反馈,告诉你生成的行缺少包含聚合值的列。

如往常一样,测试的小更新需要代码的小改动。

继续使测试通过,让 createInventoryValuesReport 在返回的字符串中包含最后一行。

列表 9.33 inventoryReport.js

const createInventoryValuesReport = items => {
  const itemRows = items
    .map(generateItemRow)                             ❶
    .join("\n");                                      ❷
  const totalRow = generateTotalRow(items);           ❸
  return itemRows + "\n" + totalRow; )                ❹
};

❶ 创建一个表示每个项目行的逗号分隔字符串数组

❷ 将每个项目的行连接成字符串,每行之间用换行符分隔

❸ 生成包含库存总价值的行

❹ 返回包含每个项目行的字符串,并追加一个换行符,然后是表示库存总价值的行

通过这些小的迭代,你能够在担心读取或写入磁盘文件之前验证 createInventoryValuesReport 是否产生了正确的字符串。这些多次的小迭代让你在实现报告生成函数的过程中建立起信心。

现在你已经知道如何生成报告内容,你只需要对写入磁盘进行少量更改。而不是返回报告内容,你将使用 Node.js 的 fs 模块将内容写入磁盘。

在更新代码之前,对你的测试进行小更新,以便在调用 createInventoryValuesReport 之后从磁盘读取报告。

列表 9.34 inventoryReport.test.js

const fs = require("fs");

// ...

describe("createInventoryValuesReport", () => {
  test("creating reports", () => {                            ❶
    // ...

    createInventoryValuesReport(items);
    expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe(
      "apple pie,3,15,45\ncheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,129"
    );

    createInventoryValuesReport(items.slice(1));
    expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe(
      "cheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,84"
    );

    createInventoryValuesReport(items.slice(2));
    expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe(
      "macaroon,20,3,60\nTotal,,,60"
    );
  });
});

❶ 使用 createInventoryValuesReport 为不同的项目集创建报告,然后读取写入磁盘的文件以检查报告内容是否正确

最后,更新 createInventoryValuesReport 函数,使其将报告内容保存到 /tmp/inventoryValues.csv

列表 9.35 inventoryReport.js

const fs = require("fs");

// ...

const createInventoryValuesReport = items => {
  const itemRows = items
    .map(generateItemRow)                                           ❶
    .join("\n");                                                    ❷
  const totalRow = generateTotalRow(items);                         ❸
  const reportContents = itemRows + "\n" + totalRow;                ❹
  fs.writeFileSync("/tmp/inventoryValues.csv", reportContents);     ❺
};

❶ 创建一个表示每个项目行的逗号分隔字符串数组

❷ 将每个项目的行连接成一个字符串,每行之间用换行符分隔

❸ 生成包含库存总价值的行

❹ 通过将每个项目的行与表示库存总价值的行连接起来生成报告内容

❺ 将报告内容写入 .csv 文件

在本节中,你逐渐建立起信心,直到完成了整个报告生成功能的实现。

在实现较小且简单的功能时,你采取了较大的步骤并快速迭代。然后,随着事情开始变得复杂,你采取了更短的步骤并缓慢迭代,以便在过程中建立信心并获得快速准确的反馈。

作为练习,尝试创建通过应用测试驱动开发生成不同报告的函数。例如,你可以尝试创建一个生成详细说明每月总销售额的函数。

在创建这些函数时,请记住先编写测试,然后使测试通过并重构你的代码。

你越自信,你采取的步骤就应该越大。在这种情况下,你应该编写更大的测试和一次写入更多的代码。

如果你正在实现一个复杂的函数,请减小步骤的大小。在每个迭代中编写更小的测试和更小的代码片段。

9.3 测试自顶向下与自底向上

作为一位法国甜点的专家,当路易斯制作闪电泡芙时,他很少停下来品尝糕点或填充其中的馅料。由于他非常自信,路易斯更喜欢在他认为最好的时候尝试他的闪电泡芙:出炉后并填充了奶油。

尽管路易斯没有尝试食谱的每一部分,但他通过在泡芙完成后进行测试,可以确认他已经正确地准备好了它们。

另一方面,卡纳洛则完全是另一回事。因为路易斯很少制作它们,他需要品尝食谱的每个步骤。想象一下,如果你只发现卡纳洛的馅料远不如你预期的那么好,就炸了一整批卡纳洛模具,那将是多么灾难性的。

提示:下次你到意大利时:在意大利语中,cannoli这个词是cannolo的复数形式。

工程师的测试工作流程,类似于糕点师,根据他们所创造的内容以及他们的信心程度而有所不同。当工程师感到更有信心时,他们会编写更高层次的抽象测试,一次覆盖他们代码的更大部分。当他们不那么自信时,他们会编写更小的测试,针对更小的功能部分,并在过程中建立信心。

在本节中,你将学习如何在不同层次上测试你的软件,以及它如何影响你的业务和你的测试驱动工作流程。

通过分析上一节的示例,你会了解自上而下和自下而上测试方法的区别,何时使用每种方法,以及它们的优缺点。

9.3.1 自下而上和自上而下测试的含义

自下而上的测试是路易斯制作卡纳洛的测试方法。它包括首先检查单个成分,然后是各种成分的混合物,最后是卡纳洛本身,如图 9.6 所示。

图片

图 9.6 在制作卡纳洛时,路易斯从下而上进行测试,从单个成分开始,然后是食谱的中间部分,最后是完成的卡纳洛。

在软件中,等效的方法是首先测试你的较小函数,然后是依赖于它们的函数,如图 9.7 所示。

图片

图 9.7 采用自下而上的测试方法意味着首先覆盖粒度更细的个体函数,然后向上移动到更粗的软件部分。

在上一节构建报告模块时,你已经采取了自下而上的方法。

相反,自上而下的测试是路易斯制作闪电泡芙时采取的方法。在他准备它们时,他不会停下来品尝食谱的每一部分。相反,他在一批泡芙完成后尝试一次。

品尝完成的闪电泡芙证明了之前步骤的质量,因为为了使其味道好,它的面团和馅料也必须制作正确,如图 9.8 所示。

图片

图 9.8 通过间接品尝完美的闪电泡芙确保每个单个成分都是好的,以及每个中间步骤的食谱都进行得很好。

如果你采取与所编写的软件相似的方法,你将从一个测试开始,这个测试是依赖层次结构中最顶层的函数,而不是从底层每个函数的测试开始。

因为最顶层的函数依赖于底层的函数,测试最顶层的函数也证明了其依赖项的质量,如图 9.9 所示。

图 9.9 测试最顶层的函数间接证明了其依赖项的质量。

9.3.2 从上到下和自下而上的方法如何影响测试驱动的工作流程

在本章的第一节中,你已经了解到在进行测试驱动开发时,你的步骤大小应根据你的信心程度来变化。你越自信,你的步骤就应该越大,这样你就可以更快地迭代。相反,如果你不太自信,你的步骤就会越小,因此迭代速度会慢一些。

除了改变测试的大小和每次编写的代码量之外,增加或减少测试大小的另一种方法是采用从上到下或自下而上的方法。

例如,在前一节中,你采用了自下而上的方法来编写测试。你首先测试了generateItemRowgenerateTotalRow函数。然后,你测试了使用这两个函数的函数,如图 9.10 所示。

图 9.10 当采用自下而上的方法编写测试时,你首先验证了最细粒度的软件组件,然后才是最粗粒度的。

因为你在实现过程中验证了各个函数,所以在测试最顶层的函数时,你已经对其依赖项能够正常工作有了信心。采用自下而上测试方法的缺点是,你不得不编写更多的测试,因此迭代速度会慢一些。

如果你采用从上到下的方法来测试相同的功能,你可以从测试最顶层的函数开始。因为最顶层的函数依赖于其他两个函数才能工作,通过测试它,你将间接覆盖它所依赖的函数。

因为你会以更高的抽象层次测试你的软件,所以你需要更少的测试来覆盖相同的代码行,因此你会更快地迭代。这种方法的缺点是它的反馈不够细粒度,你只有在写了一块更大的代码之后,才能知道被测试的单元是否正确,如图 9.11 所示。

图 9.11 从上到下测试你的代码需要你从更粗粒度的软件组件开始,这也会间接覆盖被测试单元的依赖。

通过采用自下而上的测试方法,你会在编写代码时获得细粒度的反馈并建立信心,但你需要编写更多的测试,因此迭代速度会慢一些。

另一方面,如果你感觉更自信,并愿意牺牲细粒度反馈以加快迭代速度,你应该采用自顶向下的测试方法。通过在更高的抽象级别测试你的软件,你需要编写更少的测试来覆盖相同的代码行。

重要提示:采用自顶向下或自底向上的测试方法是增加或减少你的步骤大小的一种方式。你感觉越自信,你测试软件时应采用的抽象层次就越高。

无论你选择从顶部向下还是从底部向上测试你的软件,你都应该遵循相同的步骤序列。

你选择的方法将影响你步骤的粒度。

9.3.3 自底向上与自顶向下方法的优缺点

当谈到测试驱动开发时,我已经提到了自底向上与自顶向下方法的优缺点。

  • 自底向上测试为你提供更细粒度的反馈,但会减慢你的迭代速度。

  • 自顶向下测试允许你更快地迭代,但会产生更粗略的反馈。

在本小节中,我将介绍在决定采用哪种策略时需要考虑的三个其他方面:可靠性、成本和覆盖率。

在解释了每种策略的优缺点之后,我将教你如何权衡它们,以便你可以决定对你所构建的内容来说什么是足够的。

可靠性

当准备马卡龙时,品尝其每个成分或食谱的中间步骤并不能保证从烤箱中取出的马卡龙将是完美的。另一方面,从烤箱中直接品尝马卡龙是确保其味道美妙的无懈可击的策略。

类似地,当你采用自顶向下的测试方法时,你将在更高的抽象级别编写测试。这些测试将更接近用户将与之交互的软件层,因此会生成更可靠的保证。

例如,考虑你为生成库存报告编写的函数。这个函数将由使用它的程序员直接消费。因此,通过像你的消费者一样测试它,你将更有可靠的保证它按预期工作。

在测试金字塔中,该函数的测试位于单元测试之上,因为其范围更广。它覆盖了多段代码。

采用自顶向下测试方法的好处与你在向上移动金字塔时获得的好处相同:你以牺牲精确反馈为代价创建更可靠的保证。

如果你采用自底向上的方法测试相同的函数,generateItemRowgenerateTotalRow的测试可能会让你更有信心,最高层的函数将工作。然而,除非你测试了最高层的函数本身,否则你不会确定。

通过为单个函数编写测试,你将向下移动测试金字塔,因为你的测试范围将更小。

与自上而下的方法在测试金字塔上升时产生相同的结果一样,自下而上的方法在下降时也会产生相同的结果。

当你向金字塔的底部移动时,你会得到更精确的反馈,但会创建更不可靠的保证。当你向金字塔的顶部移动时,情况则相反。

成本

尽管在烘焙过程中品尝每一部分食谱可以让你更有信心你的甜点将是美味的,但这在时间成本上会更高。

同样,采用自下而上的测试方法会导致迭代速度变慢,因此,实现被测试单元的成本会更高。

例如,通过单独测试generateItemRowgenerateTotalRow,你因为迭代时采取了更小的步骤,所以实现所需功能的时间更长。在这种情况下,采用的自下而上的方法增加了实施成本。

另一方面,如果你想降低实现该特性的成本,你可以只测试最顶层的函数,该函数依赖于其他两个函数。通过这样做,你仍然覆盖了其依赖项,但需要编写的测试会更少。

尽管在实现特定代码片段时,实施成本的不同可能相当明显,但维护成本的不同则更为显著。

如果你采用自上而下的方法测试你的代码,并且因此只有为生成报告的函数编写测试,那么如果你决定更改,例如报告行的格式,你只需要更新其自身的测试。

另一方面,如果你采用自下而上的方法进行相同的更改,你将不得不更新生成报告的函数以及生成每一行数据的各个单独函数的测试。

这种额外的成本产生是因为你的测试中有大量的重叠。最顶层函数的测试将覆盖与它所依赖的函数的测试相同的代码行。

采用自上而下的测试方法可以减少测试之间的重叠,从而降低维护成本。采用自下而上的方法则会产生相反的效果。它会导致你的测试有更多的重叠,从而增加维护成本。

覆盖率

普通顾客的味蕾可能没有美食评论家那么敏锐。对于前者来说,闪电泡芙中稍微多一点糖或玉米淀粉仍然会非常美味。对于后者来说,仅仅多一点黄油就可以完全破坏它的味道。

如果你的味蕾没有评论家那么敏锐,那么在糕点完成之后品尝你的闪电泡芙可能不足以保证你能在那本著名的美食指南中获得额外的一颗星。如果你只在泡芙从烤箱中取出后尝试一次,不同的成分会使你更难注意到瑕疵。

当采用自顶向下的测试方法时,可能会产生类似的效果。通过测试较粗粒度的代码,有时会难以触发副作用或检查哪些情况下单个函数会失败。

例如,如果你只测试生成报告的函数,你将无法检查当传递给它nullundefinedgenerateTotalRow的行为。如果你将nullundefined传递给最顶层的函数,它会在执行generateTotalRow之前就失败。

另一方面,如果generateTotalRow只被生成库存报告的函数使用,测试该方面的行为可能被认为是多余的。例如,有人可能会争辩说,最重要的是顶层函数能够正常工作,因为它是唯一一个其他人会直接使用的函数。

当另一段代码需要使用generateTotalRow时,问题就出现了。在这种情况下,检查generateTotalRow在给定不同类型的输入(如nullundefined)时是否表现适当是值得的。

如果你采取自底向上的测试方法,你可以向generateTotalRow传递任何你想要的输入。因此,你将能够检查它在多种情况下的行为,而无需依赖于调用者如何使用它。

当采用自底向上的策略时,你会直接测试被测试的单元。因此,你将更容易覆盖其多个执行分支,并给出输入以检查它在边缘情况下的行为。

正如我在谈论成本时提到的,额外的覆盖率是有代价的:维护测试的成本会更高。

决定采取哪种方法

类似于路易斯根据他是在制作卡纳洛尼还是闪电泡芙而采取不同的方法,你应该根据你正在构建的内容考虑不同的测试策略。

首先也是最重要的考虑因素是,你对需要编写的代码有多大的信心。这一点至关重要,因为它决定了当你采用测试驱动的方法编写代码时,你的步骤应该有多大。其他一切都是次要的,能够实现一个按预期工作的单元测试是最重要的。

当你感到自信时,采用自顶向下的测试方法来编写测试是完全可以的。你可以编写一个范围更广的测试,并一次编写更大的代码块。这种策略将帮助你更快地迭代,同时仍然提供你需要的安全保证。

如果你已经知道被测试的单元按预期工作,例如在处理未经测试的遗留代码库时,自顶向下的测试就更有益。

通过编写更广泛的测试,你将间接验证你正在测试的代码所依赖的小函数。因为每个测试的范围更大,所以你需要更少的测试来覆盖你的应用程序,因此,测试所需的时间会更少。

另一方面,当你需要逐步建立信心时,采用自下而上的策略会更好。你会编写范围较小的测试,但你将能够平滑地迭代,并在向正确实现迈进的过程中减少恐惧。

即使你在实现阶段采用自下而上的方法,你仍然可以在代码的维护阶段删除测试。

在实现代码时,你可以保留你的通过测试。你已经花费了时间来编写它们,现在删除它们不会有任何好处。

例如,在代码的维护阶段,如果你改变了你的测试单元,导致单个函数的测试开始失败,你可以选择删除这些测试,只更新最顶层函数的测试。

如果最顶层的函数已经覆盖了底层的函数,你可以节省更新更细粒度测试的时间,只需更新更粗粒度的测试即可。

在决定采用哪种方法时,考虑的第二重要方面是查看你的依赖图。

例如,当路易斯烘焙马卡龙时,他可能不需要测试食谱的每一步,但由于多个步骤和其他甜点依赖于相同的原料,他检查这些原料是否良好是有价值的。

如图 9.12 所示,单独检查像鸡蛋、牛奶、黄油和糖这样的关键原料,在烘焙其他多个甜点,尤其是马卡龙时,将节省他很多麻烦。

图片

图 9.12 单独检查牛奶、鸡蛋、黄油和糖更有价值,因为它们将是多个食谱的关键原料。

同样的原则也适用于软件测试。

例如,考虑一下你可能需要调整你的generateTotalRow函数,使其适用于多种类型的报告。

在这种情况下,为generateTotalRow本身编写测试是明智的,这样你可以确保它将适用于所有依赖于它的报告,如图 9.13 所示。

图片

图 9.13 通过测试你的软件将广泛使用的函数,你可以创建一个坚实的基础,在此基础上你可以自信地构建。

通过采用自下而上的方法测试generateTotalRow,你会创建更可靠的保证,这意味着你将更有信心它将适用于所有依赖于它的新报告。

此外,因为你将直接测试generateTotalRow,你可以更容易地验证许多不同类型的边缘情况,确保如果其他依赖于它的报告最终传递了不寻常的参数,它不会失败。

在这种情况下,你的维护成本会增加,但这是有道理的,因为generateTotalRow的重要性增加了。由于各种其他代码依赖于它,它能够适当地工作变得更加关键。

代码中的更多功能依赖于某段代码,那么对该段代码的测试就应该越彻底。

要使你的测试更彻底但增加成本,采用自下而上的方法,并直接检查测试单元。要使它们不那么严格但减少成本,采用自上而下的方法,并通过验证依赖于它的代码片段来覆盖测试单元。

9.4 平衡维护成本、交付速度和脆弱性

对于一家面包店来说,仅仅出售最美味的甜点是不够的。除了惊人的马卡龙,它还需要赚取一些甜美的利润。

即使它的糕点非常美味,面包店的利润率很高,它也需要生产和销售一定数量的甜食,以便其收入超过成本。

这篇介绍可能会让你认为这是你的第一堂《资本主义 101》课程的课题,但实际上它也说了很多关于测试的事情。

当涉及到生产软件时,如果你不能快速交付新功能并修复错误,那么你的代码有多好,其测试有多彻底,都没有关系。另一方面,如果软件不能工作,快速生产软件也是毫无价值的。

在本节中,你将学习如何平衡测试驱动的工作流程和交付速度,以便你可以保持你的测试可维护和健壮。

要有详尽的测试来建立信心但又不阻碍交付新功能,你需要理解,你为实现一个功能所需的测试与为维护它所需的测试是不同的。

正如我在上一节中提到的,你的软件生命周期中的不同阶段需要不同类型的测试,我将在本节中分别介绍。

注意:你的软件的不同部分可能处于不同的阶段。你可以在其他功能已经处于维护阶段的基础上,采用测试驱动的方法来实现功能。

9.4.1 测试驱动实现

在编写代码时,你希望专注于正确性和迭代速度,而不仅仅是其他方面。你希望建立信心,并逐渐向你的测试单元的最终实现迈进。

要实现一个可工作的实现,最重要的态度是根据你感觉有多自信来调整你的步骤大小。

在第一次实现一段代码时,通常不会像在维护时那样自信。因此,你的实现阶段通常需要更多的测试。

你需要更多步骤来实现一个功能,其成本就会越高。更多的步骤会导致更多的迭代,这会让你花费更多的时间才能得到最终的结果。

即使较小的步骤和更细粒度的测试会产生更高的成本,但这并不意味着你一定需要写更少的测试。如果你需要这些测试才能得到一个满足你给出的规格的最终结果,那么这些就是你应该写的测试。

如果你的软件不能正常工作,那么你写得有多快都没有关系。然而,有一些技术可以帮助你加速你的测试驱动实现阶段。

这些技术中的第一个是交替使用自顶向下和自底向上的方法,这取决于你的依赖图。

当一个软件组件有多个依赖项时,对其进行单独测试是值得的。

例如,如果有多个报告生成函数使用了前一个模块的 generateTotalRow 函数,通过对其进行测试,你也将为所有依赖它的函数生成保证,如图 9.14 所示。

图片

图 9.14 一个涵盖 generateTotalRow 函数的测试将间接创建影响所有依赖项的可靠性保证。

通过为 generateTotalRow 编写特定的测试,当你添加一个依赖于它的新函数时,你将能够迈出更大的步伐,因为你已经对 generateTotalRow 的工作有信心。这些更大的步伐导致迭代速度更快,因此在不影响质量保证的情况下,减少了实施成本。

对于 generateTotalRow 有特定的测试并不意味着你不应该严格测试使用它的函数。这意味着在测试它们时,你会更快,因为你会迈出更大的步伐,需要更少的迭代。

对具有多个依赖项的函数进行单独测试,使你在实现这些依赖项时更有信心。因此,这可以帮助你加速你的测试驱动工作流程。

考虑到测试这些常见的代码片段可以带来更多的信心,从而提高你的开发速度,利用这种技术的另一种方法是重用更多的代码。

如果你的未来 generatePendingOrdersReport 包含与 generateItemRow 生成的行相似的行,那么你不需要从头创建一个新函数并对其进行测试,而是可以更新 generateItemRow 及其测试。

通过更新 generateItemRow 以使其能够覆盖两种用例,你可以避免创建多个新的测试,而是只需更新你已有的测试,如图 9.15 所示。

图片

图 9.15 为了避免添加新的函数和额外的测试,你可以更新 generateItemRow 函数的测试,并在多个地方重用它们。

这种重用导致软件模块化程度更高,维护代码量大大减少,因为你减少了应用程序代码的数量以及你需要进行的测试数量。

这种方法的缺点是,你并不总是能够轻松地重用代码。因此,你将不得不为你要创建的各个函数编写多个新的测试。

就像我们在上一节讨论的那样,在这种情况下,推动自顶向下的方法是有价值的。如果你有一个单独的函数使用generateDealRow,例如图 9.16 中的例子,那么只为最高层的函数编写测试是有价值的,这样你可以用更少的测试获得可靠的保证。

图 9.16 因为generateSalesReport是唯一使用generateDealRow的函数,所以为最高层的函数添加测试,间接覆盖其依赖项,更有价值。

不幸的是,你并不总是有足够的信心采取这种方法,尤其是如果你正在编写你认为实现起来复杂的代码。

如果是这样,不要害怕采取更小的步骤。记住,在实现阶段,你的主要目标是编写出工作的软件。

9.4.2 测试驱动维护

在维护代码时,你的主要目标是更新其功能或修复错误,而不会破坏已经正常工作的部分。为了使你在软件生命周期这一阶段的成本保持低,你必须能够尽可能可靠和快速地完成它。

到这一阶段,你将已经为将要更新的代码片段编写了测试。因此,你必须利用这个时刻来决定哪些测试仍然需要,哪些值得重构,以及哪些可以删除。

保持测试“绿色”

假设路易斯用来校准面包店烤箱的温度计不能正确测量温度。在这种情况下,他将无法正确校准他的烤箱,他的马卡龙将无法达到面包店通常的卓越标准

同样,为了维护一个工作的软件,团队必须拥有全面的通过测试。提交失败测试的团队将不知道测试是否过时,或者他们的代码有问题。

如果你允许失败的测试合并到你的代码库中,随着代码库的逐渐老化,失败的测试数量很可能会激增,这使得区分信号和噪声变得更加困难。

合并的代码应该始终通过所有测试

如果被测试的单元的行为发生了变化,更新其测试。如果你有覆盖边缘情况或不再存在的功能的测试,删除它们。

重构测试

就像更新编写糟糕的代码很难一样,更新编写糟糕的测试也很困难。

重构测试可以降低成本,因为它使得开发者能够更快地更新测试和理解代码库。

当测试编写得很好且易于理解时,你更新它们的时间会更少,这会导致你更快地交付代码

如果你遵循良好的设计原则,例如不要重复自己,你每次更新的代码会更少,这反过来又会导致更短、更快的迭代。

此外,通过使测试更易于阅读,你可以创建更好的文档

当同事或甚至你未来的自己需要检查特定代码片段的工作方式时,他们可以查看其测试。编写良好的测试将为他们提供如何使用被测试单元的 API 的清晰示例以及它可以处理的边缘情况。

无论何时你都有重复、难以阅读或不清晰的测试,如果你有时间,就重构它们。

删除冗余测试

删除冗余测试可以加快迭代速度,因为你不必在它们损坏或在未来任务中读取或更新它们时修复它们。然而,保留质量保证是至关重要的。

要确定你可以删除哪些测试,你需要考虑哪些测试重叠。

例如,考虑本节前一部分的最后一个例子。在它里面,我鼓励你如果可能的话,采用自顶向下的方法测试generateSalesReport函数,就像图 9.17 中所示的那样。如果你没有足够的信心这样做,你可能会得到针对generateDealRow的多个测试以及针对最顶层generateSalesReport函数的测试。

在这种情况下,删除你在实现阶段编写的单独测试是不值得的,因为它们已经提供了可靠的保证。

图 9.17 在进行测试驱动开发时,你添加了针对generateDealRow函数本身的测试。因为你也为最顶层的generateSalesReport实现了测试,所以generateDealRow的测试最终变得冗余。

此外,在当时,你无法预测你是否需要更改generateSalesReportgenerateDealRow。因此,因为你已经花时间编写了这些函数,所以最好保留它们。

现在你已经进入软件的维护阶段,你可以决定是否还需要那些测试,或者它们是否与你要编写或更新的generateSalesReport测试重叠。

例如,想象一下你可能需要更新generateSalesReport并更改每个单独的交易行的格式。为此,你首先更新测试,然后更新被测试单元本身。

在更新generateSalesReport之后,针对generateDealRow的单独测试将会失败,因为它们的被测试单元已经发生了变化,如图 9.18 所示。

图 9.18 在软件的维护阶段,你可以删除具有较弱可靠保证的重叠测试。

在那种情况下,如果最顶层的测试已经证明了生成的报告是正确的,你可以安全地删除generateDealRow的测试,因为它们与最顶层的generateSalesReport测试重叠,并且提供了较弱的可靠性保证。

删除这些测试将节省你更新它们的时间,并将保持你已有的质量保证,因为你已经在generateSalesReport的最高测试中覆盖了这些测试的断言目标。

尽管你在编写这些函数时不得不采取自下而上的方法,但在维护阶段你能够迁移到自上而下的方法,因此减少了重叠并保持了质量。

通过删除断言目标重叠的测试,你将节省修复这些测试和为下一个更新该代码块的开发者移除不必要的测试的时间。

9.5 为 TDD 成功设置环境

成功的糕点师不仅关乎糖、鸡蛋和面粉,还关乎建立品牌和吸引顾客的环境,让他们享受。如果面包店能出现在热门电视剧中或拥有吸引人的甜点,那将是了不起的。纽约市的面包店,我在看着你。

同样,为了成功采用测试驱动的工作流程,企业应该做的不仅仅是遵循“红色、绿色、重构”的咒语。为了创造一个测试驱动开发可以繁荣的环境,公司必须调整他们的实践、流程和对待软件生产的态度。

在本节中,我将解释这些对实践、流程和态度的改变,以及它们如何帮助软件企业在采用测试驱动开发时繁荣发展。

9.5.1 全员采用

最好的糕点师会让他们的员工遵循他们的食谱,因为他们希望所有的甜点都能与面包店的声誉相匹配。例如,想象一下,如果每个糕点师使用不同数量和种类的糖和可可粉来制作巧克力糖霜,那可能会多么灾难性。如果不遵循面包店的标准,他们不仅会破坏糖霜本身,还会破坏与之结合的甜点。

同样,不实践测试驱动开发的工程师将使其他人的工作处于风险之中,这些软件将与他们的软件交互。

想象一下,例如,你将负责实现生成面包店需要的所有不同类型报告的函数。

即使你对自己的工作采取严格的测试驱动方法,如果实施每个单独报告的人采取粗心大意的方法,它仍然会处于风险之中。

如果你依赖的任何报告都是错误的,你的工作将产生不充分的结果。

对于你依赖的底层函数没有测试,将削弱你朝着工作解决方案迈出小步的信心。当你实现generateAllReports函数时,你不知道是代码和测试错误还是别人的,这需要你一次调查和修复更大的代码块。

通过限制你向解决方案迈进的小步骤的能力,测试驱动开发减少恐惧和改进设计的益处将受到显著限制。

重要的 工程师应该将测试视为开发过程的一个组成部分。在实践测试驱动开发时,测试与你的应用程序代码一样是交付成果。

当工程师不认为测试是他们开发过程的一个组成部分时,测试往往更加肤浅,覆盖率也往往较差。

除了要求他人采取更大的步骤外,不采取纪律性的、以测试为导向的方法来处理工作的开发者会增加其他采取这种方法的人的工作量。

例如,考虑一下我刚刚提到的generateAllReports函数。如果你在编写它时采取了一种纪律性的、以测试为导向的方法,而没有为它所依赖的函数编写测试,你将不得不首先验证这些依赖项。

通过采取以测试为导向的方法并从底部建立信心,你将再次能够创造一个能够逐步向解决方案迈进的小步骤的情况。

必须测试他人函数的问题是你将花费更长的时间来编写这些测试。如果每个代码片段的第一个实现者为他们自己的工作创建测试,其他人就不一定需要阅读或甚至更新它们。相反,他们可以在更高的抽象级别上工作,并更有信心他们所依赖的代码已被正确实现。

此外,由于函数的原始实现者编写它时在脑海中拥有更多的上下文,他们将能够编写更全面的测试,并考虑更多非常规的边缘情况。

重要的 测试自己的代码是与自己和同事之间的妥协。通过采取一种纪律性的、以测试为导向的方法来编写代码,你将改善自己的工作流程并且他人的。

在这些技术优势之上,采用测试驱动开发往往会使时间和复杂度估计更加精确

通过始终交付经过严格测试的代码,开发者将来在实现其他功能时不太可能发现错误的代码片段。

当估计发生在开发者测试他们之前的工作之前时,他们可能需要额外的时间来修复他们没有考虑到现有错误。

此外,测试还充当了代码的文档。当与别人编写的代码进行交互时,开发者可以查阅现有的测试来查看使用示例和可能的边缘情况。检查这些使用示例通常比阅读和解释各种应用程序代码要快得多。

最后,由于开发者“继承”了之前编写他们正在处理的代码的人的紧密反馈循环,他们能够以更迭代的模式工作。他们不需要自己编写现有代码的测试,他们将有所有必要的基础设施来一次进行小改动。因此,他们从一开始就遵循 TDD 的“红、绿、重构”箴言将更加自然。

9.5.2 保持独立车道

无论婚礼蛋糕食谱有多大或多宏伟,路易斯一次最多只有一到两名员工在工作。

他分配给项目的员工越多,他们之间的沟通需求就越大——他们会互相打扰以协调糖霜的外观,并决定谁负责每一层的填充和装饰。

通过一次分配较少的人参与项目,讨论时间会更短,效率更高,并且更容易达成共识。

随着分配给任务的员工数量的增加,每个个体的生产力就会下降。这种生产力下降是因为向项目中增加更多的人会增加他们之间的沟通需求,如图 9.19 所示。因此,路易斯更有效率的做法是尽早开始准备订单,但并行准备它们。

图片

图 9.19 团队中开发者的数量越多,他们之间的不同沟通渠道就越多。

在软件领域,同样的原则也适用。正如布鲁克斯定律(《神话般的软件月》;Addison-Wesley,1975)所述:

向晚期的软件项目增加人力会使项目延期。

分配给特定可交付成果的工程师越多,他们需要沟通的频率就越高,达成共识就越困难。他们经常需要停下来解决冲突,确保他们不会覆盖彼此的代码,如果他们正在处理相互依赖的代码片段,还需要在集成中测试他们的工作。

分配给同一任务的工作工程师越多,每个新工程师的效率就越低,如图 9.20 所示。

图片

图 9.20 分配给任务的每个新工程师对团队整体生产力的贡献会减少。

注意:拥有一个合格的技术领导者通常是最有效的保持开发者各自独立工作的方式。通常,技术领导者的工作是将任务分解成更小的独立可交付成果,开发者可以在这些成果上并行工作。

这个问题并不局限于采用测试驱动工作流程的工程师的项目。尽管如此,在测试驱动项目中,这个问题更为突出。

例如,想象一下你和另一位开发者已经为生成所有业务报告的功能添加了新功能。

由于你们两人都采用了测试驱动的方法,你们的应用代码和测试中都会出现冲突,增加了你们需要解决的冲突规模。

除了更重要之外,这些冲突还将更加难以解决,因为即使在解决了测试中的冲突之后,你们还必须决定哪些仍然有效,以及如何调整现有的测试。

如果你同事在更改报告生成函数时你在处理不同的功能,那么冲突将会更少,你将更容易基于他们的代码进行构建。你将能够依赖他们的测试来避免破坏现有功能,并寻找如何使用他们所实现功能的示例。

当开发者采用测试驱动的方式来编写不同的代码片段时,他们会向团队交付一系列测试来捕捉潜在的 bug,这样下一个处理该代码的人就会更有信心对其进行修改。此外,正如之前提到的,这些测试也充当了使用示例,使其他人更快地了解如何使用正在测试的软件组件。

通过避免多个开发者同时处理相同的代码片段,你们减少了冲突的可能性,并创造了一个更有利于测试驱动开发的环境。

9.5.3 配对

通常,让一个人监督另一个人的蛋糕比他们并行工作更好。额外的嘴和额外的眼睛可以帮助糕点师在苦甜顶料中达到完美的风味,并防止他们错误地测量巧克力奶油中的糖量。

在软件工程中,拥有额外一双眼睛的好处甚至更加明显。通过共同编程,工程师可以设计更优雅、更高效的解决方案,并更早地发现 bug。

例如,如果你和之前情况中的同事进行了配对,你们将会有更少的冲突(如果有的话),并且你们可能会找到更好的解决方案,因为你们会考虑多种方法。此外,你们会更快地发现彼此想法中的关键缺陷。

配对是避免工程师处理冲突任务的绝佳替代方案。当与测试驱动方法结合使用时,它可以非常高效。

采用测试驱动方法进行配对的一个主要优势是它改善了沟通。

而不是讨论一个函数应该做什么,你将编写一个测试来展示调用者如何使用它。具体的例子使人们更容易理解想法。

尤其是在配对的情况下,测试消除了误解,因为它们是明确指定需求的语言。

重要:测试驱动开发使开发者更容易沟通,因为你要编写的测试将展示测试单元的接口看起来像什么,以及它应该为每种输入产生哪些输出。

除了提高沟通,测试驱动开发简化了配对工作流程,因为它阐明了接下来要实现的代码片段是什么。

而不是在讨论下一步要做什么时走题,你和你的同事总是会有一个清晰的目标:使测试通过。

小贴士:有时当我与同事配对时,我会建议我们轮流写测试和写应用程序代码。

例如,如果我是第一个驾驶的人,我会编写失败的测试并将控制权转交给我的同事,让他们编写使我的测试通过所需的应用程序代码。一旦他们使我的测试通过,他们也会编写失败的测试并将控制权交回给我,这样我就可以使他们的测试通过。

我们遵循这个流程,直到我们最终完成任务。

这种技术帮助你将测试与目标的具体实现解耦。因为编写测试的人不会是实施其目标的人,所以他们更有动力去考虑调用者的需求和可能的边缘情况,而不是特定的实现。

除了提高测试和代码的质量,这种技术使工作更加有趣和吸引人,因为“驾驶员”和“导航员”的角色转换更加频繁。

9.5.4 补充测试

除了仔细检查准备过程中的蛋糕,路易斯还确保通过经常校准他的烤箱和定期深度清洁他的厨房来补充他的质量控制流程。

类似地,当涉及到编写测试时,进一步采取步骤以创建额外的可靠性保证大有裨益。

例如,为了创建一个单独的报告生成功能,即使你在开发过程中已经编写了很多测试,仍然值得添加另一个测试来确保触发此操作的按钮正在正常工作。

当采用测试驱动的工作流程时,你产生的测试可能比如果你从开发常规中单独编写测试要更细粒度。

这些细粒度测试位于测试金字塔的底部,因为它们的范围较小,可靠性保证较弱。尽管它们很有用,尤其是在你的测试单元实施阶段,但它们并不是最可靠的测试类型。

为了创建可靠的质量保证,你必须创建除了你在测试单元实施期间编写的测试之外的其他测试。

尤其是在实践测试驱动开发时,很容易被在整个过程中编写的测试数量冲昏头脑,而不是专注于更大的图景,编写更粗略和可靠的测试。

重要提示:如果你采用测试驱动的工作流程,测试金字塔仍然适用。你应该不断思考你正在编写的测试类型,并遵循金字塔的指导,关于每种类型的测试你应该有多少。

9.6 TDD, BDD, 验证和规范 BDD (行为驱动开发)

当一个客户对开心果过敏时,即使是世界上最好的卡纳洛利也无法取悦他们。因此,在路易的面包店中,正确烘焙甜点与为每位客户烘焙正确的甜点一样重要。

在软件工程中,同样,构建正确的软件与正确地构建软件一样重要。当你的软件出现错误时,你可以修复它们,但如果你构建了错误类型的软件,你可能需要重写它。

到目前为止,我仅将测试作为一种让你更有信心你的软件是正确的方法。在本节中,我将展示如何使用一种称为行为驱动开发(BDD)的技术来促进多个利益相关者之间的沟通和协作,从而确保你构建的软件正是他们想要的,也是客户需要的。

重要提示:除了帮助你构建出工作的软件外,行为驱动开发还帮助你提供利益相关者和客户所需要的。

在面包店的情况下,客户想要的是更多尽可能新鲜的羊角面包。路易想要的是一种高效管理订单的方式,以便他能够及时地为客户提供他们渴望的新鲜羊角面包。

作为一名软件工程师,烘焙的精细艺术以及配送新鲜羊角面包所涉及的物流可能不是你的专长。因此,你必须与路易沟通,了解订单系统应该做什么来帮助他高效地配送新鲜羊角面包。

路易需要将他的需求翻译成你可以工作的需求,而你则需要将技术限制和边缘情况翻译成路易的语言,以便他可以根据面包店的运作方式提出如何继续的建议。

为了解释他的需求,路易可以写下一系列规范或与你开会,并指导你如何使用订单系统。例如,他会告诉你,系统必须将同一地区的配送集中安排在相同的时间,以便羊角面包保持新鲜。他可能还会说,应将进一步的配送地点放在列表的顶部,以便他们的羊角面包可以更早发出并准时到达。

这种单方面方法的缺点是,当出现边缘情况时,你没有足够的信息来决定如何行动。例如,如果两个客户离面包店同样远,会怎样?如果他们的订单包含不需要像羊角面包那样快速配送的项目,会怎样?

如果遇到这些边缘情况,你将不得不在打断你的工作并回到路易斯那里提问或承担做出错误假设并不得不稍后重做工作的风险之间做出选择。

当采用以行为驱动的软件开发方法时,你和路易斯将采用一种共同的语言来描述在不同场景中应该发生什么。然后,你将使用这些场景来编写自动化测试,帮助你更有信心地确保你的软件工作,并且它确实做了路易斯需要它做的事情。

而不是收到一份文档或听路易斯谈论他的需求,你将使用“给定、当、然后”框架共同编写规范。

例如,当两个配送地点离面包房同样远时,决定要做什么,你需要编写一段看起来像这样的规范:

  • 给定,有一个位于面包房东边两英里的 A 地点的订单;

  • 在距离面包房两英里西边的 B 地点下单时;

  • 然后,包含更多项目的订单应出现在优先级列表的顶部。

在“给定”步骤中,你将描述系统的初始上下文。在“当”步骤中,你将描述执行的动作。最后,在“然后”步骤中,你将描述预期的输出。

TIP “给定、当、然后”公式与三个 A 模式——“安排、行动、断言”类似。

当需要实现覆盖此行为的测试时,它们将与描述规范所使用的语言相匹配。

你的测试描述将是说明特定场景中特定动作期望结果的句子。

为了让你的测试读起来更像规范,你可以使用describe来描述一个场景,以及it函数,它相当于test函数,来编写你的测试。

由于这些测试集是“自动化规范验证器”——以验收为重点的测试——我喜欢在它们的名称中使用.spec而不是.test

列表 9.36 orderScheduler.spec.js

describe("given there's an order for location A 2 miles east", () => {
  describe("when a bigger order is placed for location B 2 miles west", () => {
    it("places the bigger order on the top of the list", () => {
      // ...
    });

    it("refuses the order if it's too big", () => {
      // ...
    });
  });

  describe("when a bigger order is placed for a location close to A", () => {
    it("clusters the orders together", () => {
      // ...
    });
  });
});

这种语法帮助你保持测试的专注性,并在测试失败时更容易找出你的代码不遵守的规范部分。

由于你和路易斯将规范阐述得更容易让工程师将其转化为软件测试,因此你将获得自动保证,确保你的软件做路易斯需要它做的事情。

你和路易斯(技术和非技术利益相关者)之间的这种合作,产生了一个更加周全的规范。这种方法使你更容易解释和暴露边缘情况,以便路易斯可以决定当它们发生时系统应该做什么。对于路易斯来说,这将更容易沟通他的需求以及这如何为客户创造价值,以便工程师可以实施解决他问题的软件。

在创建此规范的过程中,技术利益相关者将更多地了解业务,并就非技术利益相关者可能未考虑过的问题提出问题。相反,非技术利益相关者将能够影响系统的设计,使其抽象与业务的现实情况相匹配,从而促进不同团队之间的未来对话。

此外,在实现这些功能时,你可以使用你已翻译成代码的规范在测试驱动的工作流程中使用。你可以先以这种格式编写测试,然后再编写代码。

由于你感到自信,可以一次迈出更大的步伐,因为你的范围已经扩大,采用此工作流程将帮助你生成更可靠的保证。尽管如此,如果你仍然需要以较小的步骤迭代,你始终可以编写自己的简短单元测试来建立信心,然后仅在此之后实现更大的行为测试。

摘要

  • 测试驱动开发是一种软件开发方法,它使用测试来帮助你迭代地生成正确的代码。

  • 在执行测试驱动开发时,你将遵循一个三步流程,你可以根据需要重复多次,直到达到最终的可行实现。首先,你会编写一个失败的测试,然后编写必要的代码来使测试通过,最后,你会重构你编写的代码。这三个步骤也被称为“红、绿、重构”,这是 TDD 的座右铭。

  • 在测试驱动的工作流程中,你越自信,你的步子就应该越大。如果你对需要编写的代码感到自信,你可以从更大的测试开始,一次实现更大的代码块。如果不自信,你可以从微小的测试开始,一次编写较小的代码片段。

  • 测试驱动开发可以降低成本,因为它帮助你更早地检测到开发过程中的错误。你的测试越早检测到错误,它们可以隐藏的地方就越少,你将不得不撤销的代码行就越少。此外,在开发过程中捕获错误可以防止它们影响客户,影响你的成本或收入。

  • 通过采用迭代的方法编写代码,你紧缩了你的反馈循环,随着你编写代码,你的信心也在增强。这个紧密的反馈循环减少了焦虑,并引导你通过开发过程。

  • 因为在测试驱动开发中,你将在编写必要的代码之前看到测试失败,所以你会更有信心你的测试可以检测到软件中的缺陷。

  • 采用自下而上的测试方法意味着首先测试较小的软件片段,然后向上移动到更高层次的抽象。自上而下的方法首先测试更高层次的抽象,这样你还可以覆盖它们所依赖的底层软件片段。

  • 测试的底层方法允许你采取更小的步骤,并在迭代过程中帮助你建立信心。当你对需要编写的代码没有信心时,你应该采用这种方法。底层测试策略的问题在于,与自顶向下的方法相比,它比较慢。它还导致你的测试之间产生重叠,增加了你的维护成本。

  • 自顶向下的测试方法将迫使你一次编写更大的代码块,这导致反馈循环更加松散。因为这种松散的反馈循环会使你更难找到错误,所以你应该只在感到自信时采用它。使用自顶向下测试策略的优势在于,它允许你更快地迭代,并在测试之间创建更少的重叠,从而降低维护成本。

  • 创建代码需要你关注正确性和迭代速度,而不是维护成本。为了决定在单元测试的实现阶段,你应该采取自底向上还是自顶向下的测试方法,你应该考虑你有多自信,并相应地调整你的步骤大小。如果你感到自信,采用自顶向下的方法;否则,尝试从底部向上测试。

  • 在维护代码时,你可以将采用自底向上方法时创建的细粒度测试转换为更粗粒度的测试,其范围更大,质量保证更可靠。在单元测试的维护阶段,你可以花更多的时间来减少测试的重叠,从而降低其成本。

  • 为了创造一个测试驱动开发可以繁荣的环境,你需要你的整个团队都接受它。当整个团队采用测试驱动的工作流程时,开发者将基于坚实的基础构建每个新功能,并将拥有自动检查来帮助他们更有信心地生成代码。

  • 当捆绑测试和代码交付时,沟通得到了促进,因为测试展示了如何使用软件,为正在测试的单元提供了文档。

  • 尤其是在采用测试驱动的工作流程时,尽量让工程师专注于代码的不同部分。通过在不同的软件部分工作,工程师将更加高效,因为他们需要更少地打断彼此,并且需要修复的冲突更少。

  • 配对是一种避免工程师同时工作在冲突代码片段中的优秀替代方案。当与测试驱动的工作流程结合使用时,它尤其富有成效,因为测试驱动开发使配对会话更加互动,并使工程师们更容易就他们想要实现的结果达成一致。

  • 测试驱动开发并不消除在应用程序代码开发过程中单独编写测试的需求。即使在采用测试驱动的工作流程时,你也应该仍然在多个集成级别编写额外的测试,并且关于测试范围和数量的测试金字塔指示仍然有效。

  • 行为驱动开发是一种软件开发实践,通过创建一个协作过程,使不同团队成员能够用一种共享的语言表达需求,从而促进项目的技术和非技术利益相关者之间的沟通。

  • 通过用多个不同团队成员共享的语言编写需求,工程师可以精确地交付业务所需的内容。此外,边缘情况可以更早地暴露和解决,无需返工。

  • 当采用行为驱动的方法进行软件开发时,业务需求被翻译成与规范语言匹配的自动化测试,从而产生验证软件功能要求的测试。

10. 基于 UI 的端到端测试

本章涵盖

  • UI 和端到端测试

  • 何时编写每种类型的测试

  • 端到端测试的商业影响

  • 多种基于 UI 的端到端测试工具概述

优质的甜点可以给面包店带来长足的发展。卓越的客户服务、精美的装饰桌子和令人叹为观止的景色可以更进一步。

为了让顾客着迷,他们的体验必须从他们踏入的那一刻起,到他们决定离开的那一刻,希望他们愿意再次回来,都是无懈可击的。

无论糕点师制作的甜点有多好,如果他们的面包店看起来很脏,没有人会进来。

打造令人愉悦的软件需要投入与关注细节的相似程度。如果客户端用户界面中的按钮不起作用,或者它显示的信息难以阅读,那么优雅设计的 API 就毫无价值。

在本章中,你将了解基于 UI 的端到端测试,它们与其他测试类型之间的概念性差异,它们如何影响你的业务,以及编写它们的不同工具。

我将首先解释基于 UI 的端到端测试以及它们与其他测试类型的不同之处。在本节中,我将强调这些测试在测试金字塔中的位置,解释它们的成本以及它们带来的好处,以便你可以决定是否应该采用它们,并了解如何将它们融入你的开发过程中。

一旦我解释了这些测试类型之间的差异和相似之处,我将教你如何决定何时编写每种测试。我会逐一分析每种测试的优缺点,并给出每种测试类型如何带来价值的例子。

第三和最后一节将概述端到端测试工具。其中,我将介绍并比较 Selenium、Puppeteer 和 Cypress 等工具,这些工具将是下一章中我会使用的工具。通过使用图表,我将向你展示这些工具的架构和工作原理。此外,我将解释每种工具的优缺点,以便你可以为你的项目选择适当的技术。

10.1 什么是基于 UI 的端到端测试?

制作上乘的甜点与建立成功的面包店不同。无论你的芝士蛋糕味道有多美妙,如果你在一个杂乱无章的面包店中卖,你也不会卖出任何东西。同样,即使城镇里最漂亮的面包店也无法承受酸味十足的甜点。

为了成功,面包店老板必须确保每个产品的展示完美无瑕,它们味道美味,客户服务和面包店的装饰都达到最高标准。

同样,为了软件产品的成功,它们必须满足用户的需求,行为正确,并且具有响应性和易于理解的界面。

在本节中,我将解释端到端测试、UI 测试和基于 UI 的端到端测试之间的区别。

端到端测试有助于你确保整个应用程序按预期工作。正如我在第二章中解释的,这些测试的范围很大,它们产生的可靠性保证很强。这些测试是测试金字塔的顶端。

要给一个测试贴上“端到端”的标签,查看正在测试的软件是有用的。

例如,考虑第四章和第五章中使用的应用程序的测试。当你将正在测试的软件视为库存管理后端时,通过发送 HTTP 请求验证此后端的测试是端到端测试。

另一方面,如果你认为正在测试的软件是面包店操作员使用的网络客户端,那么其端到端测试就是点击按钮、填写输入、提交表单和读取页面内容的测试。

现在,如果你认为正在测试的软件是包括其后端以及前端的整个库存管理应用程序,那么我之前提到的测试并不是“端到端”测试,因为它们没有覆盖整个正在测试的软件。

对整个应用程序进行的端到端测试将使用浏览器通过其网络客户端与应用程序交互,允许 HTTP 请求到达后端,并期望在服务器响应后页面显示正确的内容。以这种方式测试库存管理应用程序涵盖了运行它所涉及的软件的所有部分,如图 10.1 所示。

图 10.1 整个应用程序的端到端测试通过与其通过浏览器交互来覆盖其堆栈的所有部分。

用户界面测试与端到端测试的不同之处在于,它们覆盖的是应用程序的 UI,不一定是其所有功能范围或完整的软件栈。

例如,对第六章中前端应用程序的用户界面测试可能仅涉及使用浏览器来检查它是否显示正确的内容。

即使是我之前提到的端到端测试——通过 UI 与整个应用程序交互的测试——也是 UI 测试。我会将这个测试视为 UI 测试,因为它使用客户端的用户界面作为其操作的入口点,并将其作为断言目标。

或者,用户界面测试可以使用存根完全替换后端,并仅关注应用程序的界面元素。这样的测试将是 UI 测试。

重要 用户界面测试和端到端测试不是相互排斥的分类。一个测试可以是 UI 测试并且是端到端测试,或者只是其中之一。

我将基于 UI 的端到端测试称为通过与应用程序通过其 UI 交互来覆盖整个应用程序软件栈的测试。你可以在图 10.2 中看到这些测试类型是如何重叠的。

图 10.2 端到端测试验证应用程序的所有层。UI 测试通过 UI 验证应用程序。基于 UI 的端到端测试位于这两种测试的交叉点,因为它们通过与应用程序交互来验证应用程序的所有层。

10.2 何时编写每种类型的测试

当决定是否以及何时编写纯端到端测试、纯 UI 测试或基于 UI 的端到端测试时,我建议读者遵循测试金字塔的原则。

要遵循这些原则,你必须能够确定每种这类测试适合的位置,这正是我在本小节中要教你的。

10.2.1 基于 UI 的端到端测试

基于 UI 的端到端测试涉及你的应用程序所依赖的整个软件栈。它位于测试金字塔的最顶层,因为它具有最广泛的范围,并产生最可靠的保证。

即使在“端到端”测试类别中,覆盖整个系统的 UI 测试也高于 RESTful API 的端到端测试,如图 10.3 所示。

图 10.3 DaCosta

图 10.3 在测试金字塔的端到端测试部分,基于 UI 的端到端测试位于最顶层。

它与你的应用程序完全一样地交互:通过在真实浏览器中与你的页面元素交互。因为这些交互依赖于客户端发送适当的请求和服务器提供适当的响应,它涵盖了你的前端后端。

例如,考虑第六章中库存管理应用程序的基于 UI 的端到端测试。

通过填充应用程序的输入,提交表单,并检查项目列表,此测试验证前端表单是否发送了预期的请求,服务器是否正确响应,以及客户端在收到服务器的响应后是否充分更新了库存。

通过单个测试,你能够可靠地覆盖应用程序的大部分内容。因此,你需要比其他类型的测试更少的基于 UI 的端到端测试。

由于这些测试编写和执行通常需要更多时间,因此明智的做法是在开发新功能时避免多次更新它们。因此,我建议读者在实现完整的功能块之后编写这些类型的测试。

注意:在下一章中,我将在“端到端测试的最佳实践”和“处理不可靠性”部分详细说明为什么这些测试编写和维护可能很耗时。

要决定在什么情况下你应该编写基于 UI 的端到端测试,你必须仔细考虑正在测试的功能有多关键,手动验证它有多费时,以及自动化测试它将花费多少成本。

  • 正在测试的功能有多关键

  • 手动验证该功能需要多少工作量

  • 编写自动化测试将花费多少成本

一个功能越关键,你需要手动测试它的时间越多,编写基于 UI 的端到端测试就越重要。

例如,如果你需要测试当用户同时添加项目时“撤销”按钮的行为,你至少需要打开两个客户端,在两个客户端上插入项目,然后尝试在这些不同的客户端中按不同的顺序点击“撤销”按钮。这种测试耗时且因为步骤繁多,也容易出错。

此外,由于如果这个按钮表现不佳,后果可能极其严重,因此,严格验证这个功能是至关重要的。

在这种情况下,鉴于这个功能的重要性,你不得不频繁对其进行测试,因此,你将不得不投入大量时间进行手动劳动。

如果你有一个基于 UI 的端到端测试,你可以将测试委托给机器,它会更快地完成,并且永远不会忘记任何步骤。

另一方面,如果你有一个小而无关紧要的功能,例如清除表单内容的重置按钮,你不必一定投入时间编写基于 UI 的端到端测试。

对于这类功能,使用 react-testing-library 编写的测试可以提供几乎同样可靠的保证,并且实施和执行所需的时间要少得多。

开发人员和 QA 工程师都可以编写基于 UI 的端到端测试。在更精简的团队中,这些团队采用更敏捷的软件开发方法,软件工程师将自行编写这些测试,并将编写这些测试所需的时间纳入他们的估算中。

当 QA 团队可用时,QA 工程师可以编写针对除生产环境以外的环境的基于 UI 的端到端测试。通过自动化重复性测试,他们可以执行更多主动工作,并有更多时间改进自己的流程和进行探索性测试。

10.2.2 纯端到端测试

在测试金字塔中,纯端到端测试位于基于 UI 的端到端测试之下,如图 10.4 所示。它们并不完全像用户那样测试你的软件,但它们可以几乎同样可靠,并且编写起来要快得多。

图片

图 10.4 纯端到端测试位于基于 UI 的端到端测试之下。

与基于 UI 的端到端测试不同,纯端到端测试可以集成到你的开发过程中,并在你编写代码时显著缩短你的反馈循环。这些测试提供了可靠的质量保证,并覆盖了你代码的大部分内容。

此外,当测试没有图形用户界面的软件,如 RESTful API 或可分发软件包时,无法编写 UI 测试。因此,端到端测试提供了你可以拥有的最强可靠性保证。

你应该在编写代码的同时编写端到端测试。这些测试应该从消费者的角度验证你的代码。例如,当你测试第四章中的后端时,你的端到端测试通过发送 HTTP 请求并检查服务器的响应和数据库的内容来验证你的路由。

当编写端到端测试时,开发者应该注意测试之间的重叠程度,并仔细调整测试套件,以减少随着软件的增长而带来的维护负担,保持测试金字塔的平衡。

因为编写这类测试需要直接访问代码,所以它们必须由开发者编写,而不是质量保证工程师。

10.2.3 纯 UI 测试

UI 测试可以分为两种类型:它们可以通过真实浏览器或测试框架(如 Jest)来验证 UI。

如果你字面理解“UI 测试”这个术语,那么你可以将使用react-testing-library编写的测试视为 UI 测试。多亏了 JSDOM,它们可以通过派发类似浏览器的事件与你的组件交互,并通过在 DOM 中查找元素来验证你的应用程序。

尽管如此,由于这些测试使用 JSDOM 而不是真实浏览器运行时环境,它们并不完全复制应用程序在浏览器中运行时发生的情况。

在测试金字塔中,运行在浏览器中的 UI 测试位于运行在测试框架内的 UI 测试之上。在真实浏览器中运行的测试可以更准确地复制用户交互,并涉及更少的测试替身。然而,它们运行所需的时间更长,编写起来也更复杂。

考虑到在测试框架中运行的 UI 测试编写速度快,并且它们产生的保证几乎同样可靠,我建议你大多数时候选择它们。

例如,想想你需要在浏览器中运行第六章中编写的测试需要做什么。你将不得不与真实浏览器接口,并处理所有其复杂性,比如等待页面加载和与其原生 API 交互。除了使测试变得更加复杂外,完成测试所需的时间也会大大增加。

由于在测试框架中运行的 UI 测试编写速度快,且提供可靠的保证,你可以在开发功能的同时编写它们。鉴于这些测试通常很小,将它们包含在测试驱动的工作流程中也很简单。

我建议仅在需要使用 JSDOM 无法准确模拟的浏览器特定功能或进行视觉回归测试(我们将在本章后面讨论)时,才编写仅在浏览器中运行的纯 UI 测试。

大多数情况下,UI 测试必须由开发者编写,而不是质量保证工程师,因为它们依赖于直接访问被测试单元的接口或为与你的客户交互的应用程序编写测试替身。

当与其他软件组件的交互对测试无关时,如果 QA 工程师认为合适,他们可以承担这项责任。

10.2.4 关于验收测试和本章标题的说明

在软件行业,人们经常不精确地使用诸如端到端测试UI 测试基于 UI 的端到端测试等术语。

例如,我经常看到人们将任何通过浏览器与应用程序交互的测试称为“端到端测试”。

即使那个定义是正确的,因为这些测试的范围是整个被测试的应用程序,我相信我们可以采用更精确的术语,就像我在本章中使用的术语一样。

由于这种不准确,我很难为这一章挑选一个名字。最初,我认为我会将其命名为“UI 测试”,但那样的话,其名称就会过于简化,因为本章并不仅限于测试用户界面。

我当时考虑将其命名为“验收测试”。我之所以考虑这个名字,是因为我主要从客户的角度验证需求,并检查应用程序是否满足客户的需求。

将其命名为“验收测试”的问题在于,它可能会误导读者认为我会完全忽略检查技术要求。这个名字也可能最终变得过于简化。

将本章命名为“基于 UI 的端到端测试”告知读者,我将在本章中涵盖更广泛的技术和技巧。

我认为这个名字很理想,因为本章涵盖了与整个应用程序交互的测试,从端到端,通过其图形用户界面,其中大部分是验收测试。

本章与之前涵盖端到端测试的章节之间的区别在于,本章侧重于测试你的整个软件栈,而不仅仅是单个软件组件。

由于这些测试处于可能的最大集成级别,在考虑测试金字塔时,即使在“端到端”层内,它们也处于最顶层。

10.3 端到端测试工具概述

随着路易斯生意的扩张,他越来越难以同时监督面包店的装饰、监督客户服务并烘焙令人愉悦的甜点。因为他深知自己的天赋最好用在厨房而不是办公室,路易斯决定雇佣一位经理来监督整个业务。

即使面包店的新的经理无法做出像路易斯那样好的芝士蛋糕,但她足够多才多艺,能够识别出优秀的甜点,并保证业务运营顺利。

在本节中,我将介绍对软件来说就像路易斯的新经理对面包店一样重要的工具。

我将在这个部分中展示的工具不是直接与你的代码接口专业化,而是通过用户界面与你的软件交互。它们测试你的软件作为一个整体,并且能够对它如何工作的更广泛方面进行断言。

例如,这些工具可以帮助你填写输入,提交表单,并检查浏览器是否显示了正确的结果,而不是调用一个函数并期望其输出与特定值匹配。

在这种情况下,即使你使用的工具不一定需要了解你的服务器,它也要求服务器提供正确的响应,以便客户端能够适当地更新。即使这种松散耦合的测试对你的软件堆栈的每个具体部分了解不多,但它可以评估所有这些部分,就像路易斯的新经理一样。

我将从这个部分开始,先谈谈 Selenium,这是目前最古老且最广为人知的端到端测试工具之一。我将介绍 Selenium 是什么,它是如何工作的,你如何从中受益,以及它最关键和最常见的问题。

通过揭示 Selenium 的问题,你将更容易理解其他测试工具如何尝试解决这些问题以及它们必须做出的权衡。

在谈论 Selenium 之后,我将介绍 Puppeteer 和 Cypress,它们是目前这个领域中最受欢迎的两个工具。

当我介绍这些工具时,除了介绍它们的优缺点外,我还会探讨 Selenium 与它们的对比。

由于我在本章和下一章中将要解释的原因,Cypress 是我个人的首选工具,因此我选择使用它来编写几乎所有即将到来的示例,并且在这个部分中我将重点关注这个工具。

10.3.1 Selenium

Selenium 是一个常用于通过真实浏览器测试 Web 应用程序的浏览器自动化框架。它可以打开网站,点击元素,并读取页面内容,以便你可以与应用程序交互并执行断言。Selenium 是本节中你将看到的基于浏览器的端到端测试工具的先驱。

注意:你可以在www.selenium.dev/documentation找到 Selenium 的完整文档。

要了解为什么 Selenium 有用,可以将它与第六章中为 Web 应用程序编写的测试进行比较。

在那些测试中,你将你的应用程序挂载到替代 DOM 实现 JSDOM 上。然后,你使用 JSDOM 对原生 API 的纯 JavaScript 实现来派发事件和检查元素。

JSDOM 非常适合在开发过程中编写测试。因为它允许你摆脱对真实浏览器实例的需求,JSDOM 简化了测试环境的设置,并使你的测试更快、更轻量。

使用 JSDOM 的问题在于它可能并不总是准确地反映真实浏览器的行为。JSDOM 是按照规范实现浏览器 API 的尝试

尽管 JSDOM 在几乎所有情况下都做得很好,但它仍然是一个不完美的浏览器环境的复制品。此外,即使是浏览器本身也不总是充分遵循 API 规范。因此,即使 JSDOM 正确实现了这些 API,浏览器也可能不会。

例如,假设你实现了一个依赖于 API 的功能,该 API 在 Chrome 和 Firefox 中的行为不同。在这种情况下,如果 JSDOM 的实现是正确的,你的测试将会通过。然而,如果 Chrome 和 Firefox 都没有正确实现该规范,你的功能在任一浏览器上都不会工作。

由于 Selenium 通过真实的浏览器实例运行其测试,它是更接近用户如何与你的软件交互的工具。因此,它是提供最可靠保证的工具。

如果 Selenium 用于运行你的测试的浏览器没有实现特定的 API 或没有充分遵循其规范,你的测试将会失败。

除了是最准确复制用户行为的方式之外,Selenium 还为你提供了浏览器功能的全面范围。

使用 Selenium 时,你不仅可以将节点附加到“文档”上,还可以自由地在页面之间导航,限制网络速度,录制视频和截图。

例如,如果你有一个测试保证所有产品的图片在 1 秒后可见,即使在网络连接不稳定的情况下,你需要使用一个真实的浏览器。这种技术将使你的测试尽可能接近用户的测试环境。

Selenium 的工作原理

Selenium 通过称为Webdrivers的程序与浏览器交互。这些 Webdrivers 负责接收 Selenium 的命令并在真实的浏览器内执行必要的操作。

例如,当你告诉 Selenium 点击一个元素时,它将向你所选择的 Webdriver 发送一个“click”命令。因为这个 Webdriver 能够控制一个真实的浏览器,它将使浏览器点击选定的元素。

为了与 Webdriver 通信,Selenium 使用一种称为JSON Wire的协议。该协议指定了一组 HTTP 路由,用于处理在浏览器内执行的不同操作。当运行 Webdriver 时,它将管理一个实现这些路由的服务器。

例如,如果你告诉 Selenium 点击一个元素,它将向 Webdriver 的/session/:sessionId/element/:id/click路由发送一个POST请求。为了获取一个元素的文本,它将向/session/:sessionId/element/:id/text发送一个GET请求。这种通信在图 10.5 中得到了说明。

图 10.5 Selenium 向控制浏览器的 Webdrivers 发送 HTTP 请求。这些请求采用 JSON Wire 协议。

为了与浏览器通信,每个 Webdriver 都使用目标浏览器的远程控制 API。由于不同的浏览器有不同的远程控制 API,每个浏览器都需要特定的驱动程序。要驱动 Chrome,你将使用 ChromeDriver。要驱动 Firefox,你需要 Geckodriver,如图 10.6 所示。

图片

图 10.6 不同的 Web 驱动控制不同的浏览器。

当你使用 Selenium 的 JavaScript 库时,它所做的只是实现将发送到你所选择的 Webdriver 的请求,这些请求遵循 JSON Wire 协议。

不使用 Selenium 使用 Webdriver 的接口

正如我之前提到的,尽管 Selenium 主要用于测试 Web 应用程序,但实际上它是一个浏览器自动化库。因此,它在 NPM 上以 selenium-webdriver 命名的 JavaScript 库并不包含测试运行器或断言库。

注意:你可以在 www.selenium.dev/selenium/docs/api/javascript 找到 selenium-webdriver 包的文档。

要使用 Selenium 编写测试,你需要使用像 Jest 这样的单独的测试框架,如图 10.7 所示。或者,你也可以使用 Mocha,它是一个专用的测试运行器,或者 Chai,它是一个专用的断言库。

图片

图 10.7 如果你想要使用 Selenium 进行测试,你必须与一个测试框架,如 Jest,配合使用。

由于 Selenium 不附带任何测试工具,因此设置必要的环境以开始使用它来测试应用程序可能会很麻烦。

为了避免自己进行此设置过程,你可以使用像 Nightwatch.js 这样的库,其文档可在 nightwatchjs.org 找到,或者关于 WebdriverIO 的更多信息可在 webdriver.io 找到。

这些工具,就像 Selenium 一样,可以与多个 Webdriver 接口,因此能够控制真实浏览器。这些库与 Selenium 的主要区别在于它们附带测试工具。

除了捆绑测试工具之外,这些其他库还关注可扩展性,并提供不同的 API,以满足以测试为重点的受众(图 10.8)。

图片

图 10.8 不需要自己搭建测试基础设施,你可以使用 Nightwatch.js 或 WebdriverIO 等库,它们包含了编写测试所需的所有工具。

选择 Selenium 的时候

与其他浏览器测试框架和自动化工具相比,Selenium、Nightwatch.js 和 WebdriverIO 等工具最显著的优势在于其控制多种浏览器的能力。

由于其高度解耦的架构,它支持与不同类型的驱动程序接口以控制众多不同的浏览器,因此支持所有可用的主流浏览器。

如果你的用户群体的浏览器选择多样化,使用 Selenium 或其他利用 Webdriver 接口的库将非常有益。

通常情况下,如果我仅仅用它来编写测试,我会避免使用 Selenium 本身。在这种情况下,我通常会选择 Nightwatch.js 或 WebdriverIO。另一方面,如果你需要执行其他浏览器自动化任务,Selenium 可以是一个极佳的选择。

这些工具最显著的问题在于,它们使你编写易变的测试变得过于容易,因此你需要创建健壮的测试机制来进行确定性的验证。

注意:易变的测试是非确定性的测试。在相同的测试应用下,它们有时会失败,有时会成功。在本章的“处理易变性”部分,你将了解更多关于这些测试以及为什么你应该尝试消除它们的信息。

此外,由于这些工具通过 HTTP 请求控制真实浏览器,它们通常比完全在浏览器内运行的 Cypress 等替代方案要慢。

除了可能的速度较慢之外,配置和调试使用这些工具编写的测试通常具有挑战性。没有内置的工具来概述不同的测试用例、运行断言和监控测试执行,它们可能需要更多的时间来编写。

10.3.2 Puppeteer

与 Selenium 一样,Puppeteer 也不是一个专门的测试框架。相反,它是一个浏览器自动化工具。

注意:你可以在pptr.dev找到 Puppeteer 的文档。在这个网站上,你还可以找到 Puppeteer 的完整 API 文档链接。

与 Selenium 不同,如图 10.9 所示的 Puppeteer 只能控制 Chrome 和 Chromium。为了做到这一点,它使用 Chrome DevTools 协议,该协议允许其他程序与浏览器的功能进行交互。

注意:在撰写本文时,Firefox 的支持仍处于实验阶段。

由于 Puppeteer 涉及的软件组件比 Selenium 和其他基于 Webdriver 的工具要少,因此它更加精简。与这些工具相比,Puppeteer 更容易设置和调试。

尽管如此,因为它仍然是一个专门的浏览器自动化工具,所以它没有附带测试框架或库来创建测试套件和执行断言。

图片

图 10.9 Puppeteer 通过其 DevTools 协议直接控制 Chrome 和 Chromium。

如果你想要使用 Puppeteer 来运行测试,你必须使用单独的测试库,如 Jest 或 Jest Puppeteer。后者自带了使用 Puppeteer 本身运行测试所需的所有支持,包括额外的断言。

Puppeteer 相较于 Selenium 的另一个优势是其事件驱动架构,这消除了固定时间延迟或编写自己的重试机制的需求。默认情况下,使用 Puppeteer 编写的测试往往更加健壮。

此外,它的可调试性比 Selenium 的要好得多。使用 Puppeteer,你可以轻松地使用 Chrome 的开发者工具来解决错误,并使用其“慢动作”模式以这种方式回放测试步骤,这样你可以精确地了解浏览器正在做什么。

何时选择 Puppeteer

由于使用 Puppeteer 编写健壮的测试要容易得多,因此它们所需的时间更少,因此成本也更低。你调试它们的时间也更少。

如果你只需要支持 Chrome 和 Chromium,由于 Puppeteer 的简单性和可调试性,它比 Selenium 和其他基于 Webdriver 的工具是一个更好的替代品。

除了这些优势之外,由于 Puppeteer 不专注于支持众多浏览器,它可以为你提供更多功能和更详细的 API。

它只关注 Chrome 和 Chromium 的缺点是,如果你必须支持其他浏览器,甚至都不应该考虑它。除非你可以使用不同的工具来自动化将在不同浏览器中运行的测试,否则 Selenium 或其他基于 Webdriver 的工具是一个更好的选择。

注意:我将在本章倒数第二节“在多个浏览器上运行测试”中深入探讨支持多个浏览器的问题。

考虑到 Puppeteer 不附带特定的测试工具,如果你不愿意自己设置测试环境或使用像 jest-puppeteer 这样的包,它可能也不是你项目的最佳选择。

10.3.3 Cypress

如图 10.10 所示,Cypress 是一个测试工具,它直接与浏览器的远程控制 API 交互,以查找元素并执行操作。

注意:Cypress 的完整文档可在 docs.cypress.io 找到。它写得非常好,包含许多示例和长篇文章。如果你正在考虑采用 Cypress,或者已经这样做了,我强烈建议你仔细阅读他们的文档。

这种直接通信使测试更快,并减少了设置测试环境的复杂性,因为它减少了启动编写测试所需的软件数量。

图 10.10 Cypress 在浏览器自身运行的测试中启动一个 Node.js 进程,并与这些测试进行通信。

除了使编写测试更容易、更快之外,这种架构还允许你利用 Cypress 背后的 Node.js 进程来执行诸如管理文件、发送请求和访问数据库等任务。

此外,由于 Cypress 是一个专门为测试而创建的工具,它比只关注浏览器自动化的工具如 Selenium 和 Puppeteer 提供了众多优势。

这些优势之一是 Cypress 默认就包含了大量的测试实用工具。与 Selenium 和 Puppeteer 不同,当你使用它时,你不需要自己设置整个测试环境。

当使用 Cypress 时,你不需要挑选多个包来组织你的测试、运行断言或创建测试替身。相反,这些工具都打包在 Cypress 中。安装 Cypress 是你开始编写测试所需做的全部事情

Cypress 的 API 设计时也考虑了测试。你在编写测试时可能会遇到的情况已经内置到 Cypress 的方法中。这些 API 使测试更简单、更简洁、更易读。

例如,想象一下,你想要点击一个在你访问应用程序后仅几秒钟出现的按钮。

在那种情况下,浏览器自动化工具将需要你编写一行代码,明确告诉你的测试在尝试点击之前等待按钮可见。当使用这些类型的工具时,如果你的测试没有找到必须点击的元素,它们将立即失败。

与之相反,Cypress 不需要你编写显式代码来等待按钮出现。它将默认尝试找到它想要点击的按钮,直到达到超时。Cypress 只在找到按钮后才会尝试执行点击操作。

在 Cypress 的众多出色测试特性中,另一个有用的特性是“时间旅行”。当 Cypress 运行测试时,它会记录执行过程中的应用程序快照。在执行测试后,你可以回顾每个步骤,并验证你的应用程序在任何时间点的样子。

能够看到你的应用程序如何对测试的动作做出反应,让你能够更快地调试它,并确保测试正在执行你想要它执行的操作。

例如,如果你有一个填写表单、提交并期望页面更新的测试,你可以使用测试的动作日志来查看你的应用程序在每个这些步骤中的样子。

通过悬停在每个动作上,你将能够可视化测试填充任何字段之前、进入每个字段数据之后以及提交表单之后的你的应用程序状态。

由于你可以在真实浏览器中运行 Cypress 测试,随着你遍历应用程序的状态,你可以使用浏览器开发者工具详细检查它们。

如果你的测试找不到要填充的输入,例如,你可以回到过去检查它是否存在于页面上,以及你是否使用了正确的选择器来找到它。

除了检查元素外,你还可以使用浏览器的调试器逐步执行你的应用程序代码,了解它如何响应测试的动作。

当测试检测到你的应用程序中的错误时,你可以在应用程序的代码中添加断点,逐步执行其行,直到你理解错误的根本原因。

10.3.4 何时选择 Cypress

Cypress 相比其他工具的主要优势在于它是一个 测试 工具,而不是更通用的浏览器自动化软件。如果你在寻找一个 专门 用于编写测试的工具,我几乎总是推荐 Cypress。

选择 Cypress 可以节省设置测试环境的时间。使用它时,你不需要安装和配置其他包来组织测试、运行断言和创建测试替身。

由于 Cypress 包含了所有必要的测试工具,你可以更快地开始编写测试并从中获得价值。与 Selenium 和 Puppeteer 不同,你不需要设置多个软件或创建自己的测试基础设施。

除了能够更早开始编写测试之外,Cypress 的调试功能还使你能够更快地编写测试。

尤其是能够穿越时间并检查应用在任何时间点的状态,这将使检测导致测试失败的原因变得更加容易。

快速检测失败的根本原因使开发者能够在更短的时间内实施修复,从而减少了编写测试的成本。

除了这些调试功能之外,Cypress 的简单且健壮的 API 也是使其能够更快编写测试的另一个因素,这些 API 内置了可重试性。

与 Puppeteer 和 Selenium 不同,你不需要明确配置测试以等待特定元素出现或重试失败的断言,你的测试将自动执行这些操作,因此将更加健壮、易于理解且简洁。

最后,在选择 Cypress 作为测试工具时,需要考虑的两个其他特点是其出色的文档和易于理解的 UI。

这些特性改善了开发者的体验,使他们更愿意更频繁地编写更好的测试,从而创建更可靠的保证。

我唯一会建议不选择 Cypress 的场景是当你必须执行除了专门运行测试之外的任务时,或者当你必须支持除了 Edge、Chrome 和 Firefox 之外的浏览器——这是 Cypress 支持的唯一三个网络浏览器。

我认为 Cypress 是本章中所有工具中最具成本效益的,我相信它将适合你将要处理的多数项目。因此,它是我在下一章示例中选择的工具。

摘要

  • 基于 UI 的端到端测试将应用界面作为其操作的入口点,并覆盖整个应用的软件栈。

  • 测试一个功能越关键和具有挑战性,拥有基于 UI 的端到端测试就越有帮助。当你有难以测试的关键功能时,基于 UI 的端到端测试可以加速验证应用的过程,并使其更加可靠。这些测试不会忘记任何步骤,并且执行速度比人类快得多。

  • 当基于 UI 的端到端测试编写过于耗时,且被测试的单元不是那么关键时,你应该考虑编写其他类型的测试,这些测试可以提供类似的可靠保证。例如,如果你正在测试一个 Web 服务器,你可以专门为其路由编写端到端测试。如果你正在测试一个前端应用程序,如果你有一个 React 应用程序,你可以使用dom-testing-libraryreact-testing库。

  • 要编写基于 UI 的端到端测试,你可以将浏览器自动化工具如 Selenium 或 Puppeteer 与你的首选测试库集成。或者,你也可以选择像 Cypress、Nightwatch.js 或 WebdriverIO 这样的解决方案,这些解决方案捆绑了测试实用工具,这样你就不需要自己设置测试基础设施。

  • Selenium 通过一个名为 JSON Wire 的协议与浏览器交互,该协议指定了一系列 HTTP 请求,用于执行浏览器必须执行的不同操作。

  • 与 Selenium 不同,Cypress 和 Puppeteer 可以直接控制浏览器实例。这种能力使得这些工具在测试方面更加灵活,使得测试编写更快,但减少了这些测试工具可以与之交互的浏览器数量。

  • 在这本书中,我将重点关注 Cypress 工具。我选择它是因为它的灵活性、设置简单以及出色的调试功能,包括时间旅行、查看运行中的测试以及记录它们的能力。大多数时候,如果你不打算支持像 Internet Explorer 这样的浏览器,我会推荐这个工具。

11 编写基于 UI 的端到端测试

本章涵盖了

  • 编写端到端 UI 测试

  • 消除不稳定性

  • 端到端 UI 测试的最佳实践

  • 在多个浏览器上运行测试

  • 执行视觉回归测试

无论你读过多少商业书籍,还是与多少成功的糕点师交谈,要开设自己的业务,你必须卷起袖子付出努力。了解“良好原则”是有用的,但如果没有自己烘焙并与客户交谈,你就无法成功。

在测试方面,了解你可以使用的不同工具以及理解它们的权衡是有帮助的,但如果没有自己编写测试,你就不会知道如何在更短的时间内构建可靠的软件。

在本章中,你们将学习如何通过测试第六章中构建的应用程序来编写基于 UI 的端到端测试。在整个章节中,我将使用 Cypress 来演示如何测试该应用程序以及你可以使用哪些高级技术来提高测试的有效性。

注意:尽管我在本章的代码示例中使用了 Cypress,但我会教给你们必要的原则,以便能够适应和应用这些模式和最佳实践,无论你选择什么工具。

你可以在本书的 GitHub 仓库中找到这些测试的代码以及受测试的服务器和客户端的代码:github.com/lucasfcosta/testing-javascript-applications

本章的第一部分演示了如何使用 Cypress 编写实际的基于 UI 的端到端测试。在本节中,我将指导你们编写第六章中构建的应用程序的测试。你们将学习如何通过直接与 UI 交互来测试功能,以及如何检查这些交互对客户端和服务器的影响。

在第二部分,我将介绍最佳实践,使你编写的测试更加健壮,因此成本更低,同时保持其严谨性。为了教你们这一点,我将展示特定测试可能会失败的场景以及如何重构这些测试以使其更加健壮。

在你理解了如何以可维护的方式编写有效的 Cypress 测试之后,你将学习如何消除不稳定的测试,这些测试在相同的代码下有时会失败,有时会成功。

你们将理解为什么避免落入“不稳定”类别的测试至关重要,以及为什么不稳定是处理端到端测试时最常见的问题之一,尤其是在这些测试涉及与图形用户界面的交互时。

为了教你们如何避免不稳定的测试,我将实现一些容易出错的测试,提供多种替代方案来使这些测试变得确定,并解释每种策略的优缺点。

本章的倒数第二部分是关于在多个浏览器中运行测试。在本节中,我将解释你可以采用哪些策略在各个浏览器中运行测试,以及你需要哪些工具。此外,你将了解为什么这样做很重要,以及根据你构建的内容和方式选择哪种策略。

最后,我将讨论视觉回归测试。通过为第六章中构建的应用程序创建视觉回归测试,我将教你如何确保你的产品在每次发布时都应看起来如何。

在本节的最后一部分,我将演示如何使用 Percy 编写视觉回归测试,并解释这种测试的优势。除了教授如何将这些测试集成到你的工作流程中,我还会解释它们如何使你的发布过程更安全,并促进不同利益相关者之间的协作。在本节中,你将了解如何将这些工具集成到你的工作流程中,以便将更改安全快速地发布到生产环境中。

11.1 你的第一个基于 UI 的端到端测试

面点师以其制作精美甜点的能力而闻名,而不是他们的豪华烤箱、高端厨具或一千种不同的裱花袋喷嘴。

在本节中,你将编写你的第一个完全集成的端到端测试。你将通过使用 Cypress 测试第六章中使用的服务器和客户端来学习如何编写这些测试。

在整个过程中,我将演示如何测试功能,并阐明完全集成的 UI 测试与之前编写的测试之间的差异和相似之处。

注意:尽管本书的示例使用了 Cypress,但我将要教你的端到端测试原则,无论你选择什么工具都是有效的。

11.1.1 设置测试环境

在本节的第一部分,你将学习如何使用 Cypress 测试你在第六章中构建的应用程序。我将解释如何安装和配置 Cypress,以及如何与你的应用程序交互并执行断言。

注意:你可以在本书的 GitHub 仓库中找到这些测试的代码,以及要测试的服务器 客户端代码,网址为 github.com/lucasfcosta/testing-javascript-applications

在安装 Cypress 之前,创建一个新文件夹,你将把你的测试放在这个文件夹中。在这个文件夹中,你将执行 npm init 命令来生成一个 package.json 文件。

提示:我喜欢为我的测试创建一个单独的项目,因为它们通常涵盖多个软件组件。

或者,如果你的产品应用程序位于同一个文件夹中,或者你更喜欢将测试与特定项目的文件一起管理,你可以将 Cypress 安装为该项目的开发依赖项。

如果你在一个 monorepo 中工作,我建议你在那个仓库中安装 Cypress,这样你就可以像集中代码一样集中测试。

在创建此文件夹并向其中添加 package.json 文件后,通过运行 npm install --save-dev cypress 来将 Cypress 作为开发依赖项安装。

要让 Cypress 为您的端到端测试执行脚手架,您必须通过执行 ./node_modules/.bin/cypress open 来使用其二进制 open 命令。除了创建您开始编写测试所需的必要文件外,此命令还将创建一个包含测试示例的文件夹,并打开 Cypress UI。

在 Cypress UI(图 11.1)中,您可以访问之前测试的录制,检查配置选项,并选择要执行的测试以及它们将在哪个浏览器中运行。

图片

图 11.1 显示 Cypress 示例测试的图形用户界面

提示:为了使此命令更容易记忆和快速访问,创建一个 NPM 脚本来执行它。

例如,您可以创建一个 cypress:open 命令来调用 cypress open,类似于您为运行 Jest 的测试所做的那样。

列表 11.1 package.json

{
  "name": "1_setting_up_cypress",
  "scripts": {
    "cypress:open": "cypress open"         ❶
  },
  "devDependencies": {
    "cypress": "⁴.12.1"
  }
}

❶ 调用 Cypress 的二进制打开命令

创建此脚本后,您可以通过运行 npm run cypress:open 来打开 Cypress UI,而不是输入项目的完整路径。

现在,只需通过点击其中一个来尝试执行一个示例。运行这些示例测试将让您了解一旦创建测试后,您的测试将如何运行。

当这些示例运行时,在左侧,您将看到一个操作日志,您可以使用它来检查您的应用程序在测试操作发生过程中经历的不同状态。

在尝试了 Cypress 的示例之后,您可以自由地删除 examples 文件夹。

提示:要运行所有 Cypress 测试,您可以使用 cypress run 命令而不是 cypress open

就像您使用 open 命令所做的那样,您可以为 run 添加一个新的 NPM 脚本,使其更容易记忆和执行。

列表 11.2 package.json

{
  "name": "1_setting_up_cypress",
  "scripts": {
    "cypress:open": "cypress open",       ❶
    "cypress:run": "cypress run"          ❷
  },
  "devDependencies": {
    "cypress": "⁴.12.1"
  }
}

❶ 调用 Cypress 的二进制打开命令

❷ 调用 Cypress 的二进制运行命令

11.1.2 编写您的第一个测试

现在您已经知道了如何执行测试,并且 Cypress 已经为您创建了一个初始文件结构,您将为您在第六章中构建的应用程序编写第一个测试。

注意:本书 GitHub 仓库 github.com/lucasfcosta/testing-javascript-applications 中的 chapter11 文件夹下提供了测试中的 clientserver 应用程序。在该文件夹中,您还将找到本章编写的所有测试。

要运行这些应用程序中的每一个,您必须使用 npm install 安装它们的依赖项。

在安装每个项目的依赖项后,您需要通过运行 npm run migrate:dev 来确保您的数据库模式是最新的。只有在这种情况下,您才能运行服务器,默认情况下它将绑定到端口 3000。在运行客户端之前,您必须使用 npm run build 来构建其包。

要启动每个应用程序,请在各自的文件夹中运行 npm start

在编写第一个测试之前,启动您的客户端和服务器,并在 http:/./localhost:8080 上与您的应用程序进行交互。这一步是必不可少的,因为为了 Cypress 能够与您的应用程序交互,您必须确保它可访问。

最后,是时候编写您的第一个测试了。这个测试将访问 http://localhost:8000 并检查提交新物品的表单是否包含正确的字段和提交按钮。

首先,在 integration 文件夹内创建一个名为 itemSubmission.spec.js 的文件。这个新文件将包含物品提交表单的测试。

itemSubmission.spec.js 文件中,编写一个测试来检查物品提交表单是否包含预期的元素。这个测试将使用全局 cy 实例的方法。

要使 Cypress 访问 http:/./localhost:8080,您将使用 cy.visit 方法。要找到页面上的每个元素,您将使用 cy.get。一旦使用 get 定位到匹配的节点,您就可以通过调用 contains 来找到包含所需文本的那个元素。

提示:您可以使用 Cypress 的 baseUrl 配置选项,如下一列表中所示,以避免每次调用 visit 时都输入您应用程序的地址。

通过配置 baseUrl,Cypress 将在您调用 cy.visit 时使用它作为前缀。

列表 11.3 cypress.json

{
  "baseUrl": "http:/./localhost:8080"         ❶
}

❶ 将 http:/./localhost:8080 前缀添加到传递给 cy.visit 的字符串

或者,为了查找元素,您可以直接调用 contains 并将其选择器作为第一个参数传递,将内部文本作为第二个参数,如下所示。

列表 11.4 itemSubmission.spec.js

describe("item submission", () => {
  it("contains the correct fields and a submission button", () => {
    cy.visit("http://localhost:8080");                                 ❶
    cy.get('input[placeholder="Item name"]');                          ❷
    cy.get('input[placeholder="Quantity"]');                           ❸
    cy.contains("button", "Add to inventory");                         ❹
  });
});

❶ 访问应用程序的页面

❷ 查找物品名称的输入框

❸ 查找物品数量的输入框

❹ 查找添加物品到库存的按钮

注意:尽管 Cypress 的测试运行器是 Mocha,而不是 Jest,但组织测试所使用的语法是相同的。

一旦您完成测试的编写,使用 npm run cypress:open 命令启动 Cypress,并点击 itemSubmission.spec.js。运行测试后,Cypress 将显示一个类似于图 11.2 的屏幕。

图片

图 11.2 Cypress 执行检查库存管理应用程序元素的测试

提示:在 Cypress 测试中搜索元素时,您应该使用与元素本身本质相关的特征来查找它们。在这种情况下,我决定使用每个输入的占位符,例如。

如果您使用过于具体或与页面结构紧密耦合的选择器,您的测试将经常中断,因此将产生额外的维护成本。

在编写选择器时,遵循第四章和第五章中的相同指南:避免脆弱的选择器

您的下一个任务将是使这个测试更加详细,通过填写每个输入框,提交表单,并检查项目列表是否包含新项目。

在找到每个输入后,你将使用 Cypress 的 type 方法来填写每个输入。此方法将第一个参数传递的字符串输入到元素中。

在填写每个输入后,你将调用提交按钮上的 clickcontains 以找到内容与预期匹配的 li

列表 11.5 itemSubmission.spec.js

describe("item submission", () => {
  it("can add items through the form", () => {
    cy.visit("http://localhost:8080");                    ❶

    cy.get('input[placeholder="Item name"]')              ❷
      .type("cheesecake");
    cy.get('input[placeholder="Quantity"]')               ❸
      .type("10");
    cy.get('button[type="submit"]')                       ❹
      .contains("Add to inventory")
      .click();

    cy.contains("li", "cheesecake - Quantity: 10");       ❺
  });
});

❶ 访问应用程序的页面

❷ 找到物品名称的输入,并输入“cheesecake”

❸ 找到物品数量的输入,并输入“10”

❹ 找到添加物品到库存的按钮,并点击它

❺ 找到表示库存中 cheesecake 数量的列表项

小贴士:因为如果你的测试无法找到这些元素,你的测试将失败,所以你不必明确断言每个元素都存在。默认情况下,getcontains 命令将断言元素存在。

你所编写的测试的问题在于它只会通过一次。在第一次运行此测试后,你的数据库中已经包含 cheesecake,因此每次后续运行中 cheesecake 的数量都将超过 10,导致测试失败。

要使此测试具有确定性,你有几个选择,如下所示:

  1. 模拟服务器对获取和添加物品的响应。

  2. 根据数据库中当前的内容计算应该添加的物品的预期数量。

  3. 在测试之前清空数据库。

个人而言,我认为第三种方法是我会推荐的方法。

第一种方法的问题在于它没有涵盖服务器的功能。因为它模拟了获取和添加物品的服务器响应,这将导致测试只验证前端功能。

对于我自己来说,我喜欢我的端到端测试尽可能地模拟用户的操作,这就是为什么我只在测试金字塔更底部的测试中使用测试替身。

此外,第一种方法将测试耦合到应用程序用于获取和添加物品的特定路由,而不是关注用户做什么以及他们的操作结果应该是什么。

第二种方法的问题在于它的语义将取决于它是否是第一次运行。第一次运行测试时,它将在数据库中添加一行新记录并将物品附加到列表中。第二次运行时,测试将更新数据库行和现有的物品列表。本质上,这些都是不同的测试用例。

我认为第三种方法比其他方法更好,因为它保证了每次测试运行时应用程序的状态将完全相同。此外,它总是测试相同的功能,并且实现和调试都更加简单。

重要提示:就像你编写其他类型的测试一样,你的端到端测试应该是确定的

要使此测试具有确定性,让我们创建一个新的函数来清空数据库的库存表。

因为这个函数包含在 Node.js 中运行的任意代码,而不是在浏览器中运行,所以它必须绑定到一个 任务。当你调用绑定到该函数的任务时,库存表将被截断。

在编写此函数之前,你需要使用 npm install --save-dev knex 安装 knex,以便能够连接到数据库。因为 knex 需要使用 sqlite3 包来连接到 SQLite 数据库,所以你也必须使用 npm install --save-dev sqlite3 来安装它。

NOTE 为了避免需要重新构建 sqlite3 包以与 Cypress 使用的 Electron 版本兼容,你必须配置 Cypress 使用你的系统 Node.js 可执行文件。

为了做到这一点,你需要在 cypress.json 中将 nodeVersion 设置为 system,如下所示。

列表 11.6 cypress.json

{
  "nodeVersion": "system"             ❶
}

❶ 配置 Cypress 使用系统的 Node.js 可执行文件

安装 knex 后,你需要将其配置为连接到应用程序数据库,就像你编写后端应用程序时做的那样。首先,在你的测试根文件夹中,创建一个包含连接配置的 knexfile

列表 11.7 knexfile.js

module.exports = {
  development: {
    client: "sqlite3",                                              ❶
    // This filename depends on your SQLite database location
    connection: { filename: "../../server/dev.sqlite" },            ❷
    useNullAsDefault: true                                          ❸
  }
};

❶ 使用 sqlite3 作为数据库客户端

❷ 指定应用程序将存储其数据的数据库文件

❸ 使用 NULL 而不是 DEFAULT 来处理未定义的键

然后,在同一个文件夹内创建一个负责连接数据库的文件。

列表 11.8 dbConnection.js

const environmentName = process.env.NODE_ENV;                  ❶
const knex = require("knex")
const knexConfig = require("./knexfile")[environmentName])     ❷

const db = knex(knexConfig);

const closeConnection = () => db.destroy();                    ❸

module.exports = {
  db,
  closeConnection
};

❶ 获取 NODE_ENV 环境变量的值

❷ 使用分配给 environmentName 的 NODE_ENV 值来确定选择哪个数据库配置

❸ 一个关闭数据库连接的函数

TIP 当你在与你的其他应用程序相同的文件夹中安装 Cypress 时,你可以直接引入这类文件,而不是重新编写它们。此外,你也不需要重新安装应用程序已经使用的依赖项。

个人来说,我喜欢在 cypress 文件夹内创建一个脚本来创建指向测试应用程序的代码库的符号链接。这些链接帮助我更容易地重用其他应用程序的代码,即使我保持我的 Cypress 测试与其他项目分离。

最后,你将在 plugins 文件夹中创建一个名为 dbPlugin.js 的新文件,以绑定截断库存表的函数到任务。

列表 11.9 dbPlugin.js

const dbPlugin = (on, config) => {
  on(                                                      ❶
    "task",
    { emptyInventory: () => db("inventory").truncate() },
    config
  );

  return config;
};

module.exports = dbPlugin;

❶ 定义一个任务,用于截断应用程序数据库中的库存表

NOTE on 函数和 config 对象都是由 Cypress 传递的。on 函数将任务注册在作为第二个参数传递的对象中,而 config 包含 Cypress 的配置,供你读取或更新。

要使此任务在测试中可用,你必须将其附加到 plugins 文件夹内的 index.js 中的 Cypress。在这里,你将传递 Cypress 的 on 函数及其 config 对象给 dbPlugin

列表 11.10 index.js

const dbPlugin = require("./dbPlugin");

module.exports = (on, config) => {
  dbPlugin(on, config);                      ❶
};

❶ 注册包含截断应用程序数据库中库存表任务的插件

现在,你可以将此任务附加到itemSubmission.spec.js文件中的beforeEach钩子,在每个测试之前截断inventory表。

列表 11.11 itemSubmission.spec.js

describe("item submission", () => {
  beforeEach(() => cy.task("emptyInventory"));        ❶

  it("can add items through the form", () => {
    // ...
  });
});

❶ 在每个测试之前截断应用程序的库存表

在这些更改之后,你的测试应该始终通过,因为inventory表将在每个测试之前被截断,如图 11.3 所示的流程图所示。

图 11.3 在每个测试之前,Cypress 将使用系统的 Node.js 可执行文件连接到数据库并截断包含库存项目的表。

要看到这个测试通过,运行 Cypress 并将NODE_ENV环境变量设置为development。通过使用NODE_ENV=development npm run cypress:open命令来运行 Cypress,它将连接到你的应用程序使用的相同数据库。

注意:在 Windows 上设置环境变量的语法与本书中使用的语法不同。因此,如果你使用 Windows,你需要稍微修改这些命令。

在 Windows 上,例如,打开命令应该是set NODE_ENV= development & cypress open。运行命令将是set NODE_ENV= development & cypress run

或者,如果你想立即运行测试而不是打开 Cypress UI,你可以使用我之前提到的带有NODE_ENV= development的 Cypress run命令来运行测试。

提示:在运行 Cypress 测试时,我建议你为端到端测试使用一个单独的数据库,这样它们就不会干扰你的本地开发环境。

要做到这一点,你必须向你的knexfile.js添加一个新的NODE_ENV配置条目,并在创建和迁移数据库、启动服务器以及运行测试时使用新的环境名称作为NODE_ENV

你将要编写的下一个测试将验证你应用程序的“撤销”功能。它将使用表单添加一个新项目到库存中,点击撤销按钮,并检查应用程序是否正确更新了项目列表。

要编写这个测试,你将使用与上一个测试相同的方法。你将使用visit来访问应用程序的页面;使用getcontains来查找按钮、输入和列表项;以及使用type来输入每个字段的信息。

这个测试中唯一的新方法是clear,它在输入信息之前负责清除quantity字段。

警告:在这个测试的行为阶段之后,在查找指示库存中有 10 个芝士蛋糕的操作日志条目时必须小心。

因为会有两个这样的条目,你必须使用断言来确保它们都存在。否则,如果你只使用getcontains,你将找到相同的元素两次,你的测试将通过,即使只有一个 10 个芝士蛋糕的操作日志条目。

列表 11.12 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("can undo submitted items", () => {
    cy.visit("http://localhost:8080");                    ❶
    cy.get('input[placeholder="Item name"]')              ❷
      .type("cheesecake");
    cy.get('input[placeholder="Quantity"]')               ❸
      .type("10");
    cy.get('button[type="submit"]')                       ❹
      .contains("Add to inventory")
      .click();

    cy.get('input[placeholder="Quantity"]')               ❺
      .clear()
      .type("5");
    cy.get('button[type="submit"]')
      .contains("Add to inventory")
      .click();                                           ❻

    cy.get("button")
      .contains("Undo")
      .click();                                           ❼

    cy.get("p")                                           ❽
      .then(p => {
        return Array.from(p).filter(p => {
          return p.innerText.includes(
            'The inventory has been updated - {"cheesecake":10}'
          );
        });
      })
      .should("have.length", 2);
  });
});

❶ 访问应用程序的页面

❷ 查找项目名称的输入框,并将“cheesecake”输入其中

❸ 找到项目数量的输入框,并输入“10”

❹ 找到添加项目到库存的按钮,并点击它

❺ 找到项目数量的输入框,清除它,并输入“5”

❻ 找到添加项目到库存的按钮,并再次点击它

❼ 找到撤销操作的按钮,并点击它

❽ 确保有两个操作日志条目指示库存包含 10 个芝士蛋糕

你将要编写的第三个测试是验证应用程序的操作日志。这个测试向库存中添加一个项目,点击撤销按钮,并检查操作日志是否包含正确的条目。

列表 11.13 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("saves each submission to the action log", () => {
    cy.visit("http://localhost:8080");                                 ❶
    cy.get('input[placeholder="Item name"]')                           ❷
      .type("cheesecake");
    cy.get('input[placeholder="Quantity"]')                            ❸
      .type("10");
    cy.get('button[type="submit"]')                                    ❹
      .contains("Add to inventory")
      .click();

    cy.get('input[placeholder="Quantity"]')                            ❺
      .clear()
      .type("5");
    cy.get('button[type="submit"]')                                    ❻
      .contains("Add to inventory")
      .click();

    cy.get("button")                                                   ❼
      .contains("Undo")
      .click();

    cy.contains(                                                       ❽
      "p",
      "The inventory has been updated - {}"
    );

    cy.get("p")                                                        ❾
      .then(p => {
        return Array.from(p).filter(p => {
          return p.innerText.includes(
            'The inventory has been updated - {"cheesecake":10}'
          );
        });
      })
      .should("have.length", 2);

    cy.contains(                                                       ❿
      "p",
      'The inventory has been updated - {"cheesecake":15}'
    );
  });
});

❶ 访问应用程序的页面

❷ 找到项目名称的输入框,并输入“cheesecake”

❸ 找到项目数量的输入框,并输入“10”

❹ 找到添加项目到库存的按钮,并点击它

❺ 找到项目数量的输入框,清除它,并输入“5”

❻ 找到添加项目到库存的按钮,并再次点击它

❼ 找到撤销操作的按钮,并点击它

❽ 找到指示库存已加载的操作日志条目

❾ 确保有两个操作日志条目指示库存包含 10 个芝士蛋糕

❿ 找到指示库存包含 15 个芝士蛋糕的操作日志条目

尽管这个测试执行了与上一个测试相同的操作,但保持你的断言分开是好的,这样你可以得到更细致的反馈。

如果你只是在上一个测试中检查操作日志,那么你需要更多的时间来理解测试失败是因为应用程序没有正确更新项目列表,还是因为操作日志没有包含预期的条目。此外,如果找到提交的项目在项目列表中失败,Cypress 不会执行检查操作日志的代码行。

当组织 Cypress 测试和编写断言时,你仍然可以应用第二章中看到的相同建议。

你将要编写的最后一个测试将验证当用户输入无效的项目名称时,应用程序是否会禁用表单的提交按钮。这个测试应该访问应用程序,将无效的项目名称输入到表单的第一个字段,并将有效的数量输入到表单的第二个字段,并断言提交按钮已被禁用。

由于 Cypress 没有内置断言命令来检查按钮是否被禁用,因此您必须编写一个显式的断言来验证这一点。

要在 Cypress 中编写断言,你需要在你想断言的元素上链式调用其 should 方法,并将所需的断言作为第一个参数传递,如下一段代码所示。

列表 11.14 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  describe("given a user enters an invalid item name", () => {
    it("disables the form's submission button", () => {
      cy.visit("http://localhost:8080");                           ❶
      cy.get('input[placeholder="Item name"]').type("boat");       ❷
      cy.get('input[placeholder="Quantity"]').type("10");          ❸
      cy.get('button[type="submit"]')                              ❹
        .contains("Add to inventory")
        .should("be.disabled");
    });
  });
});

❶ 访问应用程序的页面

❷ 找到项目名称的输入框,并输入“boat”

❸ 找到项目数量的输入框,并输入“10”

❹ 查找添加到库存的按钮,并期望它被禁用

就像命令一样,Cypress 会在执行任何后续命令之前重试断言,直到它们通过。

作为一项练习,尝试编写一个测试来验证你的应用是否可以更新库存中项目的数量。为此,首先创建一个可以给数据库种入几个芝士蛋糕的任务。然后,在你的测试中,在访问http:/./localhost:8080之前执行该任务,使用表单添加更多的芝士蛋糕到库存中,并检查应用是否正确更新了项目列表。

注意:你可以在本书的 GitHub 仓库中找到这个练习的解决方案,GitHub 仓库地址为github.com/lucasfcosta/testing-javascript-applications

11.1.3 发送 HTTP 请求

在测试你的应用时,你可能想要测试那些依赖于直接向你的服务器发送 HTTP 请求的功能。

例如,如果你需要设置初始状态或检查应用是否在用户与后端交互时更新,你可能想要这样做。

对于正在测试的应用程序,例如,你可能想要验证当其他用户添加项目时,项目列表是否更新。

为了验证应用是否在其他人添加项目时更新项目列表,你需要编写一个测试,该测试直接向服务器发送 HTTP 请求,并检查项目列表是否包含通过请求添加的项目。

你将把这个 HTTP 请求封装到一个新的command中,然后它将成为cy全局实例可用。

你将要编写的命令应该放入support文件夹中的commands.js文件。因为此命令将在浏览器中运行,所以你可以使用原生的浏览器 API,例如fetch

列表 11.15 commands.js

Cypress.Commands.add("addItem", (itemName, quantity) => {           ❶
  return fetch(`http://localhost:3000/inventory/${itemName}`, {     ❷
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ quantity })
  });
});

❶ 创建一个名为 addItem 的命令,其处理函数接受一个项目的名称和数量

❷ 使用原生的 fetch 函数向服务器路由发送带有项目名称和数量的 POST 请求,该路由添加项目到库存

或者,如果你不喜欢fetch,你可以安装另一个模块来执行 HTTP 请求,或者使用 Cypress 自己的request命令。

列表 11.16 commands.js

Cypress.Commands.add("addItem", (itemName, quantity) => {      ❶
  return cy.request({                                          ❷
    url: `http://localhost:3000/inventory/${itemName}`,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ quantity })
  });
});

❶ 创建一个名为 addItem 的命令,其处理函数接受一个项目的名称和数量

❷ 使用 Cypress 请求方法向服务器路由发送带有项目名称和数量的 POST 请求,该路由添加项目到库存

创建此命令后,你可以在验证应用是否在其他人添加项目时更新自身的测试中使用它。

integration文件夹中,创建一个名为itemListUpdates.spec .js的新文件,并编写一个测试,该测试访问http:/./localhost:8080,等待两秒钟以建立套接字连接,发送一个 HTTP 请求将蛋糕添加到库存中,并检查列表是否更新以包括通过服务器路由添加的蛋糕。

为了确保这个测试是确定的,请确保添加一个beforeEach钩子,在每个测试之前截断库存表。

列表 11.17 itemListUpdates.spec.js

describe("item list updates", () => {
  beforeEach(() => cy.task("emptyInventory"));             ❶

  describe("as other users add items", () => {
    it("updates the item list", () => {
      cy.visit("http://localhost:8080");                   ❷
      cy.wait(2000);                                       ❸
      cy.addItem("cheesecake", 22);                        ❹
      cy.contains("li", "cheesecake - Quantity: 22");      ❺
    });
  });
});

❶ 在每个测试之前截断应用程序数据库的库存表

❷ 访问应用程序的页面

❸ 等待两秒钟

❹ 向服务器发送请求以将 22 个蛋糕添加到库存中

❺ 找到表示库存中有 22 个蛋糕的项目列表

注意:通常不建议等待固定的时间。在“处理不可靠性”部分,你将了解为什么等待固定时间段是不够的,以及如何避免这样做。

你编写的测试准确地模拟了项目列表更新的必要条件,因此它为你提供了一个可靠的保证,即应用程序将显示新项目,就像其他用户添加它们一样(图 11.4)。

图 11.4 你的测试通过直接发送到后端的 HTTP 请求添加项目。然后服务器通过 WebSockets 与客户端通信以更新产品列表。

除了允许你检查你的应用程序如何对后端发生的行为做出反应之外,能够发送 HTTP 请求将使你能够在不直接将行插入数据库的情况下对数据库进行初始化。

通过发送 HTTP 请求,你可以创建实体,而无需将你的测试与数据库的模式耦合。此外,因为你的测试将依赖于路由以正确工作,所以你将间接通过测试覆盖它们。

例如,在测试库存管理软件时,你可以编写一个测试来验证当你打开应用程序时,应用程序是否加载初始项目列表。

这个测试将向数据库发送 HTTP 请求以添加一些项目,访问http:/./localhost:8080,并检查列表是否包含通过 HTTP 请求添加的项目。

列表 11.18 itemListUpdates.spec.js

describe("item list updates", () => {
  // ...

  describe("when the application loads for the first time", () => {
    it("loads the initial list of items", () => {
      cy.addItem("cheesecake", 2);                            ❶
      cy.addItem("apple pie", 5);                             ❷
      cy.addItem("carrot cake", 96);                          ❸
      cy.visit("http://localhost:8080");                      ❹

      cy.contains("li", "cheesecake - Quantity: 2");          ❺
      cy.contains("li", "apple pie - Quantity: 5");           ❻
      cy.contains("li", "carrot cake - Quantity: 96");        ❼
    });
  });

  // ...
});

❶ 向服务器发送请求以将 2 个蛋糕添加到库存中

❷ 向服务器发送请求以将 5 个苹果派添加到库存中

❸ 向库存中添加 96 个胡萝卜蛋糕

❹ 访问应用程序的页面

❺ 找到表示库存中有 2 个蛋糕的项目列表

❻ 找到表示库存中有 5 个蛋糕的项目列表

❼ 找到表示库存中有 96 个蛋糕的项目列表

作为练习,编写一个测试来验证服务器是否可以处理删除项目。这个测试应该通过 HTTP 请求将几个产品添加到数据库中,向 /inventory/:itemName 路由发送一个包含要删除的项目数量的 DELETE 请求,并刷新页面以检查项目列表是否显示了正确的可用项目数量。

11.1.4 动作排序

到目前为止,你编写的测试中的所有命令都不是同步的。然而,你不需要使用 await 或链式承诺来排序这些命令。在本小节中,你将了解这是如何可能的以及为什么会发生。

Cypress 的命令不是承诺。当你的测试开始时,Cypress 立即调用你编写的函数,并将与之绑定的操作添加到队列中。

Cypress 然后按照它们被添加到队列的顺序执行这些操作。一旦队列中的操作失败或队列中的所有操作都已完成,你的测试就会结束。

例如,考虑当 Cypress 执行验证客户是否可以使用应用程序表单添加项目的测试时会发生什么。

列表 11.19 itemListUpdates.spec.js

it("can add items through the form", () => {
  cy.visit("http://localhost:8080");                       ❶
  cy.get('input[placeholder="Item name"]')                 ❷
    .type("cheesecake");
  cy.get('input[placeholder="Quantity"]')                  ❸
    .type("10");
  cy.get('button[type="submit"]')                          ❹
    .contains("Add to inventory")
    .click();

  cy.contains("li", "cheesecake - Quantity: 10");          ❺
});

❶ 访问应用程序的页面

❷ 查找用于项目名称的输入,并将“cheesecake”输入其中

❸ 查找用于项目数量的输入,并将“10”输入其中

❹ 查找添加项目到库存的按钮,并点击它

❺ 查找表示库存中有 10 个芝士蛋糕的列表项

当 Cypress 运行这个测试时,它会立即从上到下逐行执行其每一行,但它不会将测试标记为完成。

要查看这种行为,在你的测试最后一行之后添加一个对 console.log 的调用,并在浏览器控制台打开的情况下执行它。当测试运行时,你会看到 Cypress 在执行测试的操作之前将消息记录到浏览器的控制台。

列表 11.20 itemListUpdates.spec.js

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {
    cy.visit("http://localhost:8080");                          ❶
    cy.get('input[placeholder="Item name"]')                    ❷
      .type("cheesecake");
    cy.get('input[placeholder="Quantity"]')                     ❸
      .type("10");
    cy.get('button[type="submit"]')                             ❹
      .contains("Add to inventory")
      .click();

    cy.contains("li", "cheesecake - Quantity: 10");             ❺
    console.log("Logged, but the test is still running");       ❻
  });

  // ...
});

❶ 访问应用程序的页面

❷ 查找用于项目名称的输入,并将“cheesecake”输入其中

❸ 查找用于项目数量的输入,并将“10”输入其中

❹ 查找添加项目到库存的按钮,并点击它

❺ 查找表示库存中有 10 个芝士蛋糕的列表项

❻ 记录一条消息到控制台,该消息在测试开始执行其操作之前被写入控制台

提示:当你阅读本节时,为了获得更快的反馈,我建议你单独运行你更新的测试。

要一次运行一个测试,请向其中添加一个 .only,如下一段代码所示。

列表 11.21 itemListUpdates.spec.js

describe("item submission", () => {
  // ...

  it.only("can add items through the form", () => {          ❶
    // ...
  });
});

❶ 在隔离状态下运行测试

测试的顺序或它们是否单独运行不会影响每个测试的动作队列。

Cypress 首先执行 console.log,因为它的命令不执行操作。相反,它们立即排队你想要执行的操作,如图 11.5 所示。

图片

图 11.5 Cypress 在运行测试的控制台日志之前排队命令,但命令仅在消息记录后执行。

即使你将其他动作链接到命令,Cypress 也保证整个命令链在执行下一个命令之前会运行到终止。

当你将type命令链接到get命令时,例如,type保证在get之后立即运行,并且在下一个动作开始之前运行。

要可视化 Cypress 执行链式动作的顺序,尝试使用命令的then方法将不同的消息写入控制台。通过这样做,你会看到这些动作链是串行运行的。

列表 11.22 itemListUpdates.spec.js

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {
    cy.visit("http://localhost:8080");                             ❶

    cy.get('input[placeholder="Item name"]')                       ❷
      .type("cheesecake")
      .then(() => console.log("Always the second message."));

    cy.get('input[placeholder="Quantity"]')                        ❸
      .type("10")
      .then(() => console.log("Always the third message."));

    cy.get('button[type="submit"]')                                ❹
      .contains("Add to inventory")
      .click();
      .then(() => console.log("Always the fourth message."));

    cy.contains("li", "cheesecake - Quantity: 10");                ❺
    console.log("This will always be the first message");          ❻
  });

  // ...
});

❶ 访问应用程序的页面

❷ 查找项目名称的输入,将其输入“cheesecake”,并在输入字段后立即将消息记录到控制台

❸ 查找项目数量的输入,将其输入“10”,并在输入字段后立即将消息记录到控制台

❹ 查找添加项目到库存的按钮,点击它,并在点击按钮后将消息记录到控制台

❺ 查找表示库存中有 10 个芝士蛋糕的列表项

❻ 在任何其他消息之前记录消息到控制台

提示:Cypress 命令有一个then方法,但它们不是承诺。承诺和命令都有then方法,但与承诺不同,Cypress 的命令不能并发运行

因为每个动作链都是串行运行的,Cypress 将保留添加到测试中的console.log语句的顺序,除了最后一个,它将立即执行而不是排队,因为它没有链接到任何动作。这些动作序列如图 11.6 所示。

图 11.6 每个排队的命令都附加了一个console.log。这些命令仅在测试的最终console.log调用之后执行。

如果 Cypress 的命令是承诺,第二个动作可能会在第一个动作完成之前完成,因此记录的消息的顺序会改变。

对于记录的消息,它们以不同的顺序记录没有问题,但想象一下,如果你将这些console.log调用替换为点击操作会发生什么。如果是这样,每次测试运行时,测试可能会以不同的顺序点击元素。

Cypress 的命令不是承诺,以确保测试的动作始终以相同的顺序发生。通过串行执行测试的动作,将更容易编写确定性的测试,因为你不需要手动排序事件。

用户的行为是串行的,Cypress 也是如此

注意:此外,为了避免一个测试干扰另一个测试,即使 Cypress 在测试完成后没有执行整个队列,测试的动作也不会传递到下一个测试。

11.2 端到端测试的最佳实践

请一位方法论的法国厨师教您如何制作甜点,他们会在触摸任何黄油之前花半天时间谈论mise en place原则的重要性。

这些厨师因其天赐的食物而闻名,因为他们知道最佳实践能带来卓越的结果。烘焙蛋糕对每个人来说都是一件容易的事。在那个著名的美食指南中拥有明星地位要难得多。

在本节中,我将向您传授最佳实践——即“ mise en place”的等价物——用于编写完全集成的端到端测试。

首先,我将解释为什么您不应该在测试中重复选择器,以及如何将它们封装到名为“页面对象”的模块中,以使测试更易于维护。我将向您介绍页面对象是什么,何时使用它们,以及采用这种技术的优势。

一旦您了解了页面对象,您将学习如何通过应用程序动作直接与应用程序的代码进行交互。除了解释它们是什么以及如何使用它们之外,我还将演示它们如何补充页面对象,以及何时选择一个或另一个。

最后,在本节的最后一部分,我将回顾前几章中提到的几个最佳实践,并解释它们如何应用于您正在编写的端到端测试。

11.2.1 页面对象

当使用页面对象模式时,您将使用一个单独的对象的方法,而不是在整个测试中重复选择器和动作。

重要页面对象是封装与页面交互的交互的对象。

将动作封装到单独的方法中而不是在整个测试中重复它们的主要优势是,如果您的页面结构发生变化,更新测试将更快。

例如,目前,您所有的测试都依赖于一个项目名称占位符的输入等于Item name。如果您更改该输入的占位符,所有依赖它的测试都将失败。为了修复它们,您将不得不更新每个使用该字段的测试中的该字段选择器,如图 11.7 所示。进行这种更改既繁琐又耗时。

图 11.7

图 11.7 如果页面发生变化,您之前使用的选择器将无法工作,您需要更新多个测试。

如果您将字段的选择器封装到方法中并在整个测试中重用该方法,如果字段的占位符发生变化,您只需更新该方法的主体,如图 11.8 所示。

图 11.8

图 11.8 通过将选择器集中到页面对象中,当您的选择器损坏时,您只需要更新页面对象。

通过将选择器封装到页面对象中,当页面结构发生变化需要更新选择器时,您需要做出的更改更少。

要了解如何编写这些页面对象,在cypress目录中创建一个名为inventoryManagement的新pageObjects文件夹,并为库存管理应用程序的主页面编写一个页面对象。

此页面对象应包括访问应用程序主页面、提交项目和在项目列表中查找产品的方法。

列表 11.23 inventoryManagement.js

export class InventoryManagement {
  static visit() {                                                         ❶
    cy.visit("http://localhost:8080");
  }

  static addItem(itemName, quantity) {                                     ❷
    cy.get('input[placeholder="Item name"]')
      .clear()
      .type(itemName);
    cy.get('input[placeholder="Quantity"]')
      .clear()
      .type(quantity);
    cy.get('button[type="submit"]')
      .contains("Add to inventory")
      .click();
  }

  static findItemEntry(itemName, quantity) {                               ❸
    return cy.contains("li", `${itemName} - Quantity: ${quantity}`);
  }
}

❶ 一个静态方法,用于访问应用程序的库存管理页面

❷ 一个静态方法,用于与页面元素交互以将项目添加到库存中

❸ 一个静态方法,用于在项目列表中查找项目的条目

一旦你编写了这个页面对象,开始更新你的测试,以便它们使用对象的这些方法与页面交互,而不是频繁地重复选择器和命令。

目前,这些方法足以更新itemSubmission.spec.js中的第一个测试,使其不直接包含任何选择器。

列表 11.24 itemSubmission.spec.js

import { InventoryManagement } from "./inventoryManagement";

describe("item submission", () => {
  beforeEach(() => cy.task("emptyInventory"));                  ❶

  it("can add items through the form", () => {
    InventoryManagement.visit();                                ❷
    InventoryManagement.addItem("cheesecake", "10");            ❸
    InventoryManagement.findItemEntry("cheesecake", "10");      ❹
  });

  // ...
});

❶ 在每个测试之前截断应用程序的库存表

❷ 访问应用程序的页面

❸ 与页面表单交互,将 10 个芝士蛋糕添加到库存中

❹ 查找项目条目,指示库存中有 10 个芝士蛋糕

注意:在前面章节的练习中,我建议你添加一个测试来验证在添加已存在的项目时,项目列表是否适当更新。

如果你为该练习编写了一个测试作为解决方案,你应该能够使用这些页面对象方法来更新那个测试。

要查看此练习的解决方案和更新的测试,请访问github.com/lucasfcosta/testing-javascript-applications,并检查名为chapter11的目录中的文件。

如果你在这之后运行测试,你会看到它们仍然通过,因为它们的行为与之前完全一样。

itemSubmission.spec.js中的下一个测试验证了撤销按钮的行为,但你的页面对象还没有一个方法来点击这个按钮。

为了消除测试中任何直接使用选择器的行为,请向你的页面对象添加另一个方法,如以下代码片段所示。此方法应找到撤销按钮并点击它。

列表 11.25 inventoryManagement.js

export class InventoryManagement {
  // ...

  static undo() {                    ❶
    return cy
      .get("button")
      .contains("Undo")
      .click();
  }
}

❶ 一个静态方法,用于点击页面的撤销按钮

创建此方法后,你可以更新itemSubmission.spec.js中的第三个测试,并消除任何直接使用选择器的行为。

列表 11.26 itemSubmission.spec.js

import { InventoryManagement } from "./inventoryManagement";

describe("item submission", () => {
  // ...

  it("can undo submitted items", () => {
    InventoryManagement.visit();                               ❶
    InventoryManagement.addItem("cheesecake", "10");           ❷
    InventoryManagement.addItem("cheesecake", "5");            ❸
    InventoryManagement.undo();                                ❹
    InventoryManagement.findItemEntry("cheesecake", "10");     ❺
  });

  // ...
});

❶ 访问应用程序的页面

❷ 与页面表单交互,将 10 个芝士蛋糕添加到库存中

❸ 与页面表单交互,将 5 个芝士蛋糕添加到库存中

❹ 点击撤销按钮

❺ 查找项目条目,指示库存中有 10 个芝士蛋糕

再次强调,你的测试应该仍然通过,正如在将动作封装到页面对象中时始终应该发生的那样。

在更新后的测试之后,你应该编写一个验证应用程序操作日志的测试。此测试提交项目,点击撤销按钮,并检查操作日志是否包含正确的条目。

为了避免在操作日志中的每个条目中重复选择器和所需文本,你将在页面对象中添加一个方法来查找操作日志中的条目。此方法应接受应包含在操作中的库存状态,并找到操作日志中所有相应的条目。

找到多个条目对于你能够断言重复条目的数量至关重要。例如,当用户点击撤销按钮,将库存恢复到之前的状态时,你会遇到这种情况。

列表 11.27 inventoryManagement.js

export class InventoryManagement {
  // ...

  static findAction(inventoryState) {                       ❶
    return cy.get("p:not(:nth-of-type(1))").then(p => {
      return Array.from(p).filter(p => {
        return p.innerText.includes(
          "The inventory has been updated - "
            + JSON.stringify(inventoryState)
        );
      });
    });
  }
}

❶ 一个静态方法,给定一个库存状态,在操作日志的段落中找到相应的操作

注意:我在选择器中使用了一个not伪类,以确保 Cypress 不会在页面的第一段中搜索所需的操作消息。页面的第一段包含一个错误消息,因此你必须跳过它。

使用这种方法后,更新itemSubmission.spec.js中的第四个测试,使其仅使用页面对象的方法,如下所示。

列表 11.28 itemSubmission.spec.js

import { InventoryManagement } from "./inventoryManagement";

describe("item submission", () => {
  // ...

  it("saves each submission to the action log", () => {
    InventoryManagement.visit();                                ❶
    InventoryManagement.addItem("cheesecake", "10");            ❷
    InventoryManagement.addItem("cheesecake", "5");             ❸
    InventoryManagement.undo();                                 ❹
    InventoryManagement.findItemEntry("cheesecake", "10");      ❺
    InventoryManagement.findAction({});                         ❻
    InventoryManagement.findAction({ cheesecake: 10 })          ❼
      .should("have.length", 2);
    InventoryManagement.findAction({ cheesecake: 15 });         ❽
  });

  // ...
});

❶ 访问应用程序的页面

❷ 通过页面库存添加 10 个芝士蛋糕

❸ 通过页面库存添加 5 个芝士蛋糕

❹ 点击撤销按钮

❺ 查找表示库存包含 10 个芝士蛋糕的项目条目

❻ 查找空库存的操作日志条目

❼ 确保有两个操作日志条目表示库存包含 10 个芝士蛋糕

❽ 查找表示库存有 15 个芝士蛋糕的操作日志条目

最后,你将自动化itemSubmission.spec.js中的最后一个测试,该测试将无效的项目名称输入到表单的一个字段中,并检查提交按钮是否被禁用。

目前,你的页面对象没有单独的方法来填充输入或查找提交按钮。因此,你需要重构页面对象。

在这种情况下,你应该为在表单字段中输入值和查找提交按钮创建单独的方法。

列表 11.29 inventoryManagement.js

export class InventoryManagement {
  // ...

  static enterItemName(itemName) {                          ❶
    return cy.get('input[placeholder="Item name"]')
      .clear()
      .type(itemName);
  }

  static enterQuantity(quantity) {                          ❷
    return cy.get('input[placeholder="Quantity"]')
      .clear()
      .type(quantity);
  }

  static getSubmitButton() {                                ❸
    return cy.get('button[type="submit"]')
      .contains("Add to inventory");
  }

  static addItem(itemName, quantity) {                      ❹
    cy.get('input[placeholder="Item name"]')
      .clear()
      .type(itemName);
    cy.get('input[placeholder="Quantity"]')
      .clear()
      .type(quantity);
    cy.get('button[type="submit"]')
      .contains("Add to inventory")
      .click();
  }

  // ...
}

❶ 一个静态方法,用于查找项目名称的输入,清除它,并将传递的名称输入其中

❷ 一个静态方法,用于查找项目数量的输入,清除它,并将传递的数量输入其中

❸ 一个静态方法,用于获取表单的提交按钮

❹ 一个静态方法,通过与页面表单交互向库存添加项目

创建这些方法后,你就有足够的内容来更新item-Submission.spec.js中的最后一个测试,使其不需要直接使用任何选择器。

列表 11.30 itemSubmission.spec.js

import { InventoryManagement } from "./inventoryManagement";

describe("item submission", () => {
  // ...

  describe("given a user enters an invalid item name", () => {
    it("disables the form's submission button", () => {
      InventoryManagement.visit();                                      ❶
      InventoryManagement.enterItemName("boat");                        ❷
      InventoryManagement.enterQuantity(10);                            ❸
      InventoryManagement.getSubmitButton().should("be.disabled");      ❹
    });
  });
});

❶ 访问应用程序的页面

❷ 在表单的项目名称字段中输入“boat”

❸ 在表单的数量字段中输入“10”

❹ 查找提交按钮,并期望它处于禁用状态

注意:在这种情况下,我选择在测试中编写断言,因为它与按钮的选择器无关。通过在测试中编写断言,我可以保持getSubmitButton按钮方法的灵活性,并在需要时将其链接到任何其他断言或操作。

你应该做的最后一个改进是在页面对象内部重用页面对象的方法。目前,如果你的输入占位符发生变化,例如,即使你不需要更新你的测试,你也必须更新页面对象的两个方法。

为了避免需要在多个地方更新选择器,重构你的页面对象,使其在addItem中使用enterItemNameenterQuantitygetSubmitButton方法。

列表 11.31 inventoryManagement.js

export class InventoryManagement {
  // ...

  static enterItemName(itemName) {                             ❶
    return cy.get('input[placeholder="Item name"]')
      .type(itemName);
  }

  static enterQuantity(quantity) {                             ❷
    return cy.get('input[placeholder="Quantity"]')
      .type(quantity);
  }

  static getSubmitButton() {                                   ❸
    return cy.get('button[type="submit"]')
      .contains("Add to inventory");
  }

  static addItem(itemName, quantity) {                         ❹
    InventoryManagement.enterItemName(itemName);
    InventoryManagement.enterQuantity(quantity);
    InventoryManagement.getSubmitButton().click();
  }

  // ...
}

❶ 一个静态方法,用于查找项目的名称输入框,清除它,并将传入的名称输入到其中

❷ 一个静态方法,用于查找项目的数量输入框,清除它,并将传入的数量输入到其中

❸ 获取表单的提交按钮的静态方法

❹ 一个静态方法,使用类自己的方法填写表单并提交它

通过使测试使用页面对象的方法,你可以避免需要更改多个测试来更新选择器。相反,当你需要更新选择器时,你只需更改页面对象的方法。通过减少保持测试更新所需的努力,你可以更快地进行更改,从而降低测试的成本。

注意:我个人更喜欢在我的页面对象中使用静态方法,并避免在其中存储任何状态。

共享页面对象实例是一个坏主意,因为它可能导致一个测试干扰另一个测试。

通过将页面对象仅作为模块来集中选择器,我可以使测试更容易调试。无状态页面对象使测试更容易调试,因为它们的每个函数总是执行相同的操作。当页面对象持有内部状态时,它们可能会根据其状态执行不同的操作。

多亏了 Cypress 内置的重试机制,编写无状态页面对象变得非常简单。你不必跟踪页面是否已加载或项目是否可见,你可以依赖 Cypress 重复查找项目,直到达到配置的超时时间。

除了集中选择器之外,因为页面对象将测试的操作与页面的语义而不是其结构相关联,它们使你的测试更易于阅读和维护。

提示:单个页面对象不一定需要代表整个页面。

当你有共享常见 UI 元素的页面时,你可以为这些共享元素创建一个单独的页面对象,并重用它。

想象一下,例如,你正在测试的应用程序仅在用户点击侧边菜单的“添加项目”选项时显示添加表单。

在那种情况下,你可以创建一个名为LateralMenuPage的页面对象,专门与侧边菜单交互,并在InventoryManagement页面对象中重用它或使其继承自LateralMenuPage,如下所示。

列表 11.32 lateralMenuPage.js

export class LateralMenuPage {                       ❶
  static openMenu() { /* ... */ }
  static clickMenuItem(name) { /* ... */ }
  static closeMenu() { /* ... */ }
}

❶ 代表多个页面中存在的元素的页面对象

列表 11.33 inventoryManagement.js

import { LateralMenuPage } from "./lateralMenuPage.js";

export class InventoryManagement extends LateralMenuPage {
  // ...

  static addItem(itemName, quantity) {             ❶
    LateralMenuPage.openMenu();
    LateralMenuPage.clickItem("Add items");
    LateralMenuPage.closeMenu();

    cy.get('input[placeholder="Item name"]')
      .clear()
      .type(itemName);
    cy.get('input[placeholder="Quantity"]')
      .clear()
      .type(quantity);
    cy.get('button[type="submit"]')
      .contains("Add to inventory")
      .click();
  }

  // ...
}

❶ 一种方法,它将项目添加到库存中,并使用 LateralMenuPage 页面对象与侧边菜单交互

换句话说,通过使用页面对象,你将根据它们的功能来编写测试,而不是根据页面的结构

作为一项练习,尝试更新itemListUpdates.spec.js文件中的测试,以便它们也使用InventoryManagement页面对象。

注意:要检查更新后使用InventoryManagement页面对象的itemListUpdates.spec.js文件中的测试将如何显示,请查看此书的 GitHub 仓库github.com/lucasfcosta/testing-javascript-applications

11.2.2 应用程序动作

应用程序动作允许你的测试直接与你的应用程序代码而不是其图形界面进行接口,如图 11.9 所示的示例。当在测试中使用应用程序动作时,你将不再在页面上查找元素并与它交互,而是调用应用程序代码中的一个函数。

使用应用程序动作将你的测试与页面结构解耦,这样你可以减少多个测试之间的重叠,因此可以获得更细粒度的反馈。

此外,你的测试将更快,因为它们不会依赖于等待元素可见或 Cypress 在表单字段中输入或点击按钮。

图 11.9 应用程序动作直接调用应用程序附加到全局窗口的函数。

例如,考虑通过库存管理应用程序的表单添加项目测试与撤销按钮测试之间的重叠。

列表 11.34 itemSubmission.spec.js

// ...

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {                    ❶
    InventoryManagement.visit();
    InventoryManagement.addItem("cheesecake", "10");
    InventoryManagement.findItemEntry("cheesecake", "10");
  });

  it("can undo submitted items", () => {                          ❷
    InventoryManagement.visit();
    InventoryManagement.addItem("cheesecake", "10");
    InventoryManagement.addItem("cheesecake", "5");
    InventoryManagement.undo();
    InventoryManagement.findItemEntry("cheesecake", "10");
  });

  // ...
});

❶ 一种与应用程序的表单交互以添加项目并期望项目列表包含 10 个芝士蛋糕条目的测试

❷ 一种通过应用程序的表单添加项目、点击撤销按钮并期望项目列表指示库存包含正确数量的芝士蛋糕的测试

在这种情况下,对撤销按钮的测试不仅依赖于撤销按钮本身,还依赖于表单的 UI 元素。

如果表单的选择器发生变化,或者如果应用程序无法与表单交互,例如,这两个测试都会失败。

通过将撤销按钮的测试与表单的 UI 元素解耦,当测试失败时,你几乎可以立即知道失败是因为撤销按钮的功能问题。

为了隔离一个测试与另一个测试,而不是使用表单来设置测试场景,你将使用一个 应用程序操作,该操作直接调用当用户提交表单时调用的应用程序的 handleAddItem 方法。

为了使你的测试能够调用此方法,在客户端的源代码中,更新 domController.js 文件,使其附加到全局 window 对象的函数直接调用带有事件的 handleAddItem,如下面的代码片段所示。

列表 11.35 domController.js

const handleAddItem = event => {                                  ❶
  // Prevent the page from reloading as it would by default
  event.preventDefault();

  const { name, quantity } = event.target.elements;
  addItem(name.value, parseInt(quantity.value, 10));

  updateItemList(data.inventory);
};

window.handleAddItem = (name, quantity) => {                      ❷
  const e = {
    preventDefault: () => {},
    target: {
      elements: {
        name: { value: name },
        quantity: { value: quantity }
      }
    }
  };

  return handleAddItem(e);
};

// ...

❶ 处理表单提交事件并添加项目到库存的函数

❷ 一个附加到窗口并直接使用包含传递的项目名称和数量的事件的 handleAddItem 函数调用的方法

TIP 理想情况下,当调用应用程序的操作过于复杂时,你应该考虑重构它。

在这种情况下,我可能会重构我的应用程序,以便我可以调用一个函数来添加项目,而无需手动复制事件的界面。

为了使本章简短且专注,我已手动在附加到 window 的函数中复制了事件的界面。

更新 domController.js 文件后,别忘了进入其文件夹并运行 npm run build 来重新构建你的客户端。

现在,由于 handleAddItem 通过 window 中的函数全局暴露,你可以更新撤销按钮的测试,以便它们直接调用窗口的 handleAddItem 函数,而不是使用表单的元素来设置测试场景。

列表 11.36 itemSubmission.spec.js

// ...

describe("item submission", () => {

  // ...

  it("can undo submitted items", () => {
    InventoryManagement.visit();                                     ❶
    cy.wait(1000);                                                   ❷

    cy.window().then(                                                ❸
      ({ handleAddItem }) => handleAddItem("cheesecake", 10)
    );
    cy.wait(1000);                                                   ❹

    cy.window().then(                                                ❺
      ({ handleAddItem }) => handleAddItem("cheesecake", 5)
    );
    cy.wait(1000);                                                   ❻

    InventoryManagement.undo();                                      ❼
    InventoryManagement.findItemEntry("cheesecake", "10");           ❽
  });

  // ...
});

访问应用程序的页面

等待一秒钟

❸ 直接调用窗口的 handleAddItem 方法,将 10 个芝士蛋糕添加到库存中

等待一秒钟

❺ 直接调用窗口的 handleAddItem 方法,将 5 个芝士蛋糕添加到库存中

等待一秒钟

点击撤销按钮

期望项目列表包含一个元素,指示库存中有 10 个芝士蛋糕

在这次更新之后,除了减少此测试与通过表单添加项目的测试之间的重叠之外,此测试的设置将更快,因为它不会依赖于等待 Cypress 与页面 UI 元素交互。相反,此测试将在其 arrange 阶段直接调用你的应用程序代码。

NOTE 使用应用程序操作使此测试如此高效,以至于应用程序在每次操作后无法快速更新。为了确保这些操作将在正确的时间发生,你将不得不等待几毫秒以等待应用程序更新。

如果不使用 cy.wait 来同步这些操作与应用程序的更新,你的测试将变得不可靠。

在下一节中,你将学习如何避免等待固定的时间量,并充分利用应用程序的操作。

使用应用程序动作的缺点是它们将您的测试与您的应用程序代码耦合,而不是将其与 UI 耦合。因此,如果您总是使用应用程序动作与页面交互,您的测试将无法准确模拟用户行为。

例如,如果您在整个库存管理系统测试中只使用了应用程序动作,即使您完全从页面上移除了所有表单元素,测试也会通过。

应用程序动作应该用于减少不同测试之间的重叠,而不是 完全消除页面对象。

在前面的例子中,您使用应用程序动作来减少重叠并使测试更快,因为您已经测试了页面的表单。因此,如果表单停止工作,您仍然会有一个失败的测试。

应用程序动作的一个完美用例是登录到您的应用程序。如果您的功能隐藏在身份验证屏幕后面,您不想在每次测试之前手动与身份验证表单交互。相反,为了使您的测试更快,并使其与身份验证表单的元素解耦,您可以使用应用程序动作直接登录到系统中。

TIP 当将方法分配给全局窗口时,您可以将这些分配包裹在一个 if 语句中,该语句在执行它们之前检查 window.Cypress 是否已定义。

列表 11.37 domController.js

if (window.Cypress) {
  window.handleAddItem = (name, quantity) => {
    // ...
  };
}

因为当 Cypress 运行测试时 window.Cypress 将为真值,所以当客户访问您的应用程序时,您的应用程序不会污染全局的 window

个人来说,我只在我的测试的 arrange 步骤中使用应用程序动作。对于 actassert,我使用页面对象的选择器。

11.3 处理不稳定性

在这本书中,我们欣赏的是不稳定的馅饼皮,而不是 不稳定的测试。

不幸的是,创建稳定的测试比烘焙不稳定的馅饼更具挑战性。为了实现前者,您需要应用多种技术。为了实现后者,一些叶猪油就足够了。

在本节中,我将介绍一些技术,以确保您的测试将是确定的:对于相同的测试单元,它们将 始终 产生相同的结果。

正如我在第五章中提到的,不稳定的测试会使您难以确定失败是由于应用程序存在错误还是因为您编写的测试不好。

不稳定的测试会降低您对测试——您的错误检测机制——能够识别错误的信心

为了展示如何消除不稳定的测试,我将向您展示一些测试可能间歇性失败的案例。一旦您体验过这种不稳定的特性,我将教您如何更新您的测试,使它们变得稳健和确定。

本节的前两部分涵盖了构建稳健和确定性的测试的两个最相关实践:避免等待固定的时间量,以及模拟您无法控制的因素。

本节最后部分解释了如何重试失败的测试,以及何时这样做是有价值的。

11.3.1 避免等待固定的时间量

作为经验法则,在每次使用 Cypress 时,你应该避免等待固定的时间。换句话说,使用cy.wait几乎总是个坏主意

等待固定的时间是一种不良做法,因为你的软件每次测试运行时可能需要不同的时间来响应。例如,如果你总是等待服务器在两秒内响应请求,如果它需要三秒,你的测试将会失败。即使你试图谨慎行事,等待四秒,这仍然不能保证你的测试会通过。

此外,通过增加这些等待期来尝试使测试确定,你将使你的测试变慢。如果你总是等待四秒来等待服务器响应,但实际上 95%的时间它在一秒内就响应了,那么大多数情况下你将不必要地延迟测试三秒。

而不是等待固定的时间,你应该等待条件满足

例如,如果你期望服务器响应请求后页面会包含一个新元素,不要在检查页面元素之前等待几秒钟。相反,配置测试在检测到新元素存在时继续。

为了演示这个原则,我将向你展示如何消除在验证应用程序撤销功能测试中等待固定时间量的需求。

目前,这个测试需要使用cy.wait。否则,它可能会在应用程序有机会处理上一个动作并相应更新之前就发出动作。

这个测试等待了不必要的时间,并且仍然不能保证应用程序在第二秒后有机会更新自己,尽管在绝大多数情况下它

例如,如果你尝试移除对cy.wait的调用并重新运行这个测试几次,你将能够看到它间歇性地失败。

备注:在我的机器上,移除对cy.wait的调用就足以看到测试有 50%的时间失败。

由于这个测试有 50%的时间会通过,所以在测试执行的 50%中存在不必要的三秒延迟。

而不是等待固定的时间,你可以等待满足某些条件。

在这种情况下,在每次动作之后,你可以等待动作日志随着每个发出的动作更新。

列表 11.38 itemSubmission.spec.js

describe("item submission", () => {
  // ...
  it("can undo submitted items", () => {
    InventoryManagement.visit();                              ❶
    InventoryManagement.findAction({});                       ❷

    cy.window().then(({ handleAddItem }) => {                 ❸
      handleAddItem("cheesecake", 10)
    });
    InventoryManagement.findAction({ cheesecake: 10 });       ❹

    cy.window().then(({ handleAddItem }) => {                 ❺
      handleAddItem("cheesecake", 5)
    });
    InventoryManagement.findAction({ cheesecake: 15 });       ❻

    InventoryManagement.undo();                               ❼
    InventoryManagement.findItemEntry("cheesecake", "10");    ❽
  });
});

❶ 访问应用程序的页面

❷ 在继续之前,持续尝试找到动作日志条目,表明应用程序已加载为空库存

❸ 直接调用窗口的 handleAddItem 方法向库存中添加 10 个芝士蛋糕

❹ 在继续之前,持续尝试找到动作日志条目,表明应用程序包含 10 个芝士蛋糕

❺ 直接调用窗口的 handleAddItem 方法向库存中添加 5 个芝士蛋糕

❻ 在继续之前,持续尝试查找一个动作日志条目,指示应用程序包含 15 个芝士蛋糕

❼ 点击撤销按钮

❽ 期望项目列表包含一个指示库存中有 10 个芝士蛋糕的元素

在此更改之后,Cypress 将在允许测试继续执行下一个操作之前,持续重试在动作日志中找到必要的操作。

多亏了这个更新,你将能够根据应用程序何时准备好处理它们来按顺序排列测试的操作,而不是总是等待相同的时间量。

等待应用程序更新到所需状态可以消除不可靠性,因为它确保你的操作在正确的时间发生——当你的应用程序准备好接受和处理它们时。

此外,这种做法会使你的测试运行得更快,因为它们将只等待必要的最短时间。

等待 HTTP 请求

除了等待元素出现外,你的 Cypress 测试还可以监控 HTTP 请求,并在继续之前等待它们解决。

要等待一个请求解决,你可以使用 Cypress 的 cy.server 方法开始拦截请求,并使用 cy.route 来确定处理特定路由请求的具体操作。

要了解 cy.servercy.route 的工作原理,你将更新验证项目列表是否在其他用户添加项目时更新的测试。

你将通过等待对 /inventory 的初始 GET 请求解决来消除该测试的 cy.wait 调用。通过等待那个初始请求解决,你确保你的测试只有在你的应用程序准备好接收更新时才会继续。

为了使测试等待对 /inventoryGET 请求解决,你必须调用 cy.server 以能够拦截请求,然后链式调用 Cypress 的 route 方法来指定它应该拦截所有对 http:/./localhost:3000/inventory 的请求。

一旦你配置了要拦截哪个路由,你将链式调用 Cypress 的 as 方法来创建一个 alias,通过它你将能够稍后检索请求。

列表 11.39 itemListUpdates.spec.js

import { InventoryManagement } from "../pageObjects/inventoryManagement";

describe("item list updates", () => {
  // ...

  describe("as other users add items", () => {
    it("updates the item list", () => {
      cy.server()                                     ❶
        .route("http://localhost:3000/inventory")
        .as("inventoryRequest");

      // ....
    });
  });
});

❶ 拦截对 http:/./localhost:3000/inventory 的请求,并为 inventoryRequest 创建别名

注意:你正在测试的应用程序使用浏览器的 fetch 方法执行对 /inventory 的请求。

因为在撰写本文时,Cypress 默认不会监视浏览器的 fetch 方法,所以你需要更新 cypress.json 配置文件,使其将 experimentalFetchPolyfill 选项设置为 true

列表 11.40 cypress.json

{
  "nodeVersion": "system",
  "experimentalFetchPolyfill": true          ❶
}

❶ 允许 Cypress 拦截使用 fetch 发出的请求

在此更新之后,你将能够拦截对 /inventoryGET 请求,但你还没有配置你的测试在添加项目之前等待此请求解决。

为了使你的测试等待那个初始的GET请求解决,在访问http:/./localhost:8080后添加一个cy.wait调用。这个cy.wait调用将等待一个别名匹配inventoryRequest的请求解决,而不是等待固定的时间。

通过等待请求解决,你不再需要等待固定的时间了。

列表 11.41 itemListUpdates.spec.js

import { InventoryManagement } from "../pageObjects/inventoryManagement";

describe("item list updates", () => {
  // ...

  describe("as other users add items", () => {
    it("updates the item list", () => {
      cy.server()                                                ❶
        .route("http://localhost:3000/inventory")
        .as("inventoryRequest");
      cy.visit("http://localhost:8080");                         ❷
      cy.wait("@inventoryRequest");                              ❸
      cy.addItem("cheesecake", 22);                              ❹
      InventoryManagement.findItemEntry("cheesecake", "22");     ❺
    });
  });
});

❶ 截获对 http:/./localhost:3000/inventory 的请求,并为匹配的请求分配别名 inventoryRequest

❷ 访问应用程序的页面

❸ 等待对 http:/./localhost:3000/inventory 的请求解决,以便应用程序处于测试发生的正确初始状态

❹ 向库存发送一个 HTTP 请求以添加 22 个芝士蛋糕

❺ 期望项目列表包含一个元素,表明库存有 22 个芝士蛋糕

这种更改导致你的测试总是通过,并且不需要你的测试等待固定的时间,这可能是浪费的,如果请求解决时间过长,可能会导致你的测试不稳定。

11.3.2 存根不可控因素

你不能为不可控的行为编写健壮的测试。要编写确定性的测试,你必须在可以对结果进行断言之前,使被测试的单元可预测。

例如,如果你的应用程序向第三方 API 发出 HTTP 请求,第三方 API 可以随时更改其响应或变得不可用,因此,即使被测试的单元没有改变,你的测试也会失败。

与你在其他类型的测试中存根不可控行为的方式类似,为了有确定性的测试,在编写完全集成的 UI 测试时,你需要存根不可控的行为

在基于 UI 的端到端测试中使用存根与其他类型的测试之间的区别在于,在第一种情况下,你不应该使用存根来隔离应用程序的不同部分。你在这章中编写的测试的主要目标是尽可能准确地模拟用户的行为,而存根将破坏这个目的。

重要 当编写基于 UI 的端到端测试时,你应该使用存根来使你的测试具有确定性,而不是来隔离应用程序的不同部分。

在本小节中,我将向你展示你需要在基于 UI 的端到端测试中使用存根的三个最常见情况:当你的应用程序依赖于时间、当它依赖于你无法控制的 API 资源,以及当它依赖于像Math.random()这样的不可预测方法。

使用假定时器

与你在第四章中看到的假定时器类似,Cypress 的假定时器允许你控制与时间相关的方法。例如,如果你的应用程序使用setTimeout来安排一个函数或使用Date构造函数来获取当前时间,你可以使用假定时器来控制这些函数所依赖的时钟。

通过使用模拟计时器,你将能够控制预定函数何时运行,并且将知道 Date 构造函数将返回的确切值。

由于你使用了模拟计时器来模拟你无法控制的因素——依赖于时间的函数——你将能够使你的测试具有确定性。

为了了解你如何使用模拟计时器为依赖于时间的应用程序编写确定性测试,假设你的每个操作日志条目都必须包含一个时间戳,以便系统操作员知道每个操作发生的时间。

列表 11.42 domController.js

const { addItem, data } = require("./inventoryController");

const updateItemList = inventory => {
  // ...

  const inventoryContents = JSON.stringify(inventory);
  const p = window.document.createElement("p");
  p.innerHTML = `[${new Date().toISOString()}]` +                 ❶
    ` The inventory has been updated - ${inventoryContents}`;

  window.document.body.appendChild(p);
};

// ...

❶ 将当前日期和时间添加到每个新的操作日志条目的开头

注意:你可以在本书 GitHub 仓库的 chapter11 目录中找到的 client 文件夹已经包含了此更新。github.com/lucasfcosta/testing-javascript-applications

如果你正在自己更新第六章的客户端应用程序,请确保在此更改后再次运行 npm run build

在此更改之后,你的测试仍然会通过,因为每个操作日志条目仍然会包含与之前相同的文本,但它们将无法验证每个条目中的日期,因为日期每次运行测试时都会不同。

例如,如果你尝试更新页面对象的 findAction 方法以检查每个操作日志条目中的日期,你的测试将会失败。

注意:现在你将使用时间戳来查找每个操作日志条目,你不再需要返回多个条目。相反,你只需使用 getcontains,因为当你推进时间时,时间戳保证你将在每次查询时找到不同的条目。

列表 11.43 itemSubmission.spec.js

export class InventoryManagement {
  // ...

  static findAction(inventoryState) {
    return cy                                             ❶
      .get("p:not(:nth-of-type(1))")
      .contains(
        `[${new Date().toISOString()}]` +
          " The inventory has been updated - " +
          JSON.stringify(inventoryState)
      );
  }
}

❶ 通过库存状态查找操作日志条目,这些条目还包括当前日期和时间

为了使你的测试再次通过并断言每个操作日志条目中的日期,你必须使时间确定并对其有控制权。

与你在第四章中所做的那样,你将使用模拟计时器来模拟应用程序的时间函数。

为了演示你如何修复测试,我将使用一个示例来验证应用程序的每个操作日志条目,如下面的代码片段所示。

列表 11.44 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("saves each submission to the action log", () => {
    InventoryManagement.visit();                                    ❶
    InventoryManagement.addItem("cheesecake", "10");                ❷
    InventoryManagement.addItem("cheesecake", "5");                 ❸
    InventoryManagement.undo();                                     ❹
    InventoryManagement.findItemEntry("cheesecake", "10");          ❺

    InventoryManagement.findAction({});                             ❻
    InventoryManagement.findAction({ cheesecake: 10 });             ❼
    InventoryManagement.findAction({ cheesecake: 15 });             ❽
    InventoryManagement.findAction({ cheesecake: 10 });             ❾
  });

  // ...
});

❶ 访问应用程序的页面

❷ 使用应用程序的表单将 10 个芝士蛋糕添加到库存中

❸ 使用应用程序的表单将 5 个芝士蛋糕添加到库存中

❹ 点击撤销按钮

❺ 期望项目列表包含一个指示库存有 10 个芝士蛋糕的元素

❻ 找到一个操作日志条目,指示库存为空

❼ 找到一个操作日志条目,指示库存有 10 个芝士蛋糕

❽ 找到一个操作日志条目,指示库存有 15 个芝士蛋糕

❾ 找到一个操作日志条目,指示库存有 10 个芝士蛋糕

在 Cypress 中,要安装模拟计时器,你必须使用 cy.clock。一旦 Cypress 安装了模拟计时器,你就可以使用 cy.tick 控制它们。

请将全局 beforeEach 钩子添加到 support 文件夹中的 index.js 文件,以便在每个测试之前安装模拟计时器,并使用 fakeTimers 作为其别名。

列表 11.45 index.js

beforeEach(() => cy.clock(Date.now()).as("fakeTimers"));         ❶

❶ 在每个测试之前,安装始终返回模拟计时器安装时时间的模拟计时器

注意:Cypress 在测试之间自动重置模拟时钟,因此你不需要自己这样做。

在将你的全局钩子添加到此文件后,更新你的页面对象中的 findAction 方法,使其在检查日期时使用 Cypress 的来自模拟计时器的时间。

列表 11.46 domController.js

export class InventoryManagement {

  // ...
  static findAction(inventoryState) {
    return cy.clock(c => {                                    ❶
      const dateStr = new Date(fakeTimer.details().now)       ❷
        .toISOString();

      return cy                                               ❸
        .get("p:not(:nth-of-type(1))")
        .contains(
          `[${dateStr}]` +
            " The inventory has been updated - " +
            JSON.stringify(inventoryState)
        );
    });
  }
}

❶ 获取表示已安装的模拟计时器的对象,并使用它们调用传入的函数

❷ 获取模拟计时器的当前时间,并将其转换为 ISO 8601 兼容的字符串

❸ 查找一个包含模拟计时器的当前时间和传入的库存状态的操作日志条目

在此更改之后,所有你的测试都应该通过,因为 dateStr 将始终具有相同的值。

此外,如果你希望使你的测试更加彻底,你可以在操作之间使用 tick 生成具有不同时间的操作日志条目,如本代码摘录所示。

列表 11.47 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("saves each submission to the action log", () => {
    InventoryManagement.visit();
    InventoryManagement.findAction({});                      ❶
    cy.clock().tick(1000);                                   ❷

    InventoryManagement.addItem("cheesecake", "10");         ❸
    InventoryManagement.findAction({ cheesecake: 10 });      ❹
    cy.clock().tick(1000);                                   ❺

    InventoryManagement.addItem("cheesecake", "5");          ❻
    InventoryManagement.findAction({ cheesecake: 15 });      ❼
    cy.clock().tick(1000);                                   ❽

    InventoryManagement.undo();                              ❾

    InventoryManagement.findItemEntry("cheesecake", "10");   ❿
    InventoryManagement.findAction({ cheesecake: 10 });      ⓫
  });

  // ...
});

❶ 查找一个操作日志条目,指示库存为空,并且它有模拟计时器的当前时间

❷ 将模拟计时器向前推进一秒

❸ 使用应用程序的表单将 10 个芝士蛋糕添加到库存中

❹ 查找一个操作日志条目,指示库存中有 10 个芝士蛋糕,并且它有模拟计时器的当前时间

❺ 将模拟计时器向前推进一秒

❻ 使用应用程序的表单将五个芝士蛋糕添加到库存中

❷ 查找一个操作日志条目,指示库存中有 15 个芝士蛋糕,并且它有模拟计时器的当前时间

❽ 将模拟计时器向前推进一秒

❾ 点击撤销按钮

❿ 预期项目列表包含一个元素,指示库存中有 10 个芝士蛋糕

⓫ 查找一个操作日志条目,指示库存中有 10 个芝士蛋糕,并且它有模拟计时器的当前时间

模拟 API 请求

当你的应用程序向一个你无法控制的 API 发送请求时,该 API 可能随时更改其响应,或者在一小段时间内不可用,导致你的测试失败,即使你的应用程序没有更改。

例如,假设你想要在每个库存项目中包含指向 Recipe Puppy API 中某个菜谱的链接。

要包含该链接,你的应用程序需要向服务器的 /inventory/:itemName 路径发送一个 GET 请求,该请求反过来向 Recipe Puppy API 发送请求以获取菜谱并将其嵌入到响应中。

列表 11.48 domController.js

const { API_ADDRESS, addItem, data } = require("./inventoryController");

const updateItemList = inventory => {
  // ....

  Object.entries(inventory).forEach(async ([itemName, quantity]) => {
    const listItem = window.document.createElement("li");
    const listLink = window.document.createElement("a");
    listItem.appendChild(listLink);                                   ❶

    const recipeResponse = await fetch(                               ❷
      `${API_ADDRESS}/inventory/${itemName}`
    );
    const recipeHref = (await recipeResponse.json())                  ❸
      .recipes[0]
      .href;
    listLink.innerHTML = `${itemName} - Quantity: ${quantity}`;
    listLink.href = recipeHref;                                       ❹

    // ...
  });

  // ...
};

❶ 将锚点标签添加到应用程序将附加到列表中的列表项

❷ 从 Recipe Puppy API 获取包含菜谱的库存项目条目

❸ 获取响应列表中的第一个菜谱的 URL

❹ 将锚标签的 href 属性设置为菜谱的 URL

注意:如果你自己更新第六章的应用程序,请确保在此更改后再次运行 npm run build

此外,为了避免即将售罄的项目因为锚标签的默认样式而变成蓝色,更新客户端的 index.html 文件中的样式,使 a 标签继承 color

列表 11.49 index.html

<html lang="en">
  <head>
    <!-- ... -->
    <style>
      /* ... */

      a {
        color: inherit;
      }
    </style>
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

如果你打算实现一个简单的验证,你可以添加一个通过应用程序操作添加项目并检查新项目的 href 的测试。当检查项目的 href 时,测试假设其值将保持不变。

列表 11.50 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {
    InventoryManagement.visit();
    InventoryManagement.addItem("cheesecake", "10");
    InventoryManagement.findItemEntry("cheesecake", "10")         ❶
      .get("a")
      .should(
        "have.attr",
        "href",
        "http://example.com/the-same-url-seen-on-the-browser"
      );
  });

  // ...
});

❶ 期望列表项中的锚标签包含一个指向来自 Recipe Puppy API 的菜谱 URL 的 href 属性

这个测试的问题在于它可能会失败,因为你无法控制 Recipe Puppy API 将提供的响应。例如,如果第一个菜谱的 URL 发生变化,你的测试将失败,即使你没有更改你的应用程序代码,它仍然可以完美运行。

要解决这个问题,你可以使用 Cypress 的 cy.servercy.route 方法来拦截这个请求,阻止它到达服务器,并提供一个预定义的响应。

因为 Cypress 在每个测试之间重置你的服务器配置,所以你必须在一个 beforeEach 钩子中配置你的预定义响应,以便它适用于该测试套件中的每个测试,如下一段代码所示。

列表 11.51 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  beforeEach(() => {
    cy.server();
    cy.route("GET", "/inventory/cheesecake", {                             ❶
      recipes: [
        { href: "http://example.com/always-the-same-url/first-recipe" },
        { href: "http://example.com/always-the-same-url/second-recipe" },
        { href: "http://example.com/always-the-same-url/third-recipe" },
      ]
    });
  });

  it("can add items through the form", () => {
    InventoryManagement.visit();
    InventoryManagement.addItem("cheesecake", "10");
    InventoryManagement.findItemEntry("cheesecake", "10")                  ❷
      .get("a")
      .should(
        "have.attr",
        "href",
        "http://example.com/always-the-same-url/first-recipe"
      );
  });

  // ...
});

❶ 拦截发送到 /inventory/cheesecake 的 GET 请求,并对它们始终提供相同的响应

❷ 期望项目列表中的项目始终包含预定义响应中定义的 URL

更新后,你的测试将始终通过,因为它不再依赖于 Recipe Puppy API。相反,你已经使用了 cy.servercy.route 来完全控制你的服务器响应。

注意:另外,你也可以允许请求到达服务器并操纵其响应。在这种情况下,例如,我本可以让服务器响应,并为 Cypress 提供一个回调来操纵服务器的响应并仅更改来自 RecipePuppy API 的字段。

操作你服务器响应的缺点是服务器仍然需要到达 Recipe Puppy API。因此,如果 Recipe Puppy API 提供的响应是服务器无法处理的,测试将失败。

除了不会使你的测试完全确定之外,如果你需要提供凭证来调用第三方 API,运行测试的过程将变得更加复杂,因为每个想要运行测试的人都需要访问这些凭证。

除了使测试过程更加复杂,需要在测试期间访问凭证可能会泄露你的密钥的机密性。

此外,如果你必须为 API 调用付费,你的测试将产生额外的费用。

除了为路由提供一个预定义的响应外,您还可以使用cy.server来触发其他情况下难以模拟的边缘情况。通过使用此技术,例如,你可以模拟服务器不可用或其响应延迟的情况。

模拟这些类型的场景允许你验证你的应用程序是否能够处理错误,而无需在你的测试中引入不可预测性或更改待测试应用程序的代码。

尽管使用模拟可以帮助防止测试不稳定,但你应谨慎使用此技术,以避免损害测试的范围,从而影响其可靠性。

当你模拟一个请求的响应时,你限制了测试的范围,并阻止你的测试覆盖服务器上的任何代码。例如,如果你模拟了库存管理应用程序发出的每个 HTTP 请求,那么你将无法捕捉到当有人提交表单时,服务器返回错误数量项所引起的错误。

要确定你是否应该模拟一个 HTTP 请求,你必须考虑你是否能控制请求的响应。如果你无法预测请求的响应将是什么,你应该使用模拟来使你的测试具有确定性。在其他所有情况下,我建议尽可能避免使用模拟——除非你只想测试你的 UI 专门

模拟函数

你无法测试依赖于一个你无法预测其结果的函数的代码。例如,你不能为依赖于不可预测的Math.random的代码编写确定性测试。对于不可预测的代码,无法编写确定性测试,因为这些情况下,你无法确定预期的结果应该是什么。

假设,例如,你不必总是使用服务器返回的第一个菜谱的 URL 来列出项目,而是必须从列表中随机选择一个菜谱并使用其 URL。

列表 11.52 domController.js

const { API_ADDRESS, addItem, data } = require("./inventoryController");

const updateItemList = inventory => {
  // ....

  Object.entries(inventory).forEach(async ([itemName, quantity]) => {
    const listItem = window.document.createElement("li");
    const listLink = window.document.createElement("a");
    listItem.appendChild(listLink);

    const recipeResponse = await fetch(`${API_ADDR}/inventory/${itemName}`);
    const recipeList = (await recipeResponse.json()).recipes;
    const randomRecipe = Math.floor(                                 ❶
      Math.random() * recipeList.length - 1
    ) + 1;
    listLink.innerHTML = `${itemName} - Quantity: ${quantity}`;
    listLink.href = recipeList[randomRecipe]                         ❷
      ? recipeList[randomRecipe].href
      : "#";

    // ...
  });

  // ...
};

❶ 从服务器获取的菜谱列表中选择一个代表索引的随机数字

❷ 使用随机索引来选择在列表项中使用的哪个菜谱的 URL

注意:您可以在本书 GitHub 仓库的chapter11文件夹中找到的客户端github.com/lucasfcosta/testing-javascript-applications已经包含了这个更新。

如果你正在自己更新第六章的应用程序,请确保在此更改后再次运行npm run build

在这种情况下,即使你可以预测服务器的响应,你也无法预测应用程序将选择响应中的哪个菜谱。这种不可预测性导致你的测试变得不稳定。

为了解决这个问题,您将存根 Math.random 以确定其结果,从而能够预测您的列表项将指向哪个链接。

在 Cypress 中,可以通过 cy.stub 来进行存根,它将对象作为第一个参数,将方法名称作为第二个参数。

请继续在您的测试中使用 cy.stub,使 window 对象的 Math.random 方法始终返回 0.5,这将导致您的应用程序始终选择服务器响应中的第二个菜谱。

列表 11.53 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {
    InventoryManagement.visit();
    cy.window()                                   ❶
      .then(w => cy.stub(w.Math, "random")
      .returns(0.5));

    // ...
  });

  // ...
});

❶ 导致窗口的 Math.random 方法始终返回 0.5

在存根 Math.random 之后,您将能够确定应用程序将使用哪个 URL。

现在,更新您的断言,使其期望列表项的 href 属性与第二个菜谱的 URL 相对应。

列表 11.54 itemSubmission.spec.js

describe("item submission", () => {
  // ...

  it("can add items through the form", () => {
    InventoryManagement.visit();
    cy.window().then(w => cy.stub(w.Math, "random").returns(0.5));    ❶

    InventoryManagement.addItem("cheesecake", "10");
    InventoryManagement.findItemEntry("cheesecake", "10")             ❷
      .get("a")
      .should(
        "have.attr",
        "href",
        "http://example.com/always-the-same-url/second-recipe"
      );
  });

  // ...
});

❶ 导致窗口的 Math.random 方法始终返回 0.5

❷ 期望列表项的锚标签中的 href 属性等于预定义响应中的第二个 URL

此更改后,您的测试将变得确定化,因为 Math.random 将始终产生相同的结果。只要测试中的应用程序保持不变,您的测试应该会通过。

每当您有无法预测返回值的函数时,您应该对它们进行存根处理,以便您可以确定预期的结果。

除了使不确定的方法确定化之外,您还可以使用存根来防止调用原生方法,如窗口的 confirmprompt 方法,并向它们提供预定义的响应。

因为存根也包括间谍的功能,所以您可以检查这些方法是否被调用,并对它们的用法进行断言。

小贴士:除了使用存根之外,您还可以在 Cypress 测试中使用间谍。如前所述,间谍保留方法的原实现,但对其添加了仪器,以便您可以检查方法的用法。

您可以在 Cypress 的官方指南中找到有关存根和间谍的说明,请参阅 docs.cypress.io/guides/guides/stubs-spies-and-clocks.html

当您需要存根导致客户端导航到不同页面的 API 或您的测试必须模拟方法失败时,Cypress 的存根非常有用。

个人来说,我尽量避免使用存根,因为在编写基于 UI 的端到端测试时,我希望尽可能准确地模拟应用程序的运行时行为,以获得可靠的保证。

通过存根方法,我将防止应用程序在没有我的干预下表现正常,因此我可能无法捕获在运行时可能发生的错误。

我建议您仅在处理不确定行为或处理无法或不想与之交互的本地功能时使用存根,例如触发警报或提示、管理导航事件或模拟您无法以其他方式模拟的失败。

11.3.3 重试测试

例如,假设你发现了一些测试是易变的,但你目前没有时间修复它们,因为你有一个紧急功能要交付。

现在离圣诞节还有几周时间,面包店的员工估计,通过提供一个允许客户定制圣诞甜点的功能,他们可以将 12 月的利润翻三倍。

在这种情况下,你不想因为不得不花费时间使测试确定性而使面包店错失这样重要的收入机会。因此,你将专注于专门于功能,而不是更新你的测试。

及时交付这个功能会对业务产生立即的、显著的、可衡量的积极影响。另一方面,使测试确定性的则是长期投资。

尽管如此,在构建新功能时,你仍然希望从测试中提取价值,因为它们可以节省你进行手动测试所需的时间。此外,这些测试会使你更有信心,因为你没有破坏你的应用程序。

为了从你的测试中提取价值,即使它们是易变的,你也可以配置你的端到端测试工具,在测试失败时重试它们。

如果你确信你的测试大多数时候都能通过,而且它们失败只是因为不确定的行为,你可以尝试两次或三次,并防范你的测试在每次尝试中都失败的可能性。

例如,假设在实现新功能时,你意外引入了一个错误,导致每个购物车的价格在结账时都翻倍。只要这个错误在每次结账时都发生,无论你重试多少次,你的测试都会失败。

另一方面,如果客户的购物车价格可能只有一半的时间是正确的,重试失败的测试可能会导致这个错误被忽视。

例如,如果你的测试因为这种错误失败了两次,但在第三次幸运地没有发生这种错误时通过了,你的测试源的不确定性产生了你测试预期的结果,你的测试不会提醒你。

重要易变的测试只能捕获一致的错误。你的测试无法区分由错误引起的失败和由编写不良的测试引起的失败。

尽管无法可靠地捕获这些类型的间歇性错误确实是一个问题,但仍然比没有测试要好。

一旦你完成了面包店需要的紧急圣诞功能,路易斯无疑会非常高兴地给你一个礼物:时间来使测试确定性,这样你下次可以更快、更有信心地行动。

如你所见,决定你是否应该重试你的测试,这更多是一个商业决策,而不是技术决策。

在理想的软件世界中,每个测试都应该是确定性的,这样它就可以可靠地捕获错误。在现实世界中,企业的优先事项是增加收入和降低成本,你必须平衡拥有完美测试的成本和从它们中提取的价值。

通过配置你的端到端测试工具来重试失败的测试,你可以推迟投入大量时间和精力来使你的测试变得确定性。

延迟这种投资可以使你在处理更紧急任务的同时,仍然从你编写的测试中提取价值。

尽管如此,重试测试不应被视为长期解决方案。相反,它是一种临时策略,使你的团队能够在不完全牺牲测试套件的情况下处理其他优先事项。

从长远来看,重试测试可能会导致间歇性错误被忽视,因此这种策略将逐渐削弱你团队对其测试套件的信心。

理想情况下,我建议你只在有紧迫的截止日期要满足且无法花时间更新测试的时期启用测试重试。一旦你满足了这些截止日期,你应该争取时间来使你的测试变得确定性,这样你就可以继续快速且自信地前进。一般来说,我会建议你大多数时间保持测试重试关闭。

解释了为什么测试重试是有帮助的之后,我将向你展示如何启用它们。

要配置 Cypress 在测试失败时应该尝试重新运行测试的次数,你可以使用 runModeopenMode 选项。这些选项应该位于分配给 cypress.json 配置文件中 retries 属性的对象内。

openMode 选项确定当使用 cypress open 命令通过 Cypress 的 GUI 运行测试时,Cypress 应该重试你的测试多少次。runMode 选项确定当使用 cypress run 命令无头运行测试时的重试次数。

要在运行 cypress run 时重试测试三次,但在使用 Cypress 的 GUI 时禁用重试,例如,将 runMode 选项设置为三,将 openMode 选项设置为零,如下所示。

列表 11.55 cypress.json

{
  "nodeVersion": "system",
  "experimentalFetchPolyfill": true
  "retries": {
    "runMode": 3,                     ❶
    "openMode": 0                     ❷
  }
}

❶ 配置 Cypress 在执行其二进制文件的 run 命令时重试失败的测试三次

❷ 配置 Cypress 在执行其二进制文件的 open 命令时不要重新运行失败的测试

或者,如果你确切知道哪些测试由于不确定性行为的原因而失败,你可以配置 Cypress 仅重试这些测试。

列表 11.56 example.spec.js

describe(
  "indeterministic test suite",
  { retries: { runMode: 3, openMode: 0 } },          ❶
  () => {
    // ...
  }
);

describe("deterministic test suite", () => {
  // ...
});

❶ 配置 Cypress 在无头运行时在这个 describe 块中重试失败的测试三次,而在使用 Cypress 的 UI 时不重试失败的测试

仅对少数测试启用重试比对所有测试启用重试要好得多。通过选择性地启用此功能,禁用此功能的测试仍然能够捕获间歇性错误。

选择性地启用测试重试可以使你的整个测试套件更加彻底,并减少重试测试对你的错误检测机制可能产生的影响。

11.4 在多个浏览器上运行测试

冰淇淋:我几乎没有遇到过不喜欢它的人。可能有些人可能并不热爱它,但问问别人关于冰淇淋,他们会告诉你他们最喜欢的口味。

因为有这么多风味可供选择,冰淇淋是一种满足所有受众的甜点。

同样,网络浏览器也有多种“风味”。有些比其他更受欢迎,但无论如何,每个人都可以选择他们最喜欢的浏览器。

浏览器和冰淇淋之间的另一个相似之处在于,无论你选择哪种口味,你都会期望它是好的。无论是追求经典的香草锥还是异国情调的阿萨伊浆果,它最好让你的眼睛发光。

当谈到浏览器时,客户有类似的期望。他们期望无论他们通过传统的 Internet Explorer 还是较新的 Chrome 和 Firefox 访问,你的应用程序都能正常工作。

对于网络来说,存在这么多不同的浏览器真是太好了,因为它们创造了一个健康、竞争的环境。致力于这些令人惊叹的复杂软件的才华横溢的人们技术高超,并且总是寻找更好、更高效的方法来解决问题。

有这么多不同选项的问题在于,尽管它们的作者非常聪明,但并非所有浏览器都以相同的方式运行 JavaScript。随着新功能的推出,一些浏览器可能比其他浏览器更快地发布它们,并且在这些功能的实现中可能存在错误或误解。

这些差异导致相同的程序在一个浏览器中产生特定的结果,而在另一个浏览器中则产生完全不同的结果。

为了避免你的程序根据客户的浏览器表现不同,你应该在不同的环境中测试你的应用程序,这样你就可以捕捉到与特定浏览器相关的任何故障。

尽管在多种浏览器中测试你的应用程序很重要,但你并不一定需要支持所有浏览器。

而不是试图支持所有可能的浏览器,你应该检查你的客户倾向于使用哪些浏览器,并定义你应该支持哪些。一旦你做出了这个决定,你就可以专注于只在你和你的业务感兴趣的环境中运行测试。

注意:为了网络的福祉,在我看来,这是迄今为止人类最重要的工程成就,我建议读者支持多个浏览器。

通过给用户选择权,你有助于使网络更加民主,并促进一个多组织可以合作创新和定义标准的竞争环境。

为了在多个浏览器中测试你的应用程序,开发者通常采用的两种最常见策略是在浏览器内运行测试套件,或者让整个应用程序在浏览器内运行,然后使用 Cypress 或 Selenium 等工具进行测试。

在浏览器内运行测试套件的主要优势是可以进行更细粒度的测试,因此可以获得更精确的反馈。此外,如果你可以在浏览器和 Node.js 中执行相同的测试套件,你可以通过不必为不同的工具编写新测试来降低成本。

在浏览器内运行测试套件的问题在于,根据你使用的测试工具,你的工具可能不受浏览器支持,因为它们依赖于仅在 Node.js 这样的平台上可用的资源。例如,如果你的测试运行器需要访问文件系统,它将无法在浏览器中运行。

另一方面,通过控制真实浏览器来运行基于 UI 的测试,虽然更耗时,但结果更可靠。因为它们的范围更广,并且能更准确地模拟用户的操作,所以它们更能代表应用程序的运行时行为。

在决定采用哪种方法时,你必须考虑你可用的时间和每种策略将带来的价值。

如果你有时间和使用 Selenium 或 Cypress 等工具编写测试,我建议你这样做。否则,仅仅配置你的现有测试套件以在浏览器中运行就已经是一个很好的开始了。在浏览器中运行现有测试将带来相当大的价值,在某些情况下,可能只需要最小的努力。

11.4.1 在浏览器内使用测试框架运行测试

要在浏览器内执行现有的测试套件,我建议你使用像 Karma 这样的工具。

Karma 启动一个 Web 服务器,该服务器提供包含你的代码和测试(包括运行它们的测试框架)的页面。通过将浏览器指向该页面,你可以在浏览器内执行你的测试。

随着测试的运行,浏览器通过 WebSocket 与 Karma 服务器通信,以报告通过和失败的测试。

不幸的是,本书中主要使用的 Jest 工具无法轻松地在浏览器中执行,因为它依赖于 Node.js 特定的资源。

如果你希望这样做,我强烈建议你考虑 Mocha,这是第二受欢迎的 JavaScript 测试运行器。

由于 Mocha 不依赖于 Node.js 特定的资源,它可以在浏览器中运行。此外,它的 API 几乎与 Jest 相同。

因此,如果你的项目将运行多个浏览器作为优先事项,从 Jest 迁移到 Mocha 将会很容易。

当考虑迁移测试时,您还必须考虑您对仅存在于 Jest 中的功能的依赖程度。鉴于 Mocha 不包括管理测试替身和运行断言的工具,您将不得不考虑其他工具,如 Sinon、Proxyquire 和 Chai。

如果迁移测试对您来说太耗时,那么使用 Cypress 或 Selenium 等工具编写不同测试可能成本更低且更有益。否则,如果您不依赖这些功能,或者您正在启动一个新项目,Mocha 可能是一个可行的解决方案。

11.4.2 在多个浏览器中运行基于 UI 的测试

如果您决定在多个浏览器中运行基于 UI 的测试,您必须观察哪些工具可以与您希望支持的浏览器进行接口。

例如,Cypress 只能在 Edge、Firefox、Chrome 和 Electron 中运行测试。因此,如果您需要支持 Internet Explorer,您将需要选择不同的工具。

由于近年来浏览器之间的兼容性变得更好,即使您决定仅在 Edge、Chrome 和 Firefox 中运行测试,您仍然可以获得可靠的结果。再次强调,在这种情况下,您需要考虑您的时间和资源,以确定使用像 Cypress 这样的友好工具(它允许您快速编写测试)是否值得,或者您是否需要一个更复杂但功能更全面的解决方案。

在可用的替代方案中,Selenium 是提供多浏览器最佳支持的解决方案。由于其架构将浏览器自动化 API 与控制不同浏览器的驱动程序解耦,因此更容易找到您想要针对的浏览器的驱动程序。

除了可用的各种驱动程序之外,您还可以使用 Selenium Grid 在多台机器上并行运行测试,每台机器运行不同的浏览器,并基于不同的操作系统。

除了在多个环境中运行测试提供更多灵活性之外,Grid 通过在多台机器上并行运行测试来加速测试执行。

采用 Selenium 的主要缺点是设置运行测试所需的必要基础设施,以及分配创建测试所需的时间和资源,因为它们通常更难以编写。

11.5 视觉回归测试

展示很重要。多年来,随着路易斯经营他的烘焙业务,他注意到,他投入更多时间使甜点看起来更有吸引力,他就能卖出更多。

在构建 Web 应用程序时,以及当烘焙一盒诱人的泡芙时,外观是使客户体验愉悦的核心部分。反过来,愉快的体验往往会导致客户更加投入,从而带来更好的商业结果。

为了确保您的应用程序看起来正确,从而产生更好的商业结果,视觉回归测试是您可以采用的最有价值的实践之一。

视觉回归测试专注于你的应用程序的外观。为了确保你的应用程序正确显示,这类测试会将你的应用程序的外观与之前批准的快照进行比较。创建这些测试的过程与使用 Jest 的快照验证组件样式的过程类似。

视觉回归测试与组件样式的 Jest 快照测试之间的主要区别在于,视觉回归测试比较的是图像,而不是字符串。

使用 Jest 编写这类测试的问题在于,由于它们验证一组 CSS 规则,如果例如另一个组件的边距太大,导致节点移动到错误的位置,它们不会中断。当这种情况发生时,由于前一个组件的样式仍然与现有的快照匹配,你的测试仍然会通过。

另一方面,当你在这种场景下编写视觉回归测试时,因为这些测试比较的是图像,所以当有变化发生时,你会收到提醒。

我最喜欢的编写这类测试的工具是 Percy,你可以在docs.percy.io找到它的文档。

除了与 Cypress 轻松集成外,它还促进了组织内多个团队之间的协作。此外,它使视觉回归测试变得简单,因此,通过快速交付有价值的测试,有助于你降低成本。

在本节中,你将学习如何通过创建一个测试来验证你的应用程序是否会将即将售罄的项目着色为红色来使用 Percy 编写视觉回归测试。

要使用 Percy 编写视觉回归测试,你必须首先使用npm install --save-dev @percy/cypress安装其 Cypress 特定模块。

安装后,你需要在位于 Cypress 的support目录中的command.js文件顶部导入它。

列表 11.57 commands.js

import '@percy/cypress'         ❶

// ...

❶ 导入 Percy 的 Cypress 模块

除了在commands.js中导入必要的模块外,Percy 还要求你向 Cypress 注册一个名为percyHealthCheck的任务,并将其分配给 Percy 的@percy/cypress/task命名空间的主导出。

要注册此任务,你需要在 Cypress 的plugins目录中的index.js文件中添加以下代码。

列表 11.58 index.js

const percyHealthCheck = require("@percy/cypress/task");
const dbPlugin = require("./dbPlugin");

module.exports = (on, config) => {
  dbPlugin(on, config);
  on("task", percyHealthCheck);                    ❶
};

❶ 将 Percy 的健康检查任务注册为 Cypress 任务

在这两个更改之后,使用 Percy 编写视觉回归测试就像在测试中想要拍摄快照时调用cy.percySnapshot一样简单。

在验证列表项颜色的测试案例中,你可以编写一个测试,将单个奶酪蛋糕单元种入数据库,访问你的应用程序页面,等待列表更新,并调用cy.percySnapshot。你可以在integration文件夹中名为itemList.spec.js的新文件中编写这个测试。

列表 11.59 itemList.spec.js

import { InventoryManagement } from "../pageObjects/inventoryManagement";

describe("item list", () => {
  beforeEach(() => cy.task("emptyInventory"));

  it("can update an item's quantity", () => {
    cy.task("seedItem", { itemName: "cheesecake", quantity: 1 });    ❶
    InventoryManagement.visit();                                     ❷
    InventoryManagement.findItemEntry("cheesecake", "1");            ❸
    cy.percySnapshot();                                              ❹
  });
});

❶ 在应用程序数据库中种入单个奶酪蛋糕

❷ 访问应用程序页面

❸ 在项目列表中找到一个条目,表明库存中有 1 个芝士蛋糕

❹ 使用 Percy 在该时间点捕获页面的快照

最后,为了执行此测试并能够比较 Percy 的快照,你必须登录到Percy.io并获取你项目的令牌,你将在运行测试时将其分配给环境变量。

注意:在运行此测试之前,请确保你在一个名为master的 git 分支上。

如果你还没有创建一个用于编写本书代码示例的仓库,你可以使用git init来创建一个。

一旦你获得了令牌,将其分配给名为PERCY_TOKEN的环境变量。在 Linux 和 Mac 上,你可以在终端运行export PERCY_TOKEN =your_token_here来做到这一点。

现在,尝试使用NODE_ENV=development ./node_modules/.bin/percy exec—npm run cypress:run来运行你的测试。

提示:你可以通过更新你的 NPM 脚本来使此命令更容易记住,这样在运行 Cypress 测试时就会使用 Percy。

列表 11.60 package.json

{
  "name": "4_visual_regression_tests",
  // ...
  "scripts": {
    // ...
    "cypress:run": "NODE_ENV=development percy exec -- cypress run"      ❶
  },
  ...
}

❶ 将 Cypress 的运行命令包装到 Percy 中,以便 Percy 可以将其快照上传到其平台

记住要安全地保存你的 Percy 令牌,并且不要将其添加到你的package .json文件中。

当这个测试运行时,Percy 会检测你所在的 git 分支,并且默认情况下,如果你在master分支上,它将认为所拍摄的快照是你期望应用程序从这里开始的外观。

无论何时你在测试中捕获快照,Percy 都会将其上传到其服务。一旦它上传了这些快照,除了master分支之外的其他分支上的每个测试执行默认情况下都会与在master分支上发生的测试执行的快照进行比较。

在合并提交之前,你可以要求工程师或其他利益相关者登录到 Percy 的服务以批准新的快照。

让其他利益相关者批准这些快照可以增加每个部署的信心,并允许团队成员轻松验证应用程序是否如他们所期望,而无需产生通信开销。

注意:我建议通过将其集成到你的 pull request 审批流程中来强制执行此规则。理想情况下,在团队可以合并任何 pull request 之前,应该有一个自动检查来确保任何视觉差异已被批准。要了解更多关于如何做到这一点,请访问 Percy 的文档docs.percy.io

一旦你有机会在master分支上执行你的测试,以查看 Percy 的审批流程看起来如何,你可以使用例如git checkout -b new-feature来检查另一个分支,并重新运行你的测试。

在另一个分支上重新运行测试后,登录到 Percy 的平台,你会看到最新的构建表示需要批准,因为快照不匹配。

通过点击查看两个快照的并排比较,你会注意到它们不匹配,因为每个快照中的操作日志条目中都有一个不同的时间戳。

为了解决这个问题并使你的新快照自动获得批准,你可以模拟时间戳使其始终相同,或者你可以应用特定的 CSS 规则,在运行于 Percy 时将操作日志的可见性设置为 hidden

由于我之前已经演示了如何在 Cypress 中使用模拟计时器,在本节中,我将使用不同的策略,并在 index.html 中编写一些 CSS 代码来隐藏测试中的操作日志条目。

为了这个目的,我的 CSS 将使用一个媒体查询,只为 Percy 隐藏项目。

列表 11.61 index.html

<html lang="en">
  <head>
    <!-- ... -->
    <style>
      /* ... */

      @media only percy {                   ❶
        p:not(:first-child) {
          visibility: hidden;
        }
      }
    </style>
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

❶ 仅在 Percy 的快照中隐藏操作日志条目

master 分支上执行测试后,该测试快照将被上传到 Percy,你将得到一个稳定的快照,你可以将其与未来的测试执行进行比较。

多亏了 Percy,如果你不小心应用了一个改变页面项目颜色的 CSS 规则,你将被要求审查不同的快照并批准它们。此外,如果开发者做出了意外的视觉更改,产品经理可以介入并拒绝在 Percy 上批准快照,防止不想要的代码被合并。

个人而言,我喜欢使用 Percy,因为它易于设置,并且它便于与组织中的非技术成员协作。

如果你想要使用开源解决方案,你可以使用像 jest-image-snapshot 这样的模块,它允许 Jest 序列化和比较图像快照。

摘要

  • 你在本章中编写测试所使用的工具 Cypress,其 API 中内置了可重试性。当断言失败,或者它最初找不到元素时,它将不断重试,直到达到最大超时时间。

  • 当使用 Cypress 时,你可以通过创建可以直接发送 HTTP 请求到你的服务器的命令,或者注册可以在 Node.js 中运行的任务(例如,初始化或清除数据库表)来减少测试之间的重叠。

  • 尽管 Cypress 命令有 .then 方法,但它们不是承诺。Cypress 的命令将动作收集到一个队列中,该队列始终按顺序执行。这个设计决策使得编写能够准确模拟用户行为的测试变得容易,并防止动作并发运行。

  • 你可以通过将页面结构封装到页面对象中来降低测试的维护成本。页面对象集中管理页面选择器和交互,这样当你的应用程序更新时,你不需要更改多个测试。相反,当你的页面发生变化时,你只需更新你的页面对象。此外,页面对象使测试更加易于阅读。

  • 为了加速测试的执行并减少多个测试之间的重叠,你可以使用页面操作。页面操作将直接调用你的应用程序的功能,因此可以帮助你避免有各种测试依赖于你已经在其他地方验证过的相同 UI 部分。此外,由于调用函数比填写表单和按按钮更快,页面操作会使你的测试更快。

  • 不确定的测试会削弱你对测试套件的信心,因为当你有不确定的测试时,你无法确定测试失败是因为不确定行为的来源还是因为你的应用程序有错误。

  • 大多数时候,你可以通过利用 Cypress 的可靠性功能和它的存根,以及应用我们之前介绍的其他技术来消除不确定性。这些技术中包括确保你的测试不会相互干扰,并且你正在使用健壮的选择器来查找元素。

  • 在你的测试中,不要等待固定的时间,而应该等待条件满足。通过等待元素出现或请求解决,你可以避免测试中的不必要延迟,并使测试变得确定,因为它们不再依赖于服务器的性能。

  • 如果你有一个依赖于第三方 API 的测试,你可以模拟它们的响应,这样无论这些 API 是否不可用或它们改变了响应的格式,你的测试仍然可以通过。

  • 如果你没有时间修复不确定性的测试,你应该考虑配置 Cypress 重新尝试失败的测试。尽管你不应该将此解决方案视为永久性的,因为它会削弱你对测试套件的信心,但它是一种仍然可以从测试中提取价值而不产生更新它们的成本的优秀方法。

  • 为了支持各种浏览器,你必须在其中运行你的测试。要运行不同浏览器的测试,你可以配置你的测试套件以在多个浏览器中运行,或者使用像 Cypress 这样的工具来在不同的环境中执行你想要支持的 UI 测试。

  • 视觉回归测试允许你捕捉应用程序中的视觉不一致。与 Jest 不同,这些测试的快照比较的是图像,而不是字符串。因此,当例如组件的样式干扰另一个组件时,它们可以提醒你。

第三部分. 商业影响

第三部分为你在第一部分和第二部分中烘焙的蛋糕增添了最后的装饰。

它教你额外的工具和技术,以放大你之前编写的测试的影响。

第十二章涵盖了持续集成和持续交付。在其中,你将学习这些实践是什么,如何应用它们,它们如何帮助你以更短的时间交付更好的软件,以及为什么测试对于你成功采用这些实践至关重要。

本书最后一章,第十三章,讨论了帮助你培养质量文化的辅助工具和技术。它描述了文档和监控对你的软件的影响,解释了类型如何帮助你捕捉错误并使你的测试更高效,教你如何进行有效的代码审查,并阐明这些审查如何提高你的代码质量。

12 持续集成和持续交付

本章涵盖

  • 持续集成(CI)和持续交付(CD)

  • 采用 CI 和 CD 的原因

  • 测试在构建 CI/CD 管道中的作用

  • 版本控制检查

  • 采用版本控制检查的优势

路易斯总是告诉他的员工在互相交流之前不要烘烤食谱的不同部分。制作闪电泡芙松饼皮的糕点师通常会与其他制作糕点奶油的人以及制作巧克力糖衣的人交流。

当糕点师们不互相交流时,他们会通过尽可能多地制作糕点奶油或只烘烤所需的最少量来提高效率。这种缺乏沟通的问题在于,他们往往会因为制作过多的奶油或过少的奶油而浪费原料。如果他们制作过多,多余的奶油就会被扔掉,因为路易斯不卖任何不新鲜的东西。如果他们制作过少,那么被扔掉的就是松饼皮,因为奶油不够用来填充每个闪电泡芙。

另一方面,当他的员工一起工作时,他们会就制作多少个闪电泡芙以及糖衣应该是什么味道达成一致。他们一次烘烤一批,并持续地将每个人的工作组合起来制作新鲜的闪电泡芙。

通过早期和频繁地结合他们的工作,可以减少浪费,因为这保证了糕点的风味与奶油相得益彰,糖衣的甜度理想,能够衬托其他部分。

当开发者长时间在自己的分支上编写代码而不与其他人的工作集成时,后果是相似的。

长时间孤立工作的开发者建立在不稳定的基础上。如果他们长时间独立工作,等到他们尝试将他们的工作合并到项目的主要分支时,他们所建立的基础代码可能已经发生变化。

由于孤立工作的开发者整合工作的时间过长,他们最终不得不进行大量的返工,同时解决太多冲突,这既昂贵又风险高,令人沮丧。

通过持续集成他们的工作,开发者减少了他们必须做的返工量,因为他们一直在确保他们所建立的基础假设是可靠的。如果有什么变化,他们可以更早地纠正方向,避免不必要的劳动,从而降低成本。

使糕点师和软件生产者更高效的一个重要部分是减少从有人想出食谱到它出现在顾客盘子上所需的时间。

通过更早和更频繁地交付,糕点师可以调整他们的食谱,使其变得成功。在构建软件方面,早期和频繁的发布导致更少的返工,因为这允许团队更早地获得客户反馈,因此可以更多地了解接下来要构建的内容。

在本章中,你将了解更多关于这两种实践:持续集成(CI)和持续交付(CD),后者依赖于前者。

我将首先解释持续集成、持续交付以及这两种实践如何相互关联。除了了解它们是什么之外,我还会讨论它们的优点以及如何应用它们。

一旦你了解了这两种实践,我将解释测试在构建 CI/CD 管道中的关键作用,以及将测试与这些实践相结合如何帮助你更快、更少挫折、更经济地生产更好的软件。

最后,在本章的第三部分,我将讨论如何将检查集成到你的版本控制系统中,以及它们如何补充你已学到的实践。

在本节中,你将通过了解我是如何解决这本书所喜爱的糕点师在建立其业务在线分支时面临的假设情况来学习。

注意:持续集成和持续交付是广泛的主题。本章仅涵盖你开始并理解在采用这两种实践时测试的作用所必需的基本信息。

为了深入了解这两个主题,我推荐以下书籍:Paul M. Duvall、Steve Matyas 和 Andrew Glover 合著的《持续集成:提高软件质量和降低风险》(Addison-Wesley Professional,2007 年);以及 Jez Humble 和 David Farley 合著的《持续交付:通过构建、测试和部署自动化实现可靠的软件发布》(Addison-Wesley Professional,2010 年)。

12.1 什么是持续集成和持续交付?

成功的企业通过频繁沟通、快速迭代、尽早交付、倾听反馈并据此行动来取悦客户。这些企业专注于交付令人愉悦的成品,而不是不断开发平庸的新功能。

这些成功行为的核心是两种实践:持续交付和持续集成。

进行持续集成意味着频繁地将你的工作与其他人的工作集成在一起。持续交付意味着尽早和经常地向客户交付产品。

在本节中,你将了解这两种实践如何帮助企业成功,以及如何应用它们。

12.1.1 持续集成

路易的蛋糕总是能逗得顾客开心。他们的馅料、糖衣和面糊的味道总是和谐。尽管每个部分都是由单独的糕点师制作的,但这些糕点师经常互相交流,并尽可能早地尝试将他们的工作结合起来,以确保成分搭配得当。

通过经常更新其他人他们正在烘焙的内容,糕点师可以确保他们烘焙的是正确的数量、正确的东西、正确的时间。例如,他们不会烘焙巧克力糖霜然后不得不丢弃它,因为他们的合作伙伴正在烘焙香草芝士蛋糕。否则,那将是一个奇怪的芝士蛋糕。

此外,通过一起品尝蛋糕的不同部分,糕点师可以判断糖霜是否需要减少糖分以与额外的甜馅料相协调,反之亦然。

早期结合成分可以减少浪费和返工。如果蛋糕的不同部分不搭配,糕点师在发现这一点之前花费的时间会更少,而且不需要丢弃大量他的工作。

这种实践——持续集成——很容易应用到软件世界中,并产生类似的好处。

想象一下,你正在实现一个允许客户将折扣券应用于订单的功能。为了实现这个功能,你从项目的主分支分叉出来,并开始在自己的功能分支上提交代码。

当你正在处理折扣券机制时,另一位开发者正在自动折扣批量订单。这位开发者的工作干扰了你的工作,因为它改变了折扣工作的一些基本原理。

如果你的团队实践持续集成,你将避免在集成这两个更改时需要进行大量重工作。

持续集成让工作不再令人沮丧,更加可预测,因为当合并分支与你的折扣券实现时,你将遇到 fewer surprises。

你不会在做了整整一周的工作后才发现第二天所做的工作已经不再有效,你将频繁地将你的工作与项目的主分支集成,你的同事也是如此。因此,你将在开发功能的过程中不断纠正方向。

在软件工程中,不断将你的工作与项目的主分支集成相当于沟通你所做的更改,这样每个人都可以始终在软件的有效版本上工作。

一个主要的注意事项是,集成工作所需的时间越长,检测问题所需的时间就越长。此外,如果集成工作耗时,这种实践将不会那么高效,开发者也不会那么频繁地集成工作。

因此,为了使集成工作尽可能快且痛苦最小,你应该自动化尽可能多的任务,以便在有任何更改时更容易验证软件。你应该自动化的任务包括执行静态分析、强制执行代码风格、运行测试和构建项目,如图 12.1 中的管道所示。

小贴士:除了这些自动化检查之外,我建议团队在将代码合并到项目的主分支之前采用代码审查流程。

一旦这些任务自动化,开发者只需几秒钟就能知道他们的工作与项目主分支的更改结合后是否有效。你不需要将质量控制委托给另一个团队,而是将其纳入你的构建过程中,以便你可以缩短你的反馈循环。

图 12.1

图 12.1 在执行持续集成时,你的 CI 服务器将自动分析、构建和测试你提交的每个更改。

在自动化你的质量控制流程并将它们纳入你的构建过程之后,每当开发者将工作推送到项目仓库时,持续集成服务器应执行这些自动化任务,以持续监控软件是否按预期工作。

除了检查提交的代码是否通过测试和其他自动化验证之外,在单独的环境中运行这些任务有助于消除不同开发者机器之间可能存在的任何不一致性。此外,它排除了由于开发者忘记运行测试或使用静态分析工具而导致出现错误或不一致代码的可能性。

警告:采用持续集成工具并不一定意味着你正在实践持续集成。

经常执行持续集成的团队会频繁地将他们的工作与项目主分支合并。自动化构建和验证以及拥有持续集成服务器是使这些频繁集成更安全、更快捷的一种方式。

如果你使用持续集成工具,但每月只与项目主分支集成一次,你不是在进行持续集成。

在你采用持续集成工具之后,你应该确保项目主分支的构建过程始终工作。如果它损坏了,你和你的团队必须尽快修复它,或者回滚导致失败的更改。否则,其他人将在损坏的软件之上工作。

此外,如果你有一个损坏的构建,你将禁止其他人合并新代码,因为如果他们这样做,你将不知道他们的代码是否也有失败,因为构建已经损坏。此外,找到问题的根本原因将更具挑战性,因为你将需要调查更多的更改。

重要:你的持续集成构建始终应该工作。

除了将质量控制检查纳入始终在持续集成服务器上通过构建之外,你还应确保这些构建快速完成。

就像测试一样,构建运行所需的时间越长,开发者发现他们犯错误的时间就越长。此外,缓慢的构建可能会影响团队的交付节奏,因为每个人都需要时间将他们的更改合并并验证。

持续集成服务

自从我从事技术工作以来,我已经尝试了各种各样的持续集成工具,从本地软件到云服务。

除非你在大型公司工作或你有严格的要求强制你在自己的服务器上构建代码,否则我强烈建议你使用本地第三方 CI 软件,如 Drone、JetBrains 的 TeamCity 或 Jenkins。

否则,我会选择基于云的服务。当我构建软件时,我希望专注于构建软件,而不是修复我的持续集成正在运行的机器。

如果你选择云服务,我特别喜欢的服务是 CircleCI。除了易于设置外,该服务可靠,并且有广泛的文档。此外,CircleCI 允许你通过 SSH 进入构建运行的服务器来调试失败的构建。这个功能将使你能够在服务器上手动更新配置,以便在重试构建时了解导致构建失败的原因。

其他优秀的选择包括 TravisCI,我在开源项目中广泛使用它,JetBrains 管理的 TeamCity 服务,以及 GitLab CI。

在选择这些工具时,我会考虑的最重要因素是它们的灵活性、安全性和可调试性。此外,正如你在本章的最后部分将看到的,我认为能够将工具与你的版本控制系统(VCS)提供商集成是至关重要的。

无论你选择哪种工具,请记住,与项目主线持续集成你的工作比选择一个出色的持续集成服务更重要

我之前看到同一个持续集成服务在一些项目中失败,但在其他项目中成功。这两种项目中的工具功能都是相同的——最大的区别在于开发人员在集成他们的工作时是否自律。

注意:你可以在以下网站上找到有关这些工具的更多信息:

12.1.2 持续交付

从路易斯的糕点师那里学到的最有价值的教训之一是,尽早和经常交付有助于企业成功。

例如,在开发新甜点时,一次性烘焙一大批并试图全部卖出是有风险的,因为糕点师还不知道顾客是否会喜欢它。

例如,如果面包店的员工烘焙了一大批客户认为酸味过重的甜点,他们将浪费大量时间和资源。相反,他们先烘焙一小批,卖给几位客户,并听取他们的反馈。然后他们迭代地改进甜点,因此在这个过程中浪费的资源更少。

当厨师尝试这些新食谱时,他们也不愿意向客户提供味道极差的甜点,这可能会使他们不再回来。为了避免用实验性食谱吓到客户,面包店的员工首先品尝新甜点,然后才逐渐将新颖的甜点卖给更敏感的碳水化合物爱好者。

如果客户不喜欢新的产品,员工会迅速停止品尝测试,回到厨房改进食谱。否则,他们开始向越来越多的客户销售食谱。

通过与客户持续测试甜点,面包店的员工能够专注于使甜点成功,而不是总是试图开发销售不佳的新产品。

此外,通过每次烘焙较少的甜点,糕点师们不太可能出错,因为他们习惯了新的食谱。

最后,因为他们必须频繁地交付新的甜点,所以他们优化了厨房布局,以利于吞吐量,并建立了必要的指标和流程,以便他们能够确定甜点是否表现良好。

如果您将这种相同的实践——持续交付——应用到软件世界中,您将看到类似的优点。

对于实践持续交付的团队来说,在完成变更后,他们不仅将工作合并到项目的主分支,而是产生可以直接交给客户的工件。

为了能够在任何时间点部署软件的最新版本,这些团队执行持续集成,以确保项目主分支的每次提交都将软件从一个有效的工作状态转换到另一个。

重要 如图 12.2 所示,持续集成是能够执行持续部署的前提。

当开发者编写代码时,为了避免交付客户可能不喜欢或可能存在尚未被自动化测试捕获的 bug 的功能,他们首先将变更推出给一小群更敏感的用户。

图 12.2

图 12.2 在实施持续交付之前,您必须已经执行了持续集成。在实践持续交付时,您集成的每个变更都必须是可发布的,这样您就可以在需要时部署软件的最新版本。

如果在逐步推出变更的过程中出现任何问题,实践持续交付的团队应该已经建立了机制,允许他们快速回滚变更并部署软件的先前版本。

为了说明在软件项目中实践持续部署的好处,考虑一下你正在对面包店的底层服务进行重大更改,以便它可以显示客户的订单状态,如果订单已发货,还可以显示其位置。

想象一下,例如,如果你试图一次性部署这个功能,由于你添加的许多漫长的数据库迁移之一导致部署失败,会发生什么。在这种情况下,很难发现哪个迁移的哪个部分导致了问题,因为需要调查的代码行数更多。

如果你能够更频繁、分阶段地部署你的功能,你将能够更快地发现并修复错误,因为一旦第一次失败的迁移运行,你将需要调查的代码行数会更少。

此外,更频繁且分阶段地部署你的订单跟踪系统可以帮助你根据客户的需求进行调整。你不必花费大量时间开发客户可能不需要的详细订单跟踪系统,而是可以分阶段构建,听取客户的反馈,并交付他们真正想要的产品。

小贴士:功能标志是一种有用的技术,可以帮助团队执行持续部署。

通过在功能标志后面部署新功能,开发者可以向生产代码发送尚未影响用户或仅对一小部分用户可用的代码。

除了让开发者更频繁地发布代码外,功能标志还能减少通信开销,因为它们将功能可用性和部署过程分开。

此外,你可以使用功能标志来隐藏应用程序中尚未准备好供用户查看的部分,例如 UI 不完整的屏幕。

如果你想要了解更多关于功能标志的信息,我强烈推荐你阅读 Pete Hodgson 在 Martin Fowler 网站上关于这个主题的文章,网址为martinfowler.com/articles/feature-toggles.html

例如,想象一下,如果你花费了三个月时间构建一个详细的订单跟踪系统,却发现客户只关心他们的订单是否成功提交,这将多么灾难性。

通过将订单跟踪系统拆分为更小的可交付成果,你可以通过获得早期反馈来降低项目风险。这种早期反馈将帮助你了解客户关心什么,因此可以避免编写不必要的代码。例如,在这种情况下,你本可以构建一个小型的软件,一旦订单被接受,就会向客户发送短信或电子邮件。

重要提示:尽早和频繁地交付可以将你的重点从尽可能多地编写代码转移到尽可能多地提供价值。

存放在你的版本控制系统中的代码不会为你的客户提供任何价值。你越早将软件交到客户手中,它就越早开始创造价值。

最后,如果你和你的团队需要频繁部署变更,你就有更强的理由尽可能自动化部署过程。否则,这些缓慢的部署会显著减慢反馈循环,从而降低执行持续交付的好处,并减少整个团队的吞吐量。

正如我之前所阐述的,执行持续交付的团队可以降低部署的风险,并且由于他们实施的自动化,通常在部署上花费的时间更少。这些团队能够更早地获得反馈,以构建客户真正需要的而不是他们认为客户需要的产品。

警告 持续交付与持续部署不同。 当你执行持续交付时,你的团队可以在任何时间点将项目的主分支部署到生产环境中。理想情况下,这些部署应该尽可能自动化。然而,仍然需要有人决定“按下按钮”。持续部署将持续交付推进了一步。

实践持续部署的团队,每当项目主分支上发生新的成功构建时,他们的代码会自动部署到生产环境中。

由于不需要有人按下“部署”按钮,变更可以更快地进入生产环境。

在执行持续部署时,每个版本都会自动发送到生产环境中,如图 12.3 所示。

图 12.3 持续部署时,每个版本都会自动发送到生产环境中

图 12.3 在执行持续部署时,每个版本都会自动发送到生产环境中。

当你从持续集成过渡到持续交付,然后向持续部署迈进时,发布速度会加快,迫使你实施更复杂的部署技术,并实施更好的监控和更可靠的质量保证。

12.2 自动化测试在 CI/CD 流水线中的作用

在将整个批次的甜点卖给客户之前先品尝样品,这是路易斯面包店的一项基本活动。这有助于确保提供给客户的任何东西都符合路易斯严格的品质标准。

同样,在将软件交付给客户之前测试你的软件可以帮助你防止发布带有错误的产品。

测试你的软件是耗时的。因此,你不得不更频繁地进行测试,你的测试成本就会越高。

考虑到持续交付将迫使你的团队更频繁地交付软件,如果你没有高效的自动化测试,它可能会迅速增加你的成本。

通过实施自动化测试,你可以降低部署成本,因为机器可以比人类更严格、更快地验证你的软件,几乎不需要成本。

自动化测试是使团队能够执行持续交付的最关键技术,因为它允许你在不显著增加成本的情况下提高交付频率

这些测试集成到通常称为 CI/CD 流程中,在持续集成服务中运行,负责自动验证你的软件并准备任何必要的发布工件。

警告 设置持续集成流程与实施 CI/CD 流程不同。

在执行持续集成时,每当有人向项目的主要分支推送代码时,你的团队将验证其软件。此验证过程可能包括尝试构建应用程序、运行测试和执行静态分析。

CI/CD 流程除了验证你的软件外,还准备任何必要的发布工件,以便你可以通过“按按钮”来部署它们。

例如,如果你正在编写 Node.js 应用程序,你的 CI/CD 流程可以验证它,构建必要的容器,并将它们推送到容器仓库。

在你的流程运行后,你可以通过按按钮快速部署你的软件,因为你不需要再次构建容器。相反,你只需简单地拉取已经构建好的内容。

此外,为了测试部署过程本身,你应该有一个类似生产环境的独立环境,将你的软件部署到该环境中。

随着你增加部署的频率,你将不得不增加 CI/CD 流程执行的自动验证数量。这些验证应该提供快速而精确的反馈,以便你能够尽早发现错误。否则,你更有可能发布带有错误的软件。

为了使这些验证尽可能有价值,除了你的标准单元和集成测试外,你的 CI/CD 流程必须包括端到端测试,如果可能的话,还应包括类似于第十章中看到的验收测试。

端到端和验收测试在 CI/CD 流程的背景下尤为重要,因为它们是最接近用户行为的测试,因此更有可能防止你发布带有错误的软件。

重要 当构建你的 CI/CD 流程时,确保不仅包括单元和集成测试,还包括端到端和验收测试。

除了使部署更快、更便宜之外,有效的测试还能激励你的团队更频繁地部署,因为它们减少了代码到达生产所需的工量。像测试一样,你的部署越快,发生的频率就越高。

尽管测试有检测错误的能力,但它们并不总是能保证你的应用程序没有错误。因此,它们不能消除手动验证的需要。如果你有一个质量保证团队,其专业人员在进行手动和探索性测试时仍然很有价值,正如我在第二章中解释的那样。

尽管需要手动验证,但为了使整个验证过程更快,您可以利用允许您更早检测到错误并最小化其影响的机制。这些机制包括实施监控和使用功能标志逐步推出更改。

12.3 版本控制检查

在路易斯的面包店,没有人会在品尝巧克力霜之前就把它涂在巨大的蛋糕上。否则,如果巧克力霜不够甜,整个蛋糕就会直接进入垃圾桶。

在将配料混合之前尝试它们的单独部分,允许糕点师避免将不可食用的馅料与原本完美的面团混合。

版本控制检查,如图 12.4 所示,在软件开发过程中起着类似的作用。通过在开发者合并代码之前自动验证他们推送的代码,版本控制检查确保团队不会将损坏的代码添加到本应健康的代码库中。

图片

图 12.4 版本控制检查可以防止您的团队发布无效的版本。

这些检查可以在您提交代码时本地发生,或者在您推送代码之前,或者一旦您的更改到达远程仓库。

在第一种情况下,这些本地检查通常通过 Git 钩子来完成。这些钩子允许您在开发者在仓库中执行特定动作时执行程序。

例如,您可以配置一个 precommit 钩子,该钩子检查您的代码,执行测试,并阻止开发者提交未通过这些验证的代码。

要配置这种自动验证,您可以使用husky包,其文档可以在github.com/typicode/husky找到。

安装husky后,创建钩子就像在package.json文件中添加一个husky属性一样简单,该文件包含您希望在版本控制系统中发生不同动作时运行的命令。

列表 12.1 package.json

{
  "name": "git_hooks_example",
  "scripts": {
    "test": "jest",                        ❶
    "lint": "eslint"                       ❷
  },
  "devDependencies": {
    "husky": "4.3.0"
  },
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint",        ❶
      "pre-push": "npm run test",          ❷
    }
  }
}

❶ 在合并提交前自动检查代码

❷ 在推送代码前自动运行测试

当用户在本地执行操作时运行这些类型的验证的缺点是,它们可能会减慢开发者的工作流程。尽管开发者可以在 Git 命令中添加--no-verify选项来防止这些钩子运行,但使用此选项就失去了设置这些钩子的初衷。

个人来说,我限制我的 Git 钩子只执行快速终止的动作。例如,我不会在每个提交之前运行代码检查或测试。相反,我会运行代码格式化工具,如 Prettier。

然后,为了确保我的团队能够审查并合并只有有效代码,我配置了我的版本控制提供者以触发持续集成管道。这个管道随后运行更慢的验证,如代码风格检查和测试。如果这些验证失败,我的版本控制提供者将不允许我的团队在我修复之前合并我的代码。

除了代码风格检查和测试之外,你还可以配置其他类型的验证。例如,你可以验证每个拉取请求都不会降低测试覆盖的代码百分比。

多亏了这些自动验证,你不需要依赖人类去记住他们必须执行任何命令。相反,你让人类发挥创造力,将他们更擅长的重复性工作委托给机器:完美地执行重复性任务。

摘要

  • 实践持续集成的团队经常集成他们的代码,而不是在长期存在的功能分支上工作,这些分支只有在所有更改都完成时才会合并。

  • 如果持续集成构建失败,破坏构建的开发者必须尽快修复构建或回滚他们的更改。否则,这些失败的构建可能导致其他人基于损坏的软件工作。此外,当构建已经失败时,你将不知道新更改是否也存在问题,这将更难以找到失败的根本原因。

  • 持续集成促进了开发者之间的沟通,因为它促使他们更频繁地集成他们的工作。因此,他们能更频繁地了解代码库中的最新动态。

  • 当团队实践持续集成时,他们减少了需要重做的工作量,因为他们将拥有更紧密的反馈循环。这个紧密的反馈循环有助于开发者在他们实施更改时纠正方向,而不是迫使他们花费大量时间解决冲突或重写代码,因为底层软件已经改变。

  • 为了加快集成开发者更改的过程,实践持续集成的团队使用 CI 服务器来自动构建、分析和测试提交的代码。

  • 在 CI 服务器上执行这些验证有助于开发者消除他们自己的开发环境与其他人环境之间的一致性问题。这种做法有助于团队防止合并那些在开发者的机器上以一种方式工作,一旦部署就以一种不同方式工作的代码。

  • 你的构建越快,开发者就越早能够检测并纠正错误。此外,快速的构建将加速开发者集成代码的速度,因为他们将很快知道他们是否提交了有效的代码。

  • 实践持续交付的团队可以在任何时间点发布他们软件的最新版本。

  • 为了能够随时发布软件,这些团队必须实践持续集成,以验证项目主分支中的每个更改都能将代码从一个工作状态转换到另一个状态。

  • 能够更频繁地交付代码有助于开发者采取更小、更迭的步骤,并在构建软件时验证它们,而不是在前期进行不必要的操作。

  • 早期交付代码导致开发者更专注于尽快交付价值,而不是编写尽可能多的代码行数。存放在版本控制系统中的软件不会产生价值。软件只有在客户手中时才能产生价值。

  • 持续交付激励团队尽可能自动化任务,以便发布不会成为重大的负担。除了实施可以“一键”发生的部署流程外,他们还建立了必要的机制来监控部署后的软件。

  • 测试对于实践持续集成和持续交付至关重要,因为这两种实践都采用迭代的方法进行软件开发。因此,你需要更多的迭代,测试就越重要,因为它们运行得越频繁,就能为你节省更多时间,因此它们带来的价值就越大。

  • 为了防止开发者意外合并不合适的代码,你可以实施与你的版本控制系统相关的检查。这些检查可以本地发生,通过使用钩子,或者远程触发你的版本控制系统提供商中的操作。

  • 本地钩子应该快速,以免阻碍开发者的进度。随着开发者提交代码,这些钩子将触发快速验证和调整代码的操作,例如执行代码格式化器。

  • 在你的版本控制系统中发生的检查可能包括阻止合并,如果你在持续集成工具中构建过程失败,或者新更改降低了代码覆盖率百分比。

  • 实施此类检查可以释放人类进行创新,并将机器擅长且繁琐重复的任务委托给机器。

13 质量文化

本章涵盖了

  • 如何类型补充你的测试并使你的代码更安全

  • 代码审查的影响以及如何有效地执行它们

  • 采用 linting 和格式化及其优势

  • 设置监控以确保你的系统健康

  • 文档如何影响你的项目质量

在 12 章关于 JavaScript 测试之后,第十三章稍微改变了方向。在本章中,我将教你一些新的技术,这些技术将补充你的测试并帮助你培养项目中的质量文化。

这些技术放大了测试的影响。它们使你的测试更安全,代码更容易理解,或者捕捉到测试无法捕捉到的错误。

本章首先演示了类型系统如何补充你的测试。在本节中,我讨论了采用类型系统的优势,并使用一个实际例子来说明你必须做什么才能从它们中获得最大的安全效益。

一旦我涵盖了类型系统,我将强调团队成员相互审查代码的重要性以及如何将这种实践融入你的开发流程。此外,本节还包含大量关于如何有效审查代码的建议。

为了简化代码审查并帮助开发者专注于代码语义而不是吹毛求疵,本章的第三部分描述了 linting 和格式化。它阐明了这两种实践之间的区别,并揭示了每种实践的好处。

本章的倒数第二部分解释了监控如何帮助保持你的软件健康,指出你的软件需要哪些更新,以及如何检测测试中无法捕捉到的错误。

最后,本章的最后部分讨论了每个人都消费但很少有人生产的东西:文档。本节涵盖了文档对你的团队软件和流程可能产生的影响,哪些类型的文档应该优先考虑,以及哪些不应该编写。

因为这本书专注于测试,所以我不会在每个部分中过多地详细说明。我本章的主要目标是让你了解这些实践和技术如何帮助你创建更好的软件。

在阅读本章后,我希望你能对你要寻找的内容有一个很好的了解,以及它如何融入软件测试的更大图景。

13.1 使用类型系统使无效状态不可表示

我认为测试是确认你关于程序如何工作的假设的实验。当你编写一个测试时,你对代码将要做什么有一个假设,所以你给它一些输入,并检查被测试的代码是否产生预期的输出。

一旦你运行了这些实验,你就会外推并选择相信程序在未来将以相同的方式工作,即使这可能并不正确。可能的情况是,你没有考虑到一些边缘情况,或者还有其他因素会影响代码的行为,例如如果你处理的是时间,时区可能会影响。

如我们之前所见,测试不能证明程序是有效的。它们只能证明程序不是无效的。

相反,使用类型系统可以证明程序的性质。如果你使用类型来指定一个函数只能接收数字,那么类型检查器会警告你,如果在该函数的任何情况下都可以用字符串调用该函数,例如。

与测试不同,类型系统不是基于实验的。它们基于清晰、逻辑的规则,你的程序需要遵守这些规则才能被认为是有效的。

假设,例如,你有一个函数将订单推送到面包店的配送队列。因为订单要被交付,它们需要是完整的,所以这个函数应该只向配送队列添加状态为 done 的订单。

列表 13.1 orderQueue.js

const state = {
  deliveries: []
};

const addToDeliveryQueue = order => {              ❶
  if (order.status !== "done") {
    throw new Error("Can't add unfinished orders to the delivery queue.");
  }
  state.deliveries.push(order);
};

module.exports = { state, addToDeliveryQueue };

❶ 只在订单状态为“完成”时添加订单到配送队列

如果你测试这个函数,你可能会编写一个测试来确保状态为 in progress 的订单不能添加到队列中,如以下代码片段所示。

列表 13.2 orderQueue.spec.js

const { state, addToDeliveryQueue } = require("./orderQueue");

test("adding unfinished orders to the queue", () => {            ❶
  state.deliveries = [];
  const newOrder = {
    items: ["cheesecake"],
    status: "in progress"
  };
  expect(() => addToDeliveryQueue(newOrder)).toThrow();
  expect(state.deliveries).toEqual([]);
});

❶ 一个测试以确保当有人尝试向配送队列添加状态为“进行中”的订单时,addToDeliveryQueue 会抛出错误

依赖于测试来断言程序质量的问题在于,由于 JavaScript 的动态特性,许多可能的输入可能导致程序的状态变得无效。例如,在这种情况下,有人可能向配送队列添加状态为 done 且项目为零或 null 的订单。

此外,可能还有其他函数会更新 orderQueue.js 中的状态,这可能导致无效状态。或者,更糟糕的是,有人可能尝试提交一个 null 订单,这会导致程序在检查订单状态是否为 null 时抛出错误。

为了覆盖这些边缘情况,你需要大量的测试,即使如此,你肯定也没有涵盖所有可能导致无效状态的可能场景。

为了约束你的程序,使其状态必须有效,你可以使用类型系统。

个人来说,TypeScript 的类型系统是我最喜欢的。它灵活且易于学习,其工具和社区都很优秀,这就是我选择用它来编写本节示例的原因。

在您开始使用类型来约束程序状态之前,请使用 npm 命令 install -save typescript 安装 TypeScript 作为开发依赖项。安装 TypeScript 后,运行 ./node_modules/.bin/tsc --init 以创建一个初始的 TypeScript 配置文件,名为 tsconfig.json。最后,您还需要将文件的扩展名更改为 .ts。创建该文件后,您就可以开始使用类型来约束您的程序了。

例如,尝试创建一个表示订单的类型,并将类型分配给程序的状态。然后,更新 addToDeliveryQueue 函数,使其只能接受与 Order 类型匹配的订单。

列表 13.3 orderQueue.ts

export type Order = {                                               ❶
  status: "in progress" | "done";
  items: Array<string>;
};

export type DeliverySystemState = { deliveries: Array<Order> };     ❷

export const state: DeliverySystemState = { deliveries: [] };       ❸

export const addToDeliveryQueue = (order: Order) => {
  if (order.status !== "done") {
    throw new Error("Can't add unfinished orders to the delivery queue.");
  }
  state.deliveries.push(order);
};

❶ 定义一个类型,Order 的状态可以是“进行中”或“完成”,其项目由字符串数组表示

❷ 一个表示配送系统状态的类型,该状态包含一个名为 deliveries 的属性,它是一个订单数组

❸ 配送系统的状态,最初包含一个空的配送数组

注意:当使用 TypeScript 时,您可以使用 ES 导入语法,因为您将使用 TypeScript 编译器将程序转换为纯 JavaScript 文件。

仅通过这两个类型,您现在已经确保 TypeScript 如果代码中任何地方添加了除有效 Order 之外的内容,将会警告您。

例如,尝试调用 addToDeliveryQueue 并将其作为字符串参数传递。然后,使用 ./node_modules/.bin/tsc ./orderQueue.ts 运行 TypeScript 编译器,您将看到程序无法编译。

列表 13.4 orderQueue.ts

// ...

// ERROR: Argument of type 'string' is not assignable to parameter of type 'Order'.
addToDeliveryQueue(null);

您甚至可以更进一步,并指定任何订单必须至少包含一个项目。

列表 13.5 orderQueue.ts

export type OrderItems = { 0: string } & Array<string>    ❶

export type Order = {
  status: "in progress" | "done";
  items: OrderItems;                                      ❷
};

export const state: { deliveries: Array<Order> } = {
  deliveries: []
};

export const addToDeliveryQueue = (order: Order) => {
  if (order.status !== "done") {
    throw new Error("Can't add unfinished orders to the delivery queue.");
  }
  state.deliveries.push(order);
};

❶ 定义值类型为 OrderItems 的值必须将其第一个索引填充为字符串

❷ 声明 items 属性具有 OrderItems 类型,这阻止程序员将其分配为空数组

此更新确保程序无法向配送队列添加 items 数组为空的订单。

列表 13.6 orderQueue.ts

// ...

//
      ERROR: Property '0' is missing in type '[]' but required in type '{ 0: string; }'.
addToDeliveryQueue({ status: "done", items: [] })

最后,为了减少您需要编写的测试数量,您还可以更新程序的类型,以确保 addToDeliveryQueue 只能接受状态为 done 的订单。

列表 13.7 orderQueue.ts

export type OrderItems = { 0: string } & Array<string>;

export type Order = {
  status: "in progress" | "done";
  items: OrderItems;
};

export type DoneOrder = Order & { status: "done" };                ❶

export const state: { deliveries: Array<Order> } = {
  deliveries: []
};

export const addToDeliveryQueue = (order: DoneOrder) => {
  if (order.status !== "done") {
    throw new Error("Can't add unfinished orders to the delivery queue.");
  }
  state.deliveries.push(order);
};

❶ 创建一个新类型,仅表示状态为“完成”的订单

现在,如果您的代码中任何地方有可能添加一个不完整的订单到配送队列,程序将无法编译。

列表 13.8 orderQueue.ts

// ...

// ERROR: Type '"in progress"' is not assignable to type '"done"'.
addToDeliveryQueue({
  status: "done",
  items: ["cheesecake"]
});

由于您使用了类型,您不再需要在函数内部进行错误处理或对其进行测试。因为您已经编写了严格的类型,如果您尝试向配送队列添加无效的订单,程序甚至无法编译。

列表 13.9 orderQueue.ts

export type OrderItems = { 0: string } & Array<string>;

export type Order = {
  status: "in progress" | "done";
  items: OrderItems;
};

export type DoneOrder = Order & { status: "done" };

export const state: { deliveries: Array<Order> } = {
  deliveries: []
};

export const addToDeliveryQueue = (order: DoneOrder) => {             ❶
  state.deliveries.push(order);
};

❶ 一个函数的参数类型为 DoneOrder,这阻止了其他人使用状态不是“完成”的任何订单调用它

在这些更改之后,您唯一需要的测试是检查addToDeliveryQueue是否将完整的项目添加到交付队列中的测试。

列表 13.10 orderQueue.spec.ts

import { state, addToDeliveryQueue, DoneOrder } from "./orderQueue";

test("adding finished items to the queue", () => {                     ❶
  state.deliveries = [];
  const newOrder: DoneOrder = {

    items: ["cheesecake"],
    status: "done"
  };
  addToDeliveryQueue(newOrder);
  expect(state.deliveries).toEqual([newOrder]);
});

❶ 一个测试将状态为“完成”的订单添加到交付队列中,并期望订单队列包含新订单

注意:在您能够编译此测试之前,您需要使用npm install @types/jest安装 Jest 的类型定义。

现在尝试使用./node_modules/.bin/tsc ./*.ts来编译所有.ts文件到纯 JavaScript,然后使用 Jest 运行测试以确认测试通过。

通过使用类型,您已经足够约束了您的程序,使得无效状态变得无法表示。这些类型帮助您覆盖了更多的边缘情况,而无需编写测试,因为 TypeScript 会在您编写不符合其期望的类型代码时警告您。此外,TypeScript 无需运行您的程序就能做到这一点。相反,TypeScript 会静态地分析程序。

此外,类型系统还有助于您在编写测试时犯更少的错误,因为它还会在您的测试可能导致程序进入无效状态时给出警告(假设您的类型足够严格,允许这种情况发生)。

注意:由于这本书专注于测试,我没有深入探讨 TypeScript 本身。如果您想了解更多关于 TypeScript 的信息,我强烈推荐 Marius Schulz 的 TypeScript Evolution 系列,您可以在mariusschulz.com/blog/series/typescript-evolution找到。

13.2 审查代码以捕捉机器无法发现的问题

机器只会做它们被告知的事情。它们不会犯错误;是人类会犯错误。每当软件行为异常时,都是人类的错。

代码审查存在是为了让人类指出彼此的错误,并改进软件的设计。此外,代码审查有助于在团队中分配所有权并传播知识。如果有人需要更改其他人编写的代码,他们已经阅读过它,并且更新它时会感到更舒适。

此外,代码审查可以帮助捕捉测试和类型检查无法发现的错误。在审查过程中,其他人可以指出,例如,存在没有自动化测试的边缘情况,或者存在不够严格的类型,无法防止程序进入无效状态。

如果您的团队还没有正式的代码审查流程,在绝大多数情况下,我建议您实施一个。即使您不使用拉取请求或其他任何正式方法,仅仅让其他人审阅您的代码也会带来显著的好处。

在本节中,我将向您介绍一些技巧,以确保您和您的团队能够从代码审查中获得最大收益。

这些技术中最重要的是编写详细的拉取请求描述,这允许其他人进行彻底的审阅。当其他人理解你的更改意图和所有细微差别时,他们可以避免添加重复的评论,指出你在编写代码时已经考虑过的事情。

个人而言,我喜欢在我的拉取请求中包含以下信息:

  • 一个简要的总结,包含更改的意图和任何相关的跟踪器票据

  • 对我正在解决的问题的深入解释,或者我正在实施的功能的细微差别

  • 对我已编写或更新的代码片段的描述,强调我在实施过程中遇到的细微差别和问题

  • 一个简要指南,说明我如何验证我的更改,以便其他人可以确认代码是正确的

提示:如果你使用 GitHub,你可以为这些项目中的每一个创建单独的拉取请求模板,这样其他人可以快速轻松地理解他们应该如何编写拉取请求描述。

一旦拉取请求描述达到这样的详细程度,那么审阅者的任务就是与作者沟通,确保代码的质量尽可能高。

我给审阅者的第一条建议总是将拉取请求视为一次对话。在进行审阅时,除了要求更改之外,我还建议其他人称赞作者的优雅设计并提问。

以与作者互动的意图审阅拉取请求迫使审阅者更加注意。此外,这种态度促进了更有意义和积极的互动。

在这些互动中,我还建议审阅者清楚地表明一个更改请求是否会阻止拉取请求获得他们的批准。这种指示有助于团队减少在琐碎或主观问题上浪费时间,这些问题可能连审阅者自己都没有认为那么重要。

此外,审阅者还应解释他们认为为什么特定的代码片段需要更改。通过描述采用建议方法的优势,他们使辩论更加流畅,并为作者提供更多考虑的信息,以便做出决定。

对于审阅者来说,最后的也许是最重要的建议是在打开文本编辑器或 IDE 的情况下进行审阅。在编写代码时,作者并不仅仅按字母顺序遍历文件并实施更改。相反,他们会找到更改的起点,并通过依赖图导航,更改他们需要的代码片段。因此,审阅不应是线性的。审阅者应根据依赖图和正在实施的更改来查看文件,而不是按字母顺序。

在你的编辑器或 IDE 打开的情况下审阅代码,可以让你检查可能没有更改但会影响拉取请求更改是否有效的其他代码片段。

总结一下,以下是一份关于如何审查拉取请求的这章所有建议的列表:

  1. 编写详细的拉取请求描述。

  2. 将每个拉取请求视为一次对话——无论你是否建议更改,都要带着添加评论的意图进行审查。

  3. 明确指出你是否认为建议的更改是作者获得你批准所必需的。

  4. 解释为什么你认为特定的代码片段需要更改。

  5. 在打开文本编辑器或 IDE 的情况下审查拉取请求。跟随代码;不要线性地审查它。

在我参与的团队和开源项目中,我经常收到的最主要的赞扬之一就是我的拉取请求描述和审查非常详细和全面。这种纪律多次带来了显著的生产力提升和更多积极的互动。

最后,为了使你的更改更容易阅读和理解,尽量保持你的拉取请求小。如果你正在开发一个大型功能,你可以将其拆分为多个拉取请求,或者请求其他人审查你更改的中间状态。通常,当拉取请求太大时,人们会在 VCS 差异的众多代码行中错过重要的细节。

13.3 使用代码检查器和格式化工具生成一致的代码

本书的一个一致主题是,如果一台机器能够完成特定的任务,你应该将这项任务委托给它。代码检查和格式化就是这样的任务。

代码检查,类似于类型检查,是一种静态分析过程。当使用代码检查器时,它会分析你编写的代码,并验证它是否与可配置的规则集匹配。代码检查器可以指出可能导致错误或代码编写不一致性的问题。

你可以使用代码检查器在例如使用对象中重复的属性名称、声明未使用的变量、编写不可达的return语句或创建空代码块时触发警告。尽管所有这些结构在语法上都是有效的,但它们可能是多余的或导致缺陷。

通过使用代码检查器,你利用了机器无休止检查代码的能力,并让团队的其他成员有更多时间关注你编写的代码的实际语义。因为其他人可以相信机器已经完成了捕捉琐碎问题的任务,其他人可以专注于审查你代码的语义,而不是指出你有多余的if/else语句,例如。

/testing-javascript-applications/example.js                              ❶
  6:14  error   This branch can never execute.                           ❷
                Its condition is a duplicate or covered by previous
                conditions in the if-else-if chain
                no-dupe-else-if                                          ❸

❶ 发现代码检查错误的文件

❷ 错误的行号和列号,随后是对问题的解释

❸ 违反的代码检查规则名称

此外,许多工具和框架提供代码风格检查器插件,以便您的检查器可以警告您关于不良实践。假设您正在编写一个 React 应用程序,在这种情况下,您可以使用插件配置您的检查器,以便在您忘记指定组件属性的 PropTypes 时发出警告。

在撰写本文时,最受欢迎的 JavaScript 代码风格检查工具被称为 ESLint。它是一个可扩展且易于使用的检查器,您可以通过使用 npm 命令 install --save-dev eslint 将其作为开发依赖项安装。一旦安装,您可以通过运行 ./node_modules/.bin/eslint --init 创建一个配置文件,并通过运行 ./node_modules/.bin/eslint . 验证您的代码。

TIP 如您在本书中之前所见,如果您在 package.json 中创建一个运行 eslint 的 NPM 脚本,则可以省略 node_modules 文件夹中二进制的路径。在大多数项目中,您可能希望这样做。

除了指出危险的结构或不良实践外,代码风格检查器还可以指出并修复风格问题,例如双引号和单引号使用的不一致、不必要的括号或多余的空行。

个人而言,我不喜欢使用代码风格检查器来捕捉风格问题。相反,我更喜欢使用像 Prettier 这样的有偏见的代码格式化工具。

使用代码风格检查器处理代码风格的问题在于,您可以配置您的风格规则,尽管这个说法可能看起来有些反直觉,但在格式化方面,通常有更多的选择是件坏事。代码格式化非常主观,每个人在是否应该使用双引号或单引号、制表符或空格等方面都有自己的偏好——当然,空格的使用通常更好。

说实话,只要保持一致,代码风格并不重要。我不介意其他人更喜欢使用制表符而不是空格,只要整个代码库使用制表符即可。

通过使用 Prettier,您可以省去所有无意义的争论时间,并转而依赖 Prettier 的选择——正如我在编写本书的示例时所做的那样。

此外,Prettier 可以使代码更容易阅读,并且更愉快地工作。

NOTE 我喜欢说,讨论代码风格偏好总是 无谓的争论。无谓的争论发生在人们花费大量时间讨论项目中的琐碎且容易理解的部分,而不是专注于完成项目所需的最复杂和关键任务。

这个术语最初由 Poul-Henning Kamp 提出。它指的是 Cyril Northcote Parkinson 的虚构例子,用于他的琐碎法则,该法则指出,群体通常会对琐碎问题给予不成比例的重视。在他的例子中,Cyril 提到,一个负责批准核电站计划的委员会往往会花费大量时间讨论用于自行车棚的材料,而不是分析实际的核电站计划。

使用 Prettier 非常简单。要开始使用 Prettier 格式化你的代码,你只需要将其作为开发依赖项使用npm install --save-dev prettier安装,然后使用./node_modules/.bin/prettier --write .

TIP 在我的项目中,我经常将 Prettier 与 Git 钩子集成,以便它将自动格式化我提交的所有代码。为此,我使用了在第十二章中介绍的工具husky

13.4 监控你的系统以了解它们的实际行为

我从未听说过没有任何 bug 的软件。到目前为止,关于正确性的讨论和写作已经很多,但软件行业的现状清楚地表明,我们还没有找到编写无 bug 软件的方法。

正如在第三章中解释的那样,即使代码覆盖率达到了 100%,也不能保证你的软件没有 bug。有时,用户会使用你未曾预料到的特定输入来证明你的软件,bug 仍然会发生

监控基于假设问题最终会发生,并且最好在客户之前注意到它们。通过监控你的软件,你可以了解你对代码工作方式的哪些假设是不真实的。

此外,实施良好的监控系统将能够为你提供关于软件性能、资源消耗和利用的见解。

如果不收集关于你的软件目前做了什么的资料,就无法优化其性能,因为你将没有基准来比较你的更改,也不知道瓶颈在哪里。

或者,正如 Rob Pike 在他的五条编程规则中的第一条所陈述的(users.ece.utexas.edu/~adnan/pike.html):

你无法预测程序将在哪里花费时间。瓶颈出现在令人惊讶的地方,所以不要试图猜测并添加速度优化,除非你已经证明了那里是瓶颈所在。

—Rob Pike

例如,如果你的客户抱怨你的网站加载时间过长,如果你不知道这些页面目前是如何表现的,你将如何显著提高页面加载时间?你当然可以尝试猜测瓶颈在哪里,但如果没有测量,你就是在黑暗中射击。

另一方面,如果你有足够的监控,你可以尝试几个不同版本的网站,每个版本都有不同的更改,并监控它们的性能,这样你才能真正了解每个更改的影响。

此外,测量可以让你避免过早优化你的软件。即使你可能已经编写了一个次优算法,也许它已经足够好,可以应对你的应用程序的负载。

测量。在你测量之前不要为了速度而调整,即使如此,除非代码的一部分明显超过了其他部分。

—Rob Pike

最后,设置监控基础设施的一个重要方面是,当你的监控系统检测到异常时,能够发送警报。如果你的 API 不可达或影响业务价值的东西不工作,应该有人被叫醒。

为了实现这一点,请确保你正在跟踪所有影响客户从你的软件中获得的价值的代码部分。除了启用警报外,衡量与你的软件提供给客户的价值更加紧密相关的方面,这将使你能够在未来做出有效的商业决策。

因为这是一本关于测试的书,所以我不打算详细介绍如何设置监控系统或合适的监控基础设施看起来像什么。做这件事需要整整一本书——实际上,可能需要很多本书。

然而,我认为强调监控在编写高质量软件中的作用是必要的——在了解如何正确执行方面投入时间,当构建大规模软件时将会有回报。

13.5 用良好的文档解释你的软件

超过五百页之后,说我是写作的大粉丝似乎有些多余。尽管如此,强调精心编写的文档在代码库中产生的积极影响是很重要的。

它的第一个好处是众所周知的:它帮助他人更快地理解代码库。文档对于他人理解代码本身特别有帮助,但更重要的是,为什么要以特定的方式编写。就我个人而言,为了保持文档简洁,我避免描述不同代码部分的工作方式,而是专注于解释它们的意图。

文档的最大问题是保持其更新。当你更新代码时,如果你的文档与代码不同步,其他人理解代码应该做什么会变得更加困惑,因为他们现在有两个相互冲突的信息来源。

为了避免这种情况,我个人喜欢将文档与代码尽可能保持接近。为了实现这一目标,我更喜欢使用 JSDoc 通过注释块来记录我的代码,而不是单独使用 Markdown 文件编写文档。

在代码文件中记录你的软件文档,几乎可以确保其他人不会忘记在编写代码时需要更新文档。如果某人正在更改的函数上方有 JSDoc 块,其他人就不需要花费时间搜索 Markdown 文件或更新单独的维基百科。

此外,如果你使用 JSDoc,你可以轻松地使用你的软件文档生成静态网站,并将它们发布到互联网上。其他人不一定需要查看你的代码来阅读其文档。

此外,许多文本编辑器和 IDE 可以解析 JSDoc,并在你编写代码时显示函数文档的提示信息。

注意:如果你想开始使用 JSDoc,我强烈建议你阅读该工具的官方文档,网址为jsdoc.app

文档的第二个,在我看来,最具影响力的好处尚未得到广泛传播:编写文档迫使作者反思他们的选择并精确地组织他们的思想。这种工作反过来又往往导致更友好的设计,并帮助作者自己更好地理解代码库。正如普利策奖和全国图书奖获得者大卫·麦库卢赫曾经说过:“写作就是思考。写得越好,思考就越清晰。这就是为什么它如此困难。”

个人而言,我经常喜欢在编写任何代码之前编写文档。通过在编写代码之前解释代码的意图,我通常更少担心实现细节,并专注于模块消费者将需要什么。

最后,我对工程师的最后一条建议也是要记录他们的流程和贡献政策。拥有一份最新且写得好的工作协议有助于他人了解对他们有什么期望以及何时期望。

例如,记录你期望每个拉取请求都包含自动化测试,有助于将其正式化为一项良好实践,并在团队内设定期望。

摘要

  • 当你编写测试时,你正在进行实验。你使用样本输入执行你的程序,并观察你的程序如何表现。然后,你选择外推这些结论,并相信程序将在未来的所有输入中以类似的方式表现。另一方面,类型允许你证明你的程序只能以特定方式工作。

  • 通过使用类型系统,你可以在不执行程序本身的情况下证明程序的性质。这就是为什么类型检查被认为是“静态分析”过程的原因。

  • 严格使用类型系统可以帮助你使无效状态无法表示,因此,使得你无法犯导致你的软件进入这些无效状态的错误。

  • 此外,类型系统减少了某些函数可以接受的输入的可能宇宙,这使得软件更容易验证,因为你需要编写的自动化测试案例更少。

  • 代码审查的存在是为了捕捉机器无法捕捉到的错误。尽管你可以使用自动化测试来验证你的代码,但你必须确保你的自动化测试是正确的,并且它们实现了预期的业务目标。为了验证软件开发这两个方面,你需要一双额外的眼睛来指出错误。

  • 当提交拉取请求时,请写详细的描述。这些描述有助于你的审阅者工作,因为它们帮助他人了解你试图实现什么以及你为什么做出某些决定。

  • 如果你是一个审查者,将拉取请求视为对话。通过以与作者沟通的意图来审查拉取请求,你将能够确保你已经提出了相关的问题,并且因为你试图创建一个有意义的沟通桥梁,你不可避免地会更加关注。此外,写赞美的话可以在团队中建立健康的人际关系。

  • 在你的审查中明确指出哪些更改将阻止拉取请求获得你的批准。这种态度有助于团队避免关于双方都不认为相关的琐碎建议的讨论。

  • 不要线性地审查代码。与其浏览多个文件,不如尝试跟随作者的思路。实施更改不是一个线性过程,因此,线性审查不允许审查者正确地跳过代码的依赖图。

  • Linting 是一种类似于类型检查的静态分析过程。Linters 分析你编写的代码,并验证它是否与可配置的规则集匹配,从而指示可能导致错误或不一致的问题。

  • 格式化器专注于风格问题。它们确保你遵循一致的代码风格,并使代码更容易阅读。

  • Linters 和 formatters 减少了拉取请求中的挑剔性评论,因为代码标准是由机器而不是人类自动执行和验证的。

  • 监控使你能够了解你的软件在客户手中的行为方式。因此,它有助于你检测你关于程序如何工作的错误假设。

  • 通过监控你的软件,你可以了解其瓶颈在哪里,并衡量改进,从而避免过早优化和软件更新的试错方法的额外开销。

  • 在你的监控基础设施之上设置警报有助于确保当应用程序的业务价值受到影响时,你的团队能够迅速采取行动。

  • 在编写文档时,专注于解释代码的意图而不是其内部工作原理,这样你可以保持文档的简洁。

  • 你可以使用 JSDoc 等工具将文档捆绑到你的代码库中。这些工具使代码成为唯一的真理来源,并减少了更新文档所需的时间和精力。

    在编写代码之前编写文档可以帮助你阐明你试图实现的目标,因为这样做时,你会专注于模块的接口和意图,而不是过分担心其实现细节。

posted @ 2025-11-15 13:05  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报