单元测试的艺术-全-

单元测试的艺术(全)

原文:The Art of Unit Testing

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

第二版序言

那年一定是 2009 年。我在奥斯陆的挪威开发者大会上发表演讲。(啊,六月的奥斯陆!)活动在一个巨大的体育场馆举行。会议组织者将看台分成了几个区域,在它们前面搭建了舞台,并用厚厚的黑色布料覆盖起来,以创造八个不同的会议“房间”。我记得我差不多结束了我的演讲,内容是关于 TDD、SOLID、天文学,或者诸如此类的东西,突然,从我旁边的舞台上传来了大声而嘈杂的唱歌和弹吉他声。

遮帘如此之厚,以至于我能够透过它们看到我旁边舞台上的那个人,他正在制造所有的噪音。当然,那是 Roy Osherove。

现在,那些了解我的人知道,在关于软件的技术演讲中突然唱歌,是我可能会做的事情,如果心情来了。所以当我转身回到我的观众时,我想,这位 Osherove 先生是一个志同道合的人,我必须更好地了解他。

而去更好地了解他,正是我所做的事情。事实上,他为我的最新著作《Clean Coder》做出了重大贡献,并花三天时间与我共同教授 TDD 课程。我与 Roy 的经历都非常积极,希望还有更多。

我预测,你在阅读这本书的过程中,对 Roy 的经验也会非常积极,因为这本书是特别的。

你读过 Michener 的小说吗?我没有;但有人告诉我,它们都是从“原子”开始的。你手中的这本书不是 James Michener 的小说,但它确实是从“原子”——单元测试的原子开始的。

当你翻阅早期页面时,不要被误导。这不仅仅是一本关于单元测试的介绍。它确实是从介绍开始的,如果你有经验,可以快速浏览那些前几章。随着书的进展,章节开始相互构建,形成了一个相当惊人的深度积累。确实,当我读到最后一章(并不知道那是最后一章)时,我想,下一章可能会讨论世界和平——因为,我的意思是,在解决了将单元测试引入固执的、拥有老旧遗留系统的组织的问题之后,你还能去哪里呢?

这本书是技术性的——非常技术性。有很多代码。这是好事。但 Roy 并不局限于技术。时不时地,他会拿出吉他,在讲述他过去职业生活中的趣事或哲学地讨论设计的意义或集成的定义时即兴唱歌。他似乎很喜欢用他 2006 年深远的黑暗过去中的一些糟糕经历来娱乐我们。

哦,对了,不要过于担心代码都是用 C#编写的。我的意思是,谁又能区分 C#和 Java 的区别呢?对吧?而且,这根本不重要。他可能用 C#作为传达意图的工具,但本书中的教训也适用于 Java、C、Ruby、Python、PHP 或其他任何编程语言(也许除了 COBOL)。

如果你是一个单元测试和测试驱动开发的初学者,或者如果你已经是一个老手,你会发现这本书对你有所助益。所以准备好享受罗伊为你演唱的“单元测试的艺术”这首歌曲吧。

罗伊,请调好那把吉他!

——罗伯特·C·马丁(Uncle Bob)

cleancoder.com

第一版序言

当罗伊·奥斯霍夫(Roy Osherove)告诉我他正在写一本关于单元测试的书时,我非常高兴地听到了这个消息。测试这一概念在业界已经流行多年,但关于单元测试的资料相对匮乏。当我看看我的书架,我看到的是关于特定测试驱动开发(test-driven development)的书籍和关于一般测试的书籍,但直到现在,还没有一本关于单元测试的全面参考书——没有一本书能介绍这个主题并指导读者从第一步到广泛接受的最佳实践。这个事实令人震惊。单元测试并不是一项新实践。我们是如何到达这个地步的?

说到我们工作在一个非常年轻的行业,这几乎成了一种陈词滥调,但这是真的。数学家们不到 100 年前为我们奠定了基础,但我们只有 60 年来才有足够的硬件来利用他们的洞察力。在我们行业中,理论与实践之间存在一个初始差距,而我们现在才刚刚发现它对我们领域的影响。

在早期,机器周期非常昂贵。我们以批处理的方式运行程序。程序员有一个预定的时间段,他们必须将程序输入到卡片堆中,然后步行到机房。如果你的程序不正确,你就浪费了时间,所以你用铅笔和纸检查你的程序,在脑海中考虑所有的情况,所有的边缘情况。我怀疑自动单元测试的概念甚至无法想象。为什么要在测试时使用机器,而你可以用它来解决它本来要解决的问题呢?稀缺性让我们处于黑暗之中。

后来,机器变得更快,我们沉溺于交互式计算。我们只需输入代码,就可以随心所欲地更改它。桌面检查代码的想法逐渐消失,我们失去了一些早期年份的纪律。我们知道编程很难,但这只是意味着我们必须花更多的时间在电脑前,更改行和符号,直到找到那个神奇的咒语,让它工作。

我们从稀缺到过剩,错过了中间地带,但现在我们正在重新获得它。自动化单元测试将桌面检查的纪律与对计算机作为开发资源的全新认识相结合。我们可以用我们开发的语言编写自动化测试来检查我们的工作——不仅一次,而且只要我们能够运行它们。我认为在软件开发中没有任何其他实践具有如此强大的效果。

当我写这篇东西的时候,在 2009 年,我很高兴看到罗伊的书出版。这是一本实用的指南,它将帮助你开始,并在你进行测试任务时作为一个很好的参考。单元测试的艺术不是一本关于理想化场景的书。它教你如何测试实际存在的代码,如何利用广泛使用的框架,最重要的是,如何编写易于测试的代码。

单元测试的艺术是一个重要的标题,它本应该在几年前就写成,但我们当时还没有准备好。我们现在准备好了。享受吧。

——迈克尔·费舍斯

对象导师

前言

我参与过的最大的失败项目之一有单元测试。或者我以为是这样。我领导着一组程序员创建一个计费应用程序,我们完全采用测试驱动的方式——编写测试,然后编写代码,看到测试失败,让测试通过,重构,然后从头开始。

项目的前几个月进展顺利。一切都很顺利,我们有测试证明了我们的代码是有效的。但随着时间的推移,需求发生了变化。我们被迫改变我们的代码以适应那些新的需求,当我们这样做的时候,测试失败了,不得不修复。代码仍然有效,但我们编写的测试如此脆弱,以至于我们代码的任何微小变化都会使它们失败,尽管代码本身运行良好。在类或方法中更改代码变成了一项艰巨的任务,因为我们还必须修复所有相关的单元测试。

更糟糕的是,一些测试变得无法使用,因为编写它们的那些人离开了项目,没有人知道如何维护测试或它们在测试什么。我们给单元测试方法取的名字不够清晰,我们有一些测试依赖于其他测试。我们在项目开始不到六个月的时候就抛弃了大部分测试。

这个项目是一个悲惨的失败,因为我们让我们所编写的测试带来的伤害大于好处。它们在维护和理解上花费的时间比它们在长期为我们节省的时间要多,所以我们停止了使用它们。我转向了其他项目,在这些项目中,我们编写单元测试做得更好,并且在使用它们时取得了一些巨大的成功,节省了大量调试和集成时间。自从那个失败的项目以来,我一直整理单元测试的最佳实践,并在后续的项目中使用它们。我在每个项目上都能找到一些新的最佳实践。

理解如何编写单元测试,以及如何使它们易于维护、可读和可信,是本书的主题,无论您使用哪种语言或集成开发环境。本书涵盖了编写单元测试的基础知识,进而转向交互测试的基础,并介绍了在现实世界中编写、管理和维护单元测试的最佳实践。

—罗伊·奥斯休维

当 Manning 邀请我帮助完成一本即将完成的关于单元测试的书时,我最初的反应是拒绝。毕竟,我已经有了一本关于单元测试的书,为什么还要参与别人的项目呢?但是当我意识到这本书正是罗伊的《单元测试的艺术》时,我的想法改变了。作为《单元测试的艺术》第一版的读者之一,这本书帮助塑造了我对单元测试的看法。我为能参与这部重要作品的第三版感到荣幸。

我个人认为,这本书是关于单元测试主题的优秀入门书籍。一旦您完成它并准备好深入研究,请拿起我的书,《单元测试原则、实践和模式》(Manning,2020)。

—弗拉基米尔·科里科夫

致谢

我们要感谢许多手稿审阅者,他们的反馈帮助我们改进了本书。感谢阿布杜·萨马杜·萨雷、阿迪尔·拉姆吉瓦安、阿德里安·贝尔特茨、阿兰·洛姆波、巴纳比·诺曼、查尔斯·兰姆、康纳·雷德蒙德、达乌特·莫里纳、埃斯雷夫·德恩纳、福斯特·海因斯、哈里纳特·马莱帕利、贾里德·邓肯、贾森·黑尔斯、豪梅·洛佩斯、杰里米·陈、乔尔·霍姆斯、约翰·拉森、乔纳森·里夫斯、乔治·E·博、肯特·斯皮尔纳、金·加布里埃尔森、马塞尔·范登布林克、马克·格雷厄姆、马特·范·温克尔、马泰奥·巴蒂斯塔、马泰奥·吉尔多内、迈克·霍尔科姆、奥利弗·科滕、奥诺弗雷·乔治、保罗·罗布、帕布洛·埃雷拉·J、帕特里斯·马尔达格、拉胡尔·莫德普尔、拉尼特·萨海、里奇·扬茨、理查德·梅森、罗德里戈·恩卡纳斯、罗纳德·博尔曼、萨钦·辛吉、萨曼莎·伯克、桑德·泽格尔德、萨特杰·库马尔·萨胡、谢恩·科尔维尔、塔尼亚·威尔克、汤姆·马登、乌迪特·布拉德瓦杰、瓦迪姆·图尔科夫。

一本成功的书籍的诞生需要许多人的共同努力。我们想要感谢 Manning 的收购编辑迈克尔·斯蒂普斯、开发编辑康纳·奥布赖恩、技术发展编辑迈克·谢泼德、技术校对员让-弗朗索瓦·莫林,以及审阅编辑阿德里安娜·萨博和邓雅·尼科托维奇。我们还要感谢 Manning 的其他所有人,他们在第三版的生产和幕后工作中付出了努力。

最后,我要向在 Manning 早期访问计划中阅读本书的早期读者表示感谢,他们在在线论坛上的评论帮助塑造了本书。您们的帮助对本书的完善至关重要。

关于本书

我听说过的关于学习的最聪明的事情之一(我忘了是谁说的)是,要真正学会某样东西,就去教授它。撰写这本书的第一版并在 2009 年出版,对我来说是一次真正的学习体验。我最初写这本书是因为我厌倦了反复回答同样的问题。但还有其他原因。我想尝试新事物;我想尝试一个实验;我想知道写一本书——任何一本书——我能学到什么。我认为我擅长单元测试。诅咒在于,你经验越多,感觉越愚蠢。

第一版中有些内容,我现在并不认同——例如,一个单元指的是一个方法。这完全不对。单元是一个工作单元,正如我在本版第三版的第一章中讨论的那样。它可以小到只是一个方法,也可以大到几个类(可能是程序集),还有其他一些变化,你将在下一章了解到。

第三版的新内容

在本版第三版中,我们从.NET 切换到了 JavaScript 和 TypeScript。当然,所有相关的工具和框架也得到了更新。例如,我们不再使用 NUnit 测试运行器和 NSubstitute,而是使用了 Jest,它既是一个单元测试框架,也是一个模拟库。

我们在关于在组织层面实施单元测试的章节中增加了更多技术。

在书中展示的代码中,有很多设计上的变化。它们大多与动态类型语言(如 JavaScript)的使用相关,但我们也借助 TypeScript 讨论了静态类型技术。

关于测试可信度、可维护性和可读性的讨论已经扩展到三个单独的章节。我们还增加了一个关于测试策略的新章节:如何在不同测试类型之间做出决定以及使用哪些技术。

应该阅读这本书的人

这本书是为任何编写代码并对学习单元测试最佳实践感兴趣的人而写的。所有示例都使用 JavaScript 和 TypeScript 编写,因此 JavaScript 开发者会发现这些示例特别有用。但我们所教授的教训同样适用于大多数,如果不是所有面向对象和静态类型语言(例如 C#、VB.NET、Java 和 C++等)。如果你是架构师、开发者、团队领导、QA 工程师(编写代码的人)或新手程序员,这本书应该非常适合你。

本书组织结构:路线图

如果你从未编写过单元测试,最好从头到尾阅读这本书,以便获得全面了解。如果你有经验,你应该可以舒适地根据需要跳转到章节。本书分为四个部分。

第一部分将带您从零开始学习编写单元测试。第一章和第二章涵盖了基础知识,例如如何使用测试框架(Jest),并介绍了自动化测试的概念,如测试库、断言库和测试运行器。它们还介绍了断言、忽略测试、工作单元测试以及单元测试的三个类型的结果,以及为这些结果所需的三个类型的测试:值测试、基于状态的测试和交互测试。

第二部分讨论了打破依赖的高级技术:模拟对象、存根、隔离框架以及重构代码以使用它们的模式。第三章介绍了存根的概念,并展示了如何手动创建和使用它们。第四章介绍了使用模拟对象的交互测试。第五章将这两个概念合并,展示了隔离框架如何结合这两个想法并使它们自动化。第六章深入探讨了如何测试异步代码。

第三部分是关于如何组织测试代码、运行和重构其结构的模式,以及编写测试时的最佳实践。第七章讨论了编写可信赖测试的技术。第八章讨论了单元测试中的最佳实践,以创建可维护的测试。

第四部分全部关于如何在组织中实施变更以及如何处理现有代码。第九章是关于测试可读性的。第十章展示了如何制定测试策略。第十一章讨论了在尝试将单元测试引入组织时可能遇到的问题和解决方案,并识别并回答了您在实施此类努力过程中可能会被问到的一些问题。第十二章讨论了将单元测试引入遗留代码。它确定了确定开始测试位置的一两种方法,并讨论了一些用于测试不可测试代码的工具。

附录列出了您可能在测试工作中发现有用的猴子补丁技术。

代码约定和下载

列表中或文本中的所有源代码都使用类似于这样的固定宽度字体来区分它们与普通文本。在列表中,**粗体代码**表示与上一个示例相比已更改或将在下一个示例中更改的代码。在许多列表中,代码都有注释来指出关键概念。

您可以从 GitHub 在github.com/royosherove/aout3-samples下载本书的源代码,也可以从出版社的网站www.manning.com/books/the-art-of-unit-testing-third-edition下载。您还可以从本书的在线(liveBook)版本中获取可执行的代码片段,网址为livebook.manning.com/book/the-art-of-unit-testing-third-edition

软件需求

要使用本书中的代码,您需要 VS Code(它是免费的)。您还需要 Jest(一个开源和免费的框架)以及其他将在相关位置引用的工具。所有提到的工具都是免费、开源的,或者您可以在阅读本书时免费试用。所有工具都将在您阅读本书时免费使用。

liveBook 讨论论坛

购买《单元测试艺术,第三版》包括对 liveBook 的免费访问,这是曼宁的在线阅读平台。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为个人做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/the-art-of-unit-testing-third-edition/discussion。您还可以在livebook.manning.com/discussion了解更多关于曼宁论坛和行为准则的信息。

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

罗伊·奥谢罗夫的其他项目

罗伊还是《弹性领导:培养自我组织团队》一书的作者,可在www.manning.com/books/elastic-leadership找到,以及《给软件团队领导者的笔记:培养自我组织团队》(Team Agile Publishing,2014 年)。

其他资源:

您可以在 X 上关注他 @RoyOsherove。

弗拉基米尔·科里科夫的其他项目

弗拉基米尔还是《单元测试原则、实践和模式》一书的作者,您可以在www.manning.com/books/unit-testing找到。

其他资源:

你可以在 X 上关注他 @vkhorikov。

关于作者

Osherove

Roy Osherove 是 ALT.NET 的原始组织者之一,之前在 Typemock 担任首席架构师。他为全球团队提供关于单元测试和测试驱动开发的咨询和培训,并教导团队领导者如何在5whys.com上更好地领导。Roy 在@RoyOsherove 上发推文,并在ArtOfUnitTesting.com上有很多关于单元测试的视频。他还可以在Osherove.com预订演讲和培训。

Khorikov

Vladimir Khorikov 是微软 MVP、博客作者和 Pluralsight 作者。他从事软件开发工作已超过 10 年,包括指导团队进行单元测试的方方面面。Vladimir 是 Manning 出版的《单元测试:原则、实践和模式》一书的作者,他还撰写了几个流行的博客文章系列和关于单元测试的在线培训课程。他教学风格的最大优势,也是学生经常赞扬的地方,是他倾向于拥有强大的理论背景,然后将这些应用到实际例子中。他的博客在EnterpriseCraftsmanship.com

关于封面插图

《单元测试艺术,第三版》封面上的图像是一位“着礼服的日本人”,或称“日本礼仪男子”。这幅插图取自詹姆斯·普里查德的《人类自然史》,这是一本于 1847 年在英国出版的彩色石版画集。我们的封面设计师在旧金山的一家古董店发现了它。

在那些日子里,人们通过他们的服饰就能轻易识别出他们的居住地以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的封面设计来庆祝计算机行业的创新精神和主动性,这些文化通过像这一系列这样的图片被重新带回生活。

第一部分 入门

这一部分的书籍涵盖了单元测试的基础知识。

在第一章中,我将定义“单元”是什么,以及“良好”的单元测试意味着什么,并且我会比较单元测试集成测试。然后我们将探讨测试驱动开发及其与单元测试的关系。

在第二章中,你将尝试使用 Jest(一个常见的 JavaScript 测试框架)编写你的第一个单元测试。你将了解 Jest 的基本 API,如何断言事物,以及如何持续执行测试。

1 单元测试的基础

本章涵盖

  • 识别入口点和出口点

  • 单元测试工作单元的定义

  • 单元测试与集成测试之间的区别

  • 单元测试的简单示例

  • 理解测试驱动开发

手动测试很糟糕。你编写代码,你在调试器中运行它,你在应用中按下所有正确的键以使事情恰到好处,然后你重复所有这些操作,下次你编写新代码时。而且你必须记得检查所有可能受到新代码影响的代码。更多手动工作。太棒了。

完全手动进行测试和回归测试,一次又一次地重复相同的动作,就像猴子一样,容易出错且耗时,人们似乎对做这件事的厌恶程度不亚于软件开发中的任何事物。这些问题通过工具和我们的决定得到缓解,我们决定使用它来做好事,通过编写自动化测试来节省我们宝贵的时间和调试痛苦。集成和单元测试框架帮助开发者使用一组已知的 API 更快地编写测试,自动执行这些测试,并轻松地审查这些测试的结果。而且它们永远不会忘记!我假设你阅读这本书是因为你感觉一样,或者是因为有人强迫你阅读它,而那个人也有同样的感觉。或者也许那个人被迫强迫你阅读这本书。没关系。如果你认为重复的手动测试很棒,这本书将很难阅读。假设你想要学习如何编写好的单元测试。

本书还假设你了解如何使用 JavaScript 或 TypeScript 编写代码,至少使用 ECMAScript 6 (ES6) 功能,并且你对 node 包管理器 (npm) 感到舒适。另一个假设是你熟悉 Git 源代码控制。如果你之前见过 github.com 并且知道如何从那里克隆存储库,那么你就准备好了。

虽然本书的所有代码示例都是用 JavaScript 和 TypeScript 编写的,但你不必是 JavaScript 程序员就能阅读这本书。本书的前几版是 C# 编写的,我发现那里的大约 80% 的模式都很容易迁移。即使你来自 Java、.NET、Python、Ruby 或其他语言,你也应该能够阅读这本书。这些模式只是模式。语言被用来演示这些模式,但它们不是特定于语言的。

本书中的 JavaScript 与 TypeScript 的比较

本书包含整个过程中 vanilla JavaScript 和 TypeScript 的示例。我完全负责创建这样一个巴别塔(没有讽刺的意思),但我保证,这有一个很好的理由:这本书处理 JavaScript 中的三种编程范式:过程式函数式面向对象设计。

我使用常规 JavaScript 来处理过程式和函数式设计的示例。我使用 TypeScript 来处理面向对象的示例,因为它提供了表达这些想法所需的结构。

在本书的前几版中,当我使用 C# 进行工作时,这不是一个问题。当转向支持这些多种范式的 JavaScript 时,使用 TypeScript 是有意义的。

你可能会问,为什么不直接使用 TypeScript 来处理所有范式呢?这样做既是为了展示编写单元测试不需要 TypeScript,也是为了说明单元测试的概念并不依赖于某种语言或任何类型的编译器或代码检查工具来工作。

这意味着如果你对函数式编程感兴趣,本书中的一些示例可能对你有意义,而另一些可能看起来过于复杂或冗长。你可以自由地只关注函数式示例。

如果你从事面向对象编程或来自 C#/Java 背景,你会发现一些非面向对象的示例可能过于简单,并不能代表你在自己的项目中的日常工作。不要担心,会有很多与面向对象风格相关的章节。

1.1 第一步

总是有第一步:你第一次编写程序,第一次项目失败,以及第一次成功完成你想要达成的目标。你永远不会忘记你的第一次,我希望你也不会忘记你的第一次单元测试。

你可能已经以某种形式遇到过测试。一些你最喜欢的开源项目都附带“测试”文件夹——你在自己的工作项目中也有。你可能已经自己编写了一些测试,甚至可能记得它们是糟糕的、笨拙的、慢的或难以维护的。更糟糕的是,你可能觉得它们毫无用处,是浪费时间。(很多人很遗憾地是这样。)或者你可能对单元测试有了一次非常好的体验,你现在正在阅读这本书,看看你可能还错过了什么。

本章将分析单元测试的“经典”定义,并将其与集成测试的概念进行比较。这种区别对许多人来说可能很困惑,但了解这一点非常重要,因为正如你在本书后面将学到的,将单元测试与其他类型的测试分开可能是当测试失败或通过时对测试有高度信心的重要因素。

我们还将讨论单元测试与集成测试的优缺点,并制定一个更好的定义,说明可能是一个“好的”单元测试。我们将以对测试驱动开发(TDD)的探讨结束,因为它通常与单元测试相关联,但它是一项我强烈推荐尝试的独立技能(尽管这不是本书的主要内容)。在本章中,我还会简要提及本书其他部分更详细解释的概念。

首先,让我们定义一下单元测试应该是什么。

1.2 逐步定义单元测试

单元测试在软件开发中不是一个新概念。它自 20 世纪 70 年代 Smalltalk 编程语言的早期就已经存在,并且一次又一次地证明了自己是开发者提高代码质量、更深入理解模块、类或函数功能需求的最佳方式之一。Kent Beck 在 Smalltalk 中引入了单元测试的概念,并且这一概念已经扩展到许多其他编程语言中,使得单元测试成为一种极其有用的实践。

为了了解我们希望作为单元测试定义的内容,让我们以维基百科为起点。我将带着保留意见使用其定义,因为在我看来,其中缺少了许多重要的部分,但鉴于缺乏其他好的定义,它被许多人广泛接受。在本章中,我们的定义将逐渐演变,最终定义将在第 1.9 节出现。

单元测试通常是软件开发者编写的自动化测试,用于确保应用程序的一部分(称为“单元”)符合其设计和预期行为。在过程式编程中,一个单元可能是一个完整的模块,但更常见的是单个函数或过程。在面向对象编程中,一个单元通常是一个完整的接口,例如一个类,或者一个单独的方法(en.wikipedia.org/wiki/Unit_testing)。

你将要编写的测试对象是主题系统或测试套件(SUT)。

定义 SUT 代表主题系统或测试套件,有些人喜欢使用 CUT(组件、类或待测试代码)。当你测试某物时,你将你正在测试的东西称为 SUT。

让我们谈谈单元测试中的“单元”这个词。对我来说,“单元”代表系统内的“工作单元”或“用例”。工作单元有一个开始和一个结束,我称之为入口点出口点。工作单元的一个简单例子是一个计算并返回值的函数。然而,一个函数也可能在计算过程中使用其他函数、其他模块和其他组件,这意味着工作单元(从入口点到出口点)可能不仅仅是一个函数。

工作单元

工作单元是指从调用入口点开始,直到通过一个或多个出口点产生一个明显的结束结果之间发生的所有操作。入口点是我们触发的东西。例如,给定一个公开可见的函数

  1. 函数体是工作单元的全部或部分。

  2. 函数的声明和签名是进入函数体的入口点。

  3. 函数产生的输出或行为是其出口点。

1.3 入口点和出口点

工作单元始终有一个入口点和一个或多个出口点。图 1.1 显示了工作单元的简单示意图。

01-01

图 1.1 一个工作单元有入口点和出口点。

一个工作单元可以是一个函数、多个函数,甚至是多个模块或组件。但它始终有一个我们可以从外部触发的入口点(通过测试或其他生产代码),并且它最终总会做一些有用的事情。如果它没有做任何有用的事情,我们不妨从我们的代码库中移除它。

什么是有用的?在代码中公开可见的某些事情:一个返回值、状态变化,或者调用外部方,如图 1.2 所示。这些引人注目的行为就是我所说的出口点

01-02

图 1.2 出口点的类型

为什么叫“出口点”?

为什么使用“出口点”这个词而不是“行为”之类的词?我的想法是,行为可以是纯粹内部的,而我们寻找的是来自调用者的外部可见行为。这种差异可能一眼难以区分。此外,“出口点”很好地暗示了我们正在离开工作单元的上下文,回到测试上下文,尽管行为可能比这更灵活。关于行为类型,包括可观察行为,在 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning, 2020)中有广泛的讨论。请参考那本书以了解更多关于这个主题的信息。

以下列表展示了简单工作单元的快速代码示例。

列表 1.1 我们想要测试的简单函数

const sum = (numbers) => {
  const [a, b] = numbers.split(',');
  const result = parseInt(a) + parseInt(b);
  return result;
};

关于本书使用的 JavaScript 版本

我选择使用 Node.js 12.8 和纯 ES6 JavaScript 以及 JSDoc 风格的注释。我将使用的模块系统是 CommonJS,以保持简单。也许在未来的版本中,我会开始使用 ES 模块(.mjs 文件),但到目前为止,以及本书的其余部分,CommonJS 将足够。对于本书中的模式来说,这实际上并不重要。

您应该能够轻松地将这里使用的技巧应用到您目前正在使用的任何 JavaScript 栈中,无论您使用 TypeScript、纯 JS、ES 模块、后端或前端、Angular 还是 React。这都不重要。

获取本章的代码

您可以从 GitHub 下载本书中展示的所有代码示例。您可以在github.com/royosherove/aout3-samples找到存储库。请确保您已安装 Node 12.8 或更高版本,然后运行npm install,接着运行npm run ch[章节编号]。对于本章,您将运行npm run ch1。这将运行本章的所有测试,以便您可以看到它们的输出。

这个工作单元完全包含在一个单独的函数中。这个函数是入口点,因为它最终返回一个值,它也充当出口点。我们在触发工作单元的地方获得最终结果,所以入口点也是出口点。

如果我们将这个函数作为工作单元绘制,它看起来就像图 1.3 一样。我使用 sum(numbers) 作为入口点,而不是 numbers,因为入口点是函数签名。参数是通过入口点给出的上下文或输入。

01-03

图 1.3 一个具有相同入口点和出口点的函数

以下列表展示了这个想法的一个变体。

列表 1.2 具有入口点和出口点的工作单元

let total = 0;

const totalSoFar = () => {
 return total;
};

const sum = (numbers) => {
  const [a, b] = numbers.split(',');
  const result = parseInt(a) + parseInt(b);
 total += result; ❶
  return result;
};

❶ 新功能:计算累计总和

这个 sum 的新版本有两个出口点。它做两件事:

  • 它返回一个值。

  • 它引入了新的功能:所有求和的总计。它以某种明显的方式(通过 totalSoFar)设置模块的状态,供入口点的调用者可见。

图 1.4 展示了我会如何绘制这个工作单元。你可以将这些两个出口点视为从同一工作单元出发的两个不同路径或需求,因为它们确实 代码预期执行的两个不同的有用操作。这也意味着我可能会为每个出口点编写两个不同的单元测试:一个用于每个出口点。很快我们就会这样做。

01-04

图 1.4 具有两个出口点的工作单元

那么 totalSoFar 呢?这也是一个入口点吗?是的,它可以是一个,在单独的测试中。我可以编写一个测试来证明在调用之前不触发返回 0totalSoFar。这将使其成为一个独立的小的工作单元,这将是完全可行的。通常,一个工作单元(如 sum)可以由更小的单元组成。

如您所见,我们测试的范围可以改变和变异,但我们仍然可以使用入口点和出口点来定义它们。入口点始终是测试触发工作单元的地方。您可以为工作单元设置多个入口点,每个入口点由不同的测试集使用。

关于设计的说明

有两种主要类型的操作:“查询”操作和“命令”操作。查询操作不改变任何东西;它们只是返回值。命令操作改变东西但不返回值。

我们通常将它们结合起来,但在许多情况下,将它们分开可能是一个更好的设计选择。这本书主要不是关于设计的,但我敦促您在 Martin Fowler 的网站上了解更多关于 命令查询分离 的概念:martinfowler.com/bliki/CommandQuerySeparation.html

出口点表示需求和新的测试,反之亦然

出口点是工作单元的最终结果。对于单元测试,我通常为每个出口点至少编写一个测试,并给它一个可读的名字。然后我可以添加更多具有不同输入的测试,所有这些测试都使用相同的入口点,以获得更多的信心。

集成测试,我们将在本章和书中稍后讨论,通常包括多个最终结果,因为在那些级别上分离代码路径可能是不可能的。这也是集成测试更难调试、启动和维持的原因之一:它们所做的比单元测试多得多,正如你很快就会看到的。

下面列出了我们示例函数的第三个版本。

列表 1.3 向函数添加记录器调用

let total = 0;

const totalSoFar = () => {
  return total;
};

const logger = makeLogger();

const sum = (numbers) => {
  const [a, b] = numbers.split(',');
 logger.info( ❶
 'this is a very important log output', ❶
 { firstNumWas: a, secondNumWas: b }); ❶

  const result = parseInt(a) + parseInt(b);
  total += result;
  return result;
};

❶ 新增出口点

你可以看到函数中有一个新的出口点(或要求,或最终结果)。它将某些信息记录到外部实体——可能是文件、控制台或数据库。我们不知道,也不关心。这是第三种类型的出口点:调用第三方。我也喜欢称它为“调用依赖”。

定义 一个依赖是在单元测试期间我们没有完全控制的东西。或者它也可以是试图在测试中控制它会使我们的生活变得痛苦的东西。一些例子包括写入文件的记录器、与网络通信的事物、由其他团队控制的代码、需要很长时间(计算、线程、数据库访问)的组件等等。一般来说,如果我们可以完全且容易地控制它在做什么,并且它在内存中运行,且运行速度快,那么它就不是依赖。规则总有例外,但这应该至少能让你处理 80%的情况。

图 1.5 展示了如何用所有三个出口点绘制这个工作单元。在这个阶段,我们仍在讨论一个函数大小的工作单元。入口点是函数调用,但现在我们有三个可能路径,或出口点,执行一些有用的操作,并且调用者可以公开验证。

01-05

图 1.5 展示了一个函数的三个出口点

这里就变得有趣了:为每个出口点进行单独的测试是个好主意。这将使测试更易于阅读,并且更容易调试或更改,而不会影响其他结果。

1.4 出口点类型

我们已经看到我们有三种不同的最终结果:

  • 被调用的函数返回一个有用的值(不是未定义的)。如果在静态类型语言如 Java 或 C#中,我们会说它是一个公开的、非空函数。

  • 在调用前后,系统的状态或行为有明显的改变,可以在不查询私有状态的情况下确定。

  • 这里有一个对第三方系统的调用,测试者无法控制。该第三方系统不返回任何值,或者该值被忽略。(例如:代码调用了一个不是由你编写的第三方日志系统,并且你无法控制其源代码。)

《XUnit 测试模式》对入口点和出口点的定义

Gerard Meszaros 的书籍 XUnit Test Patterns(Addison-Wesley Professional,2007)讨论了直接输入和输出以及间接输入和输出的概念。直接输入是我喜欢称之为入口点的东西。Meszaros 称之为“使用组件的前门”。该书中的间接输出是我在前面提到的其他两种退出点类型(状态改变和调用第三方)。

这两种想法的版本都并行发展,但“工作单元”的概念只出现在这本书中。工作单元与入口和退出点结合,对我来说比直接和间接输入和输出更有意义,但你可以将这视为关于如何教授测试范围概念的风格选择。你可以在 xunitpatterns.com 上找到更多关于 XUnit Test Patterns 的信息。

让我们看看入口和退出点的概念如何影响单元测试的定义:单元测试是一段代码,它调用一个工作单元并检查该工作单元的一个特定退出点作为结果。如果关于结果假设是错误的,则单元测试失败。单元测试的范围可以从一个函数到多个模块或组件,具体取决于入口点和退出点之间使用的函数和模块的数量。

1.5 不同的退出点,不同的技术

为什么我要花这么多时间谈论退出点的类型?因为不仅将每个退出点的测试分开是一个好主意,而且不同的退出点类型可能需要不同的技术来成功测试:

  • 基于返回值的退出点(Meszaros 的 XUnit Test Patterns 中的直接输出)应该是测试起来最简单的退出点。你触发一个入口点,得到一些东西,然后检查你得到的价值。

  • 基于状态的测试(间接输出)通常需要更多的技巧。你调用某个东西,然后进行另一个调用以检查其他事情(或者再次调用之前的东西)以查看一切是否按计划进行。

在第三方情况(间接输出)中,我们需要跳过最多的障碍。我们还没有讨论这个问题,但这就是我们被迫使用像模拟对象这样的东西来替换外部系统,以便我们可以在测试中控制并质询的地方。我将在本书的后面深入探讨这个想法。

哪些退出点造成最多的问题?

按照惯例,我尽量主要使用基于返回值或状态的测试。如果可能,我会尽量避免使用基于模拟对象的测试,通常也是可以做到的。因此,我的测试中通常不超过 5%使用模拟对象进行验证。这类测试会使事情复杂化,并使维护变得更加困难。尽管如此,有时我们别无选择,我们将在接下来的章节中讨论它们。

1.6 从零开始进行测试

让我们回到代码的第一个、最简单的版本(列表 1.1),并尝试测试它,好吗?如果我们尝试为这个编写测试,它看起来会是什么样子?

让我们先从视觉方法开始,看看图 1.6。我们的入口点是 sum,输入是一个名为 numbers 的字符串。sum 也是我们的出口点,因为我们将从它那里获取一个返回值并检查其值。

01-06

图 1.6 我们测试的视觉视图

没有使用测试框架也可以编写自动化的单元测试。事实上,由于开发者越来越习惯于自动化他们的测试,我见过很多开发者在使用测试框架之前就已经这样做。在本节中,我们将不使用框架编写这样的测试,这样你就可以将这种方法与第二章中使用框架的方法进行对比。

因此,让我们假设测试框架不存在(或者我们不知道它们存在)。我们已经决定从头开始编写我们自己的小型自动化测试。以下列表显示了使用纯 JavaScript 测试我们自己的代码的一个非常简单的示例。

列表 1.4 对 sum() 的一个非常简单的测试

const parserTest = () => {
  try {
    const result = sum('1,2');
    if (result === 3) {
      console.log('parserTest example 1 PASSED');
    } else {
      throw new Error(`parserTest: expected 3 but was ${result}`);
    }
  } catch (e) {
    console.error(e.stack);
  }
};

不,这段代码并不优美。但它足以解释测试是如何工作的。要运行此代码,我们可以这样做:

  1. 打开命令行并输入一个空字符串。

  2. 在 package.json 的 "scripts" 条目下添加一个条目 "test",以执行 "node mytest.js",然后在命令行上执行 npm test

以下列表显示了这一点。

列表 1.5 我们 package.json 文件的开头

{
  "name": "aout3-samples",
  "version": "1.0.0",
  "description": "Code Samples for Art of Unit Testing 3rd Edition",
  "main": "index.js",
  "scripts": {
 "test": "node ./ch1-basics/custom-test-phase1.js",
  }
}

测试方法调用 生产模块(SUT)并检查返回的值。如果不是预期的值,测试方法将错误和堆栈跟踪写入控制台。测试方法还会捕获发生的任何异常并将它们写入控制台,这样它们就不会干扰后续方法的运行。当我们使用测试框架时,这通常会被自动处理。

显然,这是一种编写此类测试的临时方法。如果你要编写多个这样的测试,你可能希望有一个通用的 testcheck 方法,所有测试都可以使用,并且可以一致地格式化错误。你还可以添加特殊的辅助方法,用于检查诸如空对象、空字符串等事物,这样你就不需要在多个测试中编写相同的冗长代码行。

以下列表显示了使用稍微更通用的 checkassertEquals 函数的测试看起来会是什么样子。

列表 1.6 使用 Check 方法的更通用实现

const assertEquals = (expected, actual) => {
  if (actual !== expected) {
    throw new Error(`Expected ${expected} but was ${actual}`);
  }
};

const check = (name, implementation) => {
  try {
    implementation();
    console.log(`${name} passed`);
  } catch (e) {
    console.error(`${name} FAILED`, e.stack);
  }
};

check('sum with 2 numbers should sum them up', () => {
 const result = sum('1,2');
 assertEquals(3, result);
});

check('sum with multiple digit numbers should sum them up', () => {
 const result = sum('10,20');
 assertEquals(30, result);
});

我们现在创建了两个辅助方法:assertEquals,它消除了写入控制台或抛出错误的样板代码,以及 check,它接受一个字符串作为测试的名称和一个回调到实现。然后它负责捕获任何测试错误,将它们写入控制台,并报告测试的状态。

内置断言

需要注意的是,我们不需要编写自己的断言。我们可以很容易地使用 Node.js 的内置断言函数,这些函数最初是为测试 Node.js 本身而内部构建的。我们可以通过导入函数来实现这一点:

const assert = require('assert'); 

然而,我正在尝试展示这个概念背后的简单性,所以我们将会避免这一点。你可以在nodejs.org/api/assert.html找到更多关于 Node.js 的assert模块的信息。

注意,使用几个辅助方法,测试变得更容易阅读和编写。像 Jest 这样的单元测试框架可以提供更多这样的通用辅助方法,从而使测试更容易编写。我将在第二章中讨论这一点。首先,让我们谈谈本书的主要主题:好的单元测试。

1.7 一个好的单元测试的特点

无论你使用什么编程语言,定义单元测试最困难的方面之一就是定义什么是“好的”。当然,好是相对的,并且每当我们对编码有新的了解时,它都可能发生变化。这看起来可能很明显,但实际上并不是。我需要解释为什么我们需要编写更好的测试——仅仅理解工作单元是什么是不够的。

根据我多年的经验,涉及许多公司和团队,大多数试图对代码进行单元测试的人要么在某一点上放弃,要么实际上并没有进行单元测试。他们浪费了很多时间编写有问题的测试,当需要花费大量时间维护它们时,他们会放弃,或者更糟糕的是,他们不相信测试结果。

写一个糟糕的单元测试是没有意义的,除非你正处于学习如何编写一个好的单元测试的过程中。编写糟糕的测试的缺点比优点多,比如浪费时间调试有缺陷的测试,浪费时间编写没有带来任何好处的测试,浪费时间试图理解难以阅读的测试,以及浪费时间编写几个月后就会删除的测试。维护糟糕的测试也存在巨大的问题,以及它们如何影响生产代码的可维护性。糟糕的测试实际上会减慢你的开发速度,不仅是在编写测试代码时,而且在编写生产代码时也是如此。我将在本书的后面部分讨论所有这些内容。

通过了解什么是好的单元测试,你可以确保你不会走上一个以后难以修复的道路,当代码变成噩梦时。我们还会在本书的后面部分定义其他形式的测试(组件测试、端到端测试等)。

1.7.1 什么是好的单元测试?

每一个好的自动化测试(不仅仅是单元测试)都应该具备以下特性:

  • 应该很容易理解测试作者的意图。

  • 它应该易于阅读和编写。

  • 它应该是自动化的。

  • 它的结果应该是一致的(如果你在运行之间没有改变任何东西,它应该总是返回相同的结果)。

  • 它应该是有用的,并提供可操作的结果。

  • 任何人都可以通过按按钮来运行它。

  • 当它失败时,应该很容易检测到预期的内容,并确定如何定位问题。

一个好的单元测试还应具备以下特性:

  • 它应该运行得快。

  • 它应该对被测试的代码拥有完全控制权(更多内容请见第三章)。

  • 它应该是完全隔离的(独立于其他测试运行)。

  • 它应该在内存中运行,而不需要系统文件、网络或数据库。

  • 当这样做有意义时,它应该尽可能同步和线性(如果可能的话,不要使用并行线程)。

并非所有测试都能遵循良好单元测试的特性,这是完全可以接受的。这样的测试将简单地过渡到集成测试的领域(第 1.8 节的主题)。尽管如此,仍然有方法可以将一些测试重构为符合这些特性。

用存根替换数据库(或另一个依赖项)

我们将在后面的章节中讨论存根,但简而言之,它们是模拟真实依赖项的假依赖项。它们的目的在于简化测试过程,因为它们更容易设置和维护。

尽管如此,要小心内存数据库。它们可以帮助你将测试彼此隔离(只要测试之间不共享数据库实例),从而遵守良好单元测试的特性,但这样的数据库会导致一个尴尬的中间地带。内存数据库不像存根那样容易设置。同时,它们也不提供像真实数据库那样的强保证。在功能上,内存数据库可能与生产环境中的数据库大相径庭,因此通过内存数据库通过的测试可能会在真实数据库中失败,反之亦然。你通常需要手动对生产数据库重新运行相同的测试,以获得额外的信心,确保你的代码是有效的。除非你使用一组小型和标准化的 SQL 功能,否则我建议坚持使用存根(用于单元测试)或真实数据库(用于集成测试)。

对于像 jsdom 这样的解决方案也是如此。你可以用它来替换真实的 DOM,但请确保它支持你的特定用例。不要编写需要手动重新检查的测试。

使用线性、同步测试模拟异步处理

随着承诺(promises)和async/await的出现,异步编程已成为 JavaScript 的标准。尽管如此,我们的测试仍然可以同步验证异步代码。通常这意味着直接从测试中触发回调或显式等待异步操作完成执行。

1.7.2 单元测试清单

许多人混淆了测试软件的行为与单元测试的概念。首先,请自问以下关于你迄今为止编写和执行的测试的问题:

  • 我能否运行并从两个月或几个月或几年前我编写的测试中获得结果?

  • 我的团队成员中是否有人能运行并从两个月前我编写的测试中获得结果?

  • 我能否在几分钟内运行我编写的所有测试?

  • 我能否按一下按钮就运行我编写的所有测试?

  • 我能否在几分钟内编写一个基本的测试?

  • 当另一个团队的代码中存在错误时,我的测试是否通过?

  • 在不同的机器或环境中运行我的测试结果是否相同?

  • 如果没有数据库、网络或部署,我的测试是否停止工作?

  • 如果我删除、移动或更改一个测试,其他测试是否不受影响?

如果你对这些问题的任何一个回答了“不”,那么你正在实施的内容很可能既不是完全自动化的,也不是单元测试。它肯定是一种测试,可能和单元测试一样重要,但与那些能让你对所有这些问题都回答“是”的测试相比,它有缺点。

“我之前都在做什么?”你可能会问。你一直在做集成测试。

1.8 集成测试

我认为集成测试是指不符合之前概述的任何一个或多个良好单元测试条件的任何测试。例如,如果测试使用了真实的网络、真实的 REST API、真实系统时间、真实文件系统或真实数据库,那么它已经进入了集成测试的领域。

如果一个测试没有控制系统时间,例如,在测试代码中使用当前的new Date(),那么每次测试执行时,它本质上都是不同的测试,因为它使用了不同的时间。它不再是一致的。这本身并不是坏事。我认为集成测试是单元测试的重要补充,但它们应该分开,以实现“安全绿色区域”的感觉,这在本书的后面会讨论。

如果一个测试使用了真实数据库,它就不再是仅在内存中运行了——它的操作比仅使用内存中的模拟数据更难擦除。测试也将运行得更长,我们不容易控制数据访问所需的时间。单元测试应该是快速的。集成测试通常要慢得多。当你开始有成百上千个测试时,每一半秒都很重要。

集成测试增加了另一个问题的风险:一次测试太多东西。例如,假设你的车坏了。你是如何学习问题的,更不用说修复它了?发动机由许多子系统组成,它们共同工作,每个子系统都依赖其他子系统来帮助产生最终结果:一辆行驶的汽车。如果汽车停止移动,故障可能是任何子系统,或者多个子系统。这些子系统的集成(或层)使汽车移动。你可以把汽车的运动看作是这些部件在汽车下路的最终集成测试。如果测试失败,所有部件一起失败;如果成功,所有部件都成功。

在软件中,大多数开发者测试功能的方式是通过应用或 REST API 或 UI 的最终功能。点击某个按钮会触发一系列事件——函数、模块和组件协同工作以产生最终结果。如果测试失败,所有这些软件组件作为一个团队失败,可能很难找出导致整体操作失败的原因(见图 1.7)。

01-07

图 1.7 在集成测试中,你可以有多个故障点。所有单元都必须协同工作,每个单元都可能出现故障,这使得找到错误源更加困难。

根据比尔·赫塞尔(Bill Hetzel)在《软件测试完全指南》(Wiley, 1988)中的定义,集成测试是“一种有序的测试进展,其中软件和/或硬件元素被组合并测试,直到整个系统被集成。”以下是我自己对集成测试定义的变体:

集成测试是在没有完全控制所有真实依赖项的情况下测试一个工作单元,这些依赖项可能包括其他团队的其他组件、其他服务、时间、网络、数据库、线程、随机数生成器等等。

总结来说,集成测试使用真实依赖项;单元测试将工作单元与其依赖项隔离,以便它们的结果容易保持一致,并且可以轻松控制和模拟单元行为的任何方面。

让我们将 1.7.2 节中的问题应用到集成测试中,并考虑你在现实世界的单元测试中想要实现的目标:

  • 我能否运行并从两周、几个月或几年前写的测试中获得结果?

    如果你不能,你将如何知道你是否破坏了你之前创建的功能?共享数据和代码在应用程序的生命周期中会定期更改,如果你不能(或不愿意)在更改代码后为所有之前工作的功能运行测试,你可能会在不了解的情况下破坏它——这被称为回归。回归似乎在冲刺或发布的最后阶段发生得很多,当时开发者们面临着修复现有错误的压力。有时,他们在解决旧错误时无意中引入了新的错误。知道你在 60 秒内破坏了某物不是很好吗?你将在本书的后面部分看到如何做到这一点。

定义 A:回归是指损坏的功能——曾经工作过的代码。你也可以将其视为一个或多个曾经工作但现在不工作的工作单元。

  • 我的团队能否运行并从两个月前我写的测试中获得结果?

    这与上一个观点相关,但更进一步。你想要确保在更改某些内容时不会破坏他人的代码。许多开发者害怕更改旧系统中的遗留代码,因为他们不知道他们更改的代码依赖于什么其他代码。本质上,他们冒着将系统改变到一个未知稳定状态的风险。

    没有什么事情比不知道应用程序是否仍然工作更令人害怕的,尤其是当你没有编写那段代码的时候。如果你有单元测试的安全网,并且知道你没有破坏任何东西,那么你对处理不太熟悉的代码就会少很多恐惧。

    好的测试可以被任何人访问和运行。

定义遗留代码被维基百科定义为“不再在标准硬件和环境上得到支持的旧计算机源代码”(en.wikipedia.org/wiki/Legacy_system),但许多商店将当前维护的任何旧版本应用程序称为遗留代码。这通常指的是难以工作、难以测试,通常甚至难以阅读的代码。一位客户曾经用一种接地气的方式定义遗留代码:“能工作的代码。”许多人喜欢将遗留代码定义为“没有测试的代码”。Michael Feathers 的《与遗留代码有效协作》(Pearson,2004)将“没有测试的代码”作为遗留代码的官方定义,这是在阅读这本书时需要考虑的定义。

  • 我能在几分钟内运行我写的所有测试吗?

    如果你不能快速运行你的测试(秒比分钟好),你将更少地运行它们(每天,甚至在某些地方每周或每月)。问题是当你更改代码时,你希望尽早得到反馈,以查看你是否破坏了某些东西。运行测试所需的时间越长,你对系统进行的更改就越多,当你发现你破坏了某些东西时,你将不得不查找(许多)更多的地方来寻找错误。

    好的测试应该运行得很快

  • 我能否按一下按钮就能运行我写的所有测试?

    如果你做不到,这可能意味着你必须配置测试运行的机器,以确保它们能够正确运行(例如设置 Docker 环境,或设置数据库的连接字符串),或者这可能意味着你的单元测试并没有完全自动化。如果你不能完全自动化你的单元测试,你可能会避免反复运行它们,你的团队中的其他人也会这样做。

    没有人喜欢被配置细节拖累,只是为了确保系统仍然工作。开发者有更重要的事情要做,比如将更多功能写入系统。但如果你不知道系统的状态,他们就不能这样做。

    好的测试应该能够以它们原始的形式轻松执行,而不是手动执行。

  • 我能在几分钟内写一个基本的测试吗?

    识别集成测试的一种简单方法就是它需要花费时间来正确准备和实施,而不仅仅是执行。由于所有内部和有时外部的依赖关系,编写它的方法也需要时间。例如,数据库可能被视为外部依赖。)如果你没有自动化测试,依赖关系就不再是问题,但你正在失去自动化测试的所有好处。编写测试越困难,你编写更多测试或专注于除你担心的“重要事项”之外的事情的可能性就越小。单元测试的一个优点是它们往往会测试可能出错的每一个小细节,而不仅仅是重要事项。人们常常惊讶于他们可以在看似简单且无错误的代码中找到多少错误。

    当你只关注大型测试时,你对代码的整体信心仍然非常不足。代码的核心逻辑的许多部分都没有经过测试(即使你可能覆盖了更多组件),并且可能存在许多你没有考虑过且可能“非正式”担忧的错误。

    一旦你确定了想要用来测试你特定的对象集、函数和依赖关系(领域模型)的模式,针对系统的良好测试应该很容易编写,也很快速。

  • 当另一个团队的代码中有错误时,我的测试是否通过?在不同的机器或环境中运行时,我的测试结果是否相同?如果没有数据库、网络或部署,我的测试是否停止工作?

    这三点指的是我们的测试代码与各种依赖关系隔离的想法。测试结果是一致的,因为我们控制了那些间接输入到我们系统中的内容。我们可以有假数据库、假网络、假时间和假机器文化。在后面的章节中,我将把这些点称为存根接口,我们可以在这里注入这些存根。

  • 如果我删除、移动或更改一个测试,其他测试是否仍然不受影响?

    单元测试通常不需要任何共享状态,但集成测试通常需要,例如外部数据库或服务。共享状态可以在测试之间创建依赖关系。例如,以错误的顺序运行测试可能会破坏未来测试的状态。

警告 即使经验丰富的单元测试人员也可能发现,编写针对他们之前从未进行过单元测试的领域模型的第一个单元测试可能需要 30 分钟或更长时间。这是工作的一部分,是可以预料的。一旦你确定了单元工作的入口和出口点,对该领域模型的第二次和随后的测试应该很容易完成。

我们可以在之前的问题和答案中识别出三个主要标准:

  • 可读性——如果我们无法阅读它,那么维护它、调试它和了解出了什么问题都变得很困难。

  • 可维护性——如果维护测试或生产代码因为测试而痛苦,我们的生活将变成一个活生生的噩梦。

  • 信任——如果我们不相信测试失败的结果,我们将开始手动测试,从而失去测试本应提供的所有时间效益。如果我们不相信测试通过的结果,我们将开始进行更多的调试,再次失去任何时间效益。

根据我到目前为止关于单元测试不是什么以及测试要有效需要具备哪些特性的解释,我现在可以开始回答本章提出的主要问题:什么是好的单元测试?

1.9 完善我们的定义

现在我已经介绍了单元测试应该具备的重要属性,我将一次性定义单元测试:

单元测试是一段自动化的代码,它通过一个入口点调用工作单元,然后检查其出口点之一。单元测试几乎总是使用单元测试框架编写的。它编写起来容易,运行速度快。它是可信的、可读的和可维护的。只要我们控制的生成代码没有变化,它就是一致的。

这个定义显然要求很高,尤其是考虑到许多开发者实施单元测试的方式不佳。这让我们不得不认真审视我们作为开发者至今为止实施测试的方式,以及我们希望如何实施测试。(在第七章至第九章中深入讨论了可信、可读和可维护的测试。)

在本书的第一版中,我对单元测试的定义略有不同。我过去将单元测试定义为“仅针对控制流代码运行”,但我不这么认为。没有逻辑的代码通常用作工作单元的一部分。即使没有逻辑的属性也会被工作单元使用,因此它们不需要被测试专门针对。

定义 控制流代码 是指任何包含某种逻辑的代码片段,无论其逻辑多么简单。它包含以下之一或多个:一个 if 语句、一个循环、计算或任何其他类型的决策代码。

获取器和设置器是代码的好例子,通常不包含任何逻辑,因此不需要测试专门针对。这是可能会被你测试的工作单元使用的代码,但不需要直接测试它。但要注意:一旦你在获取器或设置器中添加任何逻辑,你将想要确保该逻辑正在被测试。

在下一节中,我们将停止讨论什么是好的测试,而是讨论 何时 你可能想要编写测试。我将讨论测试驱动开发,因为它经常与进行单元测试放在一起。我想确保我们在这方面记录准确。

1.10 测试驱动开发

一旦你学会了如何使用单元测试框架编写可读、可维护和可信的测试,下一个问题就是 何时 编写测试。许多人认为编写软件单元测试的最佳时间是创建了一些功能之后,但在将代码合并到远程源控制之前。

此外,坦白说,很多人不相信编写测试是一个好主意,但通过反复试验,他们意识到源代码审查中有严格的测试要求,所以他们必须编写测试来取悦代码审查之神,并将他们的代码合并到主分支中。(这种动态是产生糟糕测试的绝佳来源,我将在本书的第三部分中讨论这个问题。)

越来越多的开发者倾向于在编码会话期间以及在每个非常小的功能实现之前逐步编写单元测试。这种方法被称为测试先行测试驱动开发(TDD)。

注意:关于测试驱动开发的确切含义存在许多不同的观点。有些人说它是测试先行开发,有些人说它意味着你有很多测试。有些人说它是一种设计方法,而其他人感觉它可能只是用一些设计来驱动代码行为的方法。在这本书中,TDD 意味着测试先行开发,设计在技术中扮演着增量角色(除了本节之外,本书不会讨论 TDD)。

图 1.8 和 1.9 展示了传统编码与 TDD 之间的差异。TDD 与传统开发不同,如图 1.9 所示。你首先编写一个失败的测试;然后继续创建生产代码,看到测试通过,并继续重构你的代码或创建另一个失败的测试。

01-08

图 1.8 传统编写单元测试的方式

01-09

图 1.9 测试驱动开发——鸟瞰图。注意过程的循环性:编写测试,编写代码,重构,编写下一个测试。它展示了 TDD 的增量特性:小步骤带来有信心的高质量最终结果。

本书侧重于编写良好单元测试的技术,而不是 TDD,但我对 TDD 非常推崇。我使用 TDD 编写了好几个主要的应用程序和框架,我管理过使用它的团队,我还教授了数百门关于 TDD 和单元测试技术的课程和工作坊。在我的整个职业生涯中,我发现 TDD 有助于创建高质量的代码、高质量的测试以及我编写的代码的更好设计。我相信它对你有益,但它并非没有代价(学习时间、实施时间以及更多)。不过,如果你愿意接受学习它的挑战,这绝对物有所值。

1.10.1 TDD:不是良好单元测试的替代品

重要的是要认识到,TDD 并不能保证项目成功或测试的健壮性或可维护性。很容易陷入 TDD 技术的陷阱,而忽略了单元测试的编写方式:它们的命名、可维护性或可读性,以及它们是否测试了正确的事物或可能自身存在错误。这就是我写这本书的原因——因为编写良好的测试是一项与 TDD 不同的技能。

TDD 的技术非常简单:

  1. 编写一个失败的测试来证明最终产品中缺少代码或功能。 这个测试是“好像”生产代码已经工作一样编写的,所以测试失败意味着生产代码中存在错误。我如何知道?这个测试是编写成如果生产代码没有错误就会通过的样子。

    在 JavaScript 之外的一些语言中,测试可能一开始甚至无法编译,因为代码还不存在。一旦它运行,它应该失败,因为生产代码还没有工作。这就是在测试驱动设计思维中发生很多“设计”的地方。

  2. 通过向生产代码中添加满足测试预期的新功能来使测试通过。 生产代码应该尽可能简单。不要触摸测试。你必须只通过触摸生产代码来使其通过。

  3. *重构你的代码。 当测试通过时,你可以自由地继续下一个单元测试或重构代码(包括生产代码和测试),使其更易于阅读,移除代码重复,等等。这也是“设计”部分发生的地方。我们重构,甚至可以在保持旧功能的同时重新设计我们的组件。

    重构步骤应该非常小且逐步进行,我们在每个小步骤之后运行所有测试,以确保我们没有因为我们的更改而破坏任何东西。重构可以在编写几个测试之后或在每个测试编写之后进行。这是一个重要的实践,因为它确保你的代码更容易阅读和维护,同时仍然通过所有之前编写的测试。书中后面有一个关于重构的整个部分(8.3)。

重构的定义是指在不改变其功能的情况下改变代码的一部分。如果你曾经重命名过方法,你就已经进行了重构。如果你曾经将一个大方法拆分成多个较小的方法调用,你就已经重构了你的代码。代码仍然执行相同的功能,但它变得更容易维护、阅读、调试和修改。

前面的步骤听起来很技术性,但它们背后有很多智慧。如果正确执行,TDD 可以使你的代码质量大幅提升,减少错误数量,提高你对代码的信心,缩短查找错误的时间,改进代码的设计,并让你的经理更加满意。如果 TDD 执行不当,可能会导致你的项目进度延误,浪费你的时间,降低你的动力,并降低代码质量。这是一把双刃剑,很多人都是通过艰难的方式才意识到这一点。

从技术上讲,TDD 最大的好处之一是没有人告诉您的是,通过看到测试失败,然后看到测试在没有更改的情况下通过,您实际上是在测试测试本身。如果您期望它失败而它通过了,您可能在测试中有一个错误,或者您测试了错误的东西。如果测试失败了,您修复了它,现在您期望它通过,但它仍然失败,您的测试可能有一个错误,或者它可能期望发生错误的事情。

本书处理可读的、可维护的、值得信赖的测试,但如果您在 TDD 之上添加,您将看到失败的测试,您修复了它,当应该失败时测试失败,当应该通过时测试通过,您对自己测试的信心将会增加。在测试之后风格中,您通常只有在应该通过时才会看到它们通过,在不应该通过时才会失败(因为它们测试的代码应该已经工作)。TDD 在这方面有很大帮助,这也是开发者为什么在实践 TDD 时比在事后简单地单元测试时进行更少的调试的原因之一。如果他们信任测试,他们就不会觉得有必要“以防万一”进行调试。这种信任只能通过看到测试的双方——当应该失败时失败,当应该通过时通过——来获得。

1.10.2 成功 TDD 所需的三项核心技能

要在测试驱动开发中取得成功,您需要三种不同的技能集:知道如何编写好的测试,以测试优先的方式编写它们,以及设计良好的测试和生产代码。图 1.10 更清楚地展示了这些:

  • 仅仅因为您先编写测试并不意味着它们是可维护的、可读的或值得信赖的。良好的单元测试技能正是本书的主题。

  • 仅仅因为您编写了可读的、可维护的测试并不意味着您会得到与先编写测试相同的收益。测试优先的技能是大多数 TDD 书籍所教授的,而没有教授良好的测试技能。我特别推荐 Kent Beck 的通过示例进行测试驱动开发(Addison-Wesley Professional,2002 年)。

  • 仅仅因为您先编写测试,并且它们是可读的和可维护的,并不意味着您最终会得到一个设计良好的系统。设计技能是使您的代码美观和可维护的关键。我推荐 Steve Freeman 和 Nat Pryce 的通过测试引导面向对象软件发展(Addison-Wesley Professional,2009 年)以及 Robert C. Martin 的Clean Code(Pearson,2008 年)作为该主题的好书。

01-10

图 1.10 测试驱动开发的核心三项技能

学习 TDD 的实用方法是分别学习这三个方面;也就是说,一次专注于一项技能,同时忽略其他技能。我推荐这种方法的原因是,我经常看到人们试图同时学习这三个技能集,在学习过程中遇到极大的困难,最终因为障碍太高而放弃。通过采取更渐进的方法来学习这个领域,你可以减轻自己在当前关注领域之外的其他领域犯错的持续恐惧。

在下一章中,你将开始使用 Jest 编写你的第一个单元测试,Jest 是 JavaScript 最常用的测试框架之一。

摘要

  • 一个好的单元测试具有以下特点:

    • 它应该运行得很快。

    • 它应该完全控制被测试的代码。

    • 它应该完全隔离(它应该独立于其他测试运行)。

    • 它应该在内存中运行,无需文件系统文件、网络或数据库。

    • 它应该尽可能同步和线性(没有并行线程)。

  • 入口点是公共函数,是进入我们的工作单元的门,并触发底层逻辑。出口点是你可以用测试检查的地方。它们代表了工作单元的效果。

  • 一个出口点可以是一个返回值、状态的变化或对第三方依赖的调用。每个出口点通常需要一个单独的测试,每种类型的出口点都需要不同的测试技术。

  • 工作单元是在入口点调用和通过一个或多个出口点达到一个明显的结束结果之间的所有操作的集合。工作单元可以跨越一个函数、一个模块或多个模块。

  • 集成测试只是单元测试,其中一些或所有依赖项是真实的,并位于当前执行过程之外。相反,单元测试就像集成测试一样,但所有依赖项都在内存中(真实的和假的),并且我们在测试中控制它们的行为。

  • 任何测试最重要的属性是可读性、可维护性和可信度。可读性告诉我们测试的可读性和理解难度。可维护性是衡量维护测试代码痛苦程度的指标。没有可信度,在代码库中引入重要更改(如重构)会更困难,这会导致代码退化。

  • 测试驱动开发(TDD)是一种提倡在编写生产代码之前编写测试的技术。这种方法也被称为测试优先方法(与代码优先相对)。

  • TDD 的主要好处是验证测试的正确性。在编写生产代码之前看到测试失败可以确保如果它们覆盖的功能停止正常工作,这些相同的测试也会失败。

2 第一次单元测试

本章涵盖

  • 使用 Jest 编写你的第一个测试

  • 测试结构和命名约定

  • 使用断言库进行工作

  • 重构测试和减少重复代码

当我第一次开始使用真正的单元测试框架编写单元测试时,几乎没有文档,我工作的框架也没有合适的示例。(当时我主要在用 VB 5 和 6 进行编码。)学习如何与他们一起工作是一个挑战,我开始编写相当糟糕的测试。幸运的是,时代已经改变。在 JavaScript 中,实际上在所有语言中,都有广泛的选择,社区提供了大量的文档和支持来尝试这些有用的捆绑包。

在上一章中,我们编写了一个非常简单的自编测试框架。在这一章中,我们将探讨 Jest,它将成为本书的框架选择。

2.1 介绍 Jest

Jest 是由 Facebook 创建的开源测试框架。它易于使用,易于记忆,并且有很多优秀的功能。Jest 最初是为在 JavaScript 中测试前端 React 组件而创建的。如今,它在行业的许多部分都广泛用于后端和前端项目的测试。它支持两种主要的测试语法(一种使用单词test,另一种基于 Jasmin 语法,这是一个启发了 Jest 许多功能的框架)。我们将尝试两者,看看我们更喜欢哪一个。

除了 Jest,JavaScript 中还有许多其他的测试框架,几乎都是开源的。它们在风格和 API 上有些不同,但就本书的目的而言,这并不重要太多。

2.1.1 准备我们的环境

确保你已经在本地安装了 Node.js。你可以按照nodejs.org/en/download/上的说明来在你的机器上安装它。该网站将提供长期支持(LTS)版本或当前版本的选项。LTS 版本面向企业,而当前版本有更频繁的更新。对于本书的目的来说,两者都适用。

确保你的机器上安装了 node 包管理器(npm)。它包含在 Node.js 中,所以请在命令行上运行npm -v命令,如果你看到一个 6.10.2 或更高版本的版本,你应该可以开始了。如果不是,请确保已经安装了 Node.js。

2.1.2 准备我们的工作文件夹

要开始使用 Jest,让我们创建一个名为“ch2”的新空文件夹,并用你选择的包管理器初始化它。我会使用 npm,因为我必须选择一个。Yarn 是另一种包管理器。对于本书的目的来说,你使用哪一个并不重要。

Jest 期望有一个 jest.config.js 文件或一个 package.json 文件。我们将选择后者,npm init将为生成一个:

mkdir ch2
cd ch2
npm init --yes
//or
yarn init -yes 
git init

我也在这个文件夹中初始化 Git。这无论如何都是推荐的,以跟踪更改,但对于 Jest 来说,这个文件在幕后用于跟踪文件更改并运行特定的测试。这使得 Jest 的生活更加轻松。

默认情况下,Jest 将在其配置文件中查找配置,该文件是由此命令创建的 package.json 文件,或者在一个特殊的 jest.config.js 文件中。目前,我们不需要除了默认的 package.json 文件以外的任何东西。如果您想了解更多关于 Jest 配置选项的信息,请参阅 jestjs.io/docs/en/configuration

2.1.3 安装 Jest

接下来,我们将安装 Jest。为了将 Jest 作为开发依赖项安装(这意味着它不会被分发到生产环境中),我们可以使用以下命令:

npm install --save-dev jest
//or
yarn add jest -dev

这将在我们的 [根文件夹]/node_modules/bin 下创建一个新的 jest.js 文件。然后我们可以使用 npx jest 命令来执行 Jest。

我们还可以在本地机器上全局安装 Jest(我建议在 save-dev 安装之上执行此操作)通过执行以下命令:

npm install -g jest

这将使我们能够在任何有测试的文件夹中直接从命令行执行 jest 命令,而无需通过 npm 来执行它。

在实际项目中,通常使用 npm 命令来运行测试,而不是使用全局的 jest。我将在接下来的几页中展示如何这样做。

2.1.4 创建测试文件

Jest 有几种默认方式来查找测试文件:

  • 如果有 tests 文件夹,它将加载其中的所有文件作为测试文件,无论它们的命名约定如何。

  • 它会尝试查找任何以 *.spec.js 或 *.test.js 结尾的文件,在项目根目录下的任何文件夹中,递归地查找。

我们将使用第一种变体,但也会用 *test.js 或 *.spec.js 命名我们的文件,以便在以后移动它们时保持一致性(并完全停止使用 _tests 文件夹)。

您也可以根据个人喜好配置 Jest,指定如何查找哪些文件在哪里,通过 jest.config.js 文件或通过 package.json 实现。您可以在 jestjs.io/docs/en/configuration 查找 Jest 文档,以获取所有详细信息。

下一步是在我们的 ch2 文件夹下创建一个特殊的文件夹,名为 tests。在这个文件夹下,创建一个以 test.js 或 spec.js 结尾的文件——例如 my-component.test.js。您选择哪个后缀取决于您自己的风格。在这本书中,我会交替使用它们,因为我认为“test”是“spec”最简单的版本,所以我在展示非常简单的事情时使用它。

测试文件位置

我看到两种主要的放置测试文件的模式:有些人喜欢将测试文件直接放置在要测试的文件或模块旁边。其他人则喜欢将所有文件放在一个测试目录下。你选择哪种方法并不重要;只需在整个项目中保持一致,这样就可以轻松地找到特定项目的测试。

我发现将测试放在测试文件夹中,我还能够将辅助文件放在测试文件夹中,靠近测试。至于在测试和被测试的代码之间轻松导航,大多数 IDE 现在都有插件,允许你通过键盘快捷键在代码及其测试之间导航。

我们不需要在文件顶部使用 require() 来开始使用 Jest。它自动为我们导入全局函数。你应该感兴趣的函数主要包括 testdescribeitexpect。列表 2.1 显示了一个简单的测试可能的样子。

列表 2.1 Hello Jest

test('hello jest', () => {
    expect('hello').toEqual('goodbye');
});

我们还没有使用 describeit,但很快就会用到。

2.1.5 执行 Jest

要运行这个测试,我们需要能够执行 Jest。为了让 Jest 在命令行中被识别,我们需要执行以下操作之一:

  • 通过运行 npm install jest -g 在机器上全局安装 Jest。

  • 使用 npx 在 ch2 文件夹的根目录下输入 jest 来从 node_modules 目录执行 Jest。

如果所有星星都正确对齐,你应该会看到 Jest 测试运行的结果和失败。你的第一次失败。太棒了!图 2.1 显示了我运行命令时的终端输出。看到来自测试工具的如此可爱、多彩(如果你在阅读电子书的话)、有用的输出真是太酷了。如果你的终端处于暗模式,看起来会更酷。

02-01

图 2.1 Jest 的终端输出

让我们更仔细地看看细节。图 2.2 显示了相同的输出,但带有数字以便跟随。让我们看看这里展示了多少信息:

❶ 一份快速列表,列出了所有失败的测试(带名称),旁边有漂亮的红色 X

❷ 对失败的期望的详细报告(即我们的断言)

❸ 实际值和预期值之间的确切差异

❹ 执行的比较类型

❺ 测试的代码

❻ 测试失败的确切行(视觉上)

❼ 运行的测试数量、失败的测试数量和通过的测试数量的报告

❽ 执行时间

❾ 快照的数量(与我们讨论无关)

02-02

图 2.2 Jest 的注释终端输出

想象一下自己尝试编写所有这些报告功能。这是可能的,但谁有时间和意愿呢?此外,你还得负责任何报告机制中的错误。

如果我们将测试中的 goodbye 改为 hello,我们可以看到测试通过时会发生什么(图 2.3)。一切都很正常,就像所有事物应该的那样(再次,在数字版本中——否则它看起来很漂亮,是灰色的)。

02-03

图 2.3 Jest 通过终端输出的通过测试

你可能会注意到,运行这个单个 Hello World 测试需要 1.5 秒。如果我们使用 jest --watch 命令,Jest 可以监视文件夹中的文件系统活动,并在文件更改时自动运行测试,而无需每次都重新初始化自己。这可以节省相当多的时间,并且对于整个 持续测试 概念非常有帮助。在您的工作站的其他窗口中设置一个带有 jest --watch 的终端,您可以在编码的同时快速获得您可能创建的问题的反馈。这是进入工作流程的好方法。

Jest 还支持异步风格的测试和回调。当我们在书中稍后讨论这些主题时,我会涉及到这些内容,但如果你现在想了解更多关于这种风格的信息,请访问 Jest 关于此主题的文档:jestjs.io/docs/en/asynchronous

2.2 库、断言、执行器和报告器

Jest 为我们提供了几个角色:

  • 它充当了编写测试时使用的测试库。

  • 它充当了测试内部的断言库(expect)。

  • 它充当了测试执行器。

  • 它充当了测试运行的测试报告器。

Jest 还提供了创建模拟、存根和间谍的隔离功能,尽管我们还没有看到这一点。我们将在后面的章节中涉及到这些想法。

除了隔离功能外,在其他语言中,测试框架通常需要填补我刚才提到的所有角色——库、断言、测试执行器和测试报告器——但 JavaScript 世界似乎更加碎片化。许多其他测试框架只提供其中的一些功能。这可能是因为“做一件事,做好它”的箴言被认真对待,或者可能还有其他原因。无论如何,Jest 都是一小部分全能框架之一。这是对 JavaScript 开源文化力量的证明,对于这些类别中的每一个,都有多个工具可以混合搭配,创建自己的超级工具集。

我选择 Jest 作为这本书的原因之一是,我们不必过多地烦恼于工具或处理缺失的功能——我们只需专注于模式。这样,我们就不必在主要关注模式和反模式的书中使用多个框架。

2.3 单元测试框架提供的内容

让我们暂时放大视角,看看我们现在身处何地。与我们在上一章中开始尝试的创建自己的框架,或者手动测试相比,像 Jest 这样的框架为我们提供了什么?

  • 结构——当你想要测试一个功能时,不必每次都重新发明轮子,使用测试框架时,你总是以相同的方式开始——通过编写一个具有良好定义的结构,每个人都能轻松识别、阅读和理解。

  • 可重复性—当使用测试框架时,重复编写新测试的行为很容易。使用测试运行器重复执行测试也很容易,而且可以快速多次执行。理解失败及其原因也很容易。有人已经为我们做了所有艰苦的工作,而不是我们不得不将所有这些代码都放入我们自制的框架中。

  • 信心和时间节省—当我们自己构建测试框架时,框架中可能存在更多错误,因为它不如现有成熟且广泛使用的框架经过实战考验。另一方面,手动测试通常非常耗时。当我们时间紧迫时,我们可能会专注于测试感觉最关键的事情,而跳过可能感觉不那么重要的事情。我们可能会跳过一些小但重要的错误。通过使编写新测试变得容易,我们更有可能为那些感觉不那么重要的事情编写测试,因为我们不会在编写大型测试上花费太多时间。

  • 共享理解—框架的报告对于团队层面的任务管理很有帮助(当一个测试通过时,意味着任务已完成)。有些人发现这很有用。

简而言之,编写、运行和审查单元测试及其结果的框架可以极大地改变愿意投入时间学习如何正确使用它们的开发者的日常生活。图 2.4 显示了单元测试框架及其辅助工具在软件开发中产生影响的领域,表 2.1 列出了我们通常使用测试框架执行的动作类型。

02-04

图 2.4 单元测试作为代码编写,使用单元测试框架的库。测试从 IDE 内的测试运行器或通过命令行运行,结果由开发者或自动化构建过程通过测试报告器(无论是输出文本还是 IDE 中的内容)进行审查。

表 2.1 测试框架如何帮助开发者编写和执行测试以及审查结果

单元测试实践 框架如何帮助
轻松并以结构化的方式编写测试。 框架为开发者提供辅助函数、断言函数和与结构相关的函数。

| 执行一个或所有单元测试。 | 框架提供测试运行器,通常在命令行中,它

  1. 识别代码中的测试

  2. 自动运行测试

  3. 在运行时指示测试状态

|

| 审查测试运行的结果。 | 测试运行器通常会提供有关信息,例如

  1. 运行的测试数量

  2. 未运行的测试数量

  3. 失败的测试数量

  4. 哪些测试失败了

  5. 测试失败的原因

  6. 失败的代码位置

  7. 可能提供任何导致测试失败的异常的完整堆栈跟踪,并允许你进入调用堆栈中的各种方法调用

|

在撰写本文时,大约有 900 个单元测试框架存在,对于大多数在公共使用的编程语言来说,都有超过几个(以及一些已经废弃的)。你可以在维基百科上找到一个很好的列表:en.wikipedia.org/wiki/List_of_unit_testing_frameworks

注意:使用单元测试框架并不能保证你编写的测试是 可读的可维护的可靠的,或者它们覆盖了你想要测试的所有逻辑。我们将在第七章至第九章以及本书的其他地方探讨如何确保你的单元测试具备这些特性。

2.3.1 xUnit 框架

当我开始编写测试(在 Visual Basic 时代)时,大多数单元测试框架的标准被统称为 xUnit。xUnit 框架思想的始祖是 SUnit,它是 Smalltalk 的单元测试框架。

这些单元测试框架的名称通常以它们所构建的语言的首字母开头;例如,你可能会有 CppUnit 用于 C++,JUnit 用于 Java,NUnit 和 xUnit 用于 .NET,以及 HUnit 用于 Haskell 编程语言。虽然并非所有框架都遵循这些命名指南,但大多数都是。

2.3.2 xUnit、TAP 和 Jest 结构

不仅名称相当一致。如果你使用的是 xUnit 框架,你还可以期待一个特定的结构,其中测试被构建。当这些框架运行时,它们会以相同的结构输出结果,这通常是一个具有特定模式的 XML 文件。

这种类型的 xUnit XML 报告至今仍然很普遍,并且在大多数构建工具中广泛使用,如 Jenkins,它通过本机插件支持此格式,并使用它来报告测试运行的结果。静态语言中的大多数单元测试框架仍然使用 xUnit 模型进行结构设计,这意味着一旦你学会了使用其中之一,你应该能够轻松地使用任何其他框架(假设你了解特定的编程语言)。

另一个有趣的测试结果报告结构标准,以及更多内容,被称为 TAP,即测试任何协议。TAP 最初是 Perl 测试工具的一部分,但现在它在 C、C++、Python、PHP、Perl、Java、JavaScript 以及其他语言中都有实现。TAP 不仅仅是一个报告规范。在 JavaScript 世界中,TAP 框架是支持 TAP 协议的知名测试框架。

Jest 不是一个严格的 xUnit 或 TAP 框架。它的输出默认不是 xUnit 或 TAP 兼容的。然而,由于 xUnit 风格的报告仍然统治着构建领域,我们通常希望适应该协议以在构建服务器上的报告。为了获得大多数构建工具容易识别的 Jest 测试结果,你可以安装 npm 模块,如 jest-xunit(如果你需要特定的 TAP 输出,请使用 jest-tap-reporter),然后在你的项目中使用特殊的 jest.config.js 文件来配置 Jest,以改变其报告格式。

现在让我们继续,用 Jest 编写一些感觉更像真实测试的内容,好吗?

2.4 介绍密码验证器项目

我们将在本书中主要用于测试示例的项目最初将很简单,只包含一个函数。随着本书的进行,我们将通过添加新功能、模块和类来扩展该项目,以展示单元测试的不同方面。我们将称之为密码验证器项目。

第一个场景相当简单。我们将构建一个密码验证库,最初它只是一个函数。这个函数 verifyPassword(rules) 允许我们输入自定义验证函数,称为 rules,并根据输入的规则输出错误列表。每个规则函数将输出两个字段:

{
    passed: (boolean),
    reason: (string)
} 

在这本书中,我将教你如何编写测试,以多种方式检查随着我们添加更多功能到其中,verifyPassword 的功能。

下面的列表显示了该函数的版本 0,它有一个非常天真的实现。

列表 2.2 密码验证器版本 0

const verifyPassword = (input, rules) => {
  const errors = [];
  rules.forEach(rule => {
    const result = rule(input);
    if (!result.passed) {
      errors.push(`error ${result.reason}`);
    }
  });
  return errors;
};

虽然这并不是最功能性的代码,我们可能稍后会对其进行重构,但我想保持这里的代码非常简单,以便我们可以专注于测试。

这个函数实际上并没有做什么。它遍历所有给定的规则,并使用提供的输入运行每个规则。如果规则的输出结果没有通过,那么一个错误会被添加到最终返回的错误数组中。

2.5 对 verifyPassword 的第一个 Jest 测试

假设你已经安装了 Jest,你可以在 tests 文件夹下创建一个名为 password-verifier0.spec.js 的新文件。

使用 tests 文件夹是组织测试的一种约定,它是 Jest 默认配置的一部分。许多人更喜欢将测试文件放置在与被测试的代码相同的目录下。每种方法都有其优缺点,我们将在本书的后续部分中探讨这一点。现在,我们将采用默认设置。

这里是我们对新函数的第一个测试版本。

列表 2.3 对 verifyPassword() 的第一个测试

test('badly named test', () => {
  const fakeRule = input =>                                 ❶
    ({ passed: false, reason: 'fake reason' });             ❶

  const errors = verifyPassword('any value', [fakeRule]);   ❷

  expect(errors[0]).toMatch('fake reason');                 ❸
});

❶ 设置测试的输入

❷ 使用输入调用入口点

❸ 检查退出点

2.5.1 安排-行动-断言模式

列表 2.3 中的测试结构通常被称为安排-行动-断言(AAA)模式。它相当不错!我发现通过说“那个‘安排’部分太复杂了”或“‘行动’部分在哪里?”之类的话来推理测试的各个部分非常容易。

在安排部分,我们创建了一个总是返回 false 的假规则,这样我们就可以通过在测试结束时断言其理由来证明它实际上被使用了。然后我们将其与一个简单的输入一起发送到verifyPassword。在断言部分,我们检查得到的第一个错误是否与我们在安排部分给出的假理由匹配。.toMatch(/string/)使用正则表达式来查找字符串的一部分。它与使用.toContain('fake reason')相同。

在我们写完测试或修复某些东西之后手动运行 Jest 很麻烦,所以让我们配置 npm 来自动运行 Jest。转到 ch2 根目录下的 package.json,在scripts项下添加以下内容:

"scripts": {
   "test": "jest",
 "testw": "jest --watch" //if not using git, change to --watchAll
},

如果你在这个文件夹中没有初始化 Git,你可以使用命令--watchAll而不是--watch

如果一切顺利,你现在可以从 ch2 文件夹中在命令行中键入npm test,Jest 将运行一次测试。如果你键入npm run testw,Jest 将运行并无限循环等待更改,直到你使用 Ctrl-C 终止进程。(你需要使用单词run,因为testw不是 npm 自动识别的特殊关键字之一。)

如果你运行测试,你可以看到它通过了,因为函数按预期工作。

2.5.2 测试测试

让我们在生产代码中放一个错误,看看测试是否应该在失败时失败。

列表 2.4 添加一个错误

const verifyPassword = (input, rules) => {
  const errors = [];
  rules.forEach(rule => {
    const result = rule(input);
    if (!result.passed) {
 // errors.push(`error ${result.reason}`);  ❶
    }
  });
  return errors;
};

❶ 我们不小心注释掉了这一行。

你现在应该能看到测试失败并显示一个友好的消息。让我们取消注释这一行,看看测试是否再次通过。如果你不是在做测试驱动开发,而是在写完代码后再写测试,这是一种建立测试信心的好方法。

2.5.3 USE 命名

我们的测试名称真的很糟糕。它没有解释我们在这里试图完成什么。我喜欢在测试名称中放入三个信息点,这样测试的读者只需看一眼测试名称就能回答他们大部分的心理问题。这三个部分包括

  • 被测试的工作单元(在这个例子中是verifyPassword函数)

  • 单元测试的场景或输入(失败的规则)

  • 预期的行为或退出点(返回带有原因的错误)

在审查过程中,这本书的审稿人 Tyler Lemke 提出了一个很好的缩写 USE:被测试单元、场景、预期。我喜欢它,而且很容易记住。谢谢 Tyler!

以下列表显示了带有 USE 名称的测试的下一个版本。

列表 2.5 使用 USE 命名测试

test('verifyPassword, given a failing rule, returns errors', () => {
  const fakeRule = input => ({ passed: false, reason: 'fake reason' });

  const errors = verifyPassword('any value', [fakeRule]);
  expect(errors[0]).toContain('fake reason');
});

这有点更好。当测试失败时,尤其是在构建过程中,你通常看不到注释或完整的测试代码。你通常只能看到测试的名称。名称应该足够清晰,以至于你可能甚至不需要查看测试代码就能理解生产代码中可能出现的问题。

2.5.4 字符串比较和可维护性

我们还在下一行做了另一个小的更改:

expect(errors[0]).toContain('fake reason');

与在测试中非常常见的检查一个字符串是否等于另一个字符串不同,我们正在检查一个字符串是否包含在输出中。这使得我们的测试对输出的未来更改更不脆弱。我们可以使用 .toContain.toMatch(/fake reason/) 来实现这一点,它使用正则表达式匹配字符串的一部分。

字符串是一种用户界面。它们对人类可见,并且可能会改变——尤其是字符串的边缘。我们可能会向字符串添加空白、制表符、星号或其他装饰。我们关心字符串中包含的信息的核心。我们不希望每次有人向字符串末尾添加新行时都更改我们的测试。这是我们希望在测试中鼓励的思考方式:随着时间的推移,测试的可维护性和对测试脆弱性的抵抗力是高度优先的。

我们理想的情况是,测试只在生产代码中实际出错时失败。我们希望将误报的数量减少到最低。使用 toContain()toMatch() 是朝着这个目标迈进的好方法。

我将在整本书中讨论更多提高测试可维护性的方法,尤其是在书的第二部分。

2.5.5 使用 describe()

我们可以使用 Jest 的 describe() 函数在我们的测试周围创建更多的结构,并开始将三个 USE 信息部分彼此分离。这一步以及之后的步骤完全取决于你——你可以决定你想要如何格式化你的测试及其可读性结构。我向你展示这些步骤是因为许多人要么没有有效地使用 describe(),要么完全忽略了它。它可以非常有用。

describe() 函数用上下文包裹我们的测试:既为读者提供逻辑上下文,也为测试本身提供功能上下文。下面的列表显示了我们可以如何开始使用它们。

列表 2.6 添加 describe()

describe('verifyPassword', () => {
  test('given a failing rule, returns errors', () => {
    const fakeRule = input =>
      ({ passed: false, reason: 'fake reason' });

    const errors = verifyPassword('any value', [fakeRule]);

    expect(errors[0]).toContain('fake reason');
  });
});

我在这里做了四个更改:

  • 我添加了一个描述正在测试的工作单元的 describe() 块。对我来说,这看起来更清晰。它也让我觉得现在可以在该块下添加更多嵌套测试。这个 describe() 块还有助于命令行报告生成更漂亮的报告。

  • 我在新的块下嵌套了 test 并从测试中移除了工作单元的名称。

  • 我已经将 input 添加到了模拟规则的 reason 字符串中。

  • 我在 arrange、act 和 assert 部分之间添加了一个空行,使测试更易于阅读,尤其是对团队新成员来说。

2.5.6 结构暗示上下文

describe()的好处在于它可以嵌套在自身之下。因此,我们可以用它来创建另一个层级,解释场景,然后在这个层级下嵌套我们的测试。

列表 2.7:用于额外上下文的嵌套describe

describe('verifyPassword', () => {
  describe('with a failing rule', () => {
 test('returns errors', () => {
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason' });

      const errors = verifyPassword('any value', [fakeRule]);

      expect(errors[0]).toContain('fake reason');
    });
  });
});

有些人可能会讨厌它,但我觉得它有一定的优雅之处。这种嵌套使我们能够将三块关键信息分别分离到它们自己的层级。实际上,如果我们愿意,我们还可以在describe()下方提取出测试之外的错误规则。

列表 2.8:带有提取输入的嵌套describe

describe('verifyPassword', () => {
  describe('with a failing rule', () => {
 const fakeRule = input => ({ passed: false,
 reason: 'fake reason' });

    test('returns errors', () => {
      const errors = verifyPassword('any value', [fakeRule]);

      expect(errors[0]).toContain('fake reason');
    });
  });
});

在下一个例子中,我将这个规则重新放回测试中(我喜欢事物紧密相连——关于这一点稍后还会提到)。

这种嵌套结构也非常巧妙地暗示了,在特定场景下,你可能有多种预期的行为。你可以在一个场景下检查多个退出点,每个退出点作为一个单独的测试,并且从读者的角度来看仍然是有意义的。

2.5.7 it()函数

我至今构建的谜题中还有一块缺失。Jest 也暴露了一个it()函数。这个函数在所有意图和目的上都是test()函数的一个别名,但它与迄今为止概述的描述驱动方法在语法上更契合。

以下列表显示了当我将test()替换为it()时测试看起来像什么。

列表 2.9:将test()替换为it()

describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    it('returns errors', () => {
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason' });

      const errors = verifyPassword('any value', [fakeRule]);

      expect(errors[0]).toContain('fake reason');
    });
  });
});

在这个测试中,很容易理解it指的是什么。这是对之前describe()块的自然扩展。再次强调,是否使用这种风格取决于你。我在这里展示了我喜欢的一种变体。

2.5.8 两种 Jest 风味

正如你所看到的,Jest 支持两种主要的测试编写方式:简洁的test语法和更describe驱动的(即分层)语法。

describe驱动的 Jest 语法在很大程度上归功于 Jasmine,这是最古老的 JavaScript 测试框架之一。这种风格本身可以追溯到 Ruby 世界和著名的 RSpec Ruby 测试框架。这种嵌套风格通常被称为行为驱动开发(BDD)风格

你可以根据自己的喜好混合和匹配这些风格(我就是这样做的)。当测试目标及其所有上下文容易理解,而不需要过多麻烦时,你可以使用test语法。当你在同一场景下的同一入口点期望多个结果时,describe语法可以帮助你。我在这里展示了它们两个,因为我有时使用简洁的test风格,有时使用describe驱动的风格,这取决于复杂性和表达性要求。

BDD 的黑暗现状

BDD 有一个相当有趣的历史背景,可能值得讨论。BDD 与 TDD 无关。与发明这个术语最相关的人丹·诺斯(Dan North)将 BDD 描述为使用故事和示例来描述应用程序应该如何表现。主要目标是与非技术利益相关者(如产品所有者、客户等)合作。RSpec(受 RBehave 的启发)将故事驱动的方法普及开来,在这个过程中,许多其他框架也随之而来,包括著名的 Cucumber。

这个故事也有阴暗面:许多框架都是由开发者独立开发和使用,而没有与非技术利益相关者合作,这与 BDD 的主要思想完全相反。

今天对我来说,“BDD 框架”这个术语主要指的是“带有一些语法糖的测试框架”,因为它们几乎从未被用来创建利益相关者之间的真实对话,而几乎总是作为执行基于开发者的自动化测试的另一个闪亮的或规定的工具。我甚至看到强大的 Cucumber 也陷入了这种模式。

2.5.9 重构生产代码

由于在 JavaScript 中有许多构建相同东西的方法,我想展示我们设计的一些变体以及如果我们改变它会发生什么。假设我们希望将密码验证器变成一个具有状态的对象。

将设计改为状态性的一个原因可能是我打算让应用程序的不同部分使用这个对象。一部分将对其进行配置并添加规则,而另一部分将使用它来进行验证。另一个原因是我们需要知道如何处理状态性设计,并查看它将测试推向哪些方向,我们能做些什么。

让我们先看看生产代码。

列表 2.10 将函数重构为状态性类

class PasswordVerifier1 {
 constructor () {
 this.rules = [];
 }

 addRule (rule) {
 this.rules.push(rule);
 }

 verify (input) {
    const errors = [];
    this.rules.forEach(rule => {
      const result = rule(input);
      if (result.passed === false) {
        errors.push(result.reason);
      }
    });
    return errors;
  }
}

我已经高亮了 2.9 列表中的主要变化。这里并没有什么特别的事情发生,尽管如果你有面向对象背景的话可能会觉得更舒服。需要注意的是,这只是设计这种功能的一种方式。我使用基于类的方法,以便展示这种设计如何影响测试。

在这个新的设计中,当前场景的入口和出口在哪里?思考一下。工作单元的范围已经增加。为了测试一个有失败规则的场景,我们必须调用影响测试中工作单元状态的两个函数:addRuleverify

现在我们来看看测试可能的样子(变化通常以高亮显示)。

列表 2.11 测试状态性工作单元

describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
 const verifier = new PasswordVerifier1();
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason'});

 verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');

      expect(errors[0]).toContain('fake reason');
    });
  });
});

到目前为止,一切顺利;这里没有发生任何特别的事情。请注意,工作单元的表面已经增加。现在它跨越了两个必须一起工作的相关功能(addRuleverify)。由于设计的状态性,发生了耦合。我们需要使用两个函数来有效地进行测试,而不暴露任何对象的内部状态。

测试本身看起来足够无辜。但当我们想要为同一场景编写多个测试时会发生什么?这可能会发生在我们有多个出口点,或者我们想要从同一出口点测试多个结果时。例如,假设我们想要验证我们只有一个错误。我们可以在测试中简单地添加一行,如下所示:

verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1);                 ❶
expect(errors[0]).toContain('fake reason');

❶ 新的断言

如果新的断言失败会发生什么?第二个断言永远不会执行,因为测试运行器会收到一个错误并继续下一个测试用例。

我们仍然想知道第二个断言是否会通过,对吧?所以也许我们会开始注释掉第一个,然后重新运行测试。这不是运行测试的健康方式。在 Gerard Meszaros 的书 xUnit Test Patterns 中,这种注释掉某些内容以测试其他内容的人类行为被称为 断言轮盘游戏。它可以在你的测试运行中造成很多混淆和假阳性(认为某些事情失败或通过,而实际上并没有)。

我宁愿将这个额外的检查单独分成一个带有良好名称的测试用例,如下所示。

列表 2.12 从同一出口点检查额外的最终结果

describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
      const verifier = new PasswordVerifier1();
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason'});

      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');

      expect(errors[0]).toContain('fake reason');
    });
 it('has exactly one error', () => {
 const verifier = new PasswordVerifier1();
 const fakeRule = input => ({ passed: false,
 reason: 'fake reason'});

 verifier.addRule(fakeRule);
 const errors = verifier.verify('any value');

 expect(errors.length).toBe(1);
 });
  });
});

这开始看起来很糟糕。是的,我们解决了断言轮盘问题。每个 it() 可以单独失败,不会干扰其他测试用例的结果。但我们付出了什么代价?一切。看看我们现在有多少重复。在这个时候,那些有单元测试背景的人会开始对着这本书大喊:“使用 setup/beforeEach 方法!”

好的!

2.6 尝试使用 beforeEach() 路由

我还没有介绍 beforeEach()。这个函数及其兄弟函数 afterEach() 用于设置和撤销测试用例所需的特定状态。还有 beforeAll()afterAll(),我尽量避免在单元测试场景中使用它们。我们将在本书的后面部分更多地讨论这些兄弟函数。

beforeEach() 可以帮助我们移除测试中的重复,因为它在我们嵌套的 describe 块中的每个测试之前运行一次。我们也可以多次嵌套它,如下面的列表所示。

列表 2.13 在两个级别上使用 beforeEach()

describe('PasswordVerifier', () => {
 let verifier;
 beforeEach(() => verifier = new PasswordVerifier1()); ❶
  describe('with a failing rule', () => {
 let fakeRule, errors;
 beforeEach(() => {       ❷
 fakeRule = input => ({passed: false, reason: 'fake reason'});
 verifier.addRule(fakeRule);
 });
    it('has an error message based on the rule.reason', () => {
      const errors = verifier.verify('any value');

      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      const errors = verifier.verify('any value');

      expect(errors.length).toBe(1);
    });
  });
});

❶ 设置一个将在每个测试中使用的新的验证器

❷ 在 describe() 方法内设置一个将在此处使用的假规则

看看所有提取的代码。

在第一个 beforeEach() 中,我们正在设置一个新的 PasswordVerifier1,它将为每个测试用例创建。在之后的 beforeEach() 中,我们正在为该特定场景下的每个测试用例设置一个假规则并将其添加到新的验证器中。如果我们有其他场景,第 6 行的第二个 beforeEach() 不会为它们运行,但第一个会。

测试看起来现在更短了,这在测试中理想情况下是你想要的,可以使它更易于阅读和维护。我们移除了每个测试中的创建行,并重用了相同的更高层次的变量 verifier

有几点需要注意:

  • 我们在第 6 行的beforeEach()中忘记重置errors数组。这可能会在以后给我们带来麻烦。

  • Jest 默认并行运行单元测试。这意味着将验证器移到第 2 行可能会引起并行测试的问题,因为在并行运行中,验证器可能会被另一个测试覆盖,这将破坏我们正在运行的测试的状态。与我所知道的许多其他语言的单元测试框架相比,Jest 相当不同,这些框架强调在单个线程中运行测试,而不是并行(至少默认情况下),以避免此类问题。在使用 Jest 时,我们必须记住并行测试是现实,因此具有共享上层状态的带状态测试,就像我们在第 2 行所做的那样,可能存在潜在问题,并可能导致原因不明的不可靠测试。

我们将很快纠正这两个问题。

2.6.1 beforeEach()和滚动疲劳

在将代码重构为beforeEach()的过程中,我们失去了一些东西:

  • 如果我只想阅读it()部分,我无法知道验证器是在哪里创建和声明的。我必须向上滚动才能理解。

  • 理解添加了什么规则也是如此。我必须向上查看it()一级以查看添加了什么规则,或者查看describe()块描述。

目前,这似乎并不糟糕。但稍后我们会看到,随着场景列表的大小增加,这种结构开始变得有些复杂。较大的文件可能会带来我称之为滚动疲劳的情况,需要测试读者在测试文件中上下滚动以理解测试的上下文和状态。这使得维护和阅读测试变成了一项任务,而不是简单的阅读行为。

这种嵌套对于报告来说很棒,但对于必须不断查找事物来源的人来说很糟糕。如果你曾经尝试在浏览器的检查器窗口中调试 CSS 样式,你就会知道这种感觉。你会看到某个单元格由于某种原因而加粗。然后你向上滚动以查看哪个样式使第三个节点下的特殊table中的嵌套单元格内的<div>加粗。

让我们看看在以下列表中进一步推进会发生什么。由于我们正在删除重复,我们还可以在beforeEach()中调用verify并从每个it()中删除一行。这基本上是将 AAA 模式中的安排和执行部分放入beforeEach()函数中。

列表 2.14 将安排和执行部分推入beforeEach()

describe('PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {
      fakeRule = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRule);
 errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
 expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
 expect(errors.length).toBe(1);
    });
  });
});

代码重复已经减少到最小,但现在我们也需要查找errors数组在哪里以及如何获取它,如果我们想理解每个it()

让我们加大赌注,添加一些更多的基础场景,看看随着问题空间的增加,这种方法是否可扩展。

列表 2.15 添加额外场景

describe('v6 PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {
      fakeRule = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRule);
      errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      expect(errors.length).toBe(1);
    });
  });
 describe('with a passing rule', () => {
 let fakeRule, errors;
 beforeEach(() => {
 fakeRule = input => ({passed: true, reason: ''});
 verifier.addRule(fakeRule);
 errors = verifier.verify('any value');
 });
 it('has no errors', () => {
 expect(errors.length).toBe(0);
 });
 });
 describe('with a failing and a passing rule', () => {
 let fakeRulePass,fakeRuleFail, errors;
 beforeEach(() => {
 fakeRulePass = input => ({passed: true, reason: 'fake success'});
 fakeRuleFail = input => ({passed: false, reason: 'fake reason'});
 verifier.addRule(fakeRulePass);
 verifier.addRule(fakeRuleFail);
 errors = verifier.verify('any value');
 });
 it('has one error', () => {
 expect(errors.length).toBe(1);
 });
 it('error text belongs to failed rule', () => {
 expect(errors[0]).toContain('fake reason');
 });
 });
});

我们喜欢这样吗?我不喜欢。现在我们看到了一些额外的问题:

  • 我已经可以看到beforeEach()部分中有很多重复。

  • 滚动疲劳的潜在风险显著增加,因为现在有更多选项来确定beforeEach()会影响哪个it()状态。

在实际项目中,beforeEach()函数往往成为测试文件的垃圾箱。人们会把各种测试初始化的东西扔进去:只有某些测试需要的东西,影响所有其他测试的东西,以及不再有人使用的东西。把东西放在最容易的地方是人的本性,尤其是如果你之前的人也都这样做的话。

我对beforeEach()方法并不狂热。让我们看看我们是否可以在最小化重复的同时缓解一些这些问题。

2.7 尝试工厂方法路线

工厂方法是简单的辅助函数,帮助我们构建对象或特殊状态,并在多个地方重用相同的逻辑。也许我们可以通过使用列表 2.16 中失败和通过规则的几个工厂方法来减少一些重复和笨拙的代码。

列表 2.16 向其中添加几个工厂方法

describe('PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makeFailingRule('fake reason'));
      errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      expect(errors.length).toBe(1);
    });
  });
  describe('with a passing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makePassingRule());
      errors = verifier.verify('any value');
    });
    it('has no errors', () => {
      expect(errors.length).toBe(0);
    });
  });
  describe('with a failing and a passing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makePassingRule());
      verifier.addRule(makeFailingRule('fake reason'));
      errors = verifier.verify('any value');
    });
    it('has one error', () => {
      expect(errors.length).toBe(1);
    });
    it('error text belongs to failed rule', () => {
      expect(errors[0]).toContain('fake reason');
    });
  });
. . .
 const makeFailingRule = (reason) => {
 return (input) => {
 return { passed: false, reason: reason };
 };
 };
 const makePassingRule = () => (input) => {
 return { passed: true, reason: '' };
 };
}) 

makeFailingRule()makePassingRule()工厂方法使我们的beforeEach()函数变得更加清晰。

2.7.1 完全用工厂方法替换 beforeEach()

如果我们根本不使用beforeEach()来初始化各种东西怎么办?如果我们改为使用小的工厂方法呢?让我们看看那会是什么样子。

列表 2.17 替换beforeEach()为工厂方法

const makeVerifier = () => new PasswordVerifier1();
const passingRule = (input) => ({passed: true, reason: ''});

const makeVerifierWithPassingRule = () => {
 const verifier = makeVerifier();
 verifier.addRule(passingRule);
 return verifier;
};

const makeVerifierWithFailedRule = (reason) => {
 const verifier = makeVerifier();
 const fakeRule = input => ({passed: false, reason: reason});
 verifier.addRule(fakeRule);
 return verifier;
};

describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
 const verifier = makeVerifierWithFailedRule('fake reason');
      const errors = verifier.verify('any input');
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
 const verifier = makeVerifierWithFailedRule('fake reason');
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(1);
    });
  });
  describe('with a passing rule', () => {
    it('has no errors', () => {
 const verifier = makeVerifierWithPassingRule();
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(0);
    });
  });
  describe('with a failing and a passing rule', () => {
    it('has one error', () => {
 const verifier = makeVerifierWithFailedRule('fake reason');
 verifier.addRule(passingRule);
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(1);
    });
    it('error text belongs to failed rule', () => {
 const verifier = makeVerifierWithFailedRule('fake reason');
 verifier.addRule(passingRule);
      const errors = verifier.verify('any input');
      expect(errors[0]).toContain('fake reason');
    });
  });
});

这里的长度与列表 2.16 大致相同,但我发现代码的可读性更高,因此更容易维护。我们消除了beforeEach()函数,但并没有失去可维护性。我们消除的重复量微乎其微,但由于消除了嵌套的beforeEach()块,可读性得到了极大的提高。

此外,我们还降低了滚动疲劳的风险。作为测试的读者,我不必在文件上下滚动来找出对象何时被创建或声明。我可以从it()中获取所有信息。我们不需要知道如何创建某物,但我们知道何时创建以及它用哪些重要参数初始化。一切都得到了明确的解释。

如果需要,我可以深入到特定的工厂方法中,我喜欢每个it()都封装了自己的状态。嵌套的describe()结构是了解我们位置的好方法,但状态都是从it()块内部触发的,而不是外部。

2.8 完整地使用 test()进行测试

列表 2.17 中的测试足够自包含,以至于describe()块仅仅作为理解时的额外糖。如果我们不想使用它们,它们就不再需要。如果我们想的话,我们可以像以下列表那样编写测试。

列表 2.18 移除嵌套的 describes

test('pass verifier, with failed rule, ' +
 'has an error message based on the rule.reason', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  const errors = verifier.verify('any input');
  expect(errors[0]).toContain('fake reason');
});
test('pass verifier, with failed rule, has exactly one error', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(1);
});
test('pass verifier, with passing rule, has no errors', () => {
  const verifier = makeVerifierWithPassingRule();
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(0);
});
test('pass verifier, with passing  and failing rule,' +
          ' has one error', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  verifier.addRule(passingRule);
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(1);
});
test('pass verifier, with passing  and failing rule,' +
 ' error text belongs to failed rule', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  verifier.addRule(passingRule);
  const errors = verifier.verify('any input');
  expect(errors[0]).toContain('fake reason');
});

工厂方法为我们提供了所有需要的功能,而不会在每个特定测试中失去清晰度。

我相当喜欢列表 2.18 的简洁性。它很容易理解。我们可能会失去一些结构清晰度,所以有些情况下我会选择没有describe的方法,而有些地方嵌套的describe会使内容更易于阅读。对于你的项目来说,可维护性和可读性的最佳平衡点可能就在这两个点之间。

2.9 重构为参数化测试

让我们暂时离开verifier类,来创建和测试一个新的自定义规则。列表 2.19 显示了一个大写字母的简单规则(我意识到具有这些要求的密码不再被认为是一个好主意,但为了演示目的,我对此可以接受)。

列表 2.19 密码规则

const oneUpperCaseRule = (input) => {
  return {
    passed: (input.toLowerCase() !== input),
    reason: 'at least one upper case needed'
  };
};

我们可以像以下列表中那样编写几个测试。

列表 2.20 使用变体测试规则

describe('one uppercase rule', function () {
  test('given no uppercase, it fails', () => {
    const result = oneUpperCaseRule('abc');
    expect(result.passed).toEqual(false);
  });
 test('given one uppercase, it passes', () => {
 const result = oneUpperCaseRule('Abc');
 expect(result.passed).toEqual(true);
 });
 test('given a different uppercase, it passes', () => {
 const result = oneUpperCaseRule('aBc');
 expect(result.passed).toEqual(true);
 });
});

在列表 2.20 中,我突出显示了一些重复,如果我们尝试用单元工作输入的小幅变化来测试相同的场景。在这种情况下,我们想要测试的是,只要大写字母存在,位置无关紧要。但如果将来我们需要更改大写逻辑,或者需要以某种方式纠正该用例的断言,这种重复将会对我们造成伤害。

在 JavaScript 中创建参数化测试有几种方法,Jest 已经内置了一个:test.each(也称为别名it.each)。下一个列表展示了我们如何使用这个功能来减少测试中的重复。

列表 2.21 使用 test.each

describe('one uppercase rule', () => {
  test('given no uppercase, it fails', () => {
    const result = oneUpperCaseRule('abc');
    expect(result.passed).toEqual(false);
  });

 test.each(['Abc',                            ❶
 'aBc']) ❶
 ('given one uppercase, it passes', (input) => { ❷
 const result = oneUpperCaseRule(input);
 expect(result.passed).toEqual(true);
 });
});

❶ 将一个映射到输入参数的值数组传递

❷ 使用数组中传递的每个输入参数

在这个例子中,测试将针对数组中的每个值重复一次。一开始可能有点难以理解,但一旦你尝试了这种方法,它就会变得容易使用。它也相当易于阅读。

如果我们想传递多个参数,我们可以将它们放在一个数组中,如下面的列表所示。

列表 2.22 重构 test.each

describe('one uppercase rule', () => {
 test.each([ ['Abc', true],         ❶
 ['aBc', true],
 ['abc', false]])       ❷
    ('given %s, %s ', (input, expected) => {    ❸
      const result = oneUpperCaseRule(input);
      expect(result.passed).toEqual(expected);
    });
});

❶ 提供三个包含两个参数的数组

❷ 对于缺失的大写字符的新预期

❸ Jest 自动将数组值映射到参数。

虽然我们不必使用 Jest,但 JavaScript 足够灵活,允许我们轻松地推出自己的参数化测试,如果我们想要的话。

列表 2.23 使用纯 JavaScript for

describe('one uppercase rule, with vanilla JS for', () => {
  const tests = {
 'Abc': true,
 'aBc': true,
 'abc': false,
 };

 for (const [input, expected] of Object.entries(tests)) {
    test('given ${input}, ${expected}', () => {
      const result = oneUpperCaseRule(input);
      expect(result.passed).toEqual(expected);
 });
  }
});

取决于你想要使用哪一个(我喜欢保持简单,使用test.each)。重点是,Jest 只是一个工具。参数化测试的模式可以用多种方式实现。这个模式给我们带来了很多力量,但也带来了很多责任。滥用这个技术并创建难以理解的测试是非常容易的。

我通常试图确保整个表格中相同的场景(输入类型)保持一致。如果我在代码审查中审查这个测试,我会告诉编写这个测试的人,这个测试实际上测试了两个不同的场景:一个没有大写字母,几个有一个大写字母。我会将它们分成两个不同的测试。

在这个例子中,我想展示的是,非常容易去掉许多测试并将它们全部放入一个大的 test.each 中——即使这会损害可读性——所以运行这些特定的剪刀时要小心。

2.10 检查预期的抛出错误

有时候我们需要设计一段代码,在正确的时间和正确的数据下抛出错误。如果我们向 verify 函数中添加代码,当没有配置规则时抛出错误,会发生什么?就像下一个列表中那样?

列表 2.24 抛出错误

verify (input) {
 if (this.rules.length === 0) {
 throw new Error('There are no rules configured');
 }
  . . .

我们可以按照老式的方法通过使用 try/catch 来测试,如果没有错误发生,测试就会失败。

列表 2.25 使用 try/catch 测试异常

test('verify, with no rules, throws exception', () => {
    const verifier = makeVerifier();
 try {
        verifier.verify('any input');
 fail('error was expected but not thrown');
 } catch (e) {
 expect(e.message).toContain('no rules configured');
    }
});

使用 fail()

从技术上讲,fail() 是 Jasmine 原始分支的遗留 API,Jest 是基于 Jasmine 开发的。它是一种触发测试失败的方式,但它不在官方 Jest API 文档中,他们建议你使用 expect.assertions(1) 代替。这样,如果你从未达到 catch() 预期,测试就会失败。我发现只要 fail() 仍然有效,它就能很好地完成我的任务,我的任务是证明如果你能避免在单元测试中使用 try/catch 构造,你应该避免使用它。

这种 try/catch 模式是一种有效的方法,但非常冗长且难以输入。Jest,像大多数其他框架一样,包含一个快捷方式来完成这种类型的场景,使用 expect().toThrowError()

列表 2.26 使用 expect().toThrowError()

test('verify, with no rules, throws exception', () => {
    const verifier = makeVerifier();
    expect(() => verifier.verify('any input'))
 .toThrowError(/no rules configured/);   ❶
});

❶ 使用正则表达式而不是查找确切的字符串

注意,我正在使用正则表达式匹配来检查错误字符串是否包含特定的字符串,而不是等于它,这样可以使测试在字符串发生变化时更具未来性。toThrowError 有几种变体,你可以访问 jestjs.io/ 了解所有相关信息。

Jest 快照

Jest 有一个独特的功能叫做快照。它允许你在使用像 React 这样的框架时渲染一个组件,然后将当前的渲染与该组件保存的快照进行匹配,包括所有属性和 HTML。

我不会过多地涉及这个话题,但从我所见到的来看,这个功能往往被过度使用。你可以用它来创建难以阅读的测试,看起来像这样:

it('renders',()=>{
    expect(<MyComponent/>).toMatchSnapshot(); 
});

这很晦涩(难以推理正在测试的内容),并且测试了许多可能彼此无关的事情。它还可能因为许多你不太关心的原因而失败,因此该测试的维护成本会随着时间的推移而增加。它也是一个很好的借口,不写可读性和可维护的测试,因为你在截止日期内,但仍然需要展示你写了测试。是的,它确实有作用,但在其他类型测试更相关的地方很容易使用。

如果你需要这种变化的变体,请尝试使用 toMatchInlineSnapshot()。你可以在 jestjs.io/docs/en/snapshot-testing 找到更多信息。

2.11 设置测试类别

如果你只想运行特定类别的测试,例如只想运行单元测试、集成测试,或者只想运行触及应用程序特定部分的测试,Jest 目前还没有定义测试案例类别的功能。

尽管如此,并非一切都已失去。Jest 有一个特殊的 --testPathPattern 命令行标志,它允许我们定义 Jest 将如何找到我们的测试。我们可以通过为想要运行的特定类型的测试(例如,“集成”文件夹下的所有测试”)使用不同的路径来触发此命令。你可以在 jestjs.io/docs/en/cli 获取完整详情。

另一个替代方案是为每个测试类别创建一个单独的 jest.config.js 文件,每个文件都有其自己的 testRegex 配置和其他属性。

列表 2.27 创建单独的 jest.config.js 文件

// jest.config.integration.js
var config = require('./jest.config')
config.testRegex = "integration\\.js$" 
module.exports = config

// jest.config.unit.js
var config = require('./jest.config')
config.testRegex = "unit\\.js$" 
module.exports = config

然后,对于每个类别,你可以创建一个单独的 npm 脚本来调用具有自定义配置文件的 Jest 命令行:jest -c my.custom.jest.config.js

列表 2.28 使用单独的 npm 脚本

//Package.json
. . .
"scripts": {
    "unit": "jest -c jest.config.unit.js",
    "integ": "jest -c jest.config.integration.js"
. . .

在下一章中,我们将查看具有依赖性和可测试性问题的代码,并开始讨论伪造、间谍、模拟和存根的概念,以及如何使用它们来编写针对此类代码的测试。

概述

  • Jest 是一个流行的开源 JavaScript 应用程序测试框架。它同时充当测试时使用的 测试库、测试内的 断言库测试运行器测试报告器

  • 安排-行动-断言 (AAA) 是一种流行的测试结构模式。它为所有测试提供了一个简单、统一的布局。一旦习惯了它,你就可以轻松地阅读和理解任何测试。

  • 在 AAA 模式下,安排 部分是你将待测试的系统及其依赖项带到所需状态的地方。在 行动 部分中,你调用方法,传递准备好的依赖项,并捕获输出值(如果有)。在 断言 部分中,你验证结果。

  • 测试命名的良好模式是在测试名称中包含待测试的工作单元、单元的情景或输入,以及预期的行为或退出点。这个模式的一个方便的记忆法是 USE(单元、情景、期望)。

  • Jest 提供了几个函数,有助于围绕多个相关测试创建更多结构。describe() 是一个作用域函数,允许将多个测试(或测试组)组合在一起。describe() 的一个好比喻是包含测试或其他文件夹的文件夹。test() 是表示单个测试的函数。it()test() 的别名,但与 describe() 结合使用时,可读性更好。

  • beforeEach() 通过提取嵌套 describeit 函数中通用的代码来帮助避免重复。

  • 当你必须查看多个地方来理解测试做了什么时,使用 beforeEach() 往往会导致滚动疲劳。

  • 使用普通测试(没有任何 beforeEach())的工厂方法可以提高可读性,并有助于避免滚动疲劳。

  • 参数化测试有助于减少相似测试所需的代码量。缺点是,随着测试变得更加通用,可读性会降低。

  • 为了在测试可读性和代码重用之间保持平衡,仅参数化输入值。为不同的输出值创建单独的测试。

  • Jest 不支持测试类别,但你可以使用 --testPathPattern 标志来运行测试组。你还可以在配置文件中设置 testRegex

第二部分 核心技术

在第一部分涵盖了基础知识之后,我现在将介绍在现实世界中编写测试所必需的核心测试和重构技术。

在第三章中,我们将探讨占位符及其如何帮助打破依赖关系。我们将介绍使代码更易于测试的重构技术,并在过程中了解接口的概念。

在第四章中,我们将继续探讨模拟对象和交互测试,我们将分析模拟对象与占位符的区别,并探索伪造的概念。

在第五章中,我们将探讨隔离框架,也称为模拟框架,以及它们如何解决手工编写模拟和占位符中的一些重复性编码问题。第六章将处理异步代码,如承诺、定时器和事件,以及测试此类代码的各种方法。

3 使用存根打破依赖

本章涵盖

  • 依赖类型—模拟、存根等

  • 使用存根的原因

  • 功能注入技术

  • 模块化注入技术

  • 面向对象注入技术

在上一章中,你使用 Jest 编写了你的第一个单元测试,我们更多地关注了测试本身的可维护性。场景相当简单,更重要的是,它是完全自包含的。密码验证器没有依赖外部模块,我们可以专注于其功能,而不用担心其他可能干扰它的事情。

在那一章中,我们使用了我们示例的前两种退出点类型:返回值退出点和基于状态的退出点。在这一章中,我们将讨论最后一种类型—调用第三方。本章还将提出一个新的要求—让代码依赖于时间。我们将探讨两种不同的处理方法—重构我们的代码和不重构的猴子补丁。

对外部模块或函数的依赖可能会并且确实会使编写测试和使测试可重复变得更加困难,并且也可能导致测试变得不可靠。我们在代码中依赖的外部事物被称为依赖。我将在本章后面更详细地定义它们。这些依赖可能包括时间、异步执行、使用文件系统或使用网络,或者它们可能只是涉及使用非常难以配置或可能耗时的东西。

3.1 依赖类型

根据我的经验,我们的工作单元可以使用两种主要类型的依赖:

  • 传出依赖—代表我们工作单元的退出点的依赖,例如调用记录器、将某些内容保存到数据库、发送电子邮件、通知 API 或 webhook 发生了某些事情等。请注意,这些都是动词:“调用”、“发送”和“通知”。它们以一种类似“发射并忘记”的场景从工作单元向外流动。每个都代表一个退出点,或工作单元中特定逻辑流的结束。

  • 传入依赖—不是退出点的依赖。这些并不代表对工作单元最终行为的要求。它们仅仅是为了向工作单元提供特定的测试专用数据或行为,例如数据库查询的结果、文件系统中的文件内容、网络响应等。请注意,这些都是所有被动数据块,作为先前操作的结果流向工作单元的。

图 3.1 显示了这些内容并排展示。

03-01

图 3.1 左边,退出点是通过调用依赖来实现的。右边,依赖提供间接输入或行为,但不是退出点。

一些依赖项可以是输入和输出的——在某些测试中,它们将代表退出点,在其他测试中,它们将被用来模拟应用程序进入的数据。这些不应该非常常见,但它们确实存在,例如,一个外部 API 返回一个成功/失败响应,用于输出消息。

考虑到这些类型的依赖,让我们看看书籍《xUnit 测试模式》是如何定义测试中看似其他事物的各种模式的。表 3.1 列出了我从书籍网站 mng.bz/n1WK上的一些模式的想法。

表 3.1 清晰化存根和模拟的术语

类别 模式 目的 使用
测试替身 存根和模拟的通用名称 我也使用术语
存根 假对象 当 SUT 方法调用仅作为无关参数使用时,用于指定测试中要使用的值 作为入口点或 AAA 模式的安排部分发送参数。
测试存根 当它依赖于其他软件组件的间接输入时,用于独立验证逻辑 作为依赖项注入,并配置它向 SUT 返回特定的值或行为。
模拟 测试间谍 当它对其他软件组件有间接输出时,用于独立验证逻辑 覆盖真实对象上的单个函数,并验证假函数是否按预期被调用。
模拟对象 当它依赖于其他软件组件的间接输出时,用于独立验证逻辑 将假对象作为依赖项注入到 SUT 中,并验证假对象是否按预期被调用。

在接下来的这本书中,我们还可以这样思考:

  • 存根中断了输入依赖(间接输入)。存根是假的模块、对象或函数,它们向被测试的代码提供假行为或数据。我们不对它们进行断言。在单个测试中可以有多个存根。

  • 模拟中断了输出依赖(间接输出或退出点)。模拟是假的模块、对象或函数,我们在测试中声称它们被调用。模拟代表单元测试中的一个退出点。因此,建议每个测试中不要超过一个模拟。

不幸的是,在许多商店,你会听到“模拟”这个词被用作存根和模拟的通用术语。像“我们将模拟这个”或“我们有一个模拟数据库”这样的短语可能会造成真正的混淆。存根和模拟之间有很大的区别(在测试中应该只使用一次),我们应该使用正确的术语来确保其他人清楚所指的内容。

当不确定时,使用“测试替身”或“假”这个术语。通常,单个假依赖可以在一个测试中用作存根,在另一个测试中用作模拟。我们稍后会看到这个例子。

XUnit 测试模式和命名事物

xUnit Test Patterns: Refactoring Test Code(Gerard Meszaros 著,Addison-Wesley,2007)是单元测试的经典模式参考书。它定义了至少五种你在测试中模拟事物的模式。一旦你对这里提到的三种类型有了感觉,我鼓励你看看这本书提供的额外细节。

注意到xUnit Test Patterns对“假”这个词有一个定义:“用一个更轻量级的实现替换系统测试(SUT)所依赖的组件。”例如,你可能会使用内存数据库而不是完整的生产实例。

我仍然认为这种测试双倍是一个“存根”,我使用“假”这个词来指出任何不真实的东西,就像“测试双倍”这个术语一样,但“假”这个词更短,发音也更顺口。

这可能看起来是一次信息量很大的信息。我将在本章中深入探讨这些定义。让我们先小口品尝,从存根开始。

3.2 使用存根的原因

如果我们面临测试如下代码片段的任务怎么办?

列表 3.1 使用时间的verifyPassword

const moment = require('moment');
const SUNDAY = 0, SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

我们的密码验证器有一个新的依赖项:它不能在周末工作。想想看。具体来说,该模块直接依赖于 moment.js,这是一个非常常见的 JavaScript 日期/时间包装器。在 JavaScript 中直接处理日期并不愉快,所以我们可以假设许多商店都有类似的东西。

这种直接使用时间相关库的做法如何影响我们的单元测试?这里不幸的问题是,这种直接依赖迫使我们的测试,由于没有直接影响我们正在测试的应用程序中的日期和时间的方法,必须考虑正确的日期和时间。以下列表显示了一个不幸的测试,它只在周末运行。

列表 3.2 verifyPassword的初始单元测试

const moment = require('moment');
const {verifyPassword} = require("./password-verifier-time00");
const SUNDAY = 0, SATURDAY = 6, MONDAY = 2;

describe('verifier', () => {
    const TODAY = moment().day();

    //test is always executed, but might not do anything
    test('on weekends, throws exceptions', () => {
        if ([SATURDAY, SUNDAY].includes(TODAY)) {       ❶
            expect(()=> verifyPassword('anything',[]))
                .toThrow("It's the weekend!");
 }
    });

    //test is not even executed on week days
 if ([SATURDAY, SUNDAY].includes(TODAY)) {    ❷
        test('on a weekend, throws an error', () => {
            expect(()=> verifyPassword('anything', []))
                .toThrow("It's the weekend!");
        });
 }
});

❶ 在测试中检查日期

❷ 在测试外部检查日期

前面的列表包括了同一测试的两个变体。一个检查测试中的当前日期内部,另一个检查外部的测试,这意味着除非是周末,否则测试根本不会执行。这是不好的。

让我们回顾一下第一章中提到的一个好的测试质量,一致性:每次我运行一个测试,它都是我之前运行过的完全相同的测试。使用的值不会改变。断言不会改变。如果没有代码(测试或生产代码)发生变化,那么测试应该提供与之前运行完全相同的结果。

第二个测试有时甚至不会运行。这已经是一个足够好的理由来使用假来直接打破依赖。此外,我们无法模拟周末或工作日,这给了我们足够的动力来重新设计测试代码,使其对依赖项更具可注入性。

但是等等,还有更多。使用时间的测试往往可能不稳定。它们只在某些时候失败,除了时间变化之外没有其他原因。这个测试是这种行为的典型候选者,因为我们本地运行时只会得到其两种状态之一的反馈。如果你想了解它在周末的表现,只需等待几天。呃。

测试可能会因为影响测试中不受我们控制的变量的边缘情况而变得不稳定。常见的例子是在端到端测试期间的网络问题、数据库连接问题或各种服务器问题。当这种情况发生时,很容易通过说“再运行一次”或“没关系。只是[插入可变性问题在这里]”来忽略测试失败。

3.3 通常接受的设计方法来模拟

在接下来的几节中,我们将讨论几种常见的将存根注入到我们的工作单元中的形式。首先,我们将讨论基本参数化作为第一步,然后我们将跳入以下方法:

  • 函数式方法

    • 函数作为参数

    • 部分应用(柯里化)

    • 工厂函数

    • 构造函数

  • 模块化方法

    • 模块注入
  • 面向对象方法

    • 类构造函数注入

    • 对象作为参数(即鸭子类型)

    • 公共接口作为参数(为此我们将使用 TypeScript)

我们将逐一解决这些问题,从测试中控制时间的简单情况开始。

3.3.1 使用参数注入模拟时间

至少有两个很好的理由来控制时间,基于我们到目前为止所讨论的内容:

  • 为了从我们的测试中消除可变性

  • 为了轻松模拟我们想要测试代码的任何时间相关场景

这里是我能想到的最简单的重构,可以使事情更加可重复。让我们给我们的函数添加一个currentDay参数来指定当前日期。这将消除在函数中使用 moment.js 模块的需要,并将这个责任放在函数的调用者身上。这样,在我们的测试中,我们可以以硬编码的方式确定时间,并使测试和函数可重复且一致。以下列表显示了这种重构的一个示例。

列表 3.3 verifyPassword带有currentDay参数

const verifyPassword2 = (input, rules, currentDay) => {
    if ([SATURDAY, SUNDAY].includes(currentDay)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

const SUNDAY = 0, SATURDAY = 6, MONDAY = 1;
describe('verifier2 - dummy object', () => {
    test('on weekends, throws exceptions', () => {
        expect(() => verifyPassword2('anything',[],SUNDAY ))
            .toThrow("It's the weekend!");
    });
});

通过添加currentDay参数,我们实际上是将对时间控制权交给了函数的调用者(我们的测试)。我们注入的正式称为“哑元”——它只是一块没有行为的数据——但从现在起我们可以称它为“存根”。

这种方法是一种依赖倒置的形式。似乎“控制反转”这个术语最早出现在 Johnson 和 Foote 在 1988 年发表的论文“Designing Reusable Classes”中,该论文发表在《面向对象编程杂志》上。术语“依赖倒置”也是 Robert C. Martin 在 2000 年在其“Design Principles and Design Patterns”论文中描述的 SOLID 模式之一。我将在第八章中更多地讨论高级设计考虑因素。

添加这个参数是一个简单的重构,但效果相当显著。它除了在测试中提供一致性之外,还有一些其他的好处:

  • 我们现在可以轻松地模拟任何我们想要的日期。

  • 被测试的代码不负责管理时间导入,因此如果我们使用不同的时间库,它就少了一个改变的理由。

我们正在将“时间依赖注入”到我们的工作单元中。我们已经更改了入口点的设计,使用一个日期值作为参数。根据函数式编程的标准,这个函数现在是“纯”的,因为它没有副作用。纯函数内置了所有依赖的注入,这也是为什么你会发现函数式编程的设计通常更容易测试的原因。

03-02

图 3.2 注入时间依赖的存根

如果currentDay参数只是一个日期整数值,那么称它为存根可能感觉有点奇怪,但根据xUnit Test Patterns的定义,我们可以说这是一个“虚拟”值,在我看来,它属于“存根”类别。它不需要复杂,才能成为存根。它只需要在我们控制之下。它是一个存根,因为我们正在使用它来模拟一些输入或行为被传递到被测试的单元中。图 3.2 展示了这一点。

3.3.2 依赖、注入和控制

表 3.2 总结了一些我们在本章中讨论并即将使用的术语。

表 3.2 本章中使用的术语

依赖 使我们的测试生活和代码可维护性变得困难的事物,因为我们无法从测试中控制它们。例如包括时间、文件系统、网络、随机值等等。
控制 指示依赖如何行为的能力。据说创建依赖项的人是它们的控制者,因为他们有在代码被测试之前配置它们的能力。在列表 3.1 中,我们的测试对时间没有控制权,因为被测试的模块控制着它。该模块选择始终使用当前的日期和时间。这迫使测试做完全相同的事情,因此我们在测试中失去了一致性。在列表 3.3 中,我们通过currentDay参数通过反转控制获得了对依赖的访问。现在测试控制着时间,可以决定使用硬编码的时间。被测试的模块必须使用提供的时间,这使得测试变得容易得多。
控制反转 设计代码以移除内部创建依赖项的责任,并将其外部化。列表 3.3 展示了通过参数注入实现这一点的示例。
依赖注入 将依赖项通过设计接口发送,以便代码内部使用。注入依赖项的地方是注入点。在我们这个例子中,我们使用参数注入点。我们可以注入东西的地方也可以称为一个 缝隙
缝隙 发音为“s-ee-m”,由迈克尔·费思在《有效地与遗留代码工作》(Pearson,2004 年)一书中提出。缝隙是两个软件组件相遇并可以注入其他东西的地方。这是你可以改变程序行为而不在该处编辑的地方。例子包括参数、函数、模块加载器、函数重写,以及在面向对象的世界中,类接口、公共虚拟方法等。

在生产代码中的缝隙对于单元测试的可维护性和可读性起着重要作用。改变和注入行为或自定义数据到被测试代码中的难度越小,编写、阅读以及随着生产代码的变化而维护测试的难度就越小。我将在第八章中更多地讨论一些与设计代码相关的模式和反模式。

3.4 函数注入技术

到目前为止,我们可能对我们的设计选择不满意。添加参数确实在函数级别解决了依赖问题,但现在每个调用者都需要以某种方式处理日期。这感觉有点太啰嗦了。

JavaScript 允许两种主要的编程风格——函数式和面向对象——因此,当有道理时,我会展示两种风格的方法,你可以根据你的情况选择最合适的方法。

设计某物并没有唯一的方法。函数式编程的支持者会为函数式风格的简单性、清晰性和可证明性辩护,但这确实有一个学习曲线。因此,仅仅为了这个原因,学习两种方法都是明智的,这样你就可以应用最适合你所在团队的任何一种方法。有些团队可能会更倾向于面向对象的设计,因为他们觉得更舒服。其他团队可能会倾向于函数式设计。我认为模式在很大程度上是相同的;我们只是将它们翻译成不同的风格。

3.4.1 注入函数

下面的列表展示了针对同一问题的不同重构方法:我们期望的是一个函数作为参数,而不是数据对象。该函数返回日期对象。

列表 3.4 使用函数进行依赖注入

const verifyPassword3 = (input, rules, getDayFn) => {
    const dayOfWeek = getDayFn();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

相关的测试在下面的列表中展示。

列表 3.5 使用函数注入进行测试

describe('verifier3 - dummy function', () => {
    test('on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        expect(()=> verifyPassword3('anything',[], alwaysSunday))
            .toThrow("It's the weekend!");
    });

与之前的测试几乎没有区别,但使用函数作为参数是进行注入的有效方式。在其他情况下,它也是一个启用特殊行为的好方法,比如模拟代码中被测试的特殊情况或异常。

3.4.2 通过部分应用进行依赖注入

工厂函数或方法(“高阶函数”的一个子类别)是返回其他函数的函数,这些函数预先配置了一些上下文。在我们的情况下,上下文可以是规则列表和当前日期函数。然后我们得到一个新的函数,我们可以通过仅输入字符串来触发它,它将使用在其创建时配置的规则和getDay()函数。

下面的代码列表将工厂函数转换为测试的 arrange 部分,并调用返回的函数到 act 部分。相当漂亮。

列表 3.6 使用高阶工厂函数

const SUNDAY = 0, . . . FRIDAY=5, SATURDAY = 6;

const makeVerifier = (rules, dayOfWeekFn) => {
    return function (input) {
        if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
            throw new Error("It's the weekend!");
        }
        //more code goes here..
 };
};

describe('verifier', () => {
    test('factory method: on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        const verifyPassword = makeVerifier([], alwaysSunday);

        expect(() => verifyPassword('anything'))
            .toThrow("It's the weekend!");
    });

3.5 模块化注入技术

JavaScript 还允许我们使用模块的概念,我们可以importrequire。面对测试代码中的直接依赖导入时,例如在 3.1 列表中的代码,我们如何处理依赖注入的概念?

const moment = require('moment');
const SUNDAY = 0; const SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    // more code goes here...
    // return list of errors found..
    return [];
};

我们如何克服这种直接依赖?答案是,我们无法克服。我们必须以不同的方式编写代码,以便稍后替换该依赖。我们必须创建一个接口,通过该接口我们可以替换我们的依赖。这里有一个这样的例子。

列表 3.7 抽象所需的依赖

const originalDependencies = {                        ❶
 moment: require(‘moment’), ❶
}; ❶

let dependencies = { ...originalDependencies };       ❷

const inject = (fakes) => {                           ❸
 Object.assign(dependencies, fakes);
 return function reset() {     ❹
 dependencies = { ...originalDependencies };
 }
};

const SUNDAY = 0; const SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = dependencies.moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    // more code goes here...
    // return list of errors found..
    return [];
};

module.exports = {
    SATURDAY,
    verifyPassword,
    inject
};

❶ 用中间对象包装 moment.js

❷ 包含当前依赖的对象,无论是真实还是假

❸ 一个用假依赖替换真实依赖的函数

❹ 一个将依赖重置回真实值的函数

这里发生了什么?引入了三个新事物:

  • 首先,我们将对 moment.js 的直接依赖替换为一个对象:originalDependencies。它包含该模块导入作为其实现的一部分。

  • 接下来,我们向其中添加了另一个对象:dependencies。默认情况下,该对象承担了originalDependencies对象包含的所有真实依赖。

  • 最后,inject函数,我们也将它作为我们自己的模块的一部分暴露出来,允许导入我们的模块的人(无论是生产代码还是测试)用自定义依赖(假)覆盖我们的真实依赖。

当你调用inject时,它返回一个reset函数,该函数将原始依赖重新应用于当前的dependencies变量,从而重置当前正在使用的任何假依赖。

这就是如何在测试中使用injectreset函数。

列表 3.8 使用inject()注入假模块

const { inject, verifyPassword, SATURDAY } = require('./password-verifier-time00-modular');

const injectDate = (newDay) => {                    ❶
 const reset = inject({                   ❷
 moment: function () {
            //we're faking the moment.js module's API here.
 return {
 day: () => newDay
 }
 }
 });
 return reset;
};

describe('verifyPassword', () => {
    describe('when its the weekend', () => {
        it('throws an error', () => {
            const reset = injectDate(SATURDAY);     ❸

            expect(() => verifyPassword('any input'))
                .toThrow("It's the weekend!");

            reset();                                ❹
        });
    });
});

❶ 一个辅助函数

❷ 用假的 API 代替 moment.js

❸ 提供一个假的日期

❹ 重置依赖

让我们分解一下这里正在发生的事情:

  1. injectDate函数只是一个辅助函数,旨在减少我们测试中的样板代码。它始终构建 moment.js API 的假结构,并将其getDay函数设置为返回newDay参数。

  2. injectDate函数调用inject,并使用新的假 moment.js API。这将在我们的工作单元中将假依赖应用于我们作为参数传入的一个。

  3. 我们的测试调用inject函数并传入一个自定义的、伪造的日期。

  4. 在测试结束时,我们调用reset函数,该函数将工作单元的模块依赖项重置为原始状态。

做了几次之后,这开始变得有意义。但也有一些注意事项。在正面方面,它确实解决了我们测试中的依赖问题,并且相对容易使用。至于缺点,在我看来有一个巨大的缺点。使用这种方法伪造我们的模块依赖项迫使我们的测试紧密依赖于我们伪造的依赖项的 API 签名。如果这些是第三方依赖项,例如 moment.js、日志记录器或其他我们无法完全控制的任何东西,当需要(总是需要)升级或替换具有不同 API 的依赖项时,我们的测试将变得非常脆弱。如果只是几个测试,这并不会造成太大的伤害,但通常我们会有数百或数千个测试需要伪造几个常见的依赖项,有时这意味着在用具有破坏性 API 更改的记录器替换时,需要更改和修复数百个文件。

我有两种可能的方法来防止这种情况:

  • 永远不要在你的代码中导入你无法直接控制的第三方依赖。始终使用你能够控制的中间抽象。端口和适配器架构是这种想法的一个很好的例子(这种架构的其他名称包括六边形架构和洋葱架构)。在这种架构下,伪造这些内部 API 应该风险更小,因为我们能够控制它们的变更速度,从而使我们的测试更加稳健。(即使外部世界发生变化,我们也可以在不影响测试的情况下对它们进行内部重构。)

  • 避免使用模块注入,而是使用本书中提到的其他依赖注入方法之一:函数参数、柯里化和,如下一节所述,构造函数和接口。在这些方法之间,你应该有足够的选择,而不是直接导入。

3.6 向构造函数对象迈进

构造函数是一种稍微更面向对象的 JavaScript 风格,可以达到与工厂函数相同的结果,但它们返回类似于具有我们可以触发的方法的对象。然后我们使用关键字new来调用这个函数,并获取那个特殊对象。

下面是采用这种设计选择后的相同代码和测试的样子。

列表 3.9 使用构造函数

const Verifier = function(rules, dayOfWeekFn)
{
 this.verify = function (input) {
        if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
            throw new Error("It's the weekend!");
        }
        //more code goes here..
    };
};

const {Verifier} = require("./password-verifier-time01");

test('constructor function: on weekends, throws exception', () => {
    const alwaysSunday = () => SUNDAY;
    const verifier = new Verifier([], alwaysSunday);

    expect(() => verifier.verify('anything'))
        .toThrow("It's the weekend!");
});

你可能会看看这个并问,“为什么要转向对象?”答案实际上取决于你当前项目的上下文、其堆栈、你团队对函数编程和面向对象背景的了解,以及许多其他非技术因素。在工具箱中拥有这个工具是好事,这样你就可以在你认为合适的时候使用它。在阅读下一节之前,把这个放在心里。

3.7 面向对象注入技术

如果你倾向于更面向对象的风格,或者如果你在 C# 或 Java 等面向对象语言中工作,这里有一些在面向对象世界中广泛使用的、用于依赖注入的常见模式。

3.7.1 构造函数注入

构造函数注入是我用来描述一种设计,我们可以通过类的构造函数注入依赖项。在 JavaScript 世界中,Angular 是最著名的使用这种设计注入“服务”的 Web 前端框架,在 Angular 术语中,“服务”只是“依赖项”的代码词。这在许多其他情况下都是一个可行的设计。

拥有一个有状态的类并非没有好处。它可以消除只需要配置我们的类一次并可以多次重用配置类的客户端的重复性。

如果我们选择创建一个有状态版本的密码验证器,并且我们想要通过构造函数注入来注入日期函数,它可能看起来像以下设计。

列表 3.10 构造函数注入设计

class PasswordVerifier {
 constructor(rules, dayOfWeekFn) {
 this.rules = rules;
 this.dayOfWeek = dayOfWeekFn;
 }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.dayOfWeek())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}

test('class constructor: on weekends, throws exception', () => {
    const alwaysSunday = () => SUNDAY;
    const verifier = new PasswordVerifier([], alwaysSunday);

    expect(() => verifier.verify('anything'))
        .toThrow("It's the weekend!");
});

这看起来和感觉与第 3.6 节中的构造函数设计非常相似。这是一个更面向类的、许多人会感到更舒适的、来自面向对象背景的设计。它也更冗长。你会发现,我们使事物越加面向对象,我们就越加冗长。这是面向对象游戏的一部分。这也是为什么越来越多的人选择函数式风格的部分原因——它们更加简洁。

让我们谈谈测试的可维护性。如果我用这个类写第二个测试,我会将类的创建通过构造函数提取到一个漂亮的小工厂函数中,该函数返回待测试类的实例,这样如果(即,“当”)构造函数签名更改并一次性破坏许多测试,我只需要修复一个地方就可以让所有测试再次工作,如下面的列表所示。

列表 3.11 向我们的测试中添加一个辅助工厂函数

describe('refactored with constructor', () => {
 const makeVerifier = (rules, dayFn) => {
 return new PasswordVerifier(rules, dayFn);
 };

    test('class constructor: on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        const verifier = makeVerifier([],alwaysSunday);

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });

    test('class constructor: on weekdays, with no rules, passes', () => { 
        const alwaysMonday = () => MONDAY;
        const verifier = makeVerifier([],alwaysMonday);

        const result = verifier.verify('anything');
        expect(result.length).toBe(0);
    });
});

注意,这与第 3.4.2 节中的工厂函数设计不同。这个工厂函数位于我们的测试中;另一个位于我们的生产代码中。这个是为了测试的可维护性,并且它可以与面向对象和函数式生产代码一起工作,因为它隐藏了函数或对象是如何被创建或配置的。它是我们测试中的一个抽象层,因此我们可以将如何创建或配置函数或对象的依赖项推到我们的测试中的单个地方。

3.7.2 注入对象而不是函数

目前,我们的类构造函数接受一个函数作为第二个参数:

constructor(rules, dayOfWeekFn) {
    this.rules = rules;
    this.dayOfWeek = dayOfWeekFn;
}

让我们在面向对象的设计上再迈出一大步,使用对象而不是函数作为我们的参数。这需要我们做一些工作:重构代码。

首先,我们将创建一个名为 time-provider.js 的新文件,其中将包含我们依赖于 moment.js 的真实对象。该对象将被设计为有一个名为 getDay() 的单一函数:

import moment from "moment";

const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};

接下来,我们将更改参数使用,以使用具有函数的对象:

const SUNDAY = 0, MONDAY = 1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
    ...
}

最后,让我们给需要我们的 PasswordVerifier 实例的人提供通过默认情况下预配置真实时间提供者的能力。我们将使用一个新的 passwordVerifierFactory 函数来完成这项工作,任何需要验证器实例的生产代码都需要使用这个函数:

const passwordVerifierFactory = (rules) => {
    return new PasswordVerifier(new RealTimeProvider())
};

IoC 容器和依赖注入

有许多其他方法可以将 PasswordVerifierTimeProvider 连接起来。我只是选择了手动注入以保持事情简单。今天许多框架都能够配置将依赖注入到测试对象中,这样我们就可以定义对象的构建方式。Angular 就是这样的框架之一。

如果你正在使用 Java 中的 Spring 或 C# 中的 Autofac 或 StructureMap 这样的库,你可以轻松地配置使用构造函数注入的对象构建,而无需创建专门的函数。通常,这些功能被称为控制反转(IoC)容器或依赖注入(DI)容器。我在这本书中没有使用它们,以避免不必要的细节。你不需要它们来创建出色的测试。

实际上,我通常不在测试中使用 IoC 容器。我几乎总是使用自定义工厂函数来注入依赖。我发现这样做可以使我的测试更容易阅读和推理。

即使是测试 Angular 代码,我们也不必通过 Angular 的 DI 框架将依赖注入到内存中的对象中;我们可以直接调用该对象的构造函数并传入假数据。只要我们在工厂函数中这样做,我们就没有牺牲可维护性,也不会为测试添加额外的代码,除非这是测试所必需的。

以下列表显示了整个新代码块。

列表 3.12 注入对象

import moment from "moment";

const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};

const SUNDAY = 0, MONDAY=1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}

const passwordVerifierFactory = (rules) => {
    return new PasswordVerifier(new RealTimeProvider())
};

我们如何在测试中处理这种类型的设计,当我们需要注入一个假对象而不是一个假函数时?我们最初会手动完成这个操作,这样你可以看到这并不是什么大问题。稍后,我们将让框架帮助我们,但你也会看到,有时手动编写假对象实际上可以使你的测试比使用框架(如 Jasmine、Jest 或 Sinon)更易于阅读(我们将在第五章中介绍这些框架)。

首先,在我们的测试文件中,我们将创建一个新的假对象,它具有与我们的真实时间提供者相同的函数签名,但它将由我们的测试控制。在这种情况下,我们将只使用构造函数模式:

function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}

注意:如果你正在使用更面向对象的方式工作,你可能会选择创建一个继承自通用接口的简单类。我们将在本章稍后讨论这一点。

接下来,我们将在我们的测试中构建 FakeTimeProvider 并将其注入到正在测试的 verifier 中:

describe('verifier', () => {
    test('on weekends, throws exception', () => {
        const verifier = 
             new PasswordVerifier([], new FakeTimeProvider(SUNDAY));

        expect(()=> verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });

这就是完整的测试文件看起来像什么。

列表 3.13 创建手写的存根对象

function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}

describe('verifier', () => {
    test('class constructor: on weekends, throws exception', () => {
        const verifier = 
            new PasswordVerifier([], new FakeTimeProvider(SUNDAY));

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
}); 

这段代码之所以能工作,是因为 JavaScript 默认是一个非常宽容的语言。就像 Ruby 或 Python 一样,你可以通过鸭子类型来避免很多问题。"鸭子类型"指的是如果一个东西看起来像鸭子,说话也像鸭子,我们就把它当作鸭子来对待。在这种情况下,真实对象和假对象都实现了相同的功能,尽管它们是完全不同的对象。我们可以简单地用一个代替另一个,并且生产代码应该能够接受这种替换。

当然,我们只有在运行时才知道这是可行的,并且我们没有在函数签名方面犯任何错误或遗漏任何内容。如果我们想更有信心,我们可以尝试以更安全的类型方式来做。

3.7.3 提取公共接口

我们可以更进一步,如果我们使用 TypeScript 或像 Java 或 C#这样的强类型语言,我们可以开始使用接口来表示我们的依赖项所扮演的角色。我们可以创建一种合同,确保真实对象和假对象都必须在编译器级别遵守。

首先,我们将定义我们的新接口(注意,这现在是 TypeScript 代码):

export interface TimeProviderInterface {
    getDay(): number;
}

第二,我们将在生产代码中定义一个实现我们接口的真实时间提供者,如下所示:

import * as moment from "moment";
import {TimeProviderInterface} from "./time-provider-interface";

export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}

第三,我们将更新PasswordVerifier的构造函数,使其接受我们新的TimeProviderInterface类型作为依赖项,而不是使用RealTimeProvider参数类型。我们正在抽象化时间提供者的角色,并声明我们不在乎传递什么对象,只要它符合这个角色的接口:

export class PasswordVerifier {
 private _timeProvider: TimeProviderInterface;

    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }

    verify(input: string):string[] {
        const isWeekened = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length > 0;
        if (isWeekened) {
            throw new Error("It's the weekend!")
        }
         // more logic goes here
        return [];
    }
}

现在我们有一个定义了“鸭子”外观的接口,我们可以在测试中实现自己的鸭子。它看起来会非常像之前的测试代码,但有一个显著的区别:它将通过编译器检查来确保方法签名的正确性。

下面是我们测试文件中的假时间提供者:

class FakeTimeProvider implements TimeProviderInterface {
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}

下面是我们的测试:

describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
 const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

以下列表显示了所有代码:

列表 3.14 在生产代码中提取公共接口

export interface TimeProviderInterface {  getDay(): number;  }

export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}

export class PasswordVerifier {
    private _timeProvider: TimeProviderInterface;

    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }
    verify(input: string):string[] {
        const isWeekend = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length>0;
        if (isWeekend) {
            throw new Error("It's the weekend!")
        }
        return [];
    }
}

class FakeTimeProvider implements TimeProviderInterface{
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}

describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

我们现在已经完全从纯函数式设计过渡到了强类型、面向对象的设计。这对你的团队和项目来说哪个更好?没有唯一的答案。我将在第八章中更多地讨论设计。在这里,我主要想展示的是,无论你最终选择哪种设计,注入模式在很大程度上都是相同的。它只是通过不同的词汇或语言特性来实现。

是注入的能力使我们能够模拟在现实生活中实际上无法测试的事情。这正是 stubs 理念大放异彩的地方。我们可以告诉我们的 stubs 返回假值,甚至在我们的代码中模拟异常,以查看它如何处理由依赖项引起的错误。注入使得这一切成为可能。注入也使我们的测试更加可重复、一致和可靠,我将在本书的第三部分讨论可靠性。在下一章中,我们将探讨模拟对象,并了解它们与 stubs 的区别。

摘要

  • 测试替身是一个总称,描述了测试中所有非生产就绪的、假的依赖项。测试替身有五种变体,可以归纳为两种类型:模拟stubs

  • 模拟帮助模拟和检查 输出依赖项:代表我们工作单元的出口点的依赖项。被测试的系统(SUT)调用输出依赖项以改变这些依赖项的状态。Stubs 帮助模拟 输入依赖项:SUT 调用此类依赖项以获取输入数据。

  • Stubs 帮助用假的可信依赖项替换不可信的依赖项,从而避免 测试的不稳定性

  • 有多种方法可以将 stub 注入到工作单元中:

    • 函数作为参数——注入一个函数而不是一个普通值。

    • 部分应用(柯里化)和工厂函数——创建一个返回另一个函数的函数,该函数包含一些预先烘焙的上下文。这个上下文可能包括你用 stub 替换的依赖项。

    • 模块注入——用一个具有相同 API 的假模块替换模块。这种方法是脆弱的。如果模拟的模块在未来更改其 API,你可能需要进行大量的重构。

    • 构造函数——这基本上与部分应用相同。

    • 类构造函数注入——这是一种常见的面向对象技术,通过构造函数注入依赖项。

    • 对象作为参数(即鸭子类型)——在 JavaScript 中,只要依赖项实现了相同的函数,你就可以将其注入到所需依赖项的位置。

    • 公共接口作为参数——这与对象作为参数相同,但在编译时涉及检查。对于这种方法,你需要一个强类型语言,如 TypeScript。

4 使用模拟对象进行交互测试

本章涵盖

  • 定义交互测试

  • 使用模拟对象的原因

  • 注入和使用模拟对象

  • 处理复杂的接口

  • 部分模拟

在上一章中,我们解决了测试依赖于其他对象才能正确运行的代码的问题。我们使用存根来确保被测试的代码收到了它需要的所有输入,这样我们就可以单独测试工作单元。

到目前为止,你只编写了针对工作单元可能拥有的三种类型的出口点中的前两种类型的测试:返回值改变系统状态(你可以在第一章中了解更多关于这些类型的信息)。在本章中,我们将探讨如何测试第三种类型的出口点——调用第三方函数、模块或对象。这很重要,因为通常我们会有依赖于我们无法控制的代码。知道如何检查这种类型的代码是单元测试领域的重要技能。基本上,我们将找到方法来证明我们的工作单元最终调用了我们无法控制的函数,并识别发送作为参数的值。

我们迄今为止探讨的方法在这里不起作用,因为第三方函数通常没有专门的 API 允许我们检查它们是否被正确调用。相反,它们为了清晰性和可维护性而内部化其操作。那么,你如何测试你的工作单元是否正确地与第三方函数交互呢?你使用模拟。

4.1 交互测试、模拟和存根

交互测试是检查工作单元如何与和控制之外的依赖项交互,并发送消息(即调用函数)。使用模拟函数或对象来断言是否正确地调用了外部依赖项。

让我们回顾一下在第三章中我们讨论的模拟和存根之间的区别。主要区别在于信息流:

  • 模拟——用于断开传出的依赖。模拟是我们断言在测试中被调用的虚假模块、对象或函数。模拟代表单元测试中的一个出口点。如果我们不对它进行断言,它就不会作为模拟使用。

    对于测试的可维护性和可读性而言,每个测试最多只有一个模拟对象是正常的。(我们将在本书关于编写可维护测试的第三部分中进一步讨论这个问题。)

  • 存根——用于断开传入的依赖。存根是提供虚假行为或数据的虚假模块、对象或函数,用于被测试的代码。我们不对它们进行断言,并且可以在单个测试中拥有多个存根。

    存根代表的是途径点,而不是出口点,因为数据或行为是流向工作单元的。它们是交互点,但并不代表工作单元的最终结果。相反,它们是通往我们关心的最终结果的交互途径,因此我们不将它们视为出口点。

图 4.1 显示了这两个对象并排展示。

04-01

图 4.1 左边,一个实现为调用依赖项的出口点。右边,依赖项提供间接输入或行为,不是一个出口点。

让我们看看一个简单的例子,这是一个我们不控制的依赖项的出口点:调用日志记录器。

4.2 依赖于日志记录器

让我们将这个密码验证器函数作为我们的起始示例,并假设我们有一个复杂的日志记录器(它具有更多函数和参数,因此接口可能更具挑战性)。我们函数的一个要求是在验证通过或失败时调用日志记录器,如下所示。

列表 4.1 直接依赖于复杂的日志记录器

// impossible to fake with traditional injection techniques
const log = require('./complicated-logger');

const verifyPassword = (input, rules) => {
  const failed = rules
    .map(rule => rule(input))
    .filter(result => result === false);
  if (failed.count === 0) {
    // to test with traditional injection techniques
    log.info('PASSED');                                      ❶
    return true; //                                          ❶
  }
  //impossible to test with traditional injection techniques
  log.info('FAIL'); //                                       ❶
  return false; //                                           ❶
};

const info = (text) => {
 console.log(`INFO: ${text}`);
};
const debug = (text) => {
    console.log(`DEBUG: ${text}`);
};

❶ 出口点

图 4.2 展示了这一点。我们的verifyPassword函数是工作单元的入口点,我们总共有两个出口:一个返回值,另一个调用log.info()

04-02

图 4.2 密码验证器的入口点是verifyPassword函数。一个出口点返回一个值,另一个调用log.info()

不幸的是,我们无法使用任何传统方法来验证logger是否被调用,或者不使用一些 Jest 技巧,因为我通常只有在没有其他选择时才会使用这些技巧,因为它们往往会使得测试更难以阅读和维护(关于这一点,本章后面会详细说明)。

让我们用我们喜欢的方式处理依赖项:抽象它们。在我们的代码中创建接口有许多方法。记住,接口是两段代码相遇的地方——我们可以利用它们来注入假数据。表 4.1 列出了抽象依赖项最常见的方法。

表 4.1 注入伪造的技术

风格 技术
标准 引入参数
函数式 使用柯里化转换为高阶函数
模块化 抽象模块依赖
面向对象 注入未类型化对象注入接口

4.3 标准风格:引入参数重构

我们可以开始这段旅程的最明显方式是在我们正在测试的代码中引入一个新参数。

列表 4.2 模拟日志参数注入

const verifyPassword2 = (input, rules, logger) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);

    if (failed.length === 0) {
 logger.info('PASSED');
        return true;
    }
    logger.info('FAIL');
    return false;
};

下面的列表展示了我们可以如何使用简单的闭包机制编写这个最简单的测试。

列表 4.3 手写模拟对象

describe('password verifier with logger', () => {
    describe('when all rules pass', () => {
        it('calls the logger with PASSED', () => {
            let written = '';
            const mockLog = {
                info: (text) => {
                    written = text;
                }
            };

            verifyPassword2('anything', [], mockLog);

            expect(written).toMatch(/PASSED/);
        });
    });
});

首先要注意的是,我们给变量命名mockXXX(在这个例子中是mockLog)以表明我们在测试中有一个模拟函数或对象。我使用这种命名约定是因为我想让你,作为测试的读者,知道你应该在测试结束时对那个模拟进行断言(也称为验证)。这种命名方法消除了读者的惊喜元素,使得测试更加可预测。仅对实际是模拟的事物使用这种命名约定。

这是我们的第一个模拟对象:

let written = '';
const mockLog = {
    info: (text) => {
        written = text;
    }
};

它只有一个功能,模仿了日志记录器info函数的签名。然后它保存传递给它的参数(text),以便我们可以在测试的稍后阶段断言它被调用。如果written变量包含正确的文本,这证明了我们的函数被调用,这意味着我们已经证明了从我们的工作单元正确调用了出口点。

verifyPassword2这一侧,我们进行的重构相当常见。这几乎与我们在上一章中做的一样,当时我们提取了一个存根作为依赖项。在重构和引入应用程序代码中的接口方面,存根和模拟通常被同等对待。

这个简单的重构为参数提供了什么?

  • 我们不再需要在测试代码中显式地导入(通过requirelogger了。这意味着,如果我们更改了logger的实际依赖项,测试代码将减少一个需要更改的理由。

  • 现在我们有能力将任何我们选择的logger注入到测试代码中,只要它符合相同的接口(或者至少有info方法)。这意味着我们可以提供一个为我们服务的模拟日志记录器:模拟日志记录器帮助我们验证它是否被正确调用。

注意:我们的模拟对象只模拟了logger接口的一部分(缺少debug函数),这是一种鸭子类型的形式。我在第三章讨论了这个想法:如果它像鸭子走路,如果它像鸭子说话,那么我们可以将其用作一个假对象。

4.4 区分模拟和存根的重要性

为什么我如此关心我们给每个事物取的名字?如果我们无法区分模拟和存根,或者我们没有正确命名它们,我们可能会得到测试多个事物且可读性差、更难维护的测试。正确命名事物有助于我们避免这些陷阱。

由于模拟代表了我们工作单元的要求(“它调用日志记录器”,“它发送电子邮件”等),而存根代表传入的信息或行为(“数据库查询返回 false”,“这个特定的配置抛出错误”),我们可以设定一个简单的规则:在测试中拥有多个存根是可以接受的,但你通常不希望每个测试中只有一个模拟,因为这意味着你在单个测试中测试了多个要求。

如果我们无法(或不愿意)区分事物(命名是关键),我们可能会在每个测试中拥有多个模拟,或者断言我们的存根,这两者都可能对我们的测试产生负面影响。保持命名一致性给我们带来以下好处:

  • 可读性—你的测试名称将变得更加通用,更难以理解。你希望人们能够阅读测试名称并了解其中发生或测试的所有内容,而无需阅读测试代码。

  • 可维护性——如果你没有区分模拟和存根,你可能会不经意或甚至不在乎地针对存根进行断言。这对你几乎没有价值,并且增加了你的测试和内部生产代码之间的耦合。断言你查询了数据库就是一个很好的例子。与其测试数据库查询返回某些值,不如测试在改变数据库输入后,应用程序的行为是否发生变化。

  • 信任——如果你在单个测试中有多个模拟(需求),并且第一个模拟验证失败导致测试失败,大多数测试框架不会执行测试的其余部分(在失败的断言行以下),因为已经抛出了异常。这意味着其他模拟没有被验证,你不会从它们那里得到结果。

为了强调最后一点,想象一个只看到患者 30%症状的医生,但仍然需要做出决定——他们可能会在治疗上做出错误的决定。如果你看不到所有错误在哪里,或者两件事物都在失败而不是只有一件(因为其中一件在第一次失败后被隐藏),你更有可能修复错误的事物或错误地修复它。

《XUnit 测试模式》 (Addison-Wesley, 2007),由 Gerard Meszaros 编著,称这种情况为 断言轮盘赌 (xunitpatterns.com/Assertion%20Roulette.html)。我喜欢这个名字。这相当是一场赌博。你开始注释掉测试中的代码行,随之而来的是很多乐趣(以及可能还有酒精)。

并非所有事物都是模拟

很不幸,人们仍然倾向于使用“mock”这个词来指代任何非真实的事物,例如“mock 数据库”或“mock 服务”。大多数时候他们真正意味着他们正在使用一个存根。

虽然很难责怪他们。像 Mockito、jMock 以及大多数隔离框架(我不称它们为模拟框架,原因和我在讨论的相同),使用“mock”这个词来表示模拟和存根。

现在有一些新的框架,例如 JavaScript 中的 Sinon 和 testdouble,.NET 中的 NSubstitute 和 FakeItEasy,以及其他一些框架,它们帮助启动了命名约定的变革。我希望这种变革能够持续下去。

4.5 模块化风格的存根

我在上一章中介绍了模块化依赖注入,但现在我们将看看我们如何可以使用它来注入模拟对象并在它们上模拟答案。

4.5.1 生产代码示例

让我们看看一个比之前更复杂的例子。在这个场景中,我们的 verifyPassword 函数依赖于两个外部依赖项:

  • 一个日志记录器

  • 一个配置服务

配置服务提供所需的日志级别。通常这类代码会被移动到一个特殊的日志记录器模块中,但为了本书示例的目的,我将调用 logger.infologger.debug 的逻辑直接放在被测试的代码中。

列表 4.4 一个硬模块化依赖

const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");

const log = (text) => {
  if (getLogLevel() === "info") {
    info(text);
  }
  if (getLogLevel() === "debug") {
    debug(text);
  }
};

const verifyPassword = (input, rules) => {
  const failed = rules
    .map((rule) => rule(input))
    .filter((result) => result === false);

  if (failed.length === 0) {
    log("PASSED");   ❶
    return true;
  }
  log("FAIL");       ❶
  return false;
};

module.exports = {
  verifyPassword,
};

❶ 调用日志记录器

假设我们在调用日志记录器时意识到我们有一个错误。我们已经更改了检查失败的方式,现在当失败次数为正而不是零时,我们用 PASSED 结果调用日志记录器。我们如何通过单元测试来证明这个错误存在,或者我们已经修复了它?

我们在这里的问题是我们在代码中直接导入(或要求)模块。如果我们想替换日志记录器模块,我们必须替换文件或通过 Jest 的 API 执行一些其他黑暗魔法。我不建议这样做,因为使用这些技术会导致比处理代码时通常更多的痛苦和折磨。

4.5.2 以模块注入风格重构生产代码

我们可以将模块依赖项抽象成它们自己的对象,并允许我们的模块用户按以下方式替换该对象。

列表 4.5 重构为模块注入模式

const originalDependencies = {                     ❶
 log: require('./complicated-logger'), ❶
}; ❶

let dependencies = { ...originalDependencies }; ❷

const resetDependencies = () => {                  ❸
 dependencies = { ...originalDependencies }; ❸
}; ❸

const injectDependencies = (fakes) => {            ❹
 Object.assign(dependencies, fakes); ❹
}; ❹

const verifyPassword = (input, rules) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);

    if (failed.length === 0) {
        dependencies.log.info('PASSED');
        return true;
    }
    dependencies.log.info('FAIL');
    return false;
};

module.exports = {
    verifyPassword,                                ❺
    injectDependencies, ❺
    resetDependencies ❺
};

❶ 保留原始依赖项

❷ 间接层

❸ 一个重置依赖项的函数

❹ 一个覆盖依赖项的函数

❺ 向模块用户公开 API

这里有一些生产代码,看起来更复杂,但如果我们被迫以模块化的方式工作,这允许我们相对容易地替换测试中的依赖项。

originalDependencies 变量将始终保留原始依赖项,这样我们就不至于在测试之间丢失它们。dependencies 是我们的间接层。它默认为原始依赖项,但我们的测试可以指导被测试代码用自定义依赖项替换该变量(而无需了解模块的内部结构)。injectDependenciesresetDependencies 是模块公开的 API,用于覆盖和重置依赖项。

4.5.3 模块式注入的测试示例

以下列表展示了模块注入测试可能的样子。

列表 4.6 使用模块注入进行测试

const {
  verifyPassword,
  injectDependencies,
 resetDependencies,
} = require("./password-verifier-injectable");

describe("password verifier", () => {
  afterEach(resetDependencies);

  describe("given logger and passing scenario", () => {
    it("calls the logger with PASS", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      injectDependencies({ log: mockLog });

      verifyPassword("anything", []);

      expect(logged).toMatch(/PASSED/);
    });
  });
});

只要我们记得在每个测试后使用 resetDependencies 函数,现在我们就可以很容易地为测试目的注入模块。明显的最大缺点是,这种方法要求每个模块公开可以从外部使用的注入和重置函数。这可能或可能不适用于您当前的设计限制,但如果适用,您可以将它们都抽象成可重用的函数,从而节省大量的样板代码。

4.6 函数式风格的模拟

让我们来看看我们可以用来将模拟注入到被测试代码中的几种函数式风格。

4.6.1 使用柯里化风格工作

让我们实现第三章中介绍的柯里化技术,以执行更函数式风格的日志记录器注入。在以下列表中,我们将使用 lodash,这是一个促进 JavaScript 函数式编程的库,以在不产生太多样板代码的情况下实现柯里化。

列表 4.7 将柯里化应用于我们的函数

const verifyPassword3 = _.curry((rules, logger, input) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);
    if (failed.length === 0) {
        logger.info('PASSED');
        return true;
    }
    logger.info('FAIL');
    return false;
});

唯一的改变是在第一行调用 _.curry,并在代码块末尾关闭它。

以下列表演示了这种类型代码的测试可能的样子。

列表 4.8 使用依赖注入测试柯里化函数

describe("password verifier", () => {
  describe("given logger and passing scenario", () => {
    it("calls the logger with PASS", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const injectedVerify = verifyPassword3([], mockLog);

      // this partially applied function can be passed around
      // to other places in the code
      // without needing to inject the logger
      injectedVerify("anything");

      expect(logged).toMatch(/PASSED/);
    });
  });
});

我们的测试用前两个参数调用该函数(注入 ruleslogger 依赖项,实际上返回一个部分应用函数),然后使用最终输入调用返回的函数 injectedVerify,从而向读者展示两件事:

  • 这个函数在现实生活中是如何使用的

  • 依赖项是什么

除了这些,其他方面与之前的测试几乎相同。

4.6.2 与高阶函数一起工作而不使用柯里化

列表 4.9 是函数式编程设计的另一种变体。我们使用高阶函数,但没有使用柯里化。你可以从以下代码中看出它不包含柯里化,因为我们始终需要将所有参数作为参数发送给函数,以便它能够正确工作。

列表 4.9 在高阶函数中注入模拟

const makeVerifier = (rules, logger) => {
    return (input) => {                     ❶
        const failed = rules
            .map(rule => rule(input))
            .filter(result => result === false);

        if (failed.length === 0) {
            logger.info('PASSED');
            return true;
        }
        logger.info('FAIL');
        return false;
 };
};

❶ 返回预配置的验证器

这次我明确地创建了一个工厂函数,该函数返回一个预配置的验证器函数,它已经在其闭包的依赖项中包含了 ruleslogger

现在我们来看看对这个的测试。测试需要首先调用 makeVerifier 工厂函数,然后调用那个函数返回的函数 (passVerify)。

列表 4.10 使用工厂函数进行测试

describe("higher order factory functions", () => {
  describe("password verifier", () => {
    test("given logger and passing scenario", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const passVerify = makeVerifier([], mockLog);        ❶

      passVerify("any input");                             ❷

      expect(logged).toMatch(/PASSED/);
    });
  });
});

❶ 调用工厂函数

❷ 调用生成的函数

4.7 以面向对象风格使用模拟

现在我们已经介绍了一些函数式和模块化风格,让我们来看看面向对象风格。来自面向对象背景的人会对此类方法感到更加舒适,而来自函数式背景的人可能会讨厌它。但生活就是关于接受人们的不同之处。

4.7.1 对生产代码进行重构以实现注入

列表 4.11 展示了在 JavaScript 的基于类的设计中这种类型的注入可能看起来是什么样子。类有构造函数,我们使用构造函数来强制类的调用者提供参数。这不是实现这一点的唯一方法,但在面向对象的设计中非常常见且有用,因为它使得这些参数的要求明确,在强类型语言如 Java 或 C 以及使用 TypeScript 时几乎不可否认。我们想确保使用我们代码的任何人都能知道如何正确配置它。

列表 4.11 基于类的构造函数注入

class PasswordVerifier {
  _rules;
  _logger;

  constructor(rules, logger) {
    this._rules = rules;
    this._logger = logger;
  }

  verify(input) {
    const failed = this._rules
        .map(rule => rule(input))
        .filter(result => result === false);

    if (failed.length === 0) {
      this._logger.info('PASSED');
      return true;
    }
    this._logger.info('FAIL');
    return false;
  }
}

这只是一个标准的类,它接受几个构造函数参数,然后在 verify 函数中使用它们。以下列表展示了测试可能的样子。

列表 4.12 将模拟日志记录器作为构造函数参数注入

describe("duck typing with function constructor injection", () => {
  describe("password verifier", () => {
    test("logger&passing scenario,calls logger with PASSED", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const verifier = new PasswordVerifier([], mockLog);
      verifier.verify("any input");

      expect(logged).toMatch(/PASSED/);
    });
  });
});   

模拟注入与上章中提到的存根类似,非常简单。如果我们使用属性而不是构造函数,这意味着依赖项是可选的。使用构造函数,我们明确表示它们不是可选的。

在像 Java 或 C# 这样的强类型语言中,通常会将伪日志记录器提取为一个单独的类,如下所示:

class FakeLogger {
  logged = "";

  info(text) {
    this.logged = text;
  }
}

我们只需在类中实现 info 函数,但不是记录任何内容,而是将作为函数参数发送的值保存在一个公开可见的变量中,这样我们就可以在测试的稍后阶段断言它。

注意,我没有将伪对象命名为 MockLoggerStubLogger,而是命名为 FakeLogger。这样,我就可以在多个不同的测试中重用这个类。在某些测试中,它可能被用作存根,而在其他测试中,它可能被用作模拟对象。我使用“fake”这个词来表示任何不是真实的东西。这类事物的另一个常见术语是“测试替身”。由于“fake”这个词更短,所以我更喜欢它。

在我们的测试中,我们将实例化这个类,并将其作为构造函数参数发送,然后我们将断言类的 logged 变量,如下所示:

test("logger + passing scenario, calls logger with PASSED", () => {
   let logged = "";
   const mockLog = new FakeLogger();
   const verifier = new PasswordVerifier([], mockLog);
   verifier.verify("any input");

   expect(mockLog.logged).toMatch(/PASSED/);
});

4.7.2 使用接口重构生产代码

接口在许多面向对象程序中扮演着重要角色。它们是多态概念的一种变体:只要对象实现了相同的接口,就可以用其他对象替换一个或多个对象。在 JavaScript 和 Ruby 等其他语言中,不需要接口,因为语言允许使用无需将对象显式转换为特定接口的鸭子类型。在这里,我不会涉及鸭子类型优缺点的讨论。你应该能够根据需要,在你的选择的语言中使用任何一种技术。在 JavaScript 中,我们可以转向 TypeScript 来使用接口。我们将使用的编译器,或称为转换器,可以帮助确保我们正确地根据它们的签名使用类型。

列表 4.13 显示了三个代码文件:第一个描述了一个新的 ILogger 接口,第二个描述了一个实现了该接口的 SimpleLogger,第三个是我们的 PasswordVerifier,它只使用 ILogger 接口来获取日志记录器实例。PasswordVerifier 对注入的实际日志记录器类型一无所知。

列表 4.13 生产代码获取 ILogger 接口

export interface ILogger {                                ❶
 info(text: string); ❶
} ❶

//this class might have dependencies on files or network
class SimpleLogger implements ILogger {                   ❷
    info(text: string) {
    }
}

export class PasswordVerifier {
    private _rules: any[];
    private _logger: ILogger;                             ❸

    constructor(rules: any[], logger: ILogger) {          ❸
        this._rules = rules;
        this._logger = logger; ❸
    }

    verify(input: string): boolean {
        const failed = this._rules
            .map(rule => rule(input))
            .filter(result => result === false);

        if (failed.length === 0) {
            this._logger.info('PASSED');
            return true;
        }
 this._logger.info('FAIL');
        return false;
    }
}

❶ 一个新的接口,它是生产代码的一部分

❷ 日志记录器现在实现了该接口。

❸ 验证器现在使用接口。

注意,生产代码中发生了一些变化。我向生产代码中添加了一个新的接口,并且现有的日志记录器现在实现了这个接口。我正在改变设计,使日志记录器可替换。此外,PasswordVerifier 类现在与接口而不是 SimpleLogger 类一起工作。这允许我用一个假的实例替换 logger 类的实例,而不是对真实日志记录器有硬依赖。

以下列表显示了一个强类型语言中的测试可能的样子,但使用的是实现 ILogger 接口的手写模拟对象。

列表 4.14 注入手写的模拟 ILogger

class FakeLogger implements ILogger {
 written: string;
 info(text: string) {
 this.written = text;
 }
}
describe('password verifier with interfaces', () => {
    test('verify, with logger, calls logger', () => {
        const mockLog = new FakeLogger();
        const verifier = new PasswordVerifier([], mockLog);

        verifier.verify('anything');

        expect(mockLog.written).toMatch(/PASS/);
    });
});

在这个例子中,我创建了一个名为 FakeLogger 的手写类。它所做的只是覆盖 ILogger 接口中的一个方法,并将 text 参数保存以供未来的断言。然后我们将这个值作为 written 类中的一个字段公开。一旦这个值被公开,我们就可以通过检查这个字段来验证模拟日志记录器是否被调用。

我这样做是手动进行的,因为我想要你看到,即使在面向对象的世界里,模式也会重复。我们不再有一个模拟 函数,而是一个模拟 对象,但代码和测试的工作方式与之前的例子一样。

接口命名约定

我使用命名约定,在日志接口前缀一个“ I”,因为它将被用于多态原因(即,我正在用它来抽象系统中的一个角色)。在 TypeScript 的接口命名中,这并不总是如此,例如,当我们使用接口来定义一组参数的结构(基本上是作为强类型结构使用)时。在这种情况下,不带“ I”的命名对我来说是有意义的。

目前,可以这样想:如果你打算多次实现它,你应该用“ I”前缀来使接口的预期使用更加明确。

4.8 处理复杂接口

当接口更复杂时会发生什么,比如当它包含一个或两个以上的函数,或者每个函数包含一个或两个以上的参数时?

4.8.1 复杂接口的示例

列表 4.15 是一个复杂接口的示例,以及使用复杂日志记录器(作为接口注入)的生产代码验证器。IComplicatedLogger 接口有四个函数,每个函数有一个或多个参数。在我们的测试中,每个函数都需要被模拟,这可能导致我们的代码和测试中的复杂性和可维护性问题。

列表 4.15 与更复杂的接口一起工作(生产代码)

export interface IComplicatedLogger {                        ❶
    info(text: string)
    debug(text: string, obj: any)
    warn(text: string)
    error(text: string, location: string, stacktrace: string)
}

export class PasswordVerifier2 {
    private _rules: any[];
    private _logger: IComplicatedLogger;                     ❷

    constructor(rules: any[], logger: IComplicatedLogger) {  ❷
        this._rules = rules;
        this._logger = logger;
    }
...
}

❶ 一个新的接口,它是生产代码的一部分

❷ 类现在使用新的接口。

如您所见,新的 IComplicatedLogger 接口将成为生产代码的一部分,这将使 logger 可替换。我省略了真实日志记录器的实现,因为它对我们示例来说并不相关。这就是使用接口抽象事物的好处:我们不需要直接引用它们。注意,类构造函数中期望的参数类型是 IComplicatedLogger 接口类型。这允许我用一个模拟实例替换日志记录器类的实例,就像我们之前做的那样。

4.8.2 使用复杂接口编写测试

下面是这个测试的样子。它必须覆盖每个接口函数,这会创建冗长且令人烦恼的样板代码。

列表 4.16 具有复杂记录器接口的测试代码

describe("working with long interfaces", () => {
  describe("password verifier", () => {
  class FakeComplicatedLogger ❶
 implements IComplicatedLogger { ❶
      infoWritten = "";
      debugWritten = "";
      errorWritten = "";
      warnWritten = "";

      debug(text: string, obj: any) {
        this.debugWritten = text;
      }

      error(text: string, location: string, stacktrace: string) {
        this.errorWritten = text;
      }

      info(text: string) {
        this.infoWritten = text;
      }

      warn(text: string) {
        this.warnWritten = text;
      }
    }
    ...

    test("verify passing, with logger, calls logger with PASS", () => {
      const mockLog = new FakeComplicatedLogger();

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");

      expect(mockLog.infoWritten).toMatch(/PASSED/);
    });

    test("A more JS oriented variation on this test", () => {
      const mockLog = {} as IComplicatedLogger;
      let logged = "";
      mockLog.info = (text) => (logged = text);

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");

      expect(logged).toMatch(/PASSED/);
    });
  });
});

❶ 实现新接口的假记录器类

在这里,我们再次声明一个假记录器类(FakeComplicatedLogger),它实现了IComplicatedLogger接口。看看我们有多少样板代码。如果我们正在使用强类型面向对象语言,如 Java、C#或 C++,这尤其正确。有办法绕过所有这些样板代码,我们将在下一章中简要介绍。

4.8.3 直接使用复杂接口的缺点

在我们的测试中使用长而复杂的接口还有其他缺点:

  • 如果我们在手动保存传递的参数,验证多个方法调用中的多个参数会更麻烦。

  • 很可能我们依赖于第三方接口而不是内部接口,这最终会使我们的测试随着时间的推移变得更加脆弱。

  • 即使我们是依赖于内部接口,长接口有更多改变的理由,现在我们的测试也是如此。

这对我们意味着什么?我强烈推荐只使用满足以下两个条件的假接口:

  • 你控制接口(它们不是由第三方制作的)。

  • 它们适应了你的工作单元或组件的需求。

4.8.4 接口隔离原则

前一个条件中的第二个可能需要一些解释。它与接口隔离原则(ISP;zh.wikipedia.org/wiki/接口隔离原则)相关。ISP 意味着如果我们有一个包含比我们所需更多功能的接口,我们应该创建一个小的、更简单的适配器接口,它只包含我们所需的功能,最好有更少的函数、更好的名称和更少的参数。

这将使我们的测试变得更加简单。通过抽象掉真实依赖项,我们不需要在复杂接口更改时更改我们的测试——只需更改某个地方的单一适配器类文件。我们将在第五章中看到这个例子。

4.9 部分模拟

在 JavaScript 以及大多数其他语言及其相关测试框架中,我们可以接管现有对象和函数并“监视”它们。通过监视它们,我们可以在之后检查它们是否被调用、调用了多少次以及使用了哪些参数。

这实际上可以将真实对象的部分转换为模拟函数,同时保持对象的其余部分为真实对象。这可能会创建更复杂的测试,它们更脆弱,但有时这可能是一个可行的选择,特别是如果你在处理遗留代码(有关遗留代码的更多信息,请参阅第十二章)。

4.9.1 部分模拟的功能示例

以下列表显示了这样的测试可能的样子。我们创建了一个真实的记录器,然后我们简单地使用一个自定义函数覆盖了它的一个现有真实函数。

列表 4.17 部分模拟示例

describe("password verifier with interfaces", () => {
  test("verify, with logger, calls logger", () => {
    const testableLog: RealLogger = new RealLogger(); ❶
    let logged = "";
    testableLog.info = (text) => (logged = text);       ❷

    const verifier = new PasswordVerifier([], testableLog);
    verifier.verify("any input");

    expect(logged).toMatch(/PASSED/);
  });
});

❶ 实例化一个真实的记录器

❷ 模拟其一个函数

在这个测试中,我实例化了一个RealLogger,在下一行我将其中一个现有的函数替换为一个假的函数。更具体地说,我使用了一个模拟函数,它允许我通过一个自定义变量跟踪其最新的调用参数。

这里的重要部分是testableLog变量是一个局部模拟。这意味着至少其内部实现的一部分不是假的,可能包含真实的依赖项和逻辑。

有时候使用局部模拟是有意义的,特别是当你与遗留代码一起工作时,你可能需要将一些现有代码与其依赖项隔离。我将在第十二章中更多地讨论这一点。

4.9.2 一个面向对象的局部模拟示例

局部模拟的一个面向对象版本使用继承来覆盖真实类中的函数,以便我们可以验证它们是否被调用。以下列表展示了我们如何使用继承和覆盖在 JavaScript 中实现这一点。

列表 4.18 一个面向对象的局部模拟示例

class TestableLogger extends RealLogger { ❶
 logged = "";
 info(text) {                  ❷
 this.logged = text;                  ❷
 }                  ❷
  // the error() and debug() functions
  // are still "real"
}

describe("partial mock with inheritance", () => {
  test("verify with logger, calls logger", () => {
    const mockLog: TestableLogger = new TestableLogger();

    const verifier = new PasswordVerifier([], mockLog);
    verifier.verify("any input");

    expect(mockLog.logged).toMatch(/PASSED/);
  });
});

❶ 从真实日志记录器继承

❷ 覆盖其中一个函数

在我的测试中,我从真实的日志记录器类继承,然后使用继承的类,而不是原始类,在我的测试中使用。这种技术通常被称为提取和覆盖,你可以在 Michael Feathers 的书籍《有效地与遗留代码一起工作》(Pearson,2004)中找到更多关于这方面的内容。

注意,我将这个假的日志记录器类命名为“TestableXXX”,因为它是一个可测试的真实生产代码版本,包含假代码和真实代码的混合,这个约定有助于我向读者明确这一点。我还将这个类直接放在我的测试旁边。我的生产代码不需要知道这个类的存在。这种提取和覆盖风格要求我的生产代码中的类允许继承,并且函数允许覆盖。在 JavaScript 中,这并不是一个问题,但在 Java 和 C#中,这些是需要明确做出的设计选择(尽管有一些框架允许我们绕过这个规则;我们将在下一章讨论它们)。

在这个场景中,我们从一个我们不是直接测试的类继承(RealLogger)。我们使用这个类来测试另一个类(PasswordVerifier)。然而,这种技术可以非常有效地用来隔离和存根或模拟你直接测试的类中的单个函数。我们将在本书后面讨论遗留代码和重构技术时更多地讨论这一点。

摘要

  • 交互测试是一种检查工作单元如何与其外部依赖项交互的方法:调用了哪些调用以及使用哪些参数。交互测试与第三种退出点相关:第三方模块、对象或系统。(前两种是返回值和状态变化。)

  • 要进行交互测试,你应该使用模拟,这些是替换输出依赖的测试替身。占位符替换输入依赖。你应该在测试中验证与模拟的交互,而不是与占位符的交互。与模拟不同,与占位符的交互是实现细节,不应该进行检查。

  • 在测试中拥有多个占位符是可以的,但你通常不希望每个测试中包含超过一个模拟对象,因为这意味着你在一个测试中测试了多个需求。

  • 就像处理占位符一样,有几种方法可以将模拟对象注入到工作单元中:

    • 标准—通过引入一个参数

    • 函数式—使用部分应用或工厂函数

    • 模块化—抽象模块依赖

    • 面向对象—使用无类型对象(如在 JavaScript 语言中)或类型化接口(如在 TypeScript 中)

  • 在 JavaScript 中,可以部分实现复杂接口,这有助于减少样板代码的数量。还有使用部分模拟的选项,即从真实类继承并仅用伪造对象替换其中的一些方法。

5 隔离框架

本章涵盖

  • 定义隔离框架及其作用

  • 框架的两种主要类型

  • 使用 Jest 模拟模块

  • 使用 Jest 模拟函数

  • 使用 substitute.js 的面向对象模拟

在前面的章节中,我们探讨了手动编写模拟和存根的挑战,特别是当我们想要模拟的接口需要我们创建长、易出错、重复的代码时。我们不得不不断地声明自定义变量、创建自定义函数,或者从使用这些变量的类中继承,从而使事情比必要的更复杂(大多数时候)。

在本章中,我们将探讨一些以隔离框架形式出现的优雅解决方案,这是一种可重用的库,可以在运行时创建和配置假对象。这些对象被称为动态存根动态模拟

我称它们为隔离框架,因为它们允许您将工作单元与其依赖项隔离开来。您会发现许多资源会将它们称为“模拟框架”,但我尽量避免使用这个术语,因为它们可以用于模拟和存根。在本章中,我们将探讨一些可用的 JavaScript 框架以及我们如何在模块化、函数式和面向对象的设计中使用它们。您将看到如何使用这些框架来测试各种事物,并创建存根、模拟和其他有趣的事物。

但我将在下面展示的具体框架并不是重点。在使用它们的过程中,您将看到它们的 API 在测试中提倡的价值(可读性、可维护性、健壮且持久的测试等),并且您会发现是什么使隔离框架变得出色,以及它可能成为测试的缺点。

5.1 定义隔离框架

我将从一个非常基础的定义开始,这个定义可能听起来有点平淡无奇,但为了包括各种隔离框架,它需要具有通用性:

隔离框架是一组可编程 API,允许以对象或函数形式动态创建、配置和验证模拟和存根。当使用隔离框架时,这些任务通常比手动编写的模拟和存根更简单、更快,并且生成的代码更短。

正确使用隔离框架可以节省开发者编写重复代码以断言或模拟对象交互的需求,如果应用得当,它们可以帮助测试持续多年而无需开发者每次进行微小的生产代码更改后回来修复。如果应用不当,它们可能导致混淆和完全滥用这些框架,以至于我们无法阅读或信任自己的测试,因此要小心。我将在本书的第三部分讨论一些应该做和不应该做的事情。

5.1.1 选择一种类型:松散型与类型化

由于 JavaScript 支持多种编程设计范式,我们可以将我们世界中的框架分为两大类:

  • 松散的 JavaScript 隔离框架—这些是针对纯 JavaScript 的松散类型隔离框架(如 Jest 和 Sinon)。这些框架通常也更适合更函数式的代码风格,因为它们需要更少的仪式和样板代码来完成工作。

  • 类型化的 JavaScript 隔离框架—这些是更面向对象且 TypeScript 友好的隔离框架(如 substitute.js)。当处理整个类和接口时,它们非常有用。

你最终在项目中选择使用哪种风味将取决于几个因素,如口味、风格和可读性,但首先要问的问题是,你主要需要模拟哪种类型的依赖?

  • 模块依赖(导入、导入)—Jest 和其他松散类型框架应该可以很好地工作。

  • 函数式(单态和更高阶函数,简单的参数和值)—Jest 和其他松散类型框架应该可以很好地工作。

  • 完整对象、对象层次结构和接口—可以查看更面向对象的框架,如 substitute.js。

让我们回到我们的密码验证器,看看我们如何使用框架来模拟我们在前几章中使用的相同类型的依赖,但这次是使用一个框架。

5.2 动态模拟模块

对于试图使用requireimport直接依赖模块进行代码测试的人来说,Jest 或 Sinon 等隔离框架提供了强大的动态模拟整个模块的能力,且代码量非常少。由于我们最初选择了 Jest 作为我们的测试框架,因此在本章的示例中我们将继续使用它。

图 5.1 展示了具有两个依赖关系的密码验证器:

  • 一个帮助决定日志级别(INFOERROR)的配置服务

  • 一个我们称之为工作单元出口点的日志服务,每次我们验证密码时

05-01

图 5.1 密码验证器有两个依赖关系:一个用于确定日志级别的外来依赖,一个用于创建日志条目的外出依赖。

箭头表示行为在工作单元中的流动。另一种思考箭头的方式是通过术语命令查询。我们正在查询配置服务(以获取日志级别),但我们正在向记录器发送命令(以记录)。

命令/查询分离

有一种设计思想属于命令/查询分离的概念。如果你想了解更多关于这些术语的信息,我强烈推荐阅读马丁·福勒 2005 年关于该主题的文章,可在martinfowler.com/bliki/CommandQuerySeparation.html找到。这种模式在你探索不同的设计思想时非常有用,但在这本书中我们不会过多涉及这个话题。

下面的列表显示了一个具有对记录器模块硬依赖的密码验证器。

列表 5.1 带有硬编码模块依赖的代码

const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");

const log = (text) => {
  if (getLogLevel() === "info") {
    info(text);
  }
  if (getLogLevel() === "debug") {
    debug(text);
  }
};

const verifyPassword = (input, rules) => {
  const failed = rules
    .map((rule) => rule(input))
    .filter((result) => result === false);

  if (failed.length === 0) {
    log("PASSED");
    return true;
  }
  log("FAIL");
  return false;
};

在这个例子中,我们被迫找到一种方法来做两件事:

  • 模拟(存根)configuration服务getLogLevel函数返回的值。

  • 验证(模拟)logger模块的info函数是否被调用。

图 5.2 展示了这一过程的视觉表示。

05-02

图 5.2 测试存根一个传入的依赖(配置服务)并模拟一个传出的依赖(日志器)。

Jest 提供了几种实现模拟和验证的方法,其中它呈现的一种更干净的方式是在 spec 文件顶部使用jest.mock([module name]),然后我们在测试中引入这些模拟模块,以便我们可以配置它们。

列表 5.2 使用jest.mock()直接模拟模块 API

jest.mock("./complicated-logger");                              ❶
jest.mock("./configuration-service"); ❶

const { stringMatching } = expect;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./complicated-logger");       ❷
const stubConfigModule = require("./configuration-service"); ❷

describe("password verifier", () => {
  afterEach(jest.resetAllMocks);                                ❸

  test('with info log level and no rules, 
          it calls the logger with PASSED', () => {
    stubConfigModule.getLogLevel.mockReturnValue("info");       ❹

    verifyPassword("anything", []);

    expect(mockLoggerModule.info)                               ❺
 .toHaveBeenCalledWith(stringMatching(/PASS/)); ❺
  });

  test('with debug log level and no rules, 
        it calls the logger with PASSED', () => {
    stubConfigModule.getLogLevel.mockReturnValue("debug");      ❻

    verifyPassword("anything", []);

    expect(mockLoggerModule.debug)                              ❼
 .toHaveBeenCalledWith(stringMatching(/PASS/)); ❼
  });
});

❶ 模拟模块

❷ 获取模块的模拟实例

❸ 告诉 Jest 在测试之间重置任何模拟模块行为

❹ 配置存根以返回模拟的“info”值。

❺ 断言模拟被正确调用

❻ 修改存根配置

❼ 如前所述,对模拟日志器进行断言

通过在这里使用 Jest,我节省了很多打字时间,而且测试看起来仍然很易读。

5.2.1 关于 Jest API 的一些注意事项

Jest 几乎在所有地方都使用“mock”这个词,无论是我们是在存根还是模拟某些东西,这可能会有些令人困惑。如果它将“stub”别名到“mock”,会使事情更易读。

此外,由于 JavaScript 的“提升”方式,通过jest.mock模拟模块的行需要放在文件顶部。你可以在此处阅读更多关于 Ashutosh Verma 的“理解 JavaScript 中的提升”文章:mng.bz/j11r

还要注意,Jest 有许多其他 API 和功能,如果你对其感兴趣,值得探索它们。前往jestjs.io/以获取完整信息——这超出了本书的范围,本书主要关于模式,而不是工具。

一些其他框架,包括 Sinon(sinonjs.org),也支持模拟模块。就隔离框架而言,Sinon 相当易于使用,但就像 JavaScript 世界中的许多其他框架一样,以及 Jest 一样,它包含了许多完成同一任务的方法,这可能会让人感到困惑。然而,没有这些框架,手动模拟模块可能会相当麻烦。

5.2.2 考虑抽象直接依赖

关于jest.mock API 及其类似功能的利好消息是,它满足了开发者测试那些内置了不易更改的依赖项(即他们无法控制的代码)的非常实际的需求。这个问题在遗留代码情况下非常普遍,我将在第十二章中讨论这个问题。

关于 jest.mock API 的坏消息是,它还允许我们模拟我们控制的代码,并且可能从抽象到更简单、更短的内部 API 中受益。这种方法也称为 洋葱架构六边形架构端口和适配器,对于代码的长期可维护性非常有用。你可以在 Alistair Cockburn 的文章“Hexagonal Architecture”中了解更多关于这种类型架构的信息,该文章位于 alistair.cockburn.us/hexagonal-architecture/

为什么直接依赖可能有问题?通过直接使用这些 API,我们也被迫在我们的测试中直接伪造模块 API,而不是它们的抽象。我们将这些直接 API 的设计粘合到测试的实现上,这意味着如果(或者更确切地说,当)这些 API 发生变化时,我们也需要更改许多测试。

这里有一个快速示例。假设你的代码依赖于一个知名的 JavaScript 日志框架(例如 Winston),并且直接在代码中的数百或数千个地方依赖它。然后想象一下,Winston 发布了一个破坏性的升级。随之而来的是大量的痛苦,这些问题本可以在事情失控之前得到解决。实现这一点的简单方法之一是使用一个简单的抽象到单个适配器文件,这是唯一持有该记录器引用的文件。这种抽象可以暴露一个更简单、内部日志 API,我们确实可以控制它,因此我们可以防止代码中的大规模破坏。我将在第十二章回到这个话题。

5.3 功能性动态模拟和存根

我们已经讨论了模块依赖,现在让我们转向模拟简单的函数。我们在前面的章节中多次这样做,但我们总是手动完成。这对于存根来说效果很好,但对于模拟来说,很快就会变得令人烦恼。

下面的列表显示了之前我们使用的手动方法。

列表 5.3 手动模拟函数以验证其是否被调用

test("given logger and passing scenario", () => {
  let logged = "";                                       ❶
  const mockLog = { info: (text) => (logged = text) };   ❷
  const passVerify = makeVerifier([], mockLog);

  passVerify("any input");

  expect(logged).toMatch(/PASSED/);                      ❸
});

❶ 声明一个自定义变量来保存传入的值

❷ 将传入的值保存到该变量中

❸ 断言变量的值

它是有效的——我们能够验证记录器函数被调用,但这是一项大量工作,可能会变得非常重复。这时,隔离框架如 Jest 就派上用场。jest.fn() 是消除此类代码的最简单方法。下面的列表显示了我们可以如何使用它。

列表 5.4 使用 jest.fn() 进行简单的函数模拟

test('given logger and passing scenario', () => {
  const mockLog = { info: jest.fn() };
  const verify = makeVerifier([], mockLog);

  verify('any input');

  expect(mockLog.info)
    .toHaveBeenCalledWith(stringMatching(/PASS/));
});

将此代码与上一个示例进行比较。它很微妙,但可以节省很多时间。在这里,我们使用 jest.fn() 来获取一个由 Jest 自动跟踪的函数,这样我们就可以通过 Jest 的 API 使用 toHaveBeenCalledWith() 在以后查询它。它小巧可爱,并且在你需要跟踪对特定函数的调用时效果很好。stringMatching 函数是一个 匹配器 的例子。匹配器通常被定义为可以断言传入函数的参数值的实用函数。Jest 文档对此术语的使用更为宽松,但你可以在 Jest 文档的jestjs.io/docs/en/expect中找到匹配器的完整列表。

总结来说,jest.fn() 对于基于单个功能的模拟和存根来说效果很好。让我们继续探讨一个更面向对象的挑战。

5.4 面向对象的动态模拟和存根

正如我们刚刚看到的,jest.fn() 是一个单功能模拟实用函数的例子。它在函数式世界中效果很好,但当我们尝试在包含多个函数的完整 API 接口或类中使用它时,它就会有点崩溃。

5.4.1 使用松散类型框架

我之前提到过,存在两种隔离框架类别。首先,我们将使用第一种(松散类型,函数友好)类型。以下列表是尝试解决我们在上一章中查看的 IComplicatedLogger 的一个例子。

列表 5.5 IComplicatedLogger 接口

export interface IComplicatedLogger {
    info(text: string, method: string)
    debug(text: string, method: string)
    warn(text: string, method: string)
    error(text: string, method: string)
}

为此接口创建手写的存根或模拟可能会非常耗时,因为你需要记住每个方法上的参数,如下一个列表所示。

列表 5.6 手写存根生成大量样板代码

describe("working with long interfaces", () => {
  describe("password verifier", () => {
    class FakeLogger implements IComplicatedLogger {
 debugText = "";
 debugMethod = "";
 errorText = "";
 errorMethod = "";
 infoText = "";
 infoMethod = "";
 warnText = "";
 warnMethod = "";

      debug(text: string, method: string) {
 this.debugText = text;
 this.debugMethod = method;
      }

      error(text: string, method: string) {
 this.errorText = text;
 this.errorMethod = method;
      }
      ...
    }

    test("verify, w logger & passing, calls logger with PASS", () => {
      const mockLog = new FakeLogger();
      const verifier = new PasswordVerifier2([], mockLog);

      verifier.verify("anything");

      expect(mockLog.infoText).toMatch(/PASSED/);
    });
  });
});

真是乱七八糟。这不仅意味着手写的模拟既耗时又难以编写,如果你想在测试中让它返回特定的值,或者模拟从日志器函数调用中产生的错误,会发生什么?我们可以做到,但代码会很快变得丑陋。

使用隔离框架,执行此操作的代码变得简单、可读性更强,并且更短。让我们使用 jest.fn() 来完成同样的任务,看看我们会走到哪里。

列表 5.7 使用 jest.fn() 模拟单个接口函数

import stringMatching = jasmine.stringMatching;

describe("working with long interfaces", () => {
  describe("password verifier", () => {
    test("verify, w logger & passing, calls logger with PASS", () => {
    const mockLog: IComplicatedLogger = { ❶
    info: jest.fn(), ❶
    warn: jest.fn(), ❶
    debug: jest.fn(), ❶
    error: jest.fn(), ❶
      };

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");

      expect(mockLog.info)
        .toHaveBeenCalledWith(stringMatching(/PASS/));
    });
  });
});

❶ 使用 Jest 设置模拟

这并不太糟糕。在这里,我们只是概述了自己的对象,并将 jest.fn() 函数附加到接口中的每个函数上。这节省了很多打字,但有一个重要的注意事项:每当接口发生变化(例如添加了一个函数),我们都需要回到定义此对象的代码中并添加该函数。使用纯 JavaScript,这可能会少一些问题,但如果有代码正在测试我们未在测试中定义的函数,这仍然可能造成一些复杂性。

无论如何,将此类模拟对象的创建推入一个工厂辅助方法可能是明智的,这样创建就只存在于一个地方。

5.4.2 转向类型友好的框架

让我们切换到框架的第二类,并尝试 substitute.js (www.npmjs.com/package/@fluffy-spoon/substitute)。我们必须选择一个,我非常喜欢这个框架的 C# 版本,并在上一版这本书中使用过它。

使用 substitute.js(以及假设使用 TypeScript),我们可以编写如下代码。

列表 5.8 使用 substitute.js 模拟完整接口

import { Substitute, Arg } from "@fluffy-spoon/substitute";

describe("working with long interfaces", () => {
  describe("password verifier", () => {
    test("verify, w logger & passing, calls logger w PASS", () => {
      const mockLog = Substitute.for<IComplicatedLogger>();         ❶

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");

      mockLog.received().info(                                      ❷
    Arg.is((x) => x.includes("PASSED")), ❷
    "verify" ❷
      );
    });
  });
});

❶ 生成假对象

❷ 验证假对象被调用

在前面的列表中,我们生成了假对象,这使我们无需关心除了我们正在测试的函数之外的其他任何函数,即使对象签名在未来发生变化。然后我们使用 .received() 作为我们的验证机制,以及另一个参数匹配器,Arg.is,这次来自 substitute.js 的 API,它的工作方式与 Jasmine 的字符串匹配类似。这里的额外好处是,如果对象签名中添加了新函数,我们就不太可能需要更改测试,而且无需将这些函数添加到使用相同对象签名的任何测试中。

隔离框架和 Arrange-Act-Assert 模式

注意,你使用隔离框架的方式与我们在第一章中讨论的 Arrange-Act-Assert 结构非常匹配。你首先安排一个假对象,然后对你要测试的事物进行操作,最后在测试的末尾进行断言。

但是,这并不总是那么容易。在古代(大约 2006 年),大多数开源隔离框架都不支持 Arrange-Act-Assert 的概念,而是使用了一个叫做 Record-Replay 的概念(我们说的是 Java 和 C#)。Record-Replay 是一个讨厌的机制,你需要告诉隔离 API 它的假对象处于 record 模式,然后你需要按照预期的生产代码调用该对象上的方法。然后你需要告诉隔离 API 切换到 replay 模式,只有 那时 你才能将你的假对象发送到生产代码的核心。一个例子可以在 Baeldung 网站上看到,www.baeldung.com/easymock

与今天使用更易读的 Arrange-Act-Assert 模型编写的测试能力相比,这场悲剧让许多开发者花费了数百万小时的不懈努力去阅读测试,以确定测试失败的确切位置。

如果你拥有这本书的第一版,你可以在展示 Rhino Mocks(最初具有相同的设计)时看到 Record-Replay 的一个例子。

好的,那是模拟。那么桩(stub)呢?

5.5 动态模拟行为

Jest 为模拟模块和功能依赖的返回值提供了一个非常简单的 API:mockReturnValue()mockReturnValueOnce()

列表 5.9 使用 jest.fn() 从假函数中模拟值

test("fake same return values", () => {
  const stubFunc = jest.fn()
 .mockReturnValue("abc");

  //value remains the same
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
});

test("fake multiple return values", () => {
  const stubFunc = jest.fn()
 .mockReturnValueOnce("a")
 .mockReturnValueOnce("b")
 .mockReturnValueOnce("c");

  //value remains the same
  expect(stubFunc()).toBe("a");
  expect(stubFunc()).toBe("b");
  expect(stubFunc()).toBe("c");
  expect(stubFunc()).toBe(undefined);
});

注意,在第一个测试中,我们正在为测试期间设置一个永久的返回值。如果我可以使用它,这是我编写测试的首选方法,因为它使得测试易于阅读和维护。如果我们需要模拟多个值,我们可以使用mockReturnValueOnce

如果你需要模拟错误或进行更复杂的操作,可以使用mockImplementation()mockImplementationOnce()

yourStub.mockImplementation(() => {
  throw new Error();
});

5.5.1 使用模拟和存根的对象导向示例

让我们在密码验证器的方程中添加另一个因素。

  • 假设密码验证器在特殊维护窗口期间(当软件正在更新时)是不活跃的。

  • 当维护窗口处于活动状态时,在验证器上调用verify()将导致它调用logger.info()并显示“正在维护”。

  • 否则,它将调用logger.info()并显示“通过”或“失败”的结果。

为了这个目的(以及展示面向对象的设计决策),我们将引入一个MaintenanceWindow接口,该接口将被注入到我们的密码验证器的构造函数中,如图 5.3 所示。

05-03

图 5.3 使用MaintenanceWindow接口

以下列表显示了使用新依赖项的密码验证器的代码。

列表 5.10 带有MaintenanceWindow依赖的密码验证器

export class PasswordVerifier3 {
  private _rules: any[];
  private _logger: IComplicatedLogger;
  private _maintenanceWindow: MaintenanceWindow;

  constructor(
    rules: any[],
    logger: IComplicatedLogger,
    maintenanceWindow: MaintenanceWindow
  ) {
    this._rules = rules;
    this._logger = logger;
 this._maintenanceWindow = maintenanceWindow;
  }

  verify(input: string): boolean {
    if (this._maintenanceWindow.isUnderMaintenance()) {
      this._logger.info("Under Maintenance", "verify");
      return false;
    }
    const failed = this._rules
      .map((rule) => rule(input))
      .filter((result) => result === false);

    if (failed.length === 0) {
      this._logger.info("PASSED", "verify");
      return true;
    }
    this._logger.info("FAIL", "verify");
    return false;
  }
}

MaintenanceWindow接口作为构造函数参数注入(即使用构造函数注入),并用于确定是否执行密码验证以及向记录器发送适当的消息。

5.5.2 使用 substitute.js 的存根和模拟

现在,我们将使用 substitute.js 而不是 Jest 来创建MaintenanceWindow接口的存根和IComplicatedLogger接口的模拟。图 5.4 展示了这一点。

05-04

图 5.4 MaintenanceWindow依赖

使用 substitute.js 创建存根和模拟的方式相同:我们使用Substitute.for<T>函数。我们可以使用.returns函数配置存根,并使用.received函数验证模拟。这两个都是来自Substitute.for<T>()返回的假对象的一部分。

下面是存根创建和配置的示例:

const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(true);

模拟创建和验证看起来是这样的:

const mockLog = Substitute.for<IComplicatedLogger>();
. . .
/// later down in the end of the test...
mockLog.received().info("Under Maintenance", "verify");

以下列表显示了使用模拟和存根的几个测试的完整代码。

列表 5.11 使用 substitute.js 测试密码验证器

import { Substitute } from "@fluffy-spoon/substitute";

const makeVerifierWithNoRules = (log, maint) =>
  new PasswordVerifier3([], log, maint);

describe("working with substitute part 2", () => {
  test("verify, during maintanance, calls logger", () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(true);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);

    verifier.verify("anything");

    mockLog.received().info("Under Maintenance", "verify");
  });

  test("verify, outside maintanance, calls logger", () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(false);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);

    verifier.verify("anything");

    mockLog.received().info("PASSED", "verify");
  });
});

我们可以在测试中成功且相对容易地使用动态创建的对象来模拟值。我鼓励你研究你想要使用的隔离框架的版本。在这本书中,我只使用了 substitute.js 作为示例。它不是唯一的框架。

这个测试不需要编写手写的模拟,但请注意,它已经开始对测试读者的可读性产生影响。功能设计通常比这更简洁。在面向对象的设置中,有时这是必要的恶行。然而,我们可以在重构代码的同时轻松地将各种辅助程序、模拟和存根的创建重构为辅助函数,从而使测试更简单、更易于阅读。关于这一点,本书的第三部分将有更多介绍。

5.6 隔离框架的优势和陷阱

根据本章所涵盖的内容,我们看到了使用隔离框架的明显优势:

  • 更易模块化模拟——没有一些样板代码,模块依赖关系可能很难处理,而隔离框架帮助我们消除这些样板代码。这一点也可以被视为一个缺点,如前所述,因为它鼓励我们编写与第三方实现强耦合的代码。

  • 更易模拟值或错误——在复杂的接口上手动编写模拟可能很困难。框架在这方面有很大帮助。

  • 更易创建模拟——隔离框架可以更轻松地创建模拟和存根。

尽管使用隔离框架有许多优点,但也存在可能的风险。现在让我们谈谈需要注意的一些事项。

5.6.1 大多数时候你不需要模拟对象

隔离框架让你陷入的最大陷阱是使任何事物都容易模拟,并鼓励你首先认为你需要模拟对象。我并不是说你不需要存根,但模拟对象不应该成为大多数单元测试的标准操作程序。记住,一个工作单元可以有三种不同类型的退出点:返回值、状态变化和调用第三方依赖。只有其中一种类型可以从你的测试中的模拟对象中受益。其他则不行。

我发现,在我的测试中,模拟对象可能只占大约 2%-5%。其余的测试通常是返回值或基于状态的测试。对于功能设计,模拟对象的数量应该接近零,除非是某些边缘情况。

如果你发现自己正在定义一个测试并验证一个对象或函数是否被调用,仔细思考是否可以在不使用模拟对象的情况下证明相同的功能,而是通过验证返回值或从外部验证整体工作单元的行为变化(例如,验证一个函数在之前没有抛出异常时现在抛出了异常)。Vladimir Khorikov 所著的《单元测试原则、实践和模式》(Manning, 2020)的第六章包含了对如何将基于交互的测试重构为更简单、更可靠的测试的详细描述,这些测试检查的是返回值而不是模拟对象。

5.6.2 不可读的测试代码

在测试中使用模拟会使测试稍微难以阅读,但仍然足够清晰,以至于外人可以查看并理解正在发生的事情。在单个测试中包含许多模拟或许多期望可能会破坏测试的可读性,使其难以维护,甚至难以理解正在测试的内容。

如果你发现你的测试变得难以阅读或难以理解,考虑移除一些模拟或模拟期望,或者将测试拆分成几个更易读的小测试。

5.6.3 验证错误的事情

模拟对象允许你验证是否在接口上调用方法或函数被调用,但这并不一定意味着你正在测试正确的事情。许多刚开始接触测试的人最终只是因为可以验证而验证事情,而不是因为这样做有意义。以下是一些例子:

  • 验证一个内部函数调用另一个内部函数(不是出口点)。

  • 验证存根是否被调用(一个传入的依赖项不应该被验证;这是过度指定反模式,我们将在 5.6.5 节中讨论)。

  • 因为有人告诉你写测试,所以验证某事被调用,但你不确定真正应该测试什么。(这是验证你是否正确理解需求的好时机。)

5.6.4 每个测试中包含多个模拟

被认为是一种良好的实践,每个测试只测试一个关注点。测试多个关注点可能会导致混淆和测试维护问题。在一个测试中包含两个模拟等同于测试同一个工作单元的多个最终结果(多个出口点)。

对于每个出口点,考虑编写一个单独的测试,因为它可能被视为一个独立的需求。当你只测试一个关注点时,你的测试名称也可能变得更加专注和易读。如果你无法命名你的测试,因为它做了太多事情,名字变得非常通用(例如,“XWorksOK”),那么是时候将其拆分成多个测试了。

5.6.5 过度指定测试

如果你的测试有太多的期望(例如x.received().X()x.received().Y()等),它可能会变得非常脆弱,即使整体功能仍然正常,也会因为生产代码的微小变化而崩溃。测试交互是双刃剑:测试过多,你会开始失去对大局的视线——整体功能;测试过少,你会错过工作单元之间的重要交互。

这里有一些平衡这种效果的方法:

  • 当可能时,使用存根而不是模拟——如果你的测试中超过 5%使用了模拟对象,你可能做得有点过头了。存根可以无处不在。模拟则不然。你只需要一次测试一个场景。模拟对象越多,测试结束时进行的验证就越多,但通常只有一个是重要的。其余的都将是针对当前测试场景的噪音。

  • 尽可能避免将存根用作模拟——仅使用存根来伪造模拟值进入正在测试的工作单元或抛出异常。不要验证存根上是否调用了方法。

摘要

  • 隔离框架或模拟框架允许你动态地创建、配置和验证模拟和存根,无论是以对象还是函数的形式。与手写的伪造相比,隔离框架可以节省大量时间,尤其是在模块化依赖情况下。

  • 存在两种隔离框架类型:松散类型(如 Jest 和 Sinon)和严格类型(如 substitute.js)。松散类型框架需要更少的样板代码,适用于函数式代码;严格类型框架在处理类和接口时很有用。

  • 隔离框架可以替换整个模块,但尽量抽象出直接依赖,并伪造这些抽象。这有助于在模块的 API 发生变化时减少重构的工作量。

  • 在可能的情况下,倾向于基于返回值或状态测试,而不是交互测试,这样你的测试就可以尽可能少地假设内部实现细节。

  • 应该只在没有其他测试实现方法的情况下使用模拟,因为如果不小心,它们最终会导致难以维护的测试。

  • 根据你正在工作的代码库选择与隔离框架一起工作的方式。在遗留项目中,你可能需要伪造整个模块,因为这可能是向此类项目添加测试的唯一方法。在绿色项目中,尝试在第三方模块之上引入适当的抽象。这完全关乎选择合适的工具来完成工作,所以在考虑如何处理测试中的特定问题时,一定要从大局出发。

6 单元测试异步代码

本章涵盖

  • 异步、done() 和 await

  • 异步的集成和单元测试级别

  • 提取入口点模式

  • 提取适配器模式

  • 模拟、推进和重置计时器

当我们处理常规同步代码时,等待动作完成是 隐式的。我们不必担心它,并且我们并不真正过多地考虑它。然而,当处理异步代码时,等待动作完成变成了一种 显式 的活动,这在我们自己的控制之下。异步性使得代码及其测试变得可能更复杂,因为我们必须明确等待动作完成。

让我们从简单的获取示例开始,以说明这个问题。

6.1 处理异步数据获取

假设我们有一个模块,用于检查 example.com 上的网站是否活跃。它是通过从主 URL 获取上下文并检查特定的单词“说明”来确定网站是否运行。我们将查看这个功能的不同且非常简单的两种实现。第一个使用 callback 机制,第二个使用 async/await 机制。

图 6.1 展示了它们为我们目的的入口和出口点。注意回调箭头指向不同,这使得它更明显地表明它是一个不同类型的出口点。

06-01

图 6.1 IsWebsiteAlive() 回调与 async/await 版本

以下列表显示了初始代码。我们使用 node-fetch 来获取 URL 的内容。

列表 6.1 IsWebsiteAlive() 回调和 await 版本

//Callback version
const fetch = require("node-fetch");
const isWebsiteAliveWithCallback = (callback) => {
  const website = "http://example.com";
  fetch(website)
    .then((response) => {
      if (!response.ok) {
        //how can we simulate this network issue?
        throw Error(response.statusText);             ❶
      }
      return response;
    })
    .then((response) => response.text())
    .then((text) => {
      if (text.includes("illustrative")) {
        callback({ success: true, status: "ok" });
      } else {
        //how can we test this path?
        callback({ success: false, status: "text missing" });
      }
    })
    .catch((err) => {
      //how can we test this exit point?
      callback({ success: false, status: err });
    });
};

// Await version
const isWebsiteAliveWithAsyncAwait = async () => {
  try {
    const resp = await fetch("http://example.com");
    if (!resp.ok) {
      //how can we simulate a non ok response?
      throw resp.statusText;                        ❷
    }
    const text = await resp.text();
    const included = text.includes("illustrative");
    if (included) {
      return { success: true, status: "ok" };
    }
    // how can we simulate different website content?
    throw "text missing";
  } catch (err) {
    return { success: false, status: err };         ❸
  }
};

❶ 抛出自定义错误以处理我们代码中的问题

❷ 抛出自定义错误以处理我们代码中的问题

❸ 将错误包装到响应中

注意:在前面的代码中,我假设你知道 JavaScript 中 promises 的工作方式。如果你需要更多信息,我建议阅读 Mozilla 关于 promises 的文档,网址为 mng.bz/W11a

在这个例子中,我们将任何来自连接失败或网页上缺少文本的错误转换为回调或返回值,以向我们的函数用户表示失败。

6.1.1 使用集成测试的初步尝试

由于列表 6.1 中所有内容都是硬编码的,你将如何测试它呢?你最初的反应可能是编写一个集成测试。下面的列表展示了我们如何为回调版本编写一个集成测试。

列表 6.2 一个初始的集成测试

test("NETWORK REQUIRED (callback): correct content, true", (done) => {
  samples.isWebsiteAliveWithCallback((result) => {
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
    done();
  });
});

要测试一个出口点是回调函数的函数,我们传递给它我们自己的回调函数,在其中我们可以

  • 检查传入值的正确性

  • 告诉测试运行器通过测试框架提供给我们的任何机制停止等待(在这种情况下,那就是 done() 函数)

6.1.2 等待动作

因为我们使用回调作为出口点,所以我们的测试必须显式等待并行执行完成。这种并行执行可能是在 JavaScript 事件循环中,也可能是在单独的线程中,或者如果你使用的是其他语言,甚至可能在单独的进程中。

在 Arrange-Act-Assert 模式下,act 部分是我们需要等待的部分。大多数测试框架都会允许我们通过特殊的辅助函数来完成。在这种情况下,我们可以使用 Jest 提供的可选 done 回调来指示测试需要等待我们显式调用 done()。如果没有调用 done(),我们的测试将在默认的 5 秒后超时并失败(当然,这也可以配置)。

Jest 有其他测试异步代码的方法,其中一些我们将在本章后面介绍。

6.1.3 异步/await 的集成测试

那么 async/await 版本呢?从技术上讲,我们可以编写一个看起来几乎与上一个测试完全相同的测试,因为 async/await 只是基于承诺的语法糖。

列表 6.3 带回调和 .then() 的集成测试

test("NETWORK REQUIRED (await): correct content, true", (done) => {
  samples.isWebsiteAliveWithAsyncAwait().then((result) => {
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
    done();
  });
});

然而,使用回调如 done()then() 的测试比使用 Arrange-Act-Assert 模式的测试可读性差得多。好消息是,我们不需要通过强迫自己使用回调来复杂化我们的生活。我们也可以在我们的测试中使用 await 语法。这将迫使我们把 async 关键字放在测试函数的前面,但总体而言,我们的测试变得更加简单和可读,正如你在这里可以看到的。

列表 6.4 带有 async/await 的集成测试

test("NETWORK REQUIRED2 (await): correct content, true", async () => {
  const result = await samples.isWebsiteAliveWithAsyncAwait();
  expect(result.success).toBe(true);
  expect(result.status).toBe("ok");
});

允许我们使用 async/await 语法的不同步代码将我们的测试转变为几乎是一个普通的基于值的测试。入口点也是出口点,正如我们在图 6.1 中所看到的。

即使调用被简化了,调用仍然是异步的,这就是为什么我仍然称之为集成测试。这种类型测试的注意事项是什么?让我们来讨论。

6.1.4 集成测试的挑战

我们刚刚编写的测试从集成测试的角度来看并不糟糕。它们相对较短且可读,但它们仍然受到任何集成测试都会受到的困扰:

  • 运行时间长—与单元测试相比,集成测试的速度慢得多,有时需要几秒甚至几分钟。

  • 不可靠的—集成测试可能会呈现不一致的结果(基于运行位置的不同时间,不一致的失败或成功等)。

  • 测试可能与无关代码和环境条件相关—集成测试测试多个可能与我们所关心的无关的代码片段。(在我们的例子中,是 node-fetch 库、网络条件、防火墙、外部网站功能等。)

  • 更长的调查时间—当集成测试失败时,需要更多的时间来进行调查和调试,因为失败可能有多种原因。

  • 模拟更困难—使用集成测试来模拟负测试(模拟错误的网站内容、网站宕机、网络中断等)比所需的要困难

  • 结果难以信任—我们可能会认为集成测试的失败是由于外部问题,而实际上是我们代码中的错误。我将在下一章更多地讨论信任问题。

这是否意味着你不应该编写集成测试?不,我相信你应该绝对有集成测试,但你不需要有那么多集成测试来对你的代码有足够的信心。任何没有被集成测试覆盖的部分都应该由更低级别的测试来覆盖,例如单元测试、API 测试或组件测试。我将在第十章详细讨论这种策略,该章专注于测试策略。

6.2 使我们的代码适合单元测试

我们如何用单元测试测试代码?我将向你展示一些我用来使代码更容易进行单元测试的模式(即更容易注入或避免依赖,并检查退出点):

  • 提取入口点模式—将生产代码中纯逻辑的部分提取到它们自己的函数中,并将这些函数作为我们测试的入口点

  • 提取适配器模式—提取本质上异步的部分,并将其抽象化,以便我们可以用同步的东西来替换它

6.2.1 提取入口点

在这个模式中,我们将一个特定的异步工作单元分成两部分:

  • 异步部分(保持不变)。

  • 当异步执行完成时被调用的回调函数。这些被提取为新的函数,最终成为我们可以用纯单元测试调用的纯逻辑工作单元的入口点。

图 6.2 展示了这个想法:在之前的图中,我们有一个包含异步代码与内部处理异步结果并返回结果的逻辑混合的单个工作单元,通过回调或承诺机制返回。在第 1 步中,我们将逻辑提取到它自己的函数(或函数)中,该函数只包含异步工作的结果作为输入。在第 2 步中,我们将这些函数外部化,以便我们可以将它们用作单元测试的入口点。

06-02

图 6.2 将内部处理逻辑提取到单独的工作单元中有助于简化测试,因为我们能够同步验证新的工作单元,而不涉及外部依赖。

这为我们提供了测试异步回调的逻辑处理的重要能力(并且可以轻松模拟输入)。同时,我们可以选择编写针对原始工作单元的高级集成测试,以获得信心,即异步编排也正确工作。

如果我们只为所有场景进行集成测试,我们最终会陷入一个有很多长时间运行且不可靠的测试的世界。在新世界中,我们能够使大多数测试变得快速且一致,并在其上有一个小的集成测试层,以确保所有编排工作在之间正常进行。这样我们就不会为了信心而牺牲速度和可维护性。

提取工作单元的示例

让我们将这个模式应用到列表 6.1 中的代码。图 6.3 显示了我们将要遵循的步骤:

之前 状态包含处理逻辑,这些逻辑被嵌入到 isWebsiteAlive() 函数中。

❷ 我们将提取在获取结果边缘发生的任何逻辑代码,并将其放入两个单独的函数中:一个用于处理成功情况,另一个用于处理错误情况。

❸ 然后,我们将外部化这两个函数,以便我们可以直接从单元测试中调用它们。

06-03

图 6.3 从 isWebsiteAlive() 中提取成功和错误处理逻辑以单独测试该逻辑

以下列表显示了重构后的代码。

列表 6.5 使用 callback 提取入口点

//Entry Point
const isWebsiteAlive = (callback) => {
  fetch("http://example.com")
    .then(throwOnInvalidResponse)
    .then((resp) => resp.text())
    .then((text) => {
      processFetchSuccess(text, callback);
    })
    .catch((err) => {
      processFetchError(err, callback);
    });
};
const throwOnInvalidResponse = (resp) => {
  if (!resp.ok) {
    throw Error(resp.statusText);
  }
  return resp;
};

//Entry Point
const processFetchSuccess = (text, callback) => {        ❶
 if (text.includes("illustrative")) {
 callback({ success: true, status: "ok" });
 } else {
 callback({ success: false, status: "missing text" });
 }
};

//Entry Point
const processFetchError = (err, callback) => {           ❶
 callback({ success: false, status: err });
};

❶ 新入口点(工作单元)

如您所见,我们最初开始的原始单元现在有三个入口点,而不是我们最初的一个。新的入口点可以用于单元测试,而原始的一个仍然可以用于集成测试,如图 6.4 所示。

06-04

图 6.4 提取两个新函数后引入的新入口点。现在,新的函数可以使用更简单的单元测试来测试,而不是重构之前所需的集成测试。

我们仍然想要一个针对原始入口点的集成测试,但不超过一个或两个。任何其他场景都可以使用纯逻辑入口点快速且无痛苦地模拟。

现在,我们可以自由地编写调用新入口点的单元测试,如下所示。

列表 6.6 使用提取的入口点的单元测试

describe("Website alive checking", () => {
  test("content matches, returns true", (done) => {
    samples.processFetchSuccess("illustrative", (err, result) => { ❶
      expect(err).toBeNull();
      expect(result.success).toBe(true);
      expect(result.status).toBe("ok");
      done();
    });
  });
  test("website content does not match, returns false", (done) => {
    samples.processFetchSuccess("bad content", (err, result) => { ❶
      expect(err.message).toBe("missing text");
      done();
    });
  });
  test("When fetch fails, returns false", (done) => {
   samples.processFetchError("error text", (err,result) => { ❶
      expect(err.message).toBe("error text");
      done();
    });
  });
});

❶ 调用新的入口点

注意,我们正在直接调用新的入口点,并且能够轻松地模拟各种条件。在这些测试中没有异步操作,但我们仍然需要 done() 函数,因为回调可能根本不会被调用,我们希望捕获这一点。

我们仍然需要至少一个集成测试,以让我们有信心我们的入口点之间的异步编排工作正常。这就是原始集成测试可以提供帮助的地方,但我们不再需要将所有测试场景都写成集成测试(关于这一点,请参阅第十章)。

使用 await 提取入口点

我们刚才应用的模式也可以很好地适用于标准的 async/await 函数结构。图 6.5 阐述了这种重构。

06-05

图 6.5 使用 async/await 提取入口点

通过提供async/await语法,我们可以回到以线性方式编写代码,而不使用回调参数。isWebsiteAlive()函数看起来几乎与常规同步代码完全相同,仅在需要时返回值和抛出错误。

列表 6.7 显示了我们的生产代码中的样子。

列表 6.7 使用async/await而不是回调编写的函数

//Entry Point
const isWebsiteAlive = async () => {
  try {
    const resp = await fetch("http://example.com");
    throwIfResponseNotOK(resp);
    const text = await resp.text();
    return processFetchContent(text);
  } catch (err) {
    return processFetchError(err);
  }
};

const throwIfResponseNotOK = (resp) => {
  if (!resp.ok) {
    throw resp.statusText;
  }
};

//Entry Point
const processFetchContent = (text) => {
  const included = text.includes("illustrative");
  if (included) {
 return { success: true, status: "ok" }; ❶
  }
 return { success: false, status: "missing text" }; 
};

//Entry Point
const processFetchError = (err) => {
 return { success: false, status: err }; ❶
};

❶ 返回值而不是调用回调

注意,与回调示例不同,我们使用returnthrow来表示成功或失败。这是使用async/await编写代码的常见模式。

我们的测试也简化了,如下所示。

列表 6.8 从async/await提取的测试入口点

describe("website up check", () => {
  test("on fetch success with good content, returns true", () => {
    const result = samples.processFetchContent("illustrative");
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });

  test("on fetch success with bad content, returns false", () => {
    const result = samples.processFetchContent("text not on site");
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });

  test("on fetch fail, throws ", () => {
    expect(() => samples.processFetchError("error text"))
      .toThrowError("error text");
  });
});

再次注意,我们不需要添加任何async/await相关的关键字,也不需要明确等待执行,因为我们已经将工作逻辑单元与使我们的生活更加复杂的异步部分分离开来。

6.2.2 提取适配器模式

提取适配器模式与之前的模式观点相反。我们看待异步代码的方式就像我们看待之前章节中讨论的任何依赖项一样——我们希望在我们的测试中替换它以获得更多控制。我们不会将逻辑代码提取到自己的入口点集合中,而是将异步代码(我们的依赖项)提取出来,并在适配器下抽象它,我们可以在以后将其注入,就像任何其他依赖项一样。图 6.6 显示了这一点。

06-06

图 6.6 提取依赖关系并将其用适配器包装,帮助我们简化该依赖关系,并在测试中用模拟对象替换它。

通常还会为适配器创建一个特殊的接口,该接口针对依赖项的消费者需求进行了简化。这种方法的另一个名称是接口隔离原则。在这种情况下,我们将创建一个network-adapter模块,它隐藏了实际的获取功能,并具有自己的自定义函数,如图 6.7 所示。

06-07

图 6.7 使用我们自己的network-adapter模块包装node-fetch模块,帮助我们仅暴露应用程序需要的功能,以最合适的问题语言表达。

接口隔离原则

术语接口隔离原则由罗伯特·马丁提出。想象一下,一个数据库依赖关系,背后隐藏着数十个功能,这些功能通过一个适配器实现,而适配器的接口可能只包含几个具有自定义名称和参数的功能。适配器的作用是隐藏复杂性,并简化消费者的代码以及模拟它的测试。有关接口隔离的更多信息,请参阅维基百科上的相关文章:en.wikipedia.org/wiki/Interface_segregation_principle

下面的列表显示了network-adapter模块的外观。

列表 6.9 network-adapter代码

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

const fetchUrlText = async (url) => {
  const resp = await fetch(url);
  if (resp.ok) {
    const text = await resp.text();
    return { ok: true, text: text };
  }
  return { ok: false, text: resp.statusText };
};   

注意,network-adapter模块是项目中唯一一个导入node-fetch的模块。如果这个依赖在未来某个时刻发生变化,这增加了只有当前文件需要更改的可能性。我们还通过名称和功能简化了该函数。我们隐藏了从 URL 获取状态和文本的需求,并将它们都抽象成一个更容易使用的函数。

现在我们可以选择如何使用适配器。首先,我们可以以模块化风格使用它。然后我们将使用功能方法和具有强类型接口的面向对象方法。

模块化适配器

以下列表显示了network-adapter的模块化使用,这是我们的初始isWebsiteAlive()函数。

列表 6.10 使用network-adapter模块的isWebsiteAlive()

const network = require("./network-adapter");

const isWebsiteAlive = async () => {
  try {
    const result = await network.fetchUrlText("http://example.com");
    if (!result.ok) {
      throw result.text;
    }
    const text = result.text;
    return processFetchSuccess(text);
  } catch (err) {
    throw processFetchFail(err);
  }
};

在这个版本中,我们直接导入network-adapter模块,我们将在后面的测试中对其进行模拟。

该模块的单元测试如下所示。因为我们使用了模块化设计,我们可以在测试中使用jest.mock()来模拟该模块。我们将在后面的示例中注入该模块,请放心。

列表 6.11 使用jest.mock模拟network-adapter

jest.mock("./network-adapter");                          ❶
const stubSyncNetwork = require("./network-adapter");    ❷
const webverifier = require("./website-verifier");

describe("unit test website verifier", () => {
  beforeEach(jest.resetAllMocks);                        ❸

  test("with good content, returns true", async () => {
    stubSyncNetwork.fetchUrlText.mockReturnValue({       ❹
      ok: true,
      text: "illustrative",
    });
    const result = await webverifier.isWebsiteAlive();   ❺
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });

  test("with bad content, returns false", async () => {
    stubSyncNetwork.fetchUrlText.mockReturnValue({
      ok: true,
      text: "<span>hello world</span>",
    });
    const result = await webverifier.isWebsiteAlive();   ❺
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });

❶ 模拟网络适配器模块

❷ 导入模拟模块

❸ 重置所有存根以避免其他测试中的潜在问题

❹ 模拟存根模块的返回值

❺ 在我们的测试中使用 await

注意,我们再次使用了async/await,因为我们回到了本章开头使用的原始入口点。但仅仅因为我们使用了await并不意味着我们的测试是异步运行的。我们的测试代码以及它调用的生产代码实际上是线性运行的,具有异步友好的签名。我们还需要在功能性和面向对象的设计中使用async/await,因为入口点需要它。

我将我们的模拟网络命名为stubSyncNetwork,以使测试的同步性质更清晰。否则,仅通过查看测试很难判断它调用的代码是线性运行还是异步运行。

功能适配器

在功能设计模式中,network-adapter模块的设计保持不变,但我们以不同的方式启用其注入到我们的website-verifier中。正如您在下一列表中可以看到的,我们在入口点添加了一个新参数。

列表 6.12 为isWebsiteAlive()设计的功能注入

const isWebsiteAlive = async (network) => {
  const result = await network.fetchUrlText("http://example.com");
  if (result.ok) {
    const text = result.text;
    return onFetchSuccess(text);
  }
  return onFetchError(result.text);
};

在这个版本中,我们期望network-adapter模块通过一个公共参数注入到我们的函数中。在功能设计中,我们可以使用高阶函数和柯里化来配置一个预先注入的函数,使其具有我们自己的网络依赖。在我们的测试中,我们可以简单地通过这个参数发送一个模拟网络。在设计注入方面,除了不再导入network-adapter模块之外,几乎没有什么变化。减少导入和require的数量可以在长期内帮助维护性。

在下面的列表中,我们的测试更加简单,样板代码更少。

列表 6.13 使用功能注入的network-adapter单元测试

const webverifier = require("./website-verifier");

                  ❶
 return {
 fetchUrlText: () => {
 return fakeResult;
 },
 };
};
describe("unit test website verifier", () => {
  test("with good content, returns true", async () => {
stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "illustrative",
    });
    const result = await webverifier.isWebsiteAlive(stubSyncNetwork);   ❷
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });

  test("with bad content, returns false", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "unexpected content",
    });
    const result = await webverifier.isWebsiteAlive(stubSyncNetwork);   ❷
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });
  ...   

❶ 创建一个新辅助函数来创建一个与网络适配器接口的重要部分相匹配的自定义对象

❷ 注入自定义对象

注意,我们不需要在文件顶部添加很多样板代码,就像我们在模块化设计中做的那样。我们不需要间接地伪造模块(通过jest.mock),我们不需要为了测试而重新导入它(通过require),也不需要使用jest.resetAllMocks来重置 Jest 的状态。我们只需要在每个测试中调用我们的新makeStubNetworkWithResult辅助函数来生成一个新的模拟网络适配器,然后将模拟网络通过将其作为参数发送到我们的入口点来注入。

面向对象,基于接口的适配器

我们已经研究了模块化和功能设计。现在,让我们将注意力转向方程的面向对象方面。在面向对象的范式下,我们可以将之前所做的参数注入提升为构造函数注入模式。以下列表中,我们将从网络适配器及其接口(公共 API 和结果签名)开始。

列表 6.14NetworkAdapter及其接口

export interface INetworkAdapter {
  fetchUrlText(url: string): Promise<NetworkAdapterFetchResults>;
}
export interface NetworkAdapterFetchResults {
  ok: boolean;
  text: string;
}

ch6-async/6-fetch-adapter-interface-oo/network-adapter.ts

export class NetworkAdapter implements INetworkAdapter {
  async fetchUrlText(url: string): 
 Promise<NetworkAdapterFetchResults> {
    const resp = await fetch(url);
    if (resp.ok) {
      const text = await resp.text();
      return Promise.resolve({ ok: true, text: text });
    }
    return Promise.reject({ ok: false, text: resp.statusText });
  }
}

在下一个列表中,我们创建了一个具有接收INetworkAdapter参数的构造函数的WebsiteVerifier类。

列表 6.15 具有构造函数注入的WebsiteVerifier

export interface WebsiteAliveResult {
  success: boolean;
  status: string;
}

export class WebsiteVerifier {
  constructor(private network: INetworkAdapter) {}

  isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
    let netResult: NetworkAdapterFetchResults;
    try {
    netResult = await this.network.fetchUrlText("http://example.com");
      if (!netResult.ok) {
        throw netResult.text;
      }
      const text = netResult.text;
      return this.processNetSuccess(text);
    } catch (err) {
      throw this.processNetFail(err);
    }
  };

  processNetSuccess = (text): WebsiteAliveResult => {
    const included = text.includes("illustrative");
    if (included) {
      return { success: true, status: "ok" };
    }
    return { success: false, status: "missing text" };
  };

  processNetFail = (err): WebsiteAliveResult => {
    return { success: false, status: err };
  };
}

这个类的单元测试可以实例化一个模拟网络适配器并通过构造函数注入它。在以下列表中,我们将使用 substitute.js 创建一个符合新接口的模拟对象。

列表 6.16 面向对象的WebsiteVerifier单元测试

const makeStubNetworkWithResult = (                         ❶
 fakeResult: NetworkAdapterFetchResults
): INetworkAdapter => {
  const stubNetwork = Substitute.for<INetworkAdapter>();    ❷
  stubNetwork.fetchUrlText(Arg.any()) 
    .returns(Promise.resolve(fakeResult));                  ❸
  return stubNetwork;
};

describe("unit test website verifier", () => {
  test("with good content, returns true", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "illustrative",
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork);

    const result = await webVerifier.isWebsiteAlive();
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });

  test("with bad content, returns false", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "unexpected content",
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork);

    const result = await webVerifier.isWebsiteAlive();
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });    

❶ 模拟网络适配器的辅助函数

❷ 生成模拟对象

❸ 使模拟对象返回测试所需的内容

这种控制反转(IOC)和依赖注入(DI)类型的工作得很好。在面向对象的世界里,使用接口的构造函数注入非常常见,并且在许多情况下,可以为分离你的依赖项和逻辑提供一个有效且可维护的解决方案。

6.3 处理计时器

计时器,如 setTimeout,代表了非常 JavaScript 特定的问题。它们是领域的一部分,并且在许多代码片段中被使用,无论是好是坏。有时,与其提取适配器和入口点,不如禁用这些函数并绕过它们。我们将探讨两种绕过计时器的方法:

  • 直接模拟该函数

  • 使用 Jest 和其他框架来禁用和控制它们

6.3.1 使用 monkey-patching 模拟计时器

Monkey-patching 是一种程序扩展或修改本地支持系统软件(仅影响程序的运行实例)的方法。像 JavaScript、Ruby 和 Python 这样的编程语言和运行时可以很容易地容纳 monkey-patching。对于像 C# 和 Java 这样更严格类型化和编译时语言来说,这要困难得多。我将在附录中更详细地讨论 monkey-patching。

这里是在 JavaScript 中实现的一种方法。我们将从以下使用 setTimeout 方法的代码片段开始。

列表 6.17 我们想要模拟的 setTimeout 代码

const calculate1 = (x, y, resultCallback) => {
  setTimeout(() => { resultCallback(x + y); },
    5000);
};

我们可以通过在内存中设置该函数的原型来将 setTimeout 函数模拟为同步的,如下所示。

列表 6.18 一个简单的 monkey-patching 模式

const Samples = require("./timing-samples");

describe("monkey patching ", () => {
  let originalTimeOut;
 beforeEach(() => (originalTimeOut = setTimeout)); ❶
  afterEach(() => (setTimeout = originalTimeOut));     ❷

  test("calculate1", () => {
    setTimeout = (callback, ms) => callback();         ❸
    Samples.calculate1(1, 2, (result) => {
        expect(result).toBe(3);
    });
  });
});

❶ 保存原始 setTimeout

❷ 恢复原始的 setTimeout

❸ 模拟 setTimeout

由于一切都是同步的,我们不需要使用 done() 来等待回调调用。我们正在用纯同步实现替换 setTimeout,立即调用接收到的回调。

这种方法的唯一缺点是它需要大量的样板代码,并且通常更容易出错,因为我们需要记住正确地清理。让我们看看 Jest 等框架为我们提供了什么来处理这些情况。

6.3.2 使用 Jest 模拟 setTimeout

Jest 为我们提供了三个主要函数来处理 JavaScript 中大多数类型的计时器:

  • jest.useFakeTimers——模拟所有各种计时器函数,如 setTimeout

  • jest.resetAllTimers——将所有模拟计时器重置为真实计时器

  • jest.advanceTimersToNextTimer——触发任何模拟计时器,以便触发任何回调

这些函数共同处理了大部分的样板代码。

这里是我们在列表 6.18 中刚刚进行的相同测试,这次使用 Jest 的辅助函数。

列表 6.19 使用 Jest 模拟 setTimeout

describe("calculate1 - with jest", () => {
  beforeEach(jest.clearAllTimers);
  beforeEach(jest.useFakeTimers);

  test("fake timeout with callback", () => {
    Samples.calculate1(1, 2, (result) => {
      expect(result).toBe(3);
    });
    jest.advanceTimersToNextTimer();
  });
});

注意,再次强调,我们不需要调用 done(),因为一切都是同步的。同时,我们必须使用 advanceTimersToNextTimer,因为没有它,我们的模拟 setTimeout 将永远卡住。advanceTimersToNextTimer 也在处理如下场景时很有用:当被测试的模块安排了一个 setTimeout,其回调又安排了另一个 setTimeout 递归调用(意味着调度永远不会停止)。在这些场景中,能够逐步向前运行非常有用。

使用 advanceTimersToNextTimer,您可以潜在地将所有计时器向前推进指定的步数,以模拟即将触发的下一个计时器回调的步骤。

setInterval 的工作模式相同,如以下所示。

列表 6.20 使用 setInterval 的函数

const calculate4 = (getInputsFn, resultFn) => {
  setInterval(() => {
    const { x, y } = getInputsFn();
    resultFn(x + y);
  }, 1000);
};

在这种情况下,我们的函数接受两个回调作为参数:一个用于提供计算所需的输入,另一个用于返回计算结果。它使用 setInterval 持续获取更多输入并计算其结果。

以下列表显示了一个测试,它将推进我们的计时器,触发两次间隔,并期望两次调用都得到相同的结果。

列表 6.21 在单元测试中推进模拟计时器

describe("calculate with intervals", () => {
  beforeEach(jest.clearAllTimers);
  beforeEach(jest.useFakeTimers);

  test("calculate, incr input/output, calculates correctly", () => {
    let xInput = 1;
    let yInput = 2;
    const inputFn = () => ({ x: xInput++, y: yInput++ });      ❶
    const results = [];
    Samples.calculate4(inputFn, (result) => results.push(result));

 jest.advanceTimersToNextTimer(); ❷
 jest.advanceTimersToNextTimer(); ❷

    expect(results[0]).toBe(3);
    expect(results[1]).toBe(5);
  });
});

❶ 增加变量以验证回调次数

❷ 两次调用 setInterval

在这个例子中,我们验证新的值是否被正确计算和存储。请注意,我们只用一个调用和一个 expect 就可以编写相同的测试,并且我们会得到与这个更复杂的测试几乎相同程度的信心,但我喜欢在需要更多信心时添加额外的验证。

6.4 处理常见事件

我不能谈论异步单元测试而不讨论基本的事件流程。希望现在异步单元测试的主题看起来相对简单明了,但我想要明确地回顾事件部分。

6.4.1 处理事件发射器

为了确保我们都在同一页面上,这里有一个来自 DigitalOcean 的“在 Node.js 中使用事件发射器”教程的清晰简洁的事件发射器定义(mng.bz/844z):

事件发射器是 Node.js 中的对象,通过发送消息来触发事件,以表示操作已完成。JavaScript 开发者可以编写代码来监听事件发射器的事件,允许他们在每次这些事件被触发时执行函数。在这种情况下,事件由一个标识字符串和任何需要传递给监听器的数据组成。

考虑以下列表中的 Adder 类,每次添加东西时都会发射一个事件。

列表 6.22 基于事件发射器的简单 Adder

const EventEmitter = require("events");

class Adder extends EventEmitter {
  constructor() {
    super();
  }

  add(x, y) {
    const result = x + y;
    this.emit("added", result);
    return result;
  }
}

验证事件发射的最简单方法是在我们的测试中直接订阅该事件,并验证我们在调用 add 函数时它是否被触发。

列表 6.23 通过订阅测试事件发射器

describe("events based module", () => {
  describe("add", () => {
    it("generates addition event when called", (done) => {
      const adder = new Adder();
      adder.on("added", (result) => {
        expect(result).toBe(3);
        done();
      });
      adder.add(1, 2);
    });
  });
});

通过使用 done(),我们正在验证事件实际上已被发射。如果我们没有使用 done(),并且事件没有被发射,我们的测试将通过,因为订阅的代码从未执行。通过添加 expect(x).toBe(y),我们也在验证事件参数中发送的值,以及隐式地测试事件是否被触发。

6.4.2 处理点击事件

那些讨厌的 UI 事件,比如click,我们如何测试我们是否通过脚本正确地绑定了它们?考虑列表 6.24 和 6.25 中的简单网页及其相关逻辑。

列表 6.24 带有 JavaScript click功能的简单网页

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>File to Be Tested</title>
    <script src="index-helper.js"></script>
</head>
<body>
    <div>
        <div>A simple button</div>
        <Button data-testid="myButton" id="myButton">Click Me</Button>
        <div data-testid="myResult" id="myResult">Waiting...</div>
    </div>
</body>
</html> 

列表 6.25 网页的 JavaScript 逻辑

window.addEventListener("load", () => {
  document
    .getElementById("myButton")
    .addEventListener("click", onMyButtonClick);

  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "Document Loaded";
});

function onMyButtonClick() {
  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "Clicked!";
}

我们有一段非常简单的逻辑,确保当按钮被点击时设置一个特殊的信息。我们如何测试这个功能?

这是一个反模式:我们可以在测试中订阅点击事件并确保它被触发,但这对我们没有任何价值。我们关心的是点击实际上做了些有用的事情,而不仅仅是触发。

这是一个更好的方法:我们可以触发点击事件并确保它已经更改了页面内的正确值——这将提供真正的价值。图 6.8 展示了这一点。

06-08

图 6.8 点击作为入口点,元素作为退出点

下面的列表显示了我们的测试可能的样子。

列表 6.26 触发点击事件,并测试元素的文本

/**
 * @jest-environment jsdom                                 ❶
 */
//(the above is required for window events)
const fs = require("fs");
const path = require("path");
require("./index-helper.js");
const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.html");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
};

const loadHtmlAndGetUIElements = () => {
  loadHtml("index.html");
  const button = document.getElementById("myButton");
  const resultDiv = document.getElementById("myResult");
  return { window, button, resultDiv };
};

describe("index helper", () => {
  test("vanilla button click triggers change in result div", () => {
    const { window, button, resultDiv } = loadHtmlAndGetUIElements();
    window.dispatchEvent(new Event("load"));               ❷

    button.click();                                        ❸

    expect(resultDiv.innerText).toBe("Clicked!");          ❹
  });
});   

❶ 仅为此文件应用浏览器模拟的 jsdom 环境

❷ 模拟 document.load 事件

❸ 触发点击

❹ 验证我们的文档中的元素实际上已经改变

在这个例子中,我提取了两个实用方法,loadHtmlloadHtmlAndGetUIElements,这样我可以编写更干净、更易读的测试,并且如果将来 UI 项的位置或 ID 发生变化,我将拥有更少的测试更改问题。

在测试本身中,我们正在模拟document.load事件,以便我们的测试脚本可以开始运行并触发click,就像用户点击了按钮一样。最后,测试验证我们的文档中的某个元素实际上已经改变,这意味着我们的代码成功订阅了事件并完成了其工作。

注意,我们实际上并不关心索引辅助文件内部的底层逻辑。我们只是依赖于 UI 中观察到的状态变化,这充当我们的最终退出点。这允许我们的测试耦合度更低,因此如果我们要测试的代码发生变化,我们不太可能需要更改测试,除非可观察的(公开可见的)功能确实发生了变化。

6.5 引入 DOM 测试库

我们的测试有很多样板代码,主要是用于查找元素和验证其内容。我建议查看由 Kent C. Dodds 编写的开源 DOM 测试库(github.com/kentcdodds/dom-testing-library-with-anything)。这个库有适用于今天大多数前端 JavaScript 框架的变体,例如 React、Angular 和 Vue.js。我们将使用它的纯版本,名为 DOM Testing Library。

我喜欢这个库的地方在于,它旨在允许我们编写更接近与我们的网页交互的用户视角的测试。而不是使用元素的 ID,我们通过元素文本进行查询;触发事件更为简洁;查询和等待元素出现或消失更为简洁,并且隐藏在语法糖之下。一旦在多个测试中使用,它就非常有用。

这是使用此库的测试看起来像什么。

列表 6.27 在简单测试中使用 DOM 测试库

const { fireEvent, findByText, getByText }                                 ❶
 = require("@testing-library/dom"); ❶

const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.html");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
  return document.documentElement;                                         ❷
};

const loadHtmlAndGetUIElements = () => {
  const docElem = loadHtml("index.html");
  const button = getByText(docElem, "click me", { exact: false });
  return { window, docElem, button };
};

describe("index helper", () => {
  test("dom test lib button click triggers change in page", () => {
    const { window, docElem, button } = loadHtmlAndGetUIElements();
    fireEvent.load(window);                                                ❸

   fireEvent.click(button); ❸

  //wait until true or timeout in 1 sec
  expect(findByText(docElem,"clicked", { exact: false })).toBeTruthy(); ❹
  });
});

❶ 导入一些将要使用的库 API

❷ 库 API 需要以文档元素为基础进行大部分工作。

❸ 使用库的 fireEvent API 简化事件分发

❹ 此查询将等待直到找到项目或将在 1 秒内超时。

注意图书馆如何允许我们使用页面元素的常规文本来获取元素,而不是它们的 ID 或测试 ID。这是图书馆推动我们以更自然的方式工作的方式之一,从用户的角度来看。为了使测试在长时间内更具可持续性,我们使用了exact: false标志,这样我们就不必担心大写问题或字符串开头或结尾缺少字母。这消除了因小文本变化而更改测试的需要。

摘要

  • 直接测试异步代码会导致测试不稳定,执行时间很长。为了解决这些问题,你可以采取两种方法:提取入口点或提取适配器。

  • 提取入口点是指将纯逻辑提取到单独的函数中,并将这些函数作为测试的入口点。提取的入口点可以接受回调作为参数,也可以返回一个值。为了简单起见,优先考虑返回值而不是回调。

  • 提取适配器涉及提取一个本质上异步的依赖项,并将其抽象化,以便你可以用同步的东西替换它。适配器可能具有不同的类型:

    • 模块化——当你对整个模块(文件)进行存根处理并替换其中的特定函数时。

    • 函数式——当你向待测试的系统注入一个函数或值。你可以在测试中用存根替换注入的值。

    • 面向对象——当你使用生产代码中的接口并在测试代码中创建一个实现该接口的存根时。

  • 定时器(如setTimeoutsetInterval)可以通过猴子补丁直接替换,或者使用 Jest 或其他框架来禁用和控制它们。

  • 事件测试的最佳方式是验证它们产生的最终结果——用户可以看到的 HTML 文档中的变化。你可以直接这样做,或者使用 DOM 测试库等库。

第三部分 测试代码

本部分涵盖了管理和组织单元测试的技术,以及确保现实项目中单元测试质量高的方法。

第七章涵盖了测试的可信度。它解释了如何编写能够可靠地报告错误存在或不存在情况的测试。我们还将探讨真实和虚假测试失败之间的区别。

在第八章中,我们将探讨良好单元测试的主要支柱——可维护性,并探讨支持它的技术。为了使测试在长期内有用,它们不应该需要太多维护努力;否则,它们不可避免地会被遗弃。

7 可信的测试

本章涵盖

  • 如何知道你信任一个测试

  • 检测不可靠的失败测试

  • 检测不可靠的通过测试

  • 处理不可靠的测试

无论你如何组织你的测试,或者你有多少测试,如果你不能信任它们,维护它们,或者阅读它们,那么它们的价值非常小。你编写的测试应该具有三个属性,这三个属性共同使它们变得很好:

  • 可信度——开发者会想要运行可信的测试,并且会自信地接受测试结果。可信的测试没有错误,并且测试了正确的事情。

  • 可维护性——不可维护的测试是噩梦,因为它们可能会破坏项目进度,或者当项目被安排得更激进时,它们可能会被搁置。开发者会简单地停止维护和修复那些改变时间过长或需要经常在非常小的生产代码更改上更改的测试。

  • 可读性——这不仅指的是能够阅读测试,还包括如果测试看起来是错误的,能够找出问题。如果没有可读性,其他两个支柱会很快倒塌。维护测试变得更加困难,而且你不再信任它们,因为你不理解它们。

本章和接下来的两章将介绍一系列与这些支柱相关的实践,你可以在进行测试审查时使用。这三个支柱共同确保你的时间得到有效利用。去掉任何一个,你都有浪费大家时间的风险。

信任是我喜欢评估良好单元测试的三个支柱中的第一个,所以我们从它开始。如果我们不相信测试,运行它们有什么意义?如果它们失败了,修复它们或修复代码有什么意义?维护它们有什么意义?

7.1 如何知道你信任一个测试

对于软件开发者来说,“信任”在测试的背景下意味着什么?也许我们可以根据测试失败或通过时我们做什么或不做什么来解释得更清楚。

你可能不会信任一个测试,如果

  • 它失败了,但你并不担心(你相信这是一个假阳性)。

  • 你觉得忽略这个测试的结果是可以接受的,要么是因为它偶尔会通过,要么是因为你觉得它不相关或存在错误。

  • 它通过了,但你很担心(你相信这是一个假阴性)。

  • 你仍然觉得有必要手动调试或测试软件“以防万一”。

你可能会信任这个测试,如果

  • 测试失败了,你真正担心的是出了问题。你不会继续前进,假设测试是错误的。

  • 测试通过了,你感到放松,不需要手动测试或调试。

在接下来的几节中,我们将通过查看测试失败作为识别不可信测试的方法,我们将查看通过测试的代码,看看如何检测不可信的测试代码。最后,我们将介绍一些通用的实践,这些实践可以提高测试的可信度。

7.2 为什么测试会失败

理想情况下,你的测试(任何测试,而不仅仅是单元测试)应该只因为一个好理由而失败。当然,这个好理由是在底层生产代码中发现了真实错误。

不幸的是,测试可能因为多种原因而失败。我们可以假设,除了那个好理由之外,任何其他原因导致的测试失败都应该触发一个“不可信”的警告,但并非所有测试都以相同的方式失败,识别测试可能失败的原因可以帮助我们为每种情况制定一个路线图。

这里有一些测试失败的原因:

  • 生产代码中已发现一个真实错误

  • 有错误的测试会导致错误失败

  • 由于功能更改,测试已过时

  • 测试与另一个测试冲突

  • 测试是不稳定的

除了这里提到的第一个点之外,所有这些原因都是测试在告诉你,它当前的形式不可信。让我们逐一分析。

7.2.1 生产代码中已发现一个真实错误

测试失败的第一个原因是生产代码中存在错误。那很好!这就是我们为什么要有测试。让我们继续探讨测试失败的其他原因。

7.2.2 有错误的测试会导致错误失败

如果测试有错误,测试将失败。生产代码可能正确,但如果测试本身存在导致测试失败的错误,那就没关系了。可能是你断言了错误的退出点预期结果,或者你错误地使用了正在测试的系统。也可能是你设置测试上下文错误,或者你误解了你应该测试的内容。

无论哪种方式,有错误的测试都可能非常危险,因为测试中的错误也可能导致它通过,让你对实际情况毫无察觉。我们将在本章后面更多地讨论那些不应该失败但确实失败的测试。

如何识别有错误的测试

你有一个失败的测试,但你可能已经调试了生产代码,在那里没有找到任何错误。这时你应该开始怀疑失败的测试。没有其他办法。你将不得不慢慢调试测试代码。

这里有一些可能导致错误失败的可能原因:

  • 在错误的事物或错误的退出点上断言

  • 将错误值注入入口点

  • 错误调用入口点

也可能是你凌晨 2 点编写代码时犯的一些其他小错误。(顺便说一句,这不是一个可持续的编码策略。停止这样做。)

找到有错误的测试后,你该怎么办?

当你找到一个有错误的测试时,不要慌张。这可能是你第无数次找到这样的测试,所以你可能正在想“我们的测试很糟糕。”你也许是对的。但这并不意味着你应该慌张。修复错误,然后运行测试,看看它现在是否通过。

如果测试通过了,也不要太早高兴!去生产代码中放置一个应该被新修复的测试捕获的明显错误。例如,将布尔值始终设置为true。或者false。然后再次运行测试,确保它失败。如果它没有失败,你可能在测试中仍然有一个错误。修复测试,直到它可以找到生产错误并且你可以看到它失败。

一旦你确定测试因一个明显的生产代码问题而失败,修复你刚才制造的生产代码问题,然后再次运行测试。它应该通过。如果测试现在通过了,你就完成了。你现在已经看到了测试在应该通过时通过,在应该失败时失败。提交代码并继续。

如果测试仍然失败,可能还有另一个错误。重复整个过程,直到你验证测试在应该失败时失败,在应该通过时通过。如果测试仍然失败,你可能在生产代码中遇到了真正的错误。在这种情况下,恭喜你!

如何避免未来的有缺陷的测试

我知道的最佳方法之一来检测和防止有缺陷的测试是采用测试驱动的方式来编写代码。我在本书的第一章中简要解释了这种技术。我还在现实生活中实践了这种方法。

测试驱动开发(TDD)使我们能够看到测试的两种状态:它应该在失败时失败(这是我们开始时的初始状态)以及它应该在通过时通过(当测试下的生产代码被编写以使测试通过时)。如果测试继续失败,我们在生产代码中找到了一个错误。如果测试一开始就通过,我们在测试中有一个错误。

另一种减少测试中错误可能性的好方法是移除测试中的逻辑。关于这一点,在第 7.3 节中会有更多介绍。

7.2.3 测试因功能更改而过时

如果测试不再与正在测试的当前功能兼容,测试可能会失败。比如说,你有一个登录功能,在早期版本中,你需要提供用户名和密码来登录。在新版本中,双因素认证方案取代了旧的登录方式。现有的测试将开始失败,因为它没有向登录函数提供正确的参数。

你现在能做什么?

你现在有两个选择:

  • 将测试适应新的功能。

  • 为新的功能编写一个新的测试,并删除旧的测试,因为它现在已经变得无关紧要了。

避免或预防未来的这种情况

事情会变化。我认为在某个时间点不可能没有过时的测试。我们将在下一章中处理变化,涉及测试的可维护性和测试如何处理应用程序中的变化。

7.2.4 测试与另一个测试冲突

假设你有两个测试:一个失败,一个通过。假设它们不能同时通过。你通常会只看到失败的测试,因为通过的那个,嗯,通过了。

例如,一个测试可能因为突然与新的行为冲突而失败。另一方面,一个冲突的测试可能期望出现新的行为,但并未找到。最简单的例子是,第一个测试验证调用具有两个参数的函数会产生“3”,而第二个测试期望相同的函数产生“4”。

你现在能做什么?

根本原因是其中一个测试已经变得不相关,这意味着它需要被移除。应该移除哪一个?这是一个我们需要向产品负责人提问的问题,因为答案与期望的应用程序的正确行为有关。

避免未来出现这种情况

我觉得这是一种健康的动态,我不介意不避免它。

7.2.5 测试不可靠

一个测试可能会不一致地失败。即使被测试的生产代码没有改变,一个测试可能会突然失败,然后再次通过,然后再次失败。我们称这样的测试为“不可靠的”。

不可靠的测试是一种特殊的怪物,我将在第 7.5 节中处理它们。

7.3 避免在单元测试中使用逻辑

当你在测试中包含越来越多的逻辑时,测试中存在错误的可能性几乎呈指数增长。我见过很多应该简单的测试变得动态、生成随机数、创建线程、写入文件的怪物,它们本身就是小小的测试引擎。遗憾的是,因为它们是*“测试”,作者没有考虑到它们可能存在错误,或者没有以可维护的方式编写。这些测试怪物调试和验证所需的时间比它们节省的时间要多。

但所有怪物都是从小的开始的。通常,公司的一个经验丰富的开发者会看看一个测试,然后开始想,“如果我们让函数循环并创建随机数作为输入呢?我们肯定能找到更多错误!”而且你确实会在你的测试中这样做。

测试错误是开发者最烦恼的事情之一,因为你几乎永远不会在测试本身中寻找失败测试的原因。我并不是说带有逻辑的测试没有任何价值。事实上,在某些特殊情况下,我可能自己会编写这样的测试。但我尽可能地避免这种做法。

如果你在一个单元测试中包含以下任何一项,你的测试包含了我通常建议减少或完全删除的逻辑:

  • switchifelse语句

  • foreachforwhile循环

  • 连接操作(+符号等)

  • trycatch

7.3.1 断言中的逻辑:创建动态期望值

这里有一个快速示例,以一个连接操作开始。

列表 7.1 包含逻辑的测试

describe("makeGreeting", () => {
  it("returns correct greeting for name", () => {
    const name = "abc";
    const result = trust.makeGreeting(name);
    expect(result).toBe("hello" + name);      ❶
  });

❶ 断言部分的逻辑

要理解这个测试的问题,以下列表显示了正在被测试的代码。注意,+符号出现在两者中。

列表 7.2 被测试的代码

const makeGreeting = (name) => {
  return "hello" + name;         ❶
};

❶ 与生产代码相同的逻辑

注意到连接一个名字与 "hello" 字符串的算法(虽然很简单,但仍然是一个算法)在测试和被测试的代码中都重复出现:

expect(result).toBe("hello" + name);   ❶
return "hello" + name;                 ❷

❶ 我们的测试

❷ 被测试的代码

我对这个测试的问题在于,被测试的算法在测试本身中也重复出现。这意味着如果算法中存在错误,测试也会包含相同的错误。测试不会捕获错误,反而会期望从被测试的代码中得到错误的结果。

在这种情况下,错误的结果是我们遗漏了连接词之间的空格字符,但希望你能看到,同样的问题在更复杂的算法中可能会变得更加复杂。

这是一个信任问题。我们不能信任这个测试告诉我们真相,因为它的逻辑是重复被测试的逻辑。当代码中存在错误时,测试可能会通过,所以我们不能信任测试的结果。

警告 避免在断言中动态创建期望值;尽可能使用硬编码的值。

这个测试的更可靠版本可以重写如下。

列表 7.3 更可靠的测试

it("returns correct greeting for name 2", () => {
  const result = trust.makeGreeting("abc");
  expect(result).toBe("hello abc");           ❶
});

❶ 使用硬编码的值

由于这个测试中的输入非常简单,所以很容易编写一个硬编码的期望值。这就是我通常推荐的做法——使测试输入如此简单,以至于可以轻松创建期望值的硬编码版本。请注意,这主要适用于单元测试。对于高级测试,这要困难一些,这也是为什么高级测试应该被认为有一定的风险;它们通常动态创建期望结果,你应该尽量避免在可能的情况下这样做。

“但是罗伊,”你可能会说,“现在我们正在重复自己——字符串 "abc" 被重复了两次。我们在之前的测试中能够避免这种情况。”当事情变得棘手时,信任应该胜过可维护性。一个高度可维护但我不信任的测试有什么用?你可以在 Vladimir Khorikov 的文章“单元测试中的 DRY vs. DAMP”中了解更多关于代码重复的内容,enterprisecraftsmanship.com/posts/dry-damp-unit-tests/.

7.3.2 其他逻辑形式

这里是相反的情况:动态创建输入(使用循环)迫使我们动态地决定期望的输出应该是什么。假设我们有以下代码来测试。

列表 7.4 一个查找名字的函数

const isName = (input) => {
  return input.split(" ").length === 2;
};

以下列表显示了一个测试的明显反模式。

列表 7.5 测试中的循环和 if 语句

describe("isName", () => {
  const namesToTest = ["firstOnly", "first second", ""]; ❶

  it("correctly finds out if it is a name", () => {
    namesToTest.forEach((name) => {
      const result = trust.isName(name);
      if (name.includes(" ")) {                            ❷
        expect(result).toBe(true);                         ❷
      } else {                                             ❷
        expect(result).toBe(false);                        ❷
      }
    });
  });
});

❶ 声明多个输入

❷ 生产代码逻辑泄露到测试中

注意我们是如何使用多个输入进行测试的。这迫使我们遍历这些输入,这本身就会使测试变得复杂。记住,循环也可能有错误。

此外,因为我们有不同的值场景(带空格和不带空格),我们需要一个if/else来知道断言期望的是什么,而if/else也可能有错误。我们也在重复生产算法的一部分,这让我们回到了之前的连接示例及其问题。

最后,我们的测试名称过于通用。我们只能将其命名为“它工作”,因为我们必须考虑到多个场景和预期结果。这对可读性很不好。

这是一个全面糟糕的测试。最好将其拆分为两个或三个测试,每个测试都有自己的场景和名称。这将使我们能够使用硬编码的输入和断言,并从代码中移除任何循环和if/else逻辑。任何更复杂的东西都会导致以下问题:

  • 测试更难阅读和理解。

  • 测试难以重现。例如,想象一个多线程测试或包含随机数的测试突然失败。

  • 测试更有可能存在错误或验证错误的事情。

  • 测试的命名可能更困难,因为它做了多件事。

通常,巨型测试会取代原始的简单测试,这使得在生产代码中查找错误更困难。如果你必须创建一个巨型测试,它应该作为一个新的测试添加,而不是替代现有测试。此外,它应该位于一个明确命名为包含测试(除了单元测试以外的测试)的项目或文件夹中。我称这些为“集成测试”或“复杂测试”,并试图将它们的数量保持在可接受的最低限度。

7.3.3 更多的逻辑

逻辑不仅存在于测试中,还存在于测试辅助方法、手写的模拟和测试实用类中。记住,你在这这些地方添加的每一块逻辑都会使代码更难阅读,并增加你的测试使用的实用方法中存在错误的几率。

如果因为某种原因你发现在你的测试套件中需要复杂的逻辑(尽管这通常是我对集成测试而不是单元测试所做的事情),至少确保你在测试项目中针对你的实用方法逻辑编写了几个测试。这将为你节省很多未来的眼泪。

7.4 在通过的测试中嗅出虚假的信任感

我们现在已经讨论了失败测试作为检测我们不应该信任的测试的手段。那么,我们到处都有的所有那些安静、绿色的测试呢?我们应该信任它们吗?对于一个需要在推送到主分支之前进行代码审查的测试,我们应该寻找什么?

让我们用“虚假信任”这个词来描述信任一个你实际上不应该信任的测试,但你还没有意识到这一点。能够审查测试并发现可能的虚假信任问题具有巨大的价值,因为,你不仅能够自己修复这些测试,你还影响了所有将要阅读或运行这些测试的其他人的信任。以下是我减少对测试信任的原因,即使它们正在通过:

  • 测试不包含断言。

  • 我无法理解测试。

  • 单元测试与不稳定的集成测试混合。

  • 测试验证了多个关注点或退出点。

  • 测试不断变化。

7.4.1 没有断言的测试

我们都同意,一个实际上没有验证某事是真是假的测试是不太有帮助的,对吧?不太有帮助,因为它也增加了维护时间、重构和阅读时间,有时如果由于生产代码中的 API 更改需要更改,它还会产生不必要的噪音。

如果你看到一个没有断言的测试,考虑可能存在函数调用中的隐藏断言。如果函数没有命名来解释这一点,这会导致可读性问题。有时人们也会编写一个测试来测试一段代码,只是为了确保代码不会抛出异常。这确实有一些价值,如果你选择编写这样的测试,确保测试的名称使用“不抛出”之类的术语来表明这一点。为了更加具体,许多测试 API 支持指定某物不会抛出异常的能力。这是你在 Jest 中这样做的:

expect(() => someFunction()).not.toThrow(error)

如果你确实有这样的测试,确保它们的数量非常少。我不建议将其作为标准,而只是针对真正特殊的情况。

有时人们只是因为缺乏经验而忘记编写断言。考虑添加缺失的断言,或者如果它没有带来价值,则删除测试。人们也可能积极编写测试,以达到管理层设定的某些想象的测试覆盖率目标。这些测试通常除了让管理层不再打扰人们以便他们可以真正工作之外,没有实际价值。

提示:代码覆盖率永远不应该是一个目标本身。它并不意味着“代码质量”。事实上,它往往导致开发者编写无意义的测试,这将花费更多的时间来维护。相反,衡量“逃逸的缺陷”、“修复时间”和其他我们在第十一章中将要讨论的指标。

7.4.2 不理解测试

这是一个大问题,我将在第九章中深入探讨。有几个可能的问题:

  • 命名不好的测试

  • 过长或代码复杂的测试

  • 包含令人困惑的变量名的测试

  • 包含难以理解的隐藏逻辑或假设的测试

  • 无法确定的测试结果(既未失败也未通过)

  • 提供信息不足的测试消息

如果你不知道失败的测试或通过的测试,你不知道你是否应该担心。

7.4.3 混合单元测试和不稳定的集成测试

他们常说“一个坏苹果会坏一筐”,对于混合了非稳定测试的测试也是如此。集成测试比单元测试更容易出现不稳定,因为它们有更多的依赖。如果你发现你在同一个文件夹或测试执行命令中混合了集成和单元测试,你应该感到怀疑。

人类喜欢走阻力最小的路,在编码方面也不例外。假设一个开发者运行了所有测试,其中一个测试失败——如果有一种方法可以将责任归咎于缺失的配置或网络问题,而不是花时间调查和修复真实问题,他们会的。这尤其适用于他们在严重的时间压力下,或者他们已经迟到,却还要承诺交付更多事情的情况。

最简单的事情就是指责任何失败的测试是故障测试。因为故障和非故障测试混合在一起,所以这是一件简单的事情,也是忽视问题并专注于更有趣事情的好方法。由于这个人为因素,最好移除将测试归咎于故障的选项。你该怎么做来防止这种情况?通过将集成测试和单元测试放在不同的地方,努力保持一个安全绿色区域

一个安全的绿色测试区域应该只包含非故障的、快速的测试,开发者知道他们可以获取最新的代码版本,他们可以运行该命名空间或文件夹中的所有测试,并且测试应该都是绿色的(假设没有对生产代码的更改)。如果安全绿色区域中的某些测试未通过,开发者更有可能感到担忧。

这种分离的额外好处是,由于运行时间更快,开发者更有可能更频繁地运行单元测试。有反馈总比没有反馈好,对吧?自动构建管道应该负责运行开发者无法或不愿意在本地机器上运行的任何“缺失”的反馈测试。

7.4.4 测试多个退出点

退出点(我还会将其称为关注点)在第一章中有解释。它是从一项工作中的单个最终结果:返回值、系统状态的变化或对第三方对象的调用。

这里有一个函数的简单示例,它有两个退出点,或两个关注点。它既返回一个值,又触发一个传入的回调函数:

const trigger = (x, y, callback) => {
  callback("I'm triggered");
  return x + y;
};

我们可以编写一个测试来同时检查这两个退出点。

列表 7.6 在同一测试中检查两个退出点

describe("trigger", () => {
  it("works", () => {
    const callback = jest.fn();
    const result = trigger(1, 2, callback);
    expect(result).toBe(3);
 expect(callback).toHaveBeenCalledWith("I'm triggered");
  });
});

在测试中测试多个关注点可能适得其反的第一个原因是你的测试名称会受到影响。我将在第九章讨论可读性,但这里有一个关于命名的快速说明:命名测试对于调试和文档目的都至关重要。我花了很多时间思考测试的好名称,并且不羞于承认这一点。

给测试命名可能看起来是一个简单的任务,但如果你正在测试多个事物,给测试起一个能表明正在测试什么的良好名称是困难的。通常你最终会得到一个非常通用的测试名称,迫使读者阅读测试代码。当你只测试一个关注点时,给测试命名是容易的。但是等等,还有更多。

更令人不安的是,在大多数单元测试框架中,失败的断言会抛出一个特殊的异常类型,该异常被测试框架运行器捕获。当测试框架捕获该异常时,意味着测试失败了。大多数语言中的大多数异常,按照设计,不允许代码继续执行。所以如果这一行,

expect(result).toBe(3);

如果断言失败,这一行将完全不会执行:

expect(callback).toHaveBeenCalledWith("I'm triggered");

测试方法在抛出异常的同一行退出。每个断言都可以,并且应该被视为不同的要求,它们也可以,在这种情况下很可能应该,分别和逐步地实现。

将断言失败视为疾病的症状。你能找到的症状越多,诊断疾病就越容易。在失败之后,后续的断言不会执行,你将错过看到其他可能的症状,这些症状可能会提供有价值的数据(症状),帮助你缩小关注范围并发现根本问题。在一个单元测试中检查多个关注点会增加复杂性而价值不大。你应该在单独的、自包含的单元测试中运行额外的关注点检查,这样你才能看到真正失败的地方。

让我们将它拆分为两个单独的测试。

列表 7.7 在单独的测试中检查两个退出点

describe("trigger", () => {
  it("triggers a given callback", () => {
    const callback = jest.fn();
    trigger(1, 2, callback);
 expect(callback).toHaveBeenCalledWith("I'm triggered");
  });

  it("sums up given values", () => {
    const result = trigger(1, 2, jest.fn());
 expect(result).toBe(3);
  });
});

现在我们可以清楚地分离关注点,每个都可以单独失败。

有时候,在同一个测试中断言多个事物是完全正常的,只要它们不是多个关注点。以下是一个函数及其相关测试的例子。makePerson 被设计用来使用一些属性构建一个新的 person 对象。

列表 7.8 使用多个断言验证单个退出点

const makePerson = (x, y) => {
  return {
    name: x,
    age: y,
    type: "person",
  };
};

describe("makePerson", () => {
  it("creates person given passed in values", () => {
    const result = makePerson("name", 1);
    expect(result.name).toBe("name");
 expect(result.age).toBe(1);
  });
});

在我们的测试中,我们同时在名称和年龄上断言,因为它们是同一个关注点(构建 person 对象)的一部分。如果第一个断言失败,我们很可能不会关心第二个断言,因为可能在最初构建对象时出了大问题。

提示:以下是一个测试拆分提示:如果第一个断言失败,你是否仍然关心下一个断言的结果?如果你关心,你可能应该将测试拆分为两个测试。

7.4.5 持续变化的测试

如果一个测试在执行或断言过程中使用当前日期和时间作为其一部分,那么我们可以断言每次测试运行时,它都是一个不同的测试。同样,对于使用随机数、机器名称或任何依赖于从测试环境外部获取当前值的测试,也是如此。有很大可能性其结果不会一致,这意味着它们可能是不可靠的。对我们这些开发者来说,不可靠的测试会降低我们对测试失败结果的信任(我将在下一节中讨论)。

动态生成值的一个巨大潜在问题是,如果我们事先不知道系统可能输入的内容,我们还得计算系统的预期输出,这可能导致一个有缺陷的测试,该测试依赖于重复生产逻辑,正如第 7.3 节中提到的。

7.5 处理不稳定测试

我不确定是谁提出了“flaky tests”(不稳定测试)这个术语,但它确实符合这个描述。它用来描述那些在没有对代码进行任何更改的情况下,返回不一致结果的测试。这种情况可能经常发生,也可能非常罕见,但确实会发生。

图 7.1 说明了不稳定测试的来源。该图基于测试所具有的真实依赖项数量。另一种思考方式是测试有多少变动部分。对于这本书,我们主要关注这个图的下三分之一:单元测试和组件测试。然而,我想谈谈更高层的不稳定测试,这样我可以给你一些研究方向的提示。

07-01

图 7.1 测试的层级越高,它们使用的真实依赖项越多,这让我们对整体系统正确性有信心,但结果却导致更多的不稳定。

在最低层级,我们的测试对其所有依赖项都有完全的控制权,因此没有变动部分,要么是因为它们在伪造它们,要么是因为它们纯在内存中运行并且可以配置。我们在第三章和第四章中就是这样做的。代码中的执行路径是完全确定的,因为所有初始状态和来自各种依赖项的预期返回值都已经预先确定。代码路径几乎是静态的——如果它返回了错误的预期结果,那么生产代码的执行路径或逻辑可能已经发生了重要变化。

随着我们向上提升层级,我们的测试会逐渐减少 stub 和 mock 的使用,并开始使用越来越多的真实依赖项,例如数据库、网络、配置等。这反过来意味着我们控制力更弱的部分更多,可能会改变我们的执行路径,返回意外的值,或者根本无法执行。

在最高层级,没有伪造的依赖项。我们的测试所依赖的一切都是真实的,包括任何第三方服务、安全和网络层以及配置。这类测试通常要求我们设置一个尽可能接近生产场景的环境,如果它们不是在生产环境中运行的话。

在测试图中的层级越高,我们应该对我们的代码工作有更高的信心,除非我们不信任测试结果。不幸的是,随着我们在图中层级上升,我们的测试因为涉及的变动部分越来越多,变得不稳定的机会也越大。

我们可能认为最低级别的测试不应该有任何的不稳定性问题,因为不应该有任何移动部件导致不稳定性。这在理论上是对的,但在现实中,人们仍然在低级别的测试中添加移动部件:使用当前日期和时间、机器名称、网络、文件系统等都可以导致测试变得不稳定。

测试有时会失败,而我们并没有修改生产代码。例如:

  • 每次运行测试时,大约有三分之一会失败。

  • 测试每次都会在未知次数后失败。

  • 当各种外部条件失败时,测试会失败,例如网络或数据库可用性、其他 API 不可用、环境配置等。

更糟糕的是,测试使用的每个依赖项(网络、文件系统、线程等)通常都会增加测试运行的时间。对网络和数据库的调用需要时间。等待线程完成、读取配置和等待异步任务也是如此。

确定为什么测试失败也需要更长的时间。调试测试或阅读大量日志是令人心碎的耗时工作,会慢慢地将你的灵魂拖入“更新我的简历”的时间深渊。

7.5.1 找到不稳定的测试后,你可以做什么?

重要的是要认识到,不稳定的测试可能会对一个组织造成成本。你应该以零不稳定的测试作为长期目标。以下是一些减少处理不稳定的测试相关成本的方法:

  • 定义—就组织中的“不稳定”达成一致。例如,在不更改任何生产代码的情况下运行测试套件 10 次,并计算所有结果不一致的测试(即没有 10 次都失败或没有 10 次都通过)。

  • 将任何被认为是不稳定的测试放在一个特殊的测试类别或文件夹中,这些测试可以单独运行。我建议从常规交付构建中移除所有不稳定的测试,以免产生噪音,并将它们暂时隔离在自己的小管道中。然后,逐一检查每个不稳定的测试,玩我最喜欢的“不稳定”游戏,“修复、转换或淘汰”:

    • 修复—如果可能,通过控制其依赖项来使测试不再不稳定。例如,如果它需要数据库中的数据,可以在测试过程中将数据插入数据库。

    • 转换—通过将测试转换为更底层的测试,通过移除和控制其一个或多个依赖项来消除不稳定性。例如,使用存根模拟网络端点而不是使用真实的端点。

    • 杀死—认真考虑测试带来的价值是否足以继续运行它并支付其产生的维护成本。有时,老旧的不可靠测试最好是死去并埋葬。有时,它们已经被更新、更好的测试所覆盖,而旧的测试纯粹是技术债务,我们可以将其消除。遗憾的是,许多工程经理由于沉没成本谬误而不愿意移除这些旧测试——投入了如此多的努力,删除它们将是浪费。然而,此时,保留测试可能比你删除它所花费的成本还要高,所以我建议你认真考虑为许多不可靠测试选择这个选项。

7.5.2 防止高级测试出现不可靠性

如果你感兴趣的是防止高级测试出现不可靠性,最好的办法是确保你的测试在部署后任何环境中都是可重复的。这可能包括以下内容:

  • 回滚测试对外部共享资源所做的任何更改。

  • 不要依赖于其他测试更改外部状态。

  • 通过确保你有能力随意重新创建它们(在互联网上搜索“基础设施即代码”),创建你可以控制的它们的人工制品,或者创建特殊测试账户并祈祷它们保持安全,从而在一定程度上控制外部系统和依赖。

关于最后一点,当使用由其他公司管理的外部系统时,控制外部依赖可能很困难或不可能。当这种情况发生时,考虑以下选项是值得的:

  • 如果一些低级测试已经覆盖了那些场景,则移除一些高级测试。

  • 将一些高级测试转换为一系列低级测试。

  • 如果你正在编写新的测试,请考虑一个对管道友好的测试策略,包括测试配方(例如,我将在第十章中解释的那个)。

摘要

  • 如果你不相信失败的测试,你可能会忽略一个真实的错误,如果你不相信通过的测试,你最终会进行大量的手动调试和测试。这两种结果都应通过拥有良好的测试来减少,但如果我们不减少它们,并且我们花费所有这些时间编写我们不信任的测试,那么最初编写它们的目的是什么呢?

  • 测试可能因多种原因而失败:在生产代码中发现的真实错误、导致虚假失败的测试中的错误、由于功能更改而变得过时的测试、与另一个测试冲突的测试或测试不可靠。只有第一个原因是有效的。所有其他原因都告诉我们不应该信任这个测试。

  • 避免在测试中引入复杂性,例如创建动态预期值或复制底层生产代码中的逻辑。这种复杂性增加了在测试中引入错误的可能性,以及理解它们所需的时间。

  • 如果一个测试没有任何断言,你就无法理解它在做什么,它和易出错的测试并行运行(即使这个测试本身不是易出错的),它验证多个退出点,或者它不断变化,那么它就不能完全信赖。

  • 易出错的测试是指那些不可预测地失败的测试。测试的层级越高,它使用的实际依赖项就越多,这让我们对整个系统的正确性更有信心,但同时也导致了更多的不稳定性。为了更好地识别易出错的测试,可以将它们放入一个特殊的类别或文件夹中,以便单独运行。

  • 为了减少测试的不稳定性,可以选择修复测试、将不稳定的较高层测试转换为更稳定的较低层测试,或者删除它们。

8 可维护性

本章涵盖

  • 失败测试的根本原因

  • 常见的可避免的测试代码更改

  • 提高当前未失败的测试的可维护性

测试可以使我们开发得更快,除非它们因为所有必要的更改而使我们放慢速度。如果我们能在更改生产代码时避免更改现有的测试,我们就可以开始希望我们的测试是在帮助而不是伤害我们的底线。在本章中,我们将重点关注测试的可维护性。

不可维护的测试会破坏项目进度,当项目被安排在一个更积极的进度表上时,它们通常会被搁置。开发者会简单地停止维护和修复那些更改时间过长或由于非常小的生产代码更改而需要经常更改的测试。

如果可维护性是衡量我们被迫更改测试的频率的指标,我们希望最小化这种情况发生的次数。这迫使我们提出这些问题,如果我们想深入了解根本原因的话:

  • 我们何时注意到测试失败,因此可能需要更改?

  • 为什么测试会失败?

  • 哪些测试失败迫使我们更改测试?

  • 当我们没有被迫改变测试时,我们选择何时改变测试?

本章介绍了一系列与可维护性相关的实践,您可以在进行测试审查时使用。

8.1 由失败测试引起的更改

一个失败的测试通常是维护性潜在问题的第一个迹象。当然,我们可能在生产代码中找到了一个真正的错误,但如果没有这种情况,测试还有哪些其他原因会导致失败?我将真正的失败称为真实失败,将除在底层生产代码中找到错误之外的其他原因导致的失败称为虚假失败

如果我们想要衡量测试的可维护性,我们可以从测量随着时间的推移中错误的测试失败次数以及每次失败的原因开始。我们在第七章中已经讨论了一个这样的原因:当一个测试包含一个错误时。现在让我们讨论其他可能导致错误失败的可能原因。

8.1.1 测试不再相关或与另一个测试冲突

当生产代码引入一个与一个或多个现有测试直接冲突的新功能时,可能会出现冲突。而不是测试发现一个错误,它可能会发现冲突或新的需求。也可能有一个通过测试,它针对生产代码应该如何工作的新期望。

要么现有的失败测试不再相关,要么新的需求是错误的。假设需求是正确的,你可能会继续删除不再相关的测试。

注意,有一个常见的例外是“删除测试”规则:当你与功能开关一起工作时。当我们讨论测试策略时,在第十章中我们将涉及到功能开关。

8.1.2 生产代码 API 的变化

如果被测试的生产代码发生变化,使得现在需要以不同的方式使用正在测试的函数或对象,即使它可能仍然具有相同的功能,测试可能会失败。这种错误失败属于“我们应尽可能避免这种情况”的范畴。

考虑列表 8.1 中的PasswordVerifier类,它需要两个构造函数参数:

  • 一个rules数组(每个都是一个接收输入并返回布尔值的函数)

  • 一个ILogger接口

列表 8.1 具有两个构造函数参数的密码验证器

export class PasswordVerifier {
    ...
    constructor(rules: ((input) => boolean)[], logger: ILogger) {
        this._rules = rules;
        this._logger = logger;
    }

    ...
}

我们可以编写一些像下面的测试。

列表 8.2 无工厂函数的测试

describe("password verifier 1", () => {
  it("passes with zero rules", () => {
    const verifier = new PasswordVerifier([], { info: jest.fn() }); ❶
    const result = verifier.verify("any input");
    expect(result).toBe(true);
  });

  it("fails with single failing rule", () => {
    const failingRule = (input) => false;
    const verifier = 
      new PasswordVerifier([failingRule], { info: jest.fn() }); ❶
    const result = verifier.verify("any input");
    expect(result).toBe(false);
  });
});

❶ 使用代码现有 API 进行测试

如果从可维护性的角度来看这些测试,我们预计未来可能需要做出几个潜在的改变。

代码通常会有很长的生命周期

考虑到你正在编写的代码将在代码库中至少存在 4-6 年,有时甚至十年。在这段时间里,PasswordVerifier的设计发生变化的概率有多大?即使是简单的事情,比如构造函数接受更多的参数,或者参数类型发生变化,在更长的时间内变得更有可能。

让我们列出一些未来可能发生到我们的密码验证器上的更改:

  • 我们可能会为PasswordVerifier的构造函数添加或删除参数。

  • PasswordVerifier的一个参数可能改变为不同的类型。

  • ILogger函数的数量或它们的签名可能会随时间变化。

  • 使用模式发生了变化,所以我们不需要实例化一个新的PasswordVerifier,而是直接使用其中的函数。

如果发生任何这些事情,我们需要更改多少测试?目前,我们需要更改所有实例化PasswordVerifier的测试。我们能否防止需要一些这些更改?

让我们假设未来已经到来,我们的担忧已经成真——有人改变了生产代码的 API。让我们说构造函数签名已经更改,现在使用IComplicatedLogger而不是ILogger,如下所示。

列表 8.3 构造函数中的破坏性更改

export class PasswordVerifier2 {
  private _rules: ((input: string) => boolean)[];
  private _logger: IComplicatedLogger;

  constructor(rules: ((input) => boolean)[], 
      logger: IComplicatedLogger) {
    this._rules = rules;
    this._logger = logger;
  }
...
}

按照现状,我们可能需要更改任何直接实例化PasswordVerifier的测试。

工厂函数解耦测试对象的创建

避免未来这种痛苦的一个简单方法是将测试代码的创建解耦或抽象出来,这样对构造函数的更改只需要在集中位置处理。一个唯一目的是创建和预配置对象实例的函数通常被称为工厂函数或方法。这种更高级的版本(我们在这里不会涉及)是 Object Mother 模式。

工厂函数可以帮助我们减轻这个问题。接下来的两个列表显示了在签名更改之前我们如何最初编写测试,以及在这种情况下如何轻松适应签名更改。在列表 8.4 中,PasswordVerifier的创建已经被提取到它自己的集中化工厂函数中。我也为fakeLogger做了同样的事情——现在它也是通过它自己的单独工厂函数创建的。如果未来发生我们之前列出的任何更改,我们只需要更改我们的工厂函数;通常不需要触摸测试。

列表 8.4 重构为工厂函数

describe("password verifier 1", () => {
  const makeFakeLogger = () => {
    return { info: jest.fn() };                          ❶
  };

  const makePasswordVerifier = (
    rules: ((input) => boolean)[],
    fakeLogger: ILogger = makeFakeLogger()) => {
      return new PasswordVerifier(rules, fakeLogger); ❷
  };

  it("passes with zero rules", () => {
    const verifier = makePasswordVerifier([]);           ❸

    const result = verifier.verify("any input");

    expect(result).toBe(true);
  });

❶ 创建 fakeLogger 的集中点

❷ 创建 PasswordVerifier 的集中点

❸ 使用工厂函数创建 PasswordVerifier

在下面的列表中,我根据签名更改重构了测试。请注意,这个更改并不涉及更改测试,而只是工厂函数。这就是我可以在实际项目中接受的那种可管理的更改。

列表 8.5 重构工厂方法以适应新签名

describe("password verifier (ctor change)", () => {
  const makeFakeLogger = () => {
    return Substitute.for<IComplicatedLogger>();
  };

  const makePasswordVerifier = (
    rules: ((input) => boolean)[],
    fakeLogger: IComplicatedLogger = makeFakeLogger()) => {
    return new PasswordVerifier2(rules, fakeLogger);
  };

  // the tests remain the same
});

8.1.3 其他测试中的更改

测试隔离不足是测试阻塞的一个主要原因——我在咨询和编写单元测试时见过这种情况。你应该记住的基本概念是,一个测试应该始终在自己的小世界中运行,即使它们验证相同的功能,也应该与其他测试隔离。

哭着“失败”的测试

我参与的一个项目中,单元测试表现得很奇怪,随着时间的推移,它们变得更加奇怪。一个测试会失败,然后突然连续几天通过。一天后,它会随机失败,有时即使代码被更改以删除或改变其行为,它也会通过。到了开发者会告诉彼此,“哦,没关系。如果它有时通过,那就意味着它通过了。”的地步。

经过适当调查,我们发现测试在代码中调用了一个不同的(且不可靠的)测试,当其他测试失败时,它会破坏第一个测试。

在尝试了各种解决方案一个月后,我们花了三天时间理清了混乱,当我们最终使测试正常工作时,我们发现我们代码中有一堆我们因为测试有自身的错误和问题而忽略的真正错误。狼来了的故事即使在开发中也是成立的。

当测试没有很好地隔离时,它们可能会互相干扰,让你后悔决定尝试单元测试并承诺自己再也不这样做。我见过这种情况发生。开发者懒得在测试中寻找问题,所以当有问题时,找出问题可能需要花费很多时间。最容易的症状就是我所说的“受限测试顺序”。

受限测试顺序

当一个测试假设先执行了之前的测试,或者没有先执行,因为它依赖于其他测试设置或重置的某些共享状态时,就会发生 受限的测试顺序。例如,如果一个测试更改了内存中的共享变量或某些外部资源(如数据库),而另一个测试在第一个测试执行后依赖于该变量的值,那么我们就有了基于顺序的测试之间的依赖关系。

结合这样一个事实,即大多数测试运行器不会(也不会,也许不应该!)保证测试将按特定顺序运行。这意味着如果你今天运行了所有测试,一周后使用测试运行器的新版本再次运行所有测试,测试可能不会按之前的顺序运行。

08-01

图 8.1 一个共享的 UserCache 实例

为了说明问题,让我们看看一个简单的场景。图 8.1 展示了一个使用 UserCache 对象的 SpecialApp 对象。用户缓存持有单个实例(一个单例),作为应用程序的缓存机制,顺便说一句,也是测试的缓存机制。列表 8.6 展示了 SpecialApp、用户缓存和 IUserDetails 接口的实现。

列表 8.6 一个共享的用户缓存及其相关接口

export interface IUserDetails {
  key: string;
  password: string;
}

export interface IUserCache {
  addUser(user: IUserDetails): void;
  getUser(key: string);
  reset(): void;
}
export class UserCache implements IUserCache {
  users: object = {};
  addUser(user: IUserDetails): void {
    if (this.users[user.key] !== undefined) {
      throw new Error("user already exists");
    }
    this.users[user.key] = user;
  }

  getUser(key: string) {
    return this.users[key];
  }

  reset(): void {
    this.users = {};
  }
}
let _cache: IUserCache;
export function getUserCache() {
  if (_cache === undefined) {
    _cache = new UserCache();
  }
  return _cache;
} 

以下列表展示了 SpecialApp 的实现。

列表 8.7 SpecialApp 的实现

export class SpecialApp {
  loginUser(key: string, pass: string): boolean {
    const cache: IUserCache = getUserCache();
    const foundUser: IUserDetails = cache.getUser(key);
    if (foundUser?.password === pass) {
      return true;
    }
    return false;
  }
}

这只是一个简单的示例实现,所以不必太担心 SpecialApp。让我们看看测试。

列表 8.8 需要按特定顺序运行的测试

describe("Test Dependence", () => {
  describe("loginUser with loggedInUser", () => {
    test("no user, login fails", () => {
      const app = new SpecialApp();
      const result = app.loginUser("a", "abc");    ❶
      expect(result).toBe(false);                  ❶
    });

    test("can only cache each user once", () => {
      getUserCache().addUser({                     ❷
        key: "a",
        password: "abc",
      });

      expect(() =>
        getUserCache().addUser({
          key: "a",
          password: "abc",
        })
      ).toThrowError("already exists");
    });

    test("user exists, login succeeds", () => {
      const app = new SpecialApp();
      const result = app.loginUser("a", "abc");    ❸
      expect(result).toBe(true);                   ❸
    });
  });
});

❶ 需要用户缓存为空

❷ 将用户添加到缓存中

❸ 需要缓存包含用户

注意,第一个和第三个测试都依赖于第二个测试。第一个测试要求第二个测试尚未执行,因为它需要用户缓存为空。另一方面,第三个测试依赖于第二个测试用预期的用户填充缓存。如果我们只使用 Jest 的 test.only 关键字运行第三个测试,该测试将失败:

test.only("user exists, login succeeds", () => {
   const app = new SpecialApp();
   const result = app.loginUser("a", "abc");
   expect(result).toBe(true); 
 });

这种反模式通常发生在我们尝试重用测试的一部分而不提取辅助函数时。我们最终期望不同的测试先运行,从而避免进行一些设置。这可以工作,直到它不行了。

我们可以分几个步骤进行重构:

  • 提取一个用于添加用户的辅助函数。

  • 在多个测试中重用此函数。

  • 在测试之间重置用户缓存。

以下列表展示了我们如何重构测试以避免这个问题。

列表 8.9 重构测试以消除顺序依赖

const addDefaultUser = () =>                              ❶
  getUserCache().addUser({
    key: "a",
    password: "abc",
  });
const makeSpecialApp = () => new SpecialApp();         ❷
describe("Test Dependence v2", () => {
  beforeEach(() => getUserCache().reset());               ❸
  describe("user cache", () => {                          ❹
    test("can only add cache use once", () => {
      addDefaultUser();                                   ❺

      expect(() => addDefaultUser())
        .toThrowError("already exists");
    });
  });

  describe("loginUser with loggedInUser", () => {         ❹
    test("user exists, login succeeds", () => {
      addDefaultUser(); ❺
      const app = makeSpecialApp();

      const result = app.loginUser("a", "abc");
      expect(result).toBe(true);
    });

    test("user missing, login fails", () => {
      const app = makeSpecialApp();

      const result = app.loginUser("a", "abc");
      expect(result).toBe(false);
    });
  });
});

❶ 提取的用户创建辅助函数

❷ 提取的工厂函数

❸ 在测试之间重置用户缓存

❹ 新的嵌套 describe 函数

❺ 调用可重用的辅助函数

这里有几个事情在进行中。首先,我们提取了两个辅助函数:一个makeSpecialApp工厂函数和一个addDefaultUser辅助函数,我们可以重用它。接下来,我们创建了一个非常重要的beforeEach函数,在每个测试之前重置用户缓存。每当我有这样的共享资源时,我几乎总是有一个beforeEachafterEach函数,在测试运行前后将其重置到原始状态。

现在第一个和第三个测试现在运行在自己的小嵌套describe结构中。它们也都使用了makeSpecialApp工厂函数,其中一个使用了addDefaultUser来确保它不需要先运行其他任何测试。第二个测试也运行在自己的嵌套describe函数中,并重用了addDefaultUser函数。

8.2 重构以提高可维护性

到目前为止,我讨论了迫使我们必须做出更改的测试失败。现在让我们讨论我们选择做出的更改,以使测试随着时间的推移更容易维护。

8.2.1 避免测试私有或受保护的函数

本节更多地适用于面向对象的语言以及 TypeScript。在开发者的心目中,私有或受保护的函数通常出于良好的原因而保持私有。有时是为了隐藏实现细节,这样实现可以在不改变可观察行为的情况下进行更改。也可能是出于安全相关或知识产权相关的原因(例如,混淆)。

当你测试私有方法时,你是在测试系统内部的合同。内部合同是动态的,当你对系统进行重构时,它们可能会改变。当它们改变时,你的测试可能会失败,因为某些内部工作以不同的方式进行,尽管系统的整体功能保持不变。从测试的目的来看,公共合同(可观察的行为)是你需要关心的全部。测试私有方法的功能可能会导致测试中断,即使可观察的行为是正确的。

这样想:没有私有方法存在于真空中。在某个地方,必须有人调用它,否则它永远不会被触发。通常有一个公共方法最终调用这个私有方法,如果没有,在调用链中总有一个公共方法被调用。这意味着任何私有方法总是系统中的更大工作单元或用例的一部分,它从公共 API 开始,以三个结果之一结束:返回值、状态更改或第三方调用(或三者兼有)。

因此,如果你看到一个私有方法,找到系统中将使用它的公共用例。如果你只测试私有方法并且它工作正常,这并不意味着系统的其余部分正确地使用了这个私有方法或正确处理了它提供的结果。你可能有一个内部工作完美无缺的系统,但所有这些美好的内部东西都是从公共 API 中错误使用的。

有时,如果一个私有方法值得测试,那么将其公开、静态化或至少内部化,并定义一个公共契约,针对任何使用它的代码,可能是有价值的。在某些情况下,如果将方法放入不同的类中,设计可能会更简洁。我们稍后会探讨这些方法。

这是否意味着代码库中最终不应该有私有方法?不。在使用测试驱动设计的情况下,你通常会对公开的方法编写测试,并且这些公开的方法随后会被重构为调用更小、更私有的方法。在此过程中,对公开方法的测试仍然会通过。

将方法公开

将方法公开并不一定是坏事。在一个更功能化的世界中,这甚至不是一个问题。这种做法可能看起来与许多人都接受的面向对象原则相悖,但情况并不总是如此。

考虑到想要测试一个方法可能意味着该方法对调用代码有一个已知的行为或契约。通过将其公开,你使这成为官方的。通过保持方法私有,你告诉所有在你之后到来的开发者,他们可以更改方法的实现,而无需担心使用它的未知代码。

将方法提取到新的类或模块中

如果你的方法包含大量可以独立存在的逻辑,或者它使用了与特定方法相关的特定状态变量,那么将方法提取到新的类或具有特定系统角色的模块中可能是个好主意。然后你可以单独测试这个类。Michael Feathers 的《与遗留代码有效工作》(Pearson,2004)中有一些关于这种技术的良好示例,Robert Martin 的《代码整洁之道》(Pearson,2008)可以帮助你确定何时这样做是有益的。

将无状态私有方法公开和静态化

如果你的方法完全无状态,有些人会选择通过将其改为静态(在支持此特性的语言中)来重构该方法。这使得它更容易测试,但也表明该方法是一种具有已知公共契约的实用方法,该契约由其名称指定。

8.2.2 保持测试 DRY(不要重复自己)

单元测试中的重复可能会对你造成伤害,就像在生产代码中的重复一样,甚至可能更多。这是因为任何有重复的代码片段的更改都会迫使你更改所有重复的部分。当你处理测试时,开发者可能会避免这种麻烦,删除或忽略测试而不是修复它们的风险更大。

DRY(不要重复自己)原则应该在测试代码中生效,就像在生产代码中一样。重复的代码意味着当你测试的一个方面发生变化时,有更多的代码需要更改。更改构造函数或更改类使用的语义可能会对有大量重复代码的测试产生重大影响。

正如我们在本章前面的示例中看到的那样,使用辅助函数可以帮助减少测试中的重复。

警告:消除重复也可能做得太过分,从而损害可读性。我们将在下一章,关于可读性的章节中讨论这个问题。

8.2.3 避免使用设置方法

我不是beforeEach函数(也称为设置函数)的粉丝,这个函数在每个测试之前只发生一次,通常用于消除重复。我更倾向于使用辅助函数。设置函数很容易被滥用。开发者倾向于将它们用于它们本不应用于的事情,结果测试的可读性和可维护性会降低。

许多开发者以几种方式滥用设置方法:

  • 在设置方法中初始化仅用于文件中某些测试的对象

  • 拥有冗长且难以理解的设置代码

  • 在设置方法内设置模拟和假对象

此外,设置方法也有局限性,你可以通过使用简单的辅助方法来克服这些局限性:

  • 设置方法只有在需要初始化事物时才能提供帮助。

  • 设置方法并不总是消除重复的最佳候选者。消除重复并不总是关于创建和初始化对象的新实例。有时它关于消除断言逻辑中的重复或以特定方式调用代码。

  • 设置方法不能有参数或返回值。

  • 设置方法不能用作返回值的工厂方法。它们在测试执行之前运行,因此它们的工作方式必须更通用。测试有时需要请求特定的事物或使用参数调用特定测试的共享代码(例如,检索一个对象并将其属性设置为特定值)。

  • 设置方法应仅包含适用于当前测试类中所有测试的代码,否则方法将更难以阅读和理解。

我几乎完全停止使用设置方法来编写测试。测试代码应该既整洁又清晰,就像生产代码一样,但如果你的生产代码看起来很糟糕,请不要将其作为编写难以阅读的测试的借口。使用工厂和辅助方法,并为未来 5 年或 10 年将不得不维护你的代码的开发者创造一个更好的世界。

注意:我们在 8.2.3 节(列表 8.9)和第二章中查看了一个从使用beforeEach到辅助函数的示例。

8.2.4 使用参数化测试来消除重复

如果所有测试看起来都一样,另一个替换设置方法的绝佳选择是使用参数化测试。不同语言的测试框架支持不同的参数化测试——如果你使用 Jest,你可以使用内置的test.eachit.each函数。

参数化有助于将原本可能重复或位于beforeEach块中的设置逻辑移动到测试的安排部分。它还有助于避免断言逻辑的重复,如下面的列表所示。

列表 8.10 使用 Jest 的参数化测试

const sum = numbers => {
    if (numbers.length > 0) {
        return parseInt(numbers);
    }
    return 0;
};

describe('sum with regular tests', () => {
    test('sum number 1', () => {
        const result = sum('1');                  ❶
        expect(result).toBe(1);                   ❶
    });
    test('sum number 2', () => {
        const result = sum('2');                  ❶
        expect(result).toBe(2);                   ❶
    });
});
describe('sum with parameterized tests', () => {
    test.each([
 ['1', 1],                              ❷
 ['2', 2] ❷
 ])('add ,for %s, returns that number', (input, expected) => {
            const result = sum(input);            ❸
            expect(result).toBe(expected);        ❸
        }
    )
});

❶ 重复的设置和断言逻辑

❷ 用于设置和断言的测试数据

❸ 无重复的设置和断言

在第一个describe块中,我们有两次测试,它们使用不同的输入值和预期输出重复。在第二个describe块中,我们使用test.each提供一个数组数组,其中每个子数组列出测试函数所需的全部值。

参数化测试可以帮助减少测试之间的许多重复,但我们应该小心只在重复完全相同的场景,并且只更改输入和输出的情况下使用这种技术。

8.3 避免过度指定

一个过度指定的测试是包含关于特定单元(生产代码)应该如何实现其内部行为的假设的测试,而不是只检查可观察的行为(退出点)是否正确。

下面是单元测试通常过度指定的方法:

  • 测试断言在测试对象中纯粹的内状态。

  • 一个测试使用多个模拟。

  • 一个测试使用存根作为模拟。

  • 一个测试在不需要时假设特定的顺序或精确的字符串匹配。

让我们看看一些过度指定测试的例子。

8.3.1 使用模拟的内部行为过度指定

一个非常常见的反模式是验证一个类或模块中的内部函数是否被调用,而不是检查工作单元的退出点。以下是一个密码验证器,它调用了一个内部函数,而测试不应该关心这一点。

列表 8.11 调用受保护函数的生产代码

export class PasswordVerifier4 {
  private _rules: ((input: string) => boolean)[];
  private _logger: IComplicatedLogger;

  constructor(rules: ((input) => boolean)[],
      logger: IComplicatedLogger) {
    this._rules = rules;
    this._logger = logger;
  }

  verify(input: string): boolean {
    const failed = this.findFailedRules(input); ❶

    if (failed.length === 0) {
      this._logger.info("PASSED");
      return true;
    }
    this._logger.info("FAIL");
    return false;
  }

  protected findFailedRules(input: string) {      ❷
    const failed = this._rules
      .map((rule) => rule(input))
      .filter((result) => result === false);
    return failed;
  }
}

❶ 调用内部函数

❷ 内部函数

注意我们调用了受保护的findFailedRules函数来从它获取结果,然后对这个结果进行计算。

这是我们的测试。

列表 8.12 验证调用受保护函数的过度指定测试

describe("verifier 4", () => {
  describe("overspecify protected function call", () => {
    test("checkfailedFules is called", () => {
      const pv4 = new PasswordVerifier4(
        [], Substitute.for<IComplicatedLogger>()
      ); 
      const failedMock = jest.fn(() => []);     ❶
   pv4["findFailedRules"] = failedMock; ❶

pv4.verify("abc");

     expect(failedMock).toHaveBeenCalled(); ❷
    });
  });
});

❶ 模拟内部函数

❷ 验证内部函数调用。不要这样做。

这里的反模式是我们证明了不是退出点的东西。我们检查代码是否调用了某个内部函数,但这实际上证明了什么呢?我们没有检查计算结果是否正确;我们只是在测试。

如果函数正在返回一个值,通常这是一个强烈的迹象表明我们不应该模拟该函数,因为函数调用本身并不代表退出点。退出点是verify()函数返回的值。我们不应该关心内部函数是否存在。

通过验证一个受保护函数的模拟(该函数不是退出点),我们将测试实现耦合到被测试代码的内部实现中,这并没有带来真正的益处。当内部调用发生变化(它们会的)时,我们也必须更改所有与这些调用相关的测试,这不会是一个愉快的体验。你可以在 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning, 2020)一书的第五章中了解更多关于模拟及其与测试脆弱性的关系。

我们应该做什么呢?

寻找退出点。真正的退出点取决于我们希望执行哪种类型的测试:

  • 基于值的测试—对于基于值的测试,我强烈建议你在可能的情况下倾向于这种测试,我们寻找被调用函数的返回值。在这种情况下,verify 函数返回一个值,因此它是基于值测试的完美候选:pv4.verify("abc")

  • 基于状态的测试—对于基于状态的测试,我们寻找一个兄弟函数(一个与入口点处于相同作用域级别的函数)或一个受调用 verify() 函数影响的兄弟属性。例如,firstname()lastname() 可以被认为是兄弟函数。这就是我们应该进行断言的地方。在这个代码库中,没有东西在相同级别上被 verify() 调用所影响,因此它不是一个好的基于状态测试的候选。

  • 第三方测试—对于第三方测试,我们可能需要使用模拟,这将要求我们找出代码中“一次性”位置在哪里。findFailedRules 函数并不是这样的,因为它实际上是在向我们的 verify() 函数返回信息。在这种情况下,我们没有真正的第三方依赖需要接管。

8.3.2 精确输出和顺序过度指定

一个常见的反模式是当测试过度指定了返回值集合的顺序和结构。通常在断言中指定整个集合及其每个项目更容易,但采用这种方法,我们隐式地承担了在集合的任何细节发生变化时修复测试的负担。而不是使用单个大的断言,我们应该将验证的不同方面分离成更小、更明确的断言。

以下列表显示了一个 verify() 函数,它接受多个输入并返回一个结果对象列表。

列表 8.13 返回输出列表的验证器

interface IResult {
  result: boolean;
  input: string;
}

export class PasswordVerifier5 {
  private _rules: ((input: string) => boolean)[];

  constructor(rules: ((input) => boolean)[]) {
    this._rules = rules;
  }

  verify(inputs: string[]): IResult[] {
 const failedResults = 
 inputs.map((input) => this.checkSingleInput(input));
    return failedResults;
  }

  private checkSingleInput(input: string): IResult {
    const failed = this.findFailedRules(input);
    return {
      input,
      result: failed.length === 0,
    };
  }

这个 verify() 函数返回一个包含 IResult 对象的数组,每个对象都有一个 inputresult。以下列表显示了一个测试,它隐式检查了结果的顺序和结构,以及检查结果值。

列表 8.14 过度指定结果的顺序和模式

test("overspecify order and schema", () => {
  const pv5 = 
    new PasswordVerifier5([input => input.includes("abc")]);

  const results = pv5.verify(["a", "ab", "abc", "abcd"]);

  expect(results).toEqual([           ❶
    { input: "a", result: false },    ❶
    { input: "ab", result: false },   ❶
    { input: "abc", result: true },   ❶
    { input: "abcd", result: true },  ❶
  ]);
});

❶ 单个大的断言

这个测试在未来可能会如何变化?这里有一些它可能需要变化的原因:

  • results 数组的长度发生变化时

  • 当每个 result 对象增加或删除一个属性(即使测试不关心这些属性)

  • 当结果顺序发生变化时(即使它可能对当前测试不重要)

如果将来发生这些变化中的任何一项,但你的测试只是关注检查验证器的逻辑和其输出的结构,那么维护这个测试将会涉及很多痛苦。

我们可以通过验证对我们来说重要的部分来减少一些这种痛苦。

列表 8.15 忽略结果的模式

test("overspecify order but ignore schema", () => {
  const pv5 = 
    new PasswordVerifier5([(input) => input.includes("abc")]);

  const results = pv5.verify(["a", "ab", "abc", "abcd"]);

  expect(results.length).toBe(4);
  expect(results[0].result).toBe(false);
  expect(results[1].result).toBe(false);
  expect(results[2].result).toBe(true);
  expect(results[3].result).toBe(true);
});

而不是提供完整的预期输出,我们可以简单地断言输出中特定属性的值。然而,如果结果顺序发生变化,我们仍然会遇到麻烦。如果我们不关心顺序,我们可以简单地检查输出是否包含特定的结果,如下所示。

列表 8.16 忽略顺序和模式

test("ignore order and schema", () => {
  const pv5 = 
    new PasswordVerifier5([(input) => input.includes("abc")]);

  const results = pv5.verify(["a", "ab", "abc", "abcd"]);

  expect(results.length).toBe(4);
  expect(findResultFor("a")).toBe(false);
  expect(findResultFor("ab")).toBe(false);
  expect(findResultFor("abc")).toBe(true);
  expect(findResultFor("abcd")).toBe(true);
});

这里我们使用 findResultFor() 来找到给定输入的特定结果。现在结果的顺序可以改变,或添加额外的值,但我们的测试只有在计算真实或假结果发生变化时才会失败。

另一个人们倾向于重复的常见反模式是在单元的返回值或属性中针对硬编码的字符串进行断言,而只需要字符串的特定部分。问问自己,“我能否检查字符串包含某些内容,而不是等于某些内容?”以下是一个密码验证器,它给我们一条消息,描述在验证过程中违反了多少规则。

列表 8.17 返回字符串消息的验证器

export class PasswordVerifier6 {
  private _rules: ((input: string) => boolean)[];
  private _msg: string = "";

  constructor(rules: ((input) => boolean)[]) {
    this._rules = rules;
  }

  getMsg(): string {
    return this._msg;
  }

  verify(inputs: string[]): IResult[] {
    const allResults = 
      inputs.map((input) => this.checkSingleInput(input));
    this.setDescription(allResults);
    return allResults;
  }

  private setDescription(results: IResult[]) {
    const failed = results.filter((res) => !res.result);
    this._msg = `you have ${failed.length} failed rules.`;
  }

以下列表展示了两个使用 getMsg() 的测试。

列表 8.18 使用等式过度指定字符串

describe("verifier 6", () => {
  test("over specify string", () => {
    const pv5 = 
      new PasswordVerifier6([(input) => input.includes("abc")]);

    pv5.verify(["a", "ab", "abc", "abcd"]);

    const msg = pv5.getMsg();
    expect(msg).toBe("you have 2 failed rules.");   ❶
  });

  //Here's a better way to write this test
  test("more future proof string checking", () => {
    const pv5 = 
      new PasswordVerifier6([(input) => input.includes("abc")]);

    pv5.verify(["a", "ab", "abc", "abcd"]);

    const msg = pv5.getMsg();
    expect(msg).toMatch(/2 failed/);                ❷
  });
});

❶ 过于具体的字符串期望

❷ 更好地断言字符串的方法

第一个测试检查字符串是否与另一个字符串完全相等。这通常适得其反,因为字符串是一种用户界面。我们倾向于随着时间的推移对其进行轻微的修改和装饰。例如,我们关心字符串末尾是否有句号吗?我们的测试要求我们关心,但断言的核心是显示正确的数字(特别是由于字符串在不同计算机语言或文化中会发生变化,但数字通常保持不变)。

第二个测试简单地查找消息中的“2 failed”字符串。这使得测试更具未来性:字符串可能略有变化,但核心信息保持不变,而不需要我们修改测试。

摘要

  • 测试会随着被测试的系统而增长和变化。如果我们不关注可维护性,我们的测试可能需要我们进行很多修改,这可能不值得修改它们。我们可能最终会删除它们,并丢弃所有投入创建它们的辛勤工作。为了使测试在长期内有用,它们应该只因为真正关心的原因而失败。

  • 真正失败 是指测试失败是因为它发现了生产代码中的错误。假失败 是指测试因其他任何原因而失败。

  • 为了估计测试的可维护性,我们可以随着时间的推移测量错误测试失败的数量和每次失败的原因。

  • 测试可能由于多种原因而错误地失败:它与另一个测试冲突(在这种情况下,你应该只是移除它);生产代码 API 的变化(这可以通过使用工厂和辅助方法来缓解);其他测试的变化(这些测试应该相互解耦)。

  • 避免测试私有方法。私有方法是实现细节,由此产生的测试将会很脆弱。测试应该验证可观察的行为——对最终用户相关的行为。有时,需要测试私有方法可能是一个缺少抽象的迹象,这意味着该方法应该公开,甚至可以提取到一个单独的类中。

  • 保持测试 DRY。使用辅助方法来抽象安排和断言部分的非必要细节。这将简化你的测试,而不会使它们相互耦合。

  • 避免使用如beforeEach函数这样的设置方法。再次强调,使用辅助方法代替。另一个选择是参数化你的测试,并将beforeEach块的内容移动到测试的安排部分。

  • 避免过度指定。过度指定的例子包括断言被测试代码的私有状态、对存根的调用断言,或者在没有要求的情况下假设结果集合中元素的具体顺序或精确字符串匹配。

第四部分 设计与流程

这些最后一章涵盖了当你向现有组织或代码库引入单元测试时可能会遇到的问题和所需的技术。

在第九章中,我们将讨论测试的可读性。我们将讨论测试的命名约定和它们的输入值。我们还将涵盖测试结构化和编写更好的断言消息的最佳实践。

第十章解释了如何制定测试策略。我们将探讨在测试新功能时应优先考虑哪些测试级别,讨论测试级别中的常见反模式,并讨论测试配方策略。

在第十一章中,我们将处理在组织中实施单元测试的难题,并介绍一些可以使你的工作更轻松的技术。本章提供了在首次实施单元测试时常见的难题的答案。

在第十二章中,我们将探讨与遗留代码相关的一些常见问题,并检查一些与之工作的工具。

9 可读性

本章涵盖

  • 单元测试的命名规范

  • 编写可读的测试

没有可读性,你编写的测试对后来阅读它们的人来说几乎毫无意义。可读性是编写测试的人和几个月或几年后必须阅读它的可怜人之间的联系纽带。测试是你向项目的下一代程序员讲述的故事。它们允许开发者确切地看到应用程序由什么组成以及它从哪里开始。

这章主要确保在你之后的开发者能够维护你编写的生产代码和测试。他们需要理解他们在做什么以及他们应该在何处做。

可读性有几个方面:

  • 命名单元测试

  • 命名变量

  • 将断言与操作分离

  • 设置和拆除

让我们逐一分析。

9.1 命名单元测试

命名标准很重要,因为它们为你提供了舒适的规则和模板,概述了你应该解释关于测试的内容。无论我如何排序,或者我使用什么特定的框架或语言,我都试图确保这三个重要的信息点存在于测试的名称中或在测试存在的文件结构中:

  • 单位工作入口点(或正在测试的功能的名称)

  • 测试入口点的测试场景

  • 单位工作出口点的预期行为

入口点(或工作单元)的名称至关重要,这样你可以轻松理解正在测试的逻辑的起始范围。将此作为测试名称的第一部分也允许在测试文件中进行轻松导航和即写即完成的操作(如果您的 IDE 支持的话)。

测试的场景给出了名称的“with”部分:“当我调用入口点 X with一个 null 值时,它应该执行 Y。”

单位工作出口点的预期行为是测试在平实的英语中指定单位工作应该做什么或返回什么,或者根据当前场景它应该如何表现:“当我调用入口点 X with一个 null 值时,它应该从这个单位工作的出口点可见地执行 Y。”

这三个元素必须位于阅读测试的人的视线附近。有时它们都可以封装在测试函数的名称中,有时你可以通过嵌套的describe结构来包含它们。有时你可以简单地使用字符串描述作为参数或注释来表示测试。

以下列出了一些示例,所有示例都包含相同的信息,但布局不同。

列表 9.1 相同的信息,不同的变体

test('verifyPassword, with a failing rule, returns error based on rule.reason', () => { ... }

describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    it('returns error based on the rule.reason', () => { ... }

verifyPassword_withFailingRule_returnsErrorBasedonRuleReason()

你当然可以想出其他方法来组织这个结构。(谁说一定要用下划线?那只是我用来提醒自己和别人有三个方面信息的个人偏好。)需要记住的关键点是,如果你移除这些信息中的一个,你就是在迫使阅读测试的人阅读测试内部的代码来找出答案,浪费宝贵的时间。

下面的列表展示了缺少信息的测试示例。

列表 9.2 缺少信息的测试名称

test(failing rule, returns error based on rule.reason', () => { ... }      ❶

test('verifyPassword, returns error based on rule.reason', () => { ... }   ❷

test('verifyPassword, with a failing rule', () => { ... }                  ❸

❶ 要测试的是什么?

❷ 这应该在什么时候发生?

❸ 那接下来会发生什么?

你在可读性方面的主要目标是让下一个开发者从阅读测试代码的负担中解脱出来,以便理解测试在测试什么。

将所有这些信息包含在测试名称中的另一个很好的理由是,名称通常是自动化构建管道失败时唯一出现的东西。你会在构建失败的日志中看到失败的测试名称,但你看不到任何注释或测试代码。如果名称足够好,你可能不需要阅读测试代码或调试它们;你只需阅读失败的构建日志,就可能理解失败的原因。这可以节省宝贵的调试和阅读时间。

一个好的测试名称也有助于可执行文档的概念——如果你可以让一个刚加入团队的开发者阅读测试,以便他们可以理解特定组件或应用程序的工作方式,那么这是一个可读性的好迹象。如果仅从测试中无法理解应用程序或组件的行为,那么这可能是可读性的一个红旗。

9.2 魔法值和变量命名

你听说过“魔法值”这个术语吗?听起来很酷,但它恰恰相反。它应该真正被称为“巫术值”,以传达使用它们的负面影响。它们是什么?它们是硬编码的、未记录的或理解不佳的常量或变量。魔法一词的引用表明这些值是有效的,但你不知道为什么。

考虑以下测试。

列表 9.3 包含魔法值的测试

describe('password verifier', () => {
  test('on weekends, throws exceptions', () => {
))   ❶
      .toThrowError("It's the weekend!");
  });
});

❶ 魔法值

这个测试包含三个魔法值。一个没有编写测试且不了解被测试 API 的人能轻易理解 0 值的含义吗?[] 数组呢?那个函数的第一个参数看起来有点像密码,但甚至这一点也有一种神奇的感觉。让我们来讨论一下:

  • 0 可能意味着很多事。作为读者,我可能需要在代码中四处搜索,或者跳到被调用函数的签名中,才能理解这指定的是星期几。

  • [] 强迫我查看被调用函数的签名,以理解该函数期望一个密码验证规则数组,这意味着测试验证的是没有规则的用例。

  • jhGGu78!看起来是一个明显的密码值,但作为一个读者,我会有的一个大问题是,为什么是这个特定的值?这个特定密码值有什么重要之处?显然,使用这个值而不是其他任何值对这个测试来说很重要,因为它看起来如此具体。实际上并不是这样,但读者不会知道这一点。他们可能会在其他测试中使用这个密码值只是为了安全起见。魔法值往往会自行在测试中传播。

以下列表显示了具有固定魔法值的相同测试。

列表 9.4 修复魔法值

describe("verifier2 - dummy object", () => {
  test("on weekends, throws exceptions", () => {
    const SUNDAY = 0, NO_RULES = [];
    expect(() => verifyPassword2("anything", NO_RULES, SUNDAY))
      .toThrowError("It's the weekend!");
  });
});

通过将具有意义名称的变量中放入魔法值,我们可以消除人们在阅读我们的测试时可能会有的疑问。对于密码值,我决定简单地改变直接值来向读者解释这个测试中重要的事情。

变量名称和值同样重要的是向读者解释他们不应该关心的事情,就像它们是解释什么重要的一样。

9.3 将断言与操作分离

为了可读性和所有神圣的东西,避免在同一语句中编写断言和方法调用。以下列表显示了我是指什么。

列表 9.5 将断言与操作分离

expect(verifier.verify("any value")[0]).toContain("fake reason");   ❶

const result = verifier.verify("any value");                        ❷
expect(result[0]).toContain("fake reason");                         ❷

❶ 不好的例子

❷ 优秀的例子

看看这两个例子之间的区别?第一个例子由于行长度和操作和断言部分的嵌套,在真实测试的上下文中阅读和理解起来要困难得多。

如果你想要在调用后专注于结果值,那么调试第二个例子比第一个例子要容易得多。不要忽视这个小技巧。当你的测试没有让他们因为不理解而感到愚蠢时,他们会在你之后低声说一声小小的感谢。

9.4 设置和销毁

单元测试中的设置和销毁方法可能会被滥用到测试或设置和销毁方法本身变得难以阅读的程度。这种情况在设置方法中通常比在销毁方法中更糟糕。

以下列表显示了一种非常常见的滥用方式:使用设置(或beforeEach函数)来设置模拟或存根。

列表 9.6 使用设置(beforeEach)函数进行模拟设置

describe("password verifier", () => {
  let mockLog;
  beforeEach(() => {
    mockLog = Substitute.for<IComplicatedLogger>();         ❶
  });

  test("verify, with logger & passing, calls logger with PASS",() => {
    const verifier = new PasswordVerifier2([], mockLog);    ❷
    verifier.verify("anything");

    mockLog.received().info(                                ❷
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });
}); 

❶ 设置模拟

❷ 使用模拟

如果你在一个设置方法中设置模拟和存根,这意味着它们在实际测试中不会被设置。这反过来意味着,阅读你的测试的人可能甚至都没有意识到正在使用模拟对象,或者测试期望它们做什么。

列表 9.6 中的测试使用了mockLog变量,该变量在beforeEach函数(一个设置方法)中初始化。想象一下,如果你在文件中有数十个或更多的这些测试,设置函数位于文件的开头,而你却卡在阅读文件底部的测试。你遇到了mockLog变量,你必须开始提出问题,例如,“这个是如何初始化的?它在测试中会如何表现?”等等。

如果在同一个文件中的各种测试中使用了多个模拟和存根,可能会出现另一个问题,那就是设置函数变成了所有测试使用的各种状态的垃圾桶。这会变得一团糟,像是一锅混合了众多参数的大杂烩,有些参数被一个测试使用,而其他参数则被用在其他地方。管理和理解这样的设置变得困难。

直接在测试中初始化模拟对象及其所有期望,这样更易于阅读。以下列表是一个在每个测试中初始化模拟的示例。

列表 9.7 避免设置函数

describe("password verifier", () => {
  test("verify, with logger & passing,calls logger with PASS",() => {
    const mockLog = Substitute.for<IComplicatedLogger>();             ❶

    const verifier = new PasswordVerifier2([], mockLog);
    verifier.verify("anything");

    mockLog.received().info(
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });

❶ 在测试中初始化模拟

当我看这个测试时,一切都很清晰。我可以看到模拟何时被创建,它的行为,以及我需要知道的其他任何信息。

如果你担心可维护性,可以将模拟的创建重构为一个辅助函数,每个测试都会调用它。这样,你避免了通用的设置函数,而是从多个测试中调用相同的辅助函数。如下所示,你保持了可读性并获得了更多的可维护性。

列表 9.8 使用辅助函数

describe("password verifier", () => {
  test("verify, with logger & passing,calls logger with PASS",() => {
    const mockLog = makeMockLogger();                                 ❶

    const verifier = new PasswordVerifier2([], mockLog);
    verifier.verify("anything");

    mockLog.received().info(
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });

❶ 使用辅助函数初始化模拟

并且是的,如果你遵循这个逻辑,你会发现我对你在测试中没有任何设置函数感到非常满意。我经常编写没有设置函数的完整测试套件,而是从每个测试中调用辅助方法,为了维护性。这些测试仍然可读且易于维护。

摘要

  • 当命名一个测试时,包括正在测试的工作单元的名称、当前的测试场景以及工作单元的预期行为。

  • 不要在测试中留下魔法值。要么用有意义的变量将它们包裹起来,要么如果是一个字符串,将描述放入值本身。

  • 将断言与操作分开。合并两者缩短了代码,但会使代码理解难度显著增加。

  • 尽量不要使用任何测试设置(例如beforeEach方法)。引入辅助方法来简化测试的安排部分,并在每个测试中使用这些辅助方法。

10 开发测试策略

本章涵盖

  • 测试级别的优缺点

  • 测试级别中的常见反模式

  • 测试配方策略

  • 阻塞和非阻塞测试

  • 交付与发现管道

  • 测试并行化

单元测试只是你可以和应该编写的测试类型之一。在本章中,我们将讨论单元测试如何融入组织测试策略。一旦我们开始查看其他类型的测试,我们就开始提出一些真正重要的问题:

  • 我们想在哪个级别测试各种功能?(UI、后端、API、单元等)

  • 我们如何决定在哪个级别测试一个功能?我们在多个级别上多次测试它吗?

  • 我们应该有更多功能性的端到端测试还是更多单元测试?

  • 我们如何在不牺牲对测试的信任的情况下优化测试速度?

  • 谁应该编写每种类型的测试?

这些问题的答案,以及许多其他问题,就是我所说的测试策略

我们旅程的第一步是将测试策略的范围用测试类型来界定。

10.1 常见测试类型和级别

不同的行业可能会有不同的测试类型和级别。图 10.1,我们在第七章中首次讨论过,是一组相当通用的测试类型,我认为它适合 90%以上的我咨询的组织,如果不是更多。测试级别越高,它们使用的真实依赖项就越多,这让我们对整体系统的正确性更有信心。缺点是这种测试较慢且不稳定。

10-01

图 10.1 常见软件测试级别

很好的图表,但我们该如何使用它?我们在设计关于编写哪个测试的决策框架时使用它。有几个标准(使我们的工作更容易或更难的事情)我喜欢明确指出;这些帮助我决定使用哪种测试类型。

10.1.1 判断测试的标准

当我们面临超过两个选项可供选择时,我发现帮助我做出决定的最佳方法之一是确定我对当前问题的明显价值观。这些明显价值观是我们都可以基本达成共识的有用事物或我们在做出选择时应避免的事物。表 10.1 列出了我对测试的明显价值观。

表 10.1 通用测试评分卡

标准 评分尺度 备注
复杂性 1-5 编写、阅读或调试测试的复杂程度。越低越好。
不稳定性 1-5 测试失败的可能性有多大,因为它是由于它无法控制的事物——其他团队的代码、网络、数据库、配置等等。越低越好。
通过时的信心 1-5 当测试通过时,我们在心中和心中产生的信心有多大。越高越好。
可维护性 1-5 测试需要更改的频率以及更改的难易程度。越高越好。
执行速度 1-5 测试完成得多快?越高越好。

所有值都是从 1 到 5 进行缩放的。正如您将看到的,图 10.1 中的每个级别在这些标准中都有优点和缺点。

10.1.2 单元测试和组件测试

单元测试和组件测试是我们在本书中迄今为止讨论的测试类型。它们都属于同一类别,唯一的区别是组件测试可能包含更多的工作单元中的函数、类或组件。换句话说,组件测试在入口点和出口点之间包含更多的“内容”。

这里有两个测试示例来说明差异:

  • 测试 A——一个内存中自定义 UI 按钮对象的单元测试。您可以实例化它,点击它,并看到它触发了某种形式的点击事件。

  • 测试 B——一个组件测试,它实例化了一个高级别表单组件,并将按钮作为其结构的一部分。该测试验证了高级别表单,其中按钮在高级别场景中扮演着小角色。

两个测试仍然是单元测试,在内存中执行,并且我们对所有使用的内容都有完全控制权;没有对文件、数据库、网络、配置或其他我们不控制的东西的依赖。测试 A 是一个低级别的单元测试,而测试 B 是一个组件测试,或者是一个高级别的单元测试。

需要进行这种区分的原因是,我经常被问及如何称呼具有不同抽象级别的测试。答案是,一个测试是否属于单元/组件测试类别,取决于它是否有或没有依赖关系,而不是它使用的抽象级别。表 10.2 显示了单元/组件测试层的评分卡。

表 10.2 单元/组件测试评分卡

复杂性 1/5 由于范围较小以及我们可以控制测试中的所有内容,这些测试类型是最不复杂的。
不稳定性 1/5 由于我们可以在测试中控制所有内容,这些测试类型中不稳定性最低。
通过时的信心 1/5 当单元测试通过时,感觉很好,但我们并不真正确信我们的应用程序是否工作正常。我们只知道其中一小部分是正常的。
可维护性 5/5 这些测试类型中,这些是最容易维护的,因为它们相对简单,易于阅读和推理。
执行速度 5/5 这些是所有测试类型中最快的,因为所有内容都在内存中运行,没有任何对文件、网络或数据库的硬依赖。

10.1.3 集成测试

集成测试几乎与常规单元测试完全相同,但其中一些依赖项没有被模拟。例如,我们可能使用真实的配置、真实的数据库、真实的文件系统,或者三者都有。但为了调用测试,我们仍然在内存中从我们的生产代码中实例化一个对象,并直接在该对象上调用入口点函数。表 10.3 显示了集成测试的评分卡。

表 10.3 集成测试评分卡

复杂性 2/5 这些测试的复杂度略高或非常高,这取决于我们在测试中未模拟的依赖项数量。
稳定性 2-3/5 这些测试的不可靠性略高或很高,这取决于我们使用的真实依赖项数量。
通过时的信心 2-3/5 当集成测试通过时感觉会好很多,因为我们正在验证代码使用了我们不控制的东西,比如数据库或配置文件。
可维护性 3-4/5 由于依赖关系,这些测试比单元测试更复杂。
执行速度 3-4/5 由于依赖于文件系统、网络、数据库或线程,这些测试的执行速度略慢或慢得多。

10.1.4 API 测试

在之前的测试低级别中,我们不需要部署待测试的应用程序或使其正常运行来测试它。在 API 测试级别,我们最终需要至少部分部署待测试的应用程序并通过网络调用它。与单元、组件和集成测试不同,这些测试可以归类为内存测试,API 测试是进程外测试。我们不再直接在内存中实例化待测试的单元。这意味着我们在混合中添加了一个新的依赖项:网络,以及某些网络服务的部署。表 10.4 显示了 API 测试的评分卡。

表 10.4 API 测试评分卡

复杂性 3/5 这些测试的复杂度略高或非常高,这取决于部署复杂性、配置和所需的 API 设置。有时我们需要在测试中包含 API 模式,这需要额外的工作和思考。
稳定性 3-4/5 网络增加了更多的不可靠性。
通过时的信心 3-4/5 当 API 测试通过时感觉会更好。我们可以相信,在部署后,其他人可以自信地调用我们的 API。
可维护性 2-3/5 网络增加了更多的设置复杂性,并且在更改测试或添加/更改 API 时需要更多的关注。
执行速度 2-3/5 网络显著减慢了测试速度。

10.1.5 E2E/UI 隔离测试

在隔离端到端(E2E)和用户界面(UI)测试的层面上,我们是从用户的角度测试我们的应用程序。我使用“隔离”一词来指明我们只测试我们自己的应用程序或服务,而不部署任何依赖的应用程序或服务,这些应用程序或服务可能是我们的应用程序所需要的。这样的测试会模拟第三方认证机制,需要在同一服务器上部署的其他应用程序的 API,以及任何不是待测试主应用程序特定部分的代码(包括来自同一组织其他部门的 app——这些也会被模拟)。

表 10.5 显示了 E2E/UI 隔离测试的评分卡。

表 10.5 E2E/UI 隔离测试评分卡

复杂度 4/5 这些测试比之前的测试要复杂得多,因为我们正在处理用户流程、基于 UI 的变化,以及捕获或抓取 UI 以进行集成和断言。等待和超时现象普遍存在。
不稳定性 4/5 由于涉及到的许多依赖项,测试可能会减慢速度、超时或无法工作。
通过时的信心 4/5 当这种类型的测试通过时,这是一个巨大的安慰。我们在我们的应用程序中获得了很大的信心。
可维护性 1-2/5 更多的依赖项增加了设置复杂性,并且在更改测试或添加或更改工作流程时需要更多的关注。测试很长,通常有多个步骤。
执行速度 1-2/5 在导航用户界面时,这些测试可能会非常慢,有时包括登录、缓存、多页导航等。

10.1.6 端到端/UI 系统测试

在系统端到端和 UI 测试级别,没有任何是假的。这是我们能够达到的生产部署的近似:所有依赖的应用程序和服务都是真实的,但它们可能被配置得不同,以允许我们的测试场景。表 10.6 显示了端到端/UI 系统测试的评分表。

表 10.6 端到端/UI 系统测试评分表

复杂度 5/5 由于依赖项的数量,这些是最复杂、设置和编写难度最大的测试。
不稳定性 5/5 这些测试可能因为成千上万的不同原因而失败,并且通常有多种原因。
通过时的信心 5/5 由于测试执行时测试了所有代码,这些测试给我们带来了最高的信心。
可维护性 1/5 由于许多依赖项和长工作流程,这些测试难以维护。
执行速度 1/5 这些测试非常慢,因为它们使用了 UI 和真实依赖项。单个测试可能需要几分钟到几小时。

10.2 测试级别的反模式

测试级别的反模式不是技术性的,而是组织性的。你可能亲眼见过。作为一个顾问,我可以告诉你,它们非常普遍。

10.2.1 仅端到端测试的反模式

组织通常会采用的一种非常常见的策略是主要使用,如果不是唯一使用,端到端测试(包括隔离和系统测试)。图 10.2 显示了在测试级别和类型图中的这种样子。

10-02

图 10.2 仅端到端测试的反模式

为什么这是一个反模式?在这个级别的测试非常慢,难以维护,难以调试,并且非常不稳定。这些成本保持不变,而每个新的端到端测试带来的价值却在减少。

端到端测试的边际效益递减

你写的第一个端到端测试会给你带来最大的信心,因为该场景包含了大量其他代码路径,也因为胶水——协调应用程序与其他系统之间工作的代码——作为测试的一部分被调用。

但第二个端到端测试怎么办?它通常是对第一个测试的变体,这意味着它可能只带来一小部分相同的价值。也许组合框和其他 UI 元素有所不同,但所有依赖项,如数据库和第三方系统,都保持不变。

从第二个端到端测试中获得的额外信心也仅仅是第一个端到端测试中获得额外信心的一小部分。然而,调试、更改、阅读和运行该测试的成本并不是一小部分;它基本上与之前的测试相同。你正在为非常小的额外信心承担大量的额外工作,这就是为什么我喜欢说端到端测试的回报迅速递减。

如果我想对第一个测试进行变化,那么在比上一个测试更低的级别进行测试会更为实际。我已经知道大多数,如果不是所有,层之间的粘合剂都工作正常,从第一个测试开始。如果我能证明下一个场景在较低级别,并且只需支付一小部分费用就能获得几乎相同的信心,那么就没有必要为另一个端到端测试支付税收。

构建 whisperer

使用端到端测试,我们不仅得到了递减的回报,还在组织中创造了一个新的瓶颈。因为高级测试通常不可靠,它们会因为许多不同的原因而失败,其中一些与测试本身无关。然后你需要组织中的特殊人员(通常是质量保证负责人)坐下来分析许多失败的测试,并追查原因,确定它是否真的是一个问题或是一个小问题。

我把这些可怜的人称为 构建 whisperer。当构建是红色的时候,这通常是大多数情况,构建 whisperer 是必须进来解析数据,并在经过数小时检查后,有意识地表示,“是的,看起来是红色的,但实际上是绿色的。”

通常,组织会将构建 whisperer 推向角落,要求他们声称构建是绿色的,因为“我们必须把这个发布版本推出门。”他们是发布的守门人,这是一份没有回报、压力很大、通常手动且令人沮丧的工作。whisperer 通常在一年或两年内就会筋疲力尽,然后被咀嚼并吐出到下一个组织,在那里他们再次做同样的没有回报的工作。你经常会看到当存在许多高级端到端测试的反模式时,就会有构建 whisperer。

避免构建 whisperer

有一种方法可以解决这个混乱,那就是创建和培养强大的、自动化的测试流水线,这样它可以自动判断构建是否为绿色,即使你有不可靠的测试。Netflix 公开博客介绍了他们自己的工具,用于统计测量构建在野外的表现,以便可以自动批准全面发布部署(http://mng.bz/BAA1)。这是可行的,但需要时间和文化来实现这样的流水线。我在我的博客 https://pipelinedriven.org 上写了更多关于这些类型流水线的内容。

一种“扔过墙”的心态

只拥有端到端测试还会伤害组织的另一个原因是,负责维护和监控这些测试的人是 QA 部门的人。这意味着组织的开发者可能不关心甚至不知道这些构建的结果,他们也没有投入精力去修复或关心这些测试。他们不拥有这些测试。

这种“扔过墙”的心态会导致很多沟通和质量问题,因为组织的某一部分没有与其行动的后果相联系,而另一部分则遭受了后果却无法控制问题的源头。难道在许多组织中,开发人员和 QA 人员不和睦不是令人惊讶的吗?围绕他们的系统通常被设计成让他们成为死敌而不是合作者。

当这种反模式发生时

这些是我看到这种情况发生的一些原因:

  • 职责分离—在许多组织中,QA 部门和开发部门有独立的管道(自动构建作业和仪表板)。当一个 QA 部门有自己的管道时,它很可能会编写更多同类型的测试。此外,QA 部门倾向于只编写特定类型的测试——他们习惯于编写并且期望编写的测试(有时基于公司政策)。

  • “如果它有效,就不要改变它”的心态—一个团队可能从端到端测试开始,发现他们喜欢这些结果。他们继续以同样的方式添加所有新的测试,因为这正是他们所知道的,并且已经证明是有用的。当运行测试所需的时间变得过长时,改变方向已经太晚了(这与下一个点相关)。

  • 沉没成本谬误—“我们有很多这类测试,如果我们改变它们或用低级测试替换它们,那就意味着我们在移除的测试上浪费了所有的时间和精力。”这是一个谬误,因为维护、调试和理解测试失败需要大量的人力时间。实际上,删除这些测试(只保留一些基本场景)并找回这些时间成本更低。

你应该完全避免端到端测试吗?

不,我们无法避免端到端测试。它们提供的好处之一是信心,即应用程序可以正常工作。与单元测试相比,这是一个完全不同的信心水平,因为它们从用户的角度测试了整个系统的集成,包括所有子系统及其组件。当它们通过时,你会感到巨大的解脱,因为你期望用户遇到的主要场景实际上是可以工作的。

所以,不要完全避免它们。相反,我强烈建议最小化端到端测试的数量。我们将在第 10.3.3 节中讨论这个最小值。

10.2.2 仅低级测试的反模式

只有端到端测试的反面是只有低级测试。单元测试可以提供快速的反馈,但它们并不能提供足够的信心,以确保你的应用程序作为一个单一集成的单元正常工作(见图 10.3)。

10-03

图 10.3 只有低级测试的反模式

在这个反模式中,组织的自动化测试主要是或完全是低级测试,如单元测试或组件测试。可能会有一些集成测试的迹象,但看不到端到端测试。

这种做法的最大问题是,当这些类型的测试通过时,你获得的信心水平远远不足以让你确信应用程序是正常工作的。这意味着人们会运行测试,然后继续进行手动调试和测试,以获得发布所需最终信心。除非你发布的代码库是打算以你的单元测试使用的方式使用的,否则这还不够。是的,测试会很快运行,但你仍然会花费大量时间进行手动测试和验证。

这种反模式通常发生在你的开发者只习惯于编写低级测试,如果他们不习惯编写高级测试,或者如果他们期望 QA 人员编写这些类型的测试时。

这是否意味着你应该避免单元测试?显然不是。但我强烈建议你不仅要单元测试,还要有高级测试。我们将在第 10.3 节中讨论这个建议。

10.2.3 分离的低级和高级测试

这种模式一开始可能看起来很健康,但实际上并不是。它可能看起来有点像图 10.4。

10-04

图 10.4 分离的低级和高级测试

是的,你想要既有低级测试(为了速度)又有高级测试(为了信心)。但当你在组织中看到这种情况时,你可能会遇到以下一种或多种反行为:

  • 许多测试在多个级别上重复。

  • 编写低级测试的人与编写高级测试的人不是同一批人。这意味着他们不关心彼此的测试结果,并且他们可能会设置不同的管道来执行不同的测试类型。当一个管道变红时,另一组人可能甚至不知道也不关心那些测试失败了。

  • 我们遭受了两个世界的最坏情况:在顶层,我们遭受了长测试时间、难以维护、构建低语者和易变性的困扰;在底层,我们遭受了缺乏信心。由于通常缺乏沟通,我们无法从低级测试中获得速度优势,因为它们无论如何都会在顶层重复。我们也无法获得顶层信心,因为如此大量的测试如此不稳定。

这种模式通常发生在我们拥有不同的目标和指标、不同的工作、管道、权限,甚至代码存储库的独立测试和开发组织时。公司越大,这种情况发生的可能性就越大。

10.3 测试食谱作为策略

我提出的在组织使用的测试类型之间实现平衡的策略是使用测试食谱。想法是制定一个非正式的计划,说明特定功能将如何进行测试。这个计划不仅应该包括主要场景(也称为成功路径),还应该包括所有重要的变体(也称为边缘情况),如图 10.5 所示。一个清晰定义的测试食谱可以清楚地展示每个场景适合的测试级别。

10-05

图 10.5 测试食谱是一个测试计划,概述了特定功能应该在哪个级别进行测试。

10.3.1 如何编写测试食谱

最好至少有两个人创建测试食谱——希望一个是开发者的视角,另一个是测试者的视角。如果没有测试部门,两个开发者或者一个开发者和一个高级开发者就足够了。将每个场景映射到测试层次结构中的特定级别可能是一个非常主观的任务,所以有两双眼睛可以帮助彼此检查隐含的假设。

这些食谱本身可以存储为 TODO 列表中的额外文本,或者作为任务跟踪板上的功能故事的组成部分。你不需要一个单独的工具来规划测试。

创建测试食谱的最佳时机是在开始开发功能之前。这样,测试食谱就成为了功能“完成”定义的一部分,这意味着功能在没有通过完整的测试食谱之前是不完整的。

当然,随着时间的推移,食谱可能会发生变化。团队可以从中添加或删除场景。食谱不是一个僵化的工件,而是一个持续进行的工作,就像软件开发中的其他一切一样。

测试食谱代表了将给其创作者提供“相当好的信心”的功能是否正常工作的场景列表。作为一个经验法则,我喜欢在测试级别之间保持 1 到 5 或 1 到 10 的比例。对于任何高级别的端到端测试,我可能会有 5 个低级别的测试。或者,如果你从底部向上思考,比如说你有 100 个单元测试。你通常不需要超过 10 个集成测试和 1 个端到端测试。

尽管如此,不要将测试配方视为正式的东西。测试配方不是一项约束性的承诺,也不是测试计划软件中的测试用例列表。不要将其用作公共报告、用户故事或对利益相关者的任何其他承诺。本质上,配方是一个简单的 5 到 20 行的文本列表,详细说明了要自动测试的场景及其级别。这个列表可以更改、添加或删除。把它当作一个注释。我通常喜欢直接把它放在 Jira 或我使用的任何程序中的用户故事或功能中。

这里是一个例子,看看它可能是什么样子:

*User profile feature testing recipe*

E2E - Login, go to profile screen, update email, log out, log in with new email, verify profile screen updated

API - Call UpdateProfile API with more complicated data
Unit test - Check profile update logic with bad email
Unit test - Profile update logic with same email
Unit test - Profile serialization/deserialization

10.3.2 我什么时候编写和使用测试配方?

在你开始编码一个功能或用户故事之前,和另一个人坐下来,尝试想出各种要测试的场景。讨论这个场景应该在哪个层面上进行最佳测试。这次会议通常不会超过 5 到 15 分钟,之后就开始编码,包括编写测试。(如果你在做 TDD,你会从测试开始。)

在有自动化或 QA 角色的组织中,开发者将编写低级测试,QA 将专注于编写高级测试,而功能编码正在进行时。这两个人同时工作。一个人不会等待另一个人完成工作才开始编写测试。

如果你正在使用功能开关,它们也应该作为测试的一部分进行检查,这样如果功能关闭,其测试将不会运行。

10.3.3 测试配方的规则

在有自动化或 QA 角色的组织中,开发者将编写低级测试,QA 将专注于编写高级测试,而功能编码正在进行时。这两个人同时工作。一个人不会等待另一个人完成工作才开始编写测试。

  • 更快—优先编写低级测试,除非高级测试是你获得对功能工作信心唯一方式。

  • 信心—当你能对自己说,“如果所有这些测试都通过了,我会对这个功能的工作感到相当满意。”如果你不能这么说,就编写更多的场景,让你能这么说。

  • 修订—在编码过程中,你可以自由地添加或删除列表中的测试。只需确保通知你合作的配方中的人即可。

  • 及时—在你知道谁将要编码之前,开始编码之前编写这个配方。

  • 结对编程—如果可能的话,不要独自编写。人们思考的方式不同,讨论场景并从彼此那里学习测试想法和心态是很重要的。

  • 不要从其他功能重复—如果这个场景已经被现有的测试覆盖(可能是一个来自先前功能的端到端测试),在那个层面上重复这个场景就没有必要了。

  • 不要重复其他层级的代码—尽量不在多个层面上重复相同的场景。如果你在端到端层面上检查成功的登录,低级测试应该只检查该场景的变体(使用不同提供者的登录、失败的登录结果等)。

  • 更多,更快—一个很好的经验法则是最终在级别之间达到至少一比五的比例(对于一个端到端测试,你可能会有五个或更多的低级别测试)。

  • 实用主义—对于给定的功能,没有必要在每个级别都编写测试。有些功能或用户故事可能只需要单元测试。其他可能只需要 API 或端到端测试。基本思想是,如果配方中的所有场景都通过了,无论它们在哪个级别进行测试,你都应该感到自信。如果不是这样,将场景移动到不同的级别,直到你感到更有信心,同时不要牺牲太多的速度或维护负担。

通过遵循这些规则,你将获得快速反馈的好处,因为大部分测试将是低级别的,同时不会牺牲信心,因为少数最重要的场景仍然由高级测试覆盖。测试配方方法还允许你通过在主场景以下级别定位场景变体来避免测试之间的大多数重复。最后,如果 QA 人员也参与编写测试配方,你将在组织内部形成一个新的沟通渠道,这有助于提高对软件项目的相互理解。

10.4 管理交付管道

性能测试?安全测试?负载测试?还有许多其他可能需要花费很长时间运行的测试,我们应该在哪里和什么时候运行它们?它们属于哪一层?它们应该成为我们自动化流程的一部分吗?

许多组织将这些测试作为集成自动化管道的一部分运行,该管道为每个发布或拉取请求运行。然而,这会导致反馈延迟巨大,而且反馈通常是“失败”,尽管对于这些类型的测试,失败并不是发布所必需的。

我们可以将这些测试类型分为两大类:

  • 交付阻止测试—这些是提供即将发布和部署的更改是否可行的测试。单元测试、端到端测试、系统测试和安全测试都属于这一类别。它们的反馈是二元的:要么通过并宣布更改没有引入任何错误,要么失败并指示在发布之前需要修复代码。

  • 值得了解的测试—这些是为了发现和持续监控关键性能指标(KPI)而创建的测试。例如,包括代码分析和复杂性扫描、高负载性能测试以及其他提供非二进制反馈的长时间运行的非功能性测试。如果这些测试失败,我们可能会将新的工作项添加到我们的下一个迭代中,但我们仍然可以发布我们的软件。

10.4.1 交付与发现管道

我们不希望我们的“值得了解”的测试从我们的交付过程中夺取宝贵的反馈时间,因此我们还将有两种类型的管道:

  • 交付管道——用于阻止交付的测试。当管道为绿色时,我们应该有信心可以自动将代码发布到生产环境。这个管道中的测试应该提供相对快速的反馈。

  • 发现管道——用于应知应会的测试。这个管道与交付管道并行运行,但持续进行,并且不被视为发布标准。由于不需要等待其反馈,这个管道中的测试可以持续很长时间。如果发现错误,它们可能会成为团队在下个冲刺中的新工作项,但发布不会受阻。

图 10.6 展示了这两种管道的功能。

10-06

图 10.6 交付与发现管道

交付管道的目的在于提供一个通过/不通过检查,如果一切看起来都是绿色的,甚至可能部署我们的代码到生产环境。发现管道的目的是为团队提供重构目标,例如处理变得过于复杂的代码复杂性。它还可以显示这些重构努力是否随着时间的推移而有效。发现管道除了运行专门的测试或分析代码及其各种 KPI 指标之外,不部署任何内容。它以仪表板上的数字结束。

速度是让团队更加投入的一个大因素,将测试分为发现和交付管道是你要保留在武器库中的另一种技术。

10.4.2 测试层并行化

由于快速反馈非常重要,你可以在许多场景中采用并应该采用的一种常见模式是并行运行不同的测试层以加快管道反馈,如图 10.7 所示。你甚至可以使用在测试结束时动态创建和销毁的并行环境。

10-07

图 10.7 为了加快交付,你可以并行运行管道,甚至管道中的阶段。

这种方法从能够访问动态环境中受益很大。把钱花在环境和自动并行测试上几乎总是比花钱雇佣更多的人做更多的手动测试,或者简单地让人们等待更长的时间以获得反馈(因为环境正在被使用)要有效得多。

手动测试是不可持续的,因为这种手动工作只会随着时间的推移而增加,变得越来越脆弱和容易出错。同时,简单地等待更长时间的管道反馈结果会导致每个人大量的时间浪费。等待时间乘以等待的人数和每天的构建次数,结果是一个月投资可能比投资动态环境和自动化要大得多。拿一个 Excel 文件,向你的经理展示一个简单的公式来获取那个预算。

你不仅可以并行化管道内的阶段;你还可以进一步并行运行单个测试。例如,如果你遇到了大量 E2E 测试,你可以将它们拆分成并行测试套件。这将从你的反馈循环中节省大量时间。

不要进行夜间构建

最好在每次代码提交后运行你的交付管道,而不是在特定时间。每次代码更改时运行测试,比仅仅积累前一天所有更改的粗略夜间构建提供更细粒度和更快的反馈。但如果由于某种原因,你绝对需要在规定时间内运行管道,至少要持续运行它们,而不是每天只运行一次。

如果你的交付管道构建过程耗时较长,不要等待魔法触发器或安排来运行它。想象一下,作为一个开发者,你需要等到明天才能知道你是否破坏了某些东西。随着测试的持续运行,你仍然需要等待,但至少只需要几个小时而不是整整一天。这不是更有效率吗?

此外,不要仅在需要时运行构建。如果假设自上次构建以来有代码更改,那么在完成上一个构建后自动运行构建将使反馈循环更快。

摘要

  • 测试有多个层级:内存中运行的单元测试、组件测试和集成测试;以及运行在进程外的 API、隔离端到端(E2E)和系统 E2E 测试。

  • 每个测试都可以通过五个标准来评判:复杂性、易出错性、通过时的信心、可维护性和执行速度。

  • 单元和组件测试在可维护性、执行速度、缺乏复杂性和易出错性方面表现最佳,但在提供信心方面表现最差。集成和 API 测试在信心与其他指标之间的权衡中处于中间位置。端到端测试与单元测试采取相反的方法:它们提供最佳的信心,但代价是可维护性、速度、复杂性和易出错性。

  • 端到端测试唯一反模式是指你的构建过程仅包含端到端测试。每个额外端到端测试的边际价值很低,而所有测试的维护成本是相同的。如果你只有少数几个端到端测试覆盖最重要的功能,你将获得最大的努力回报。

  • 仅低级测试反模式是指你的构建过程仅包含单元和组件测试。低级测试无法提供足够的信心来确保整体功能正常工作,它们必须通过更高层级的测试来补充。

  • 低级与高级测试脱节是一种反模式,因为这强烈表明你的测试是由两组不同的人编写的,他们之间没有沟通。这样的测试往往相互重复,并且维护成本很高。

  • 测试配方是一份简单的 5 到 20 行的文本列表,详细说明应以自动化方式测试哪些简单场景以及测试的级别。测试配方应让您有信心,如果所有概述的测试都通过,则功能按预期工作。

  • 将构建管道拆分为交付发现管道。交付管道应用于阻止交付的测试,如果测试失败,将停止测试代码的交付。发现管道用于需要了解的测试,并且与交付管道并行运行。

  • 您不仅可以并行化管道,还可以并行化管道内的阶段,甚至阶段内的测试组。

11 将单元测试集成到组织中

本章涵盖

  • 成为变革推动者

  • 从上到下或从下到上实施变革

  • 准备回答关于单元测试的棘手问题

作为一名顾问,我帮助了几家大公司和中小企业将持续交付流程和各种工程实践,如测试驱动开发和单元测试,整合到他们的组织文化中。有时这会失败,但那些成功的企业有几个共同点。在任何类型的组织中,改变人们的习惯更多的是心理上的,而不是技术上的。人们不喜欢改变,改变通常伴随着大量的 FUD(恐惧、不确定性和怀疑)。对大多数人来说,这不会像散步一样轻松,正如你将在本章中看到的。

11.1 成为变革推动者的步骤

如果你打算成为你组织中的变革推动者,你应该首先接受这个角色。人们会把你视为负责(有时是问责)正在发生的事情的人,无论你是否愿意,隐藏是没有用的。事实上,隐藏可能会导致事情变得非常糟糕。

当你开始实施或推动变革时,人们会开始询问与他们关心的问题相关的棘手问题。这将“浪费”多少时间?这对作为 QA 工程师的我意味着什么?我们如何知道它有效?准备好回答。最常见的问答讨论在第 11.5 节中。你会发现,在你开始做出改变之前说服组织内部的人会极大地帮助你做出艰难的决定和回答这些问题。

最后,有人必须留在舵手的位置上,确保变革不会因为缺乏动力而失败。那个人就是你。有一些方法可以保持事物的活力,你将在下一节中看到。

11.1.1 准备回答棘手的问题

做好研究。阅读本章末尾的问题和答案,查看相关资源。阅读论坛、邮件列表和博客,并与你的同行咨询。如果你能回答你自己的棘手问题,那么你很可能也能回答别人的。

11.1.2 说服内部人士:支持者和阻挠者

在组织中,很少有事情能让你感到像反对潮流的决定那样孤独。如果你是唯一一个认为你所做的事情是个好主意的人,那么很少有人会努力去实施你所倡导的事情。考虑一下谁可以帮助或阻碍你的努力:支持者和阻挠者。

支持者

当你开始推动变革时,确定你认为最有可能帮助你的人。他们将成为你的支持者。他们通常是早期采用者,或者足够开放心态去尝试你所倡导的事情的人。他们可能已经半信半疑,但正在寻找开始变革的动力。他们甚至可能已经尝试过并失败了。

在其他人之前接近他们,并询问他们对你要做的事情的看法。他们可能会告诉你一些你没有考虑过的事情,包括

  • 可能适合开始的团队

  • 人们更容易接受这种变化的地方

  • 在你的追求中要注意什么(和谁)

通过接近他们,你正在帮助确保他们是这个过程的一部分。感觉自己是过程一部分的人通常会尽力让它成功。让他们成为你的支持者:询问他们是否可以帮助你,并成为人们可以就问题来咨询的人。为他们准备这样的情况。

阻碍者

接下来,确定阻碍者。这些是在组织中最有可能抵制你所做改变的的人。例如,一个经理可能会反对添加单元测试,声称这将增加开发时间,并增加需要维护的代码量。通过让他们(至少是那些愿意并且能够的)在过程中扮演积极角色,使他们成为过程的参与者而不是抵制者。

人们可能抵制改变的原因多种多样。一些可能的反对意见的答案在 11.4 节关于影响力因素中有所涉及。有些人会担心工作安全,而有些人可能只是对目前的情况感到过于舒适。我发现,详细说明潜在的阻碍者可能做得更好的事情通常不是建设性的,因为这是我艰难学到的。人们不喜欢被告知他们的孩子长得丑。

相反,让阻碍者通过负责定义单元测试的编码标准,例如,或者通过每隔一天与同事进行代码和审查来帮助你在这个过程中。或者让他们成为选择课程材料或外部顾问的团队的一部分。你将赋予他们一个新的责任,这将帮助他们感到在组织中受到信赖和重要。他们需要成为变革的一部分,否则他们几乎肯定会破坏它。

11.1.3 确定可能的起点

确定在组织中你可以从哪里开始实施改变。大多数成功的实施都采取稳定的路线。从一个小型团队的试点项目开始,看看会发生什么。如果一切顺利,再转向其他团队和其他项目。

这里有一些可以帮助你的建议:

  • 选择较小的团队。

  • 创建子团队。

  • 考虑项目的可行性。

  • 使用代码和审查作为教学工具。

这些建议可以在一个主要敌对的环境中带你走得很远。

选择较小的团队

确定可能开始的团队通常很容易。你通常会希望有一个小团队在低知名度项目上工作,风险较低。如果风险很小,就更容易说服人们尝试你提出的改变。

一个需要注意的问题是,团队需要有一些愿意改变工作方式和学习新技能的成员。具有讽刺意味的是,团队中经验较少的人通常最有可能接受改变,而经验较多的人往往更固守自己的做事方式。如果你能找到一个愿意改变且包括经验较少的开发者的有经验的领导者团队,那么这个团队很可能不会提出太多反对意见。去到团队中询问他们对于进行试点项目的看法。他们会告诉你这是否(或不是)一个合适的开始地点。

创建子团队

另一个可能的试点测试候选方案是在现有团队内组建一个子团队。几乎每个团队都会有一个需要维护的“黑洞”组件,虽然它做很多事情都做得很好,但也存在许多错误。为这样的组件添加功能是一项艰巨的任务,这种痛苦可以促使人们尝试试点项目。

考虑项目的可行性

对于试点项目,确保你不会超出自己的能力范围。管理更困难的项目需要更多的经验,因此你可能想要至少有两个选择——一个复杂的项目和一个简单的项目——这样你就可以在它们之间进行选择。

将代码和测试审查作为教学工具使用

如果你是一个小型团队(最多八人)的技术负责人,那么最好的教学方法之一就是实施包括测试审查在内的代码审查。想法是,当你审查他人的代码和测试时,你教会他们你在测试中寻找的内容以及你思考编写测试或采用 TDD 的方式。以下是一些建议:

  • 进行面对面审查,而不是通过远程软件。个人联系可以让你们之间通过非言语方式传递更多信息,因此学习效果更好、更快。

  • 在最初的几周内,审查所有提交的代码行。这将帮助你避免“我们以为这段代码不需要审查”的问题。

  • 在你的代码审查中增加第三个人——他将在旁边观察你如何审查代码。这将使他们能够后来自己进行代码审查并教授他人,这样你就不必成为团队中唯一能够进行审查的人的瓶颈。想法是培养他人进行代码审查的能力并承担更多的责任。

如果你想了解更多关于这种技术的信息,我在我的技术领导者博客中对此进行了讨论:“一个好的代码审查应该是什么样的?”请参阅5whys.com/blog/what-should-a-good-code-review-look-and-feel-like.html

11.2 成功的 11 种方法

一个组织或团队可以开始改变流程的主要方式有两种:自下而上或自上而下(有时两者兼而有之)。这两种方式非常不同,正如你将看到的,它们中的任何一种都可能适合你的团队或公司。没有一种绝对正确的方法。

在你继续前进的过程中,你需要学会如何说服管理层,你的努力也应该是他们的努力,或者何时引入外部人员来帮助会更为明智。使进展可见是很重要的,同样重要的是设定可以衡量的明确目标。识别和避免障碍也应该在你的清单上。有许多战斗可以打,你需要选择正确的战斗。

11.2.1 游击战式实施(自下而上)

游击战式实施完全关乎从一个团队开始,取得成果,然后才说服其他人这些实践是值得的。通常,游击战式实施的推动力是一个厌倦了按传统方式做事的团队。他们着手以不同的方式做事;他们自学并促成改变。当团队展示出成果时,组织中的其他人可能会决定在自己的团队中开始实施类似的变化。

在某些情况下,游击战式实施是首先由开发者采用,然后由管理层采用的过程。在其他时候,它是由开发者首先倡导,然后由管理层采用的过程。区别在于你可以秘密地完成第一个,而上级并不知道。后者是与管理层一起完成的。取决于你决定哪种方法更有效。有时改变事物的唯一方法是通过秘密行动。如果可能的话,避免这样做,但如果没有其他方法,而且你确信改变是必要的,你就可以直接这样做。

不要把这当作一个建议去做一个限制你职业生涯的举动。开发者经常在没有许可的情况下做事:调试代码、阅读电子邮件、编写代码注释、创建流程图等等。这些都是开发者作为日常工作的一部分所做的事情。单元测试也是如此。大多数开发者已经编写了某种类型的测试(自动化的或非自动化的)。想法是将花在测试上的时间重新分配到长期会带来好处的事情上。

11.2.2 说服管理层(自上而下)

自上而下的转变通常以两种方式之一开始。一位经理或开发者会启动这个过程,并逐步引导整个组织朝着这个方向前进。或者,一位中层经理可能会看到一场演示,阅读一本书(比如这本书),或者与同事讨论他们对工作方式具体改变的益处。这样的经理通常会通过向其他团队的人做演示,甚至使用他们的权力来实现改变来启动这个过程。

11.2.3 实验作为开门钥匙

这是一个在大型组织中开始单元测试的强大方法(它也可能适合其他类型的转型或新技能)。宣布一个为期两到三个月的实验。它将仅适用于一个预先挑选的团队,并且与真实应用中的一个或两个组件相关。确保它风险不大。如果它失败了,公司不会倒闭或失去主要客户。它也不应该是无用的:实验必须提供真正的价值,而不仅仅是作为游乐场。它必须是最终会推入你的代码库并在生产中使用的;它不应该是一段写完就忘记的代码。

“实验”这个词传达了这种变化是暂时的,如果它不起作用,团队可以回到之前的方式。此外,这种努力是时间限制的,因此我们知道实验何时结束。

这种方法有助于人们更轻松地接受大的变化,因为它降低了组织风险、受影响的人数(以及因此反对的人数),以及与害怕“永远改变”事物相关的反对意见的数量。

这里还有一个提示:当面对实验的多个选项,或者如果有人反对推动另一种工作方式时,问自己,“我们想先实验哪个想法?”

言行一致

准备好你的想法可能不会被选为实验的所有选项之一。当事情变得紧迫时,你必须根据领导层的共识来举行实验,无论你是否喜欢。

跟随他人的实验的好处是,就像你的实验一样,它们是时间限制的,暂时的!最好的结果可能是另一种方法修复了你试图修复的问题,你可能想继续进行他人的实验。然而,如果你讨厌这个实验,只需记住它是暂时的,你可以推动下一个实验。

指标和实验

在实验前后一定要记录一组基线指标。这些指标应该与你要尝试改变的事情相关,比如消除构建的等待时间、减少产品出厂的提前期,或者减少在生产中发现的错误数量。

要深入了解你可能使用的各种指标,请查看我的演讲“谎言、该死的谎言和指标”,您可以在我的博客pipelinedriven.org/article/video-lies-damned-lies-and-metrics中找到。

11.2.4 获得外部支持者

我强烈建议找一个外部人员来帮助进行变革。一个外部顾问进入公司帮助进行单元测试和相关事务,比公司内部的人有优势:

  • 言论自由——顾问可以说出公司内部的人可能不愿意从公司员工那里听到的话(“代码完整性差”,“你的测试不可读”,等等)。

  • 经验—顾问将更有经验处理来自内部的阻力,提出对棘手问题的良好答案,并知道哪些按钮可以推动事情进展。

  • 专用时间—对于顾问来说,这是他们的工作。与公司中其他有更重要事情去做(如编写软件)以推动变革的员工不同,顾问全职投入并致力于这一目标。

我经常看到变化失败,因为过度劳累的支持者没有时间投入这个过程。

11.2.5 使进展可见

保持变革的进展和状态可见非常重要。在走廊或人们聚集的食物相关区域墙上挂上白板或海报。显示的数据应与您试图实现的目标相关。例如:

  • 显示上次夜间构建中通过或失败的测试数量。

  • 保持一个图表,显示哪些团队已经在运行自动化构建流程。

  • 如果你的目标是迭代进度或测试代码覆盖率报告(如图 11.1 所示),请挂起 Scrum 燃尽图。 (您可以在www.controlchaos.com上了解更多关于 Scrum 的信息。)

11-01

图 11.1 TeamCity 中 NCover 的测试代码覆盖率报告示例

  • 公布你自己和所有支持者的联系方式,以便有人可以回答出现的任何问题。

  • 设置一个始终显示的巨幕显示器,以大号粗体图形显示构建状态、当前运行的内容和失败的内容。将其放置在所有开发者都能看到的地方——例如,在一个繁忙的走廊或团队房间的主要墙壁顶部。

使用这些图表的目标是连接两个群体:

  • 正在经历变革的群体—随着图表(对每个人都是开放的)更新,这个群体的人将获得更大的成就感与自豪感,他们会更有动力完成这个过程,因为它对他人来说是可见的。他们还将能够跟踪自己与其他群体的表现。他们可能会更加努力,因为他们知道另一个群体更快地实施了特定的实践。

  • 那些不是流程一部分的组织成员—你正在激发这些人的兴趣和好奇心,引发对话和热议,并形成一股他们可以选择加入的潮流。

11.2.6 设定具体的目标、指标和 KPI

没有目标,变化将难以衡量,也难以向他人传达。它将是一个模糊的“某事”,在出现任何问题迹象时很容易被关闭。

滞后指标

在组织层面,单元测试通常是更大目标集的一部分,通常与持续交付相关。如果你也是这样,我强烈建议使用四个常见的 DevOps 指标:

  • 部署频率—一个组织成功发布到生产环境的频率。

  • 变更的领先时间—功能请求进入生产所需的时间。请注意,许多地方错误地将此发布为提交进入生产所需的时间,这仅仅是功能所经历的旅程的一部分,从组织角度来看。如果你从提交时间开始测量,你更接近于测量从提交到特定点的“周期时间”。领先时间由多个周期时间组成。

  • 逃逸的虫子/变更失败率—在生产中发现的失败数量,通常以发布、部署或时间作为单位。你也可以使用导致生产失败的部署百分比。

  • 恢复服务的时间—组织从生产中的故障中恢复所需的时间。

这四个是我们所说的滞后指标,它们很难伪造(尽管在大多数地方它们很容易测量)。它们在确保我们不会对自己实验的结果撒谎方面非常出色。

领先指标

我们通常希望得到更快的反馈,以确保我们走的是正确的方向。这就是领先指标的作用。领先指标是我们可以在日常基础上控制的事情—代码覆盖率、测试数量、构建运行时间等。它们更容易伪造,但与滞后指标结合,它们通常可以为我们提供我们可能走对了方向的早期迹象。

图 11.2 展示了你可以在组织中使用的滞后和领先指标的结构和想法示例。你可以在pipelinedriven.org/article/a-metrics-framework-for-continuous-delivery找到高分辨率的彩色图像。

11-02

图 11.2 用于持续交付的指标框架示例

指标类别和组

我通常将领先指标分为两组:

  • 团队级别—单个团队可以控制的指标

  • 工程管理级别—需要跨团队协作或跨多个团队汇总的指标

我还喜欢根据它们将用于解决的问题来分类:

  • 进步—用于解决计划的可视化和决策

  • 瓶颈和反馈—正如其名所示

  • 质量—生产中的逃逸虫子

  • 技能—跟踪我们在团队内部或跨团队中逐渐消除知识障碍

  • 学习—表现得像我们是一个学习型组织

定性指标

这些指标大多是定量的(即,它们是可以测量的数字),但其中一些是定性的,即你询问人们他们对某事的感受或看法。我使用的是

  • 你对测试能否以及会找到代码中出现的虫子(从 1 到 5)有多自信?取团队成员或多个团队的平均响应。

  • 代码是否做了它应该做的事情(从 1 到 5)?

这些是在每次回顾会议中可以询问的调查,回答它们只需要五分钟。

趋势线是你的朋友

对于所有领先和滞后指标,你希望看到的是趋势线,而不仅仅是数字的快照。随着时间的推移,线条是如何显示你是否在变得更好或更差的。

不要陷入拥有一个漂亮的仪表板,上面有大量数字的陷阱。没有背景的数字并不好或坏。趋势线会告诉你这周是否比上周做得更好。

11.2.7 认识到会有障碍

总是会有障碍。大多数障碍来自组织结构内部,其中一些将是技术性的。技术性的障碍更容易解决,因为这是一个找到正确解决方案的问题。组织性的障碍需要关注和注意,以及心理上的方法。

当迭代失败、测试速度比预期慢等情况发生时,不要屈服于暂时失败的感受。有时很难开始,你可能需要坚持至少几个月才能对新流程感到舒适,并消除所有问题。即使事情没有按计划进行,管理层也承诺至少持续三个月。重要的是要事先获得他们的同意。你不想在压力重重的一个月中四处奔波,试图说服人们。

此外,吸收这个由 Twitter 上的 Tim Ottinger(@Tottinge)分享的简短认识:“如果你的测试没有捕捉到所有缺陷,它们仍然使未捕捉到的缺陷更容易修复。这是一个深刻的真理。”

现在我们已经探讨了确保事情顺利进行的方法,让我们看看可能导致失败的一些事情。

11.3 失败的方式

在这本书的序言中,我谈到了一个我参与过的失败项目,部分原因是因为单元测试没有正确实施。这是项目可能失败的一种方式。我将在下面讨论几个其他方面,包括一个让我失去那个项目的方面,以及可以采取的一些措施。

11.3.1 缺乏推动力

在我看到变革失败的地方,缺乏推动力是起决定性作用的因素。成为一个持续的变革推动者是有代价的。这需要你从正常工作中抽出时间来教导他人,帮助他们,以及进行内部政治斗争以推动变革。你需要愿意为这些任务牺牲时间,否则变革就不会发生。正如 11.2.4 节中提到的,引入外部人员将有助于你在寻找持续推动力的过程中。

11.3.2 缺乏政治支持

如果你的老板明确告诉你不要进行变革,除了试图说服管理层看到你所看到的东西之外,你几乎无能为力。但有时缺乏支持比这更微妙,关键是意识到你正在面对反对。

例如,你可能会被告知,“当然,继续实施这些测试。我们增加 10%的时间来做这件事。”低于 30%的增幅对于开始单元测试工作来说并不现实。这是管理者可能试图阻止趋势的一种方式——通过扼杀它来使其消失。

你需要认识到你正在面临反对,但一旦你知道要寻找什么,识别起来就很容易。当你告诉他们他们的限制不现实时,你会被告知,“那么就别做了。”

11.3.3 临时实施和第一印象

如果你计划在不了解如何编写好的单元测试的情况下实施单元测试,那么请给自己一个很大的恩惠:找一个有经验的人并遵循良好的实践(如本书中概述的那样)。

我看到开发者在没有正确理解要做什么或从哪里开始的情况下跳入深水区,这不是一个好地方。这不仅需要花费大量时间来学习如何进行可接受的改变,而且你还会在过程中失去很多信誉,因为你的实施方式不好。这可能导致试点项目被关闭。

如果你阅读这本书的序言,你会知道这发生在我身上。你只有几个月的时间来提高效率,并说服上级你通过实验取得了成果。让这段时间变得有意义,并消除你能消除的所有风险。如果你不知道如何编写好的测试,就阅读一本书或者找一个顾问。如果你不知道如何使你的代码可测试,就做同样的事情。不要浪费时间重新发明测试方法。

11.3.4 团队支持不足

如果你的团队不支持你的努力,你几乎不可能成功,因为你很难将你在新流程上的额外工作与你的日常工作结合起来。你应该努力让团队成为新流程的一部分,或者至少不要干扰它。

与你的团队成员讨论这些变化。逐个获得他们的支持有时是一个好的开始,但作为一组与他们讨论你的努力——并回答他们棘手的问题——也可能非常有价值。无论你做什么,不要理所当然地认为团队会支持你。确保你知道你要面对什么;这些人是你每天都要与之共事的人。

11.4 影响因素

我在我的书《弹性领导》(Manning,2016)中专门用了一章来讨论影响行为。如果你对这个主题感兴趣,我建议你阅读那本书,或者更多关于这个主题的信息可以在5whys.com上找到。

我发现,除了单元测试之外,我甚至对人们以及他们为什么以这种方式行事更感兴趣。试图让某人开始做某事(比如 TDD,例如)可能会非常令人沮丧,无论你付出多大的努力,他们就是不会做。你可能已经尝试过与他们进行推理,但你看到他们对你的小谈话没有任何反应。

在凯瑞·帕特森、约瑟夫·格伦尼、大卫·马克斯菲尔德、罗恩·麦克米伦和阿尔·斯威策勒合著的《影响力:改变一切的力量》(麦格劳-希尔,2007 年)一书中,你会发现以下咒语(释义):

对于你看到的每一个行为,世界都完美地设计好了让这种行为发生。这意味着除了个人想要做某事或能够做到之外,还有其他因素会影响他们的行为。然而,我们很少超越这两个因素。

这本书让我们了解到六个影响因素:

  • 个人能力—这个人是否具备完成所需任务的所有技能或知识?

  • 个人动机—这个人是否从正确的行为中获得满足感或不喜欢错误的行为?他们在最难做到的时候是否具备自我控制力去参与这种行为?

  • 社会能力—你或其他人是否提供了那个人在关键时刻所需的帮助、信息和资源?

  • 社会动机—他们周围的人是否积极鼓励正确的行为并阻止错误的行为?你或其他人是否以有效的方式树立了正确的榜样?

  • 结构性(环境)能力—环境中(建筑、预算等)是否有使行为变得方便、容易和安全方面的因素?是否有足够的提示和提醒来保持正确的方向?

  • 结构性动机—当你或他人表现出正确或错误的行为时,是否有明确且有意义的结果(如工资、奖金或激励)?短期奖励是否与期望的长期结果和行为相匹配,你想要加强或避免的行为?

将此视为一个简短的清单,以了解为什么事情没有按照你的意愿进行。然后考虑另一个重要的事实:可能存在多个影响因素。为了改变行为,你应该改变所有在起作用的影响因素。如果你只改变一个,行为就不会改变。

表 11.1 是一个关于某人没有进行 TDD 的假设清单示例。(请记住,这将在每个组织中的每个人中有所不同。)

表 11.1 影响因素清单

影响因素 需要提出的问题 示例回答
个人能力 这个人是否具备完成所需任务的所有技能或知识? 是的。他们参加了由罗伊·奥斯霍夫主持的三天 TDD 课程。
个人动机 这个人是否从正确的行为中获得满足感或不喜欢错误的行为?他们在最难做到的时候是否具备自我控制力去参与这种行为? 我和他们谈过,他们喜欢做 TDD。
社会能力 你或其他人是否提供了那个人在关键时刻所需的帮助、信息和资源? 是的。
社会动机 他们周围的人是否积极鼓励正确的行为并阻止错误的行为?你或其他人是否以有效的方式树立了正确的榜样? 尽可能地。
结构性(环境)能力 环境中(建筑、预算等)是否有使行为方便、容易和安全方面的因素?是否有足够的提示和提醒来保持方向? 他们没有为构建机器预留预算。*
结构性动机 当你或他人行为正确或错误时,是否有明确和有意义的奖励(如工资、奖金或激励)?短期奖励是否与你想加强或避免的长期结果和行为相匹配? 当他们试图进行单元测试时,他们的经理告诉他们这是浪费时间。如果他们提前交付质量低劣的产品,他们就能得到奖金。*

我在右侧列出的需要工作的项目旁边加上了星号。在这里,我已经确定了需要解决的两个问题。仅仅解决构建机器预算问题不会改变行为。他们必须获得构建机器并且阻止他们的经理在快速交付质量低劣的产品时给予奖金。

我在《软件团队领导笔记》(Team Agile Publishing, 2014)这本书中对此有更详细的阐述,这是一本关于如何管理技术团队的书籍。您可以在5whys.com找到它。

11.5 困难的问题和答案

本节涵盖了一些我在不同地方遇到的问题。它们通常源于这样一个前提,即实施单元测试可能会伤害到某个人——一个担心截止日期的经理或一个担心自己相关性的 QA 员工。一旦你了解了问题的来源,直接或间接地解决问题就很重要。否则,总会存在微妙的阻力。

11.5.1 单元测试将增加当前过程多少时间?

团队领导、项目经理和客户通常是询问单元测试将增加多少时间到过程中的人。他们在时间方面处于最前线。

让我们从一些事实开始。研究表明,提高项目中的整体代码质量可以提高生产力和缩短时间表。这与编写测试使编码变慢的事实如何相符?主要通过可维护性和修复错误的便利性。

注意:关于代码质量和生产力的研究,请参阅《编程生产力》McGraw-Hill College, 1986)和《软件评估、基准和最佳实践》Addison-Wesley Professional, 2000),这两本书都是由 Capers Jones 所著。

当询问时间时,团队领导可能实际上是在问:“当我们远远超出截止日期时,我应该告诉项目经理什么?”他们可能实际上认为这个过程是有用的,但正在寻找即将到来的战斗的弹药。他们也可能不是从整个产品的角度,而是从特定的功能集或功能的角度来提问。另一方面,询问时间表的项目经理或客户通常会谈论完整产品的发布。

由于不同的人关心不同的范围,你的答案可能会有所不同。例如,单元测试可以将实现特定功能所需的时间加倍,但产品的整体发布日期实际上可能会减少。为了理解这一点,让我们看看我参与的一个真实例子。

两个功能的对比故事

我咨询的一家大公司希望在其流程中实施单元测试,从试点项目开始。试点项目包括一组开发者为一个大型的现有应用程序添加新功能。该公司的主要收入来源是创建这个大型计费应用程序,并为各种客户定制其部分。该公司在全球有数千名开发者。

为了测试试点的成功,采取了以下措施:

  • 团队在各个开发阶段花费的时间

  • 项目发布给客户的总时间

  • 发布后客户发现的错误数量

为不同客户创建的类似功能收集了相同的统计数据。这两个功能的大小几乎相同,团队的技术和经验水平大致相同。两个任务都是定制工作——一个有单元测试,另一个没有。表 11.2 显示了时间上的差异。

表 11.2 带测试和不带测试的团队进度和产出

阶段 无测试的团队 有测试的团队
实施(编码) 7 天 14 天
集成 7 天 2 天
测试和错误修复 测试,3 天 修复,3 天 测试,3 天 修复,2 天 测试,1 天 总计:12 天 测试,3 天 修复,1 天 测试,1 天 修复,1 天 测试,1 天 总计:7 天
总体发布时间 26 天 23 天
生产中发现的错误 71 11

总体而言,带有测试的发布所需时间少于不带测试的发布。尽管如此,拥有单元测试的团队的管理人员最初并不相信试点项目会成功,因为他们只把实施(编码)统计数据(表 11.2 中的第一行)作为成功的标准,而不是底线。编码功能所需的时间是两倍(因为单元测试要求你编写更多的代码)。尽管如此,当 QA 团队发现需要处理的错误更少时,额外的时间得到了充分的补偿。

因此,强调虽然单元测试可能会增加实现一个功能所需的时间,但由于质量和可维护性的提高,总体时间需求在产品的发布周期中会得到平衡。

11.5.2 单元测试会让我在 QA 的工作岗位有风险吗?

单元测试并不能消除与 QA 相关的工作。QA 工程师将收到包含完整单元测试套件的程序,这意味着他们可以在开始自己的测试过程之前确保所有单元测试都通过。有单元测试实际上会使他们的工作更有趣。他们不再需要做 UI 调试(每次按钮点击都可能导致某种异常),他们能够专注于在现实场景中找到更多逻辑(应用)错误。单元测试为错误提供了第一层防御,而 QA 工作提供了第二层——用户接受层。就像安全一样,应用程序始终需要有多层保护。让 QA 流程专注于更大的问题可以产生更好的应用程序。

在某些地方,QA 工程师编写代码,他们可以帮助编写应用程序的单元测试。这与应用程序开发人员的工作是同时进行的,而不是替代它。开发人员和 QA 工程师都可以编写单元测试。

11.5.3 有证据表明单元测试有帮助吗?

我无法指出任何具体的研究来证明单元测试有助于提高代码质量。大多数相关研究都讨论了采用特定的敏捷方法,其中单元测试只是其中之一。可以从网络上找到一些经验证据,例如一些公司和同事取得了很好的成果,并且再也不想回到没有测试的代码库。在 The QA Lead 这里可以找到一些关于 TDD 的研究:mng.bz/dddo

11.5.4 为什么 QA 部门仍在发现错误?

你可能已经没有 QA 部门了,但这仍然是一个非常普遍的做法。无论如何,你仍然会发现错误。请使用第十章中描述的多个级别的测试来增强你对应用程序多个层次的信心。单元测试为你提供快速反馈和易于维护,但它们留下了一些信心,这只能通过某些级别的集成测试来获得。

11.5.5 我们有很多没有测试的代码:我们从哪里开始?

20 世纪 70 年代和 80 年代进行的研究表明,通常 80% 的错误都出现在 20% 的代码中。关键是找到问题最多的代码。通常情况下,任何团队都可以告诉你哪些组件是最有问题的。从这里开始。你总是可以添加一些与每个类中错误数量相关的指标。

80/20 比例的数据来源

显示 80%的错误出现在 20%的代码中的研究表明:Albert Endres,“系统程序中错误及其原因的分析”,IEEE 软件工程杂志 2 (1975 年 6 月),140-49;Lee L. Gremillion,“程序修复维护需求的决定因素”,ACM 通讯 27,第 8 期 (1984 年 8 月),826-32;Barry W. Boehm,“工业软件度量十大列表”,IEEE 软件 4,第 9 期 (1987 年 9 月),84-85(在 IEEE 通讯中重印,并在mng.bz/rjjJ上在线提供);以及 Shull 等人,“我们关于对抗缺陷所学到的东西”,第 8 届国际软件度量研讨会论文集 (2002 年),249-58。

测试遗留代码需要与编写带测试的新代码时采取不同的方法。参见第十二章以获取更多详细信息。

11.5.6 如果我们开发一个软硬件结合的组合会怎样?

你即使在开发软硬件结合的组合时也可以使用单元测试。查看前一章中提到的测试层,以确保你涵盖了软件和硬件。硬件测试通常需要在不同级别使用模拟器和仿真器,但为低级嵌入式代码和高级代码都有一套测试是常见的做法。

11.5.7 我们如何知道我们的测试中没有错误?

你需要确保测试在应该失败的时候失败,在应该通过的时候通过。TDD 是一种确保你不会忘记检查这些事情的好方法。参见第一章,了解 TDD 的简要概述。

11.5.8 如果我的调试器显示我的代码工作正常,我还需要测试吗?

调试器在处理多线程代码时帮助不大。此外,你可能确信你的代码工作正常,但其他人的代码呢?你怎么知道它也工作正常?他们怎么知道你的代码工作正常,并且在他们进行更改时没有破坏任何东西?记住,编码是代码生命周期的第一步。在其大部分生命周期中,代码将处于维护模式。你需要确保它会在出现问题时通知人们,使用单元测试。

Curtis,Krasner 和 Iscroe 进行的一项研究(“大型系统软件设计过程现场研究”,ACM 通讯 31,第 11 期 (1988 年 11 月),1268-87)表明,大多数缺陷不是来自代码本身,而是源于人们之间的误解、不断变化的需求以及缺乏应用领域知识。即使你是世界上最伟大的程序员,如果你被告知编写错误的东西,你很可能会这么做。当你需要更改它时,你会很高兴你有其他所有东西的测试,以确保你不会破坏它。

11.5.9 关于 TDD 呢?

TDD 是一种风格选择。我个人认为 TDD 有很多价值,很多人发现它既高效又有益,但也有人认为在代码之后编写测试对他们来说已经足够好。你可以自己做出选择。

摘要

  • 在他们的组织中实施单元测试是这本书的许多读者迟早都要面对的事情。

  • 确保你不要疏远了那些可以帮助你的人。识别组织内的倡导者和阻碍者。让这两组人都成为变革过程的一部分。

  • 确定可能的起点。从一个规模较小、范围有限的小团队或项目开始,以快速取得胜利并最小化项目持续时间风险。

  • 让每个人的进步都变得可见。设定具体的目标、指标和关键绩效指标(KPI)。

  • 注意潜在失败原因,例如缺乏驱动力和缺乏政治或团队支持。

  • 准备好回答你可能会被问到的问题。

12 与遗留代码一起工作

本章涵盖

  • 检查遗留代码的常见问题

  • 决定从哪里开始编写测试

我曾经为一家大型软件开发公司提供咨询服务,该公司生产计费软件。他们有超过 10,000 名开发者,在产品、子产品和相互交织的项目中混合使用.NET、Java 和 C++。该软件以某种形式存在了超过五年,大多数开发者都被分配去维护和构建在现有功能之上的代码。

我的任务是帮助几个部门(使用所有语言)学习 TDD 技术。对于我合作的大约 90%的开发者来说,由于几个原因,这从未成为现实,其中一些原因是遗留代码:

  • 对现有代码编写测试很困难。

  • 重构现有代码几乎是不可能的(或者没有足够的时间来做)。

  • 一些人不希望改变他们的设计。

  • 工具(或工具不足)阻碍了进程。

  • 确定从哪里开始很难。

任何尝试向现有系统添加测试的人都知道,大多数这样的系统几乎不可能编写测试。它们通常在没有适当位置(称为接口)的情况下编写,以允许扩展或替换现有组件。

在处理遗留代码时,需要解决两个问题:

  • 有这么多工作要做,你应该从哪里开始添加测试?你应该把精力集中在哪里?

  • 如果你的代码一开始就没有测试,你如何安全地进行重构?

本章将列出技术、参考和工具,以帮助解决通过接近遗留代码库所关联的难题。

12.1 你从哪里开始添加测试?

假设你已经在组件内部有现有的代码,你需要为那些测试最有意义的组件创建一个优先级列表。有几个因素需要考虑,这些因素会影响每个组件的优先级:

  • 逻辑复杂性—这指的是组件中的逻辑量,例如嵌套的if语句、switch 情况或递归。这种复杂性也称为循环复杂性,你可以使用各种工具自动检查它。

  • 依赖级别—这指的是组件中的依赖项数量。你需要打破多少个依赖项才能将这个类纳入测试?它是否与外部的电子邮件组件通信,或者是否在某个地方调用静态日志方法?

  • 优先级—这是组件在项目中的总体优先级。

你可以为每个组件根据这些因素进行评分,从 1(低优先级)到 10(高优先级)。表 12.1 显示了具有这些因素评分的类。我称之为测试可行性表

表 12.1 简单的测试可行性表

组件 逻辑复杂性 依赖级别 优先级 备注
Utils 6 1 5 这个实用类依赖项很少,但包含大量逻辑。它将很容易进行测试,并且提供了很多价值。
Person 2 1 1 这是一个逻辑简单且无依赖的数据持有类。测试它的实际价值很小。
TextParser 8 4 6 这个类逻辑复杂且依赖性多。更糟糕的是,它是项目中的一个高优先级任务的一部分。测试它将提供很多价值,但也会很困难且耗时。
ConfigManager 1 6 1 这个类持有配置数据,并从磁盘读取文件。它逻辑简单但依赖性很多。测试它对项目的价值很小,而且也会很困难且耗时。

从表 12.1 中的数据,你可以创建一个类似于图 12.1 所示的图表,该图表通过项目价值和依赖数量来绘制你的组件。你可以安全地忽略低于你设定的逻辑阈值(我通常设定为 2 或 3)的项目,因此可以忽略PersonConfigManager。你只剩下图 12.1 中排名前两位的组件。

12-01

图 12.1 测试可行性组件映射

有两种基本方法来查看图表并决定你想先测试什么(见图 12.2):

  • 选择更复杂但更容易测试的(左上角)。

  • 选择更复杂且更难测试的(右上角)。

12-02

图 12.2 基于逻辑和依赖关系的简单、困难和无关组件映射

现在的问题是你应该选择哪条路径。你应该从简单的事情开始,还是从困难的事情开始?

12.2 选择选择策略

如前所述,你可以从容易测试的组件开始,或者从难以测试的组件开始(因为它们*有很多依赖关系)。每种策略都带来了不同的挑战。

12.2.1 简单优先策略的优缺点

从依赖性较少的组件开始,将使最初编写测试变得更快、更容易。但正如图 12.3 所展示的,这里有一个陷阱。

12-03

图 12.3 当从简单的组件开始时,测试组件所需的时间会越来越多,直到最困难的组件完成。

图 12.3 显示了在整个项目生命周期中,将组件纳入测试所需的时间。最初编写测试很容易,但随着时间的推移,你将面临越来越难以测试的组件,特别是那些特别困难的组件,它们会在项目周期的末尾等待你,那时每个人都正忙于推动产品上市。

如果你的团队相对较新,对单元测试技术不太熟悉,从简单的组件开始是值得的。随着时间的推移,团队将学会处理更复杂组件和依赖关系所需的技术。对于这样的团队,最初避免所有超过特定数量依赖关系的组件(4 个是一个合理的限制)可能是明智的。

12.2.2 硬件优先策略的优缺点

起初,从更困难的组件开始可能看起来像是一个失败的选择,但只要你的团队有单元测试技术的经验,它就有优点。图 12.4 显示了在整个项目生命周期中,如果你首先测试具有最多依赖项的组件,编写单个组件测试的平均时间。

12-04

图 12.4 当你使用硬件优先策略时,测试组件所需的时间最初很高,但随着更多依赖项的重构而降低。

使用这种策略,你可能会花费一天或更长时间才能在更复杂的组件上启动最简单的测试。但请注意,相对于图 12.3 中的缓慢上升,编写测试所需的时间迅速下降。每次你将一个组件置于测试状态并重构它以使其更具可测试性时,你也可能解决了它所使用的依赖项或其他组件的可测试性问题。因为这个组件有很多依赖项,重构它可以改善系统的其他部分。这就是快速下降的原因。

硬件优先策略只有在你的团队有单元测试技术经验的情况下才可行,因为它的实现更困难。如果你的团队确实有经验,请使用组件的优先级方面来决定是否从硬件或软件组件开始。你可能想要选择混合策略,但重要的是你事先知道将涉及多少工作量以及可能的后果是什么。

12.3 在重构之前编写集成测试

如果你确实计划重构代码以提高可测试性(以便可以编写单元测试),确保在重构阶段不会破坏任何东西的一个实用方法是针对你的生产系统编写集成风格的测试。

我在一个大型遗留项目中提供咨询,与一个需要处理 XML 配置管理器的开发者合作。该项目没有测试,几乎不可测试。它也是一个 C++ 项目,所以我们不能在不重构代码的情况下使用工具轻松地将组件从依赖中隔离出来。

开发者需要在 XML 文件中添加另一个值属性,并且能够通过现有的配置组件读取和更改它。我们最终编写了一些集成测试,这些测试使用了真实系统来保存和加载配置数据,并断言配置组件检索和写入文件中的值。这些测试设定了配置管理器的“原始”工作行为作为我们的工作基础。

接下来,我们编写了一个集成测试,表明一旦组件开始读取文件,它就不会在内存中包含我们试图添加的名称的属性。我们证明了该功能缺失,现在我们有一个测试,一旦我们将新属性添加到 XML 文件中并从组件中正确写入,它就会通过。

一旦我们编写了保存和加载额外属性的代码,我们就运行了三个集成测试(两个针对原始基实现,一个尝试读取新属性)。所有三个都通过了,所以我们知道在添加新功能的同时没有破坏现有功能。

如你所见,这个过程相对简单:

  • 向系统中添加一个或多个集成测试(无模拟或存根),以证明原始系统按需工作。

  • 对您想要添加到系统中的功能进行重构或添加一个失败的测试。

  • 小块重构和改变系统,尽可能频繁地运行集成测试,以查看你是否破坏了某些东西。

有时,集成测试可能比单元测试更容易编写,因为你不需要了解代码的内部结构或在哪里注入各种依赖项。但是,在本地系统上运行这些测试可能会很烦人或耗时,因为你必须确保系统需要的每一件小事都到位。

关键在于专注于需要修复或添加功能的系统部分。不要关注其他部分。这样,系统就会在正确的位置增长,留待以后解决其他问题。

随着你继续添加越来越多的测试,你可以重构系统并添加更多的单元测试,使其成为一个更易于维护和测试的系统。这需要时间(有时是几个月甚至更长时间),但这是值得的。

单元测试原则、实践和模式》第七章,由弗拉基米尔·科里科夫(Manning,2020)所著,包含了一个此类重构的深入示例。更多细节请参阅该书。

12.3.1 阅读迈克尔·费瑟斯的《遗留代码》书籍

迈克尔·费瑟斯的《与遗留代码有效工作》(Pearson,2004)是另一个非常有价值的资源,它处理了你在遗留代码中会遇到的问题。它深入展示了这本书没有尝试涵盖的许多重构技术和陷阱。它价值连城。去获取它吧。

12.3.2 使用 CodeScene 调查你的生产代码

另一个名为 CodeScene 的工具可以帮助你发现遗留代码中的许多技术债务和隐藏问题,以及其他许多事情。这是一个商业工具,虽然我本人没有使用过它,但我听说了很多好话。你可以在codescene.com/了解更多信息。

摘要

  • 在开始为遗留代码编写测试之前,根据组件的依赖关系数量、逻辑量以及每个组件在项目中的总体优先级,对各种组件进行规划非常重要。组件的逻辑复杂性(或圈复杂度)指的是组件中的逻辑量,例如嵌套的if语句、switch 情况或递归。

  • 一旦你有了这些信息,你可以根据将组件置于测试之下是容易还是困难来选择要工作的组件。

  • 如果你的团队在单元测试方面经验很少或没有经验,从容易的部分开始是一个好主意,随着他们向系统中添加越来越多的测试,团队的信心也会逐渐增长。

  • 如果你的团队经验丰富,首先测试困难的部分可以帮助你更快地完成整个系统的其余部分。

  • 在进行大规模重构之前,编写将主要保持不变的集成测试。重构完成后,用更小、更易于维护的单元测试替换这些集成测试中的大部分。

附录. 修补函数和模块

在第三章中,我介绍了各种我称之为“可接受”的模拟技术,因为它们通常被认为对代码的可维护性和可读性以及它们引导我们编写的测试都是安全的。在本附录中,我将描述一些不太被接受且不太安全的方法,我们可以用它们在测试中模拟整个模块。

A.1 必须的警告

关于全局修补和模拟函数和模块,我有好消息和坏消息。是的,你可以做到这一点——我将向你展示几种实现这一目标的方法。这是一个好主意吗?我并不确信。根据我的经验,使用我将展示的技术来维护测试的成本往往比维护参数化良好或具有适当接口的代码的成本要高。

然而,可能存在一些特殊时刻,你需要使用这些技术。这些时刻包括但不限于,在无法拥有和更改的代码中模拟依赖项,有时在使用立即执行的函数或模块时,以及当模块只暴露函数而没有对象时,这大大限制了模拟选项。

尽可能避免使用我在附录中描述的技术。如果你能找到一种方法来编写测试或重构代码,使其不需要这些方法,请使用那种方法。如果所有其他方法都失败了,附录中的技术是一种必要的恶。如果你必须使用它们,尽量减少使用它们的程度。你的测试将受到影响,变得更为脆弱且难以阅读。

让我们深入探讨。

A.2 修补函数、全局变量和可能的问题

Monkey-patching 指的是在运行时更改正在运行的程序实例的行为。我第一次遇到这个术语是在我从事 Ruby 开发工作时,在那里 monkey-patching 非常常见。在 JavaScript 中,在运行时“修补”一个函数同样简单。

在第三章中,我们探讨了测试和代码中的时间管理问题。通过 monkey-patching,我们可以查看任何函数,无论是全局的还是局部的,并将其(对于特定的 JavaScript 作用域)替换为不同的实现。如果我们想修补时间,我们可以修补全局的 Date.now,这样从那时起的所有代码都会受到影响,包括生产代码和测试代码。

列表 A.1 展示了一个测试,它为直接使用 Date.now 的原始生产代码执行了这一操作。它模拟全局 Date.now 函数以在测试期间控制时间。

列表 A.1 模拟全局 Date.now() 的问题

describe('v1 findRecentlyRebooted', () => {
  test('given 1 of 2 machines under threshold, it is found', () => {
    const originalNow = Date.now;             ❶
    const fromDate = new Date(2000,0,3);      ❷
    Date.now = () => fromDate.getTime(); ❷

    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];

    const result = findRecentlyRebooted(machines, 1, fromDate);

    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');

    Date.now = originalNow;                   ❸
  });
}); 

❶ 保存原始的 Date.now

❷ 用自定义日期替换 Date.now

❸ 恢复原始的 Date.now

在这个列表中,我们正在用自定义日期替换全局 Date.now。由于这是一个全局函数,其他测试可能会受到影响,因此我们在测试结束时清理,将原始的 Date.now 恢复到其正确的位置。

这样的测试存在几个主要问题。首先,这些断言在失败时抛出异常,这意味着如果它们失败,原始 Date.now 的恢复可能永远不会执行,其他测试将遭受“脏”的全局时间,这可能会影响它们。

保存时间函数然后再将其恢复也很麻烦。这给测试留下了痕迹,使得测试变得更长、更难阅读,而且编写起来也更困难。很容易忘记重置全局状态。

最后,我们损害了并行性。Jest 似乎处理得很好,因为它为每个测试文件创建了一组独立的依赖关系,但与其他可能并行运行测试的框架相比,可能会出现竞争条件。多个测试可以更改或期望全局时间具有某个值。当并行运行时,这些测试可能会发生冲突,并在全局状态中创建竞争条件,相互影响。在我们的情况下这不是必需的,但如果你想消除不确定性,Jest 允许你使用额外的 --runInBand 命令行参数来避免并行性。

我们可以通过使用 beforeEach()afterEach() 辅助函数来避免一些这些问题。

列表 A.2 使用 beforeEach()afterEach()

describe('v2 findRecentlyRebooted', () => {
  let originalNow;
  beforeEach(() => originalNow = Date.now);     ❶
  afterEach(() => Date.now = originalNow);      ❷

  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    Date.now = () => fromDate.getTime();

    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];

    const result = findRecentlyRebooted(machines, 1, fromDate);

    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');
  });
});

❶ 保存原始的 Date.now

❷ 恢复原始的 Date.now

列表 A.2 解决了我们的一些问题,但不是全部。好处是,我们不再需要记住保存和重置 Date.now,因为 beforeEach()afterEach() 会处理它。现在阅读测试也更简单了。

但是,我们仍然存在一个潜在的重大问题,即并行测试。Jest 足够智能,只按文件并行运行测试,这意味着这个规范文件中的测试将线性运行,但其他文件中的测试行为没有保证。任何一个并行测试都可能有自己的 beforeEach()afterEach() 来重置全局状态,可能会在不经意间影响我们的测试。

当我能帮忙时,我不是很喜欢伪造全局对象(即在大多数类型语言中的“单例”)。总有附加条件——额外的编码、额外的维护、额外的测试脆弱性,或者间接影响其他测试并担心一直清理都是一些原因。大多数时候,当我把接口因素融入到待测试代码的设计中,而不是以隐式方式围绕它时,代码会更好。就像我们刚才做的那样。

尤其是考虑到越来越多的框架可能会开始复制 Jest 的功能并并行运行测试,全局伪造变得越来越危险。

A.2.1 以 Jest 的方式猴子补丁一个函数

为了使画面更完整,Jest 还支持通过使用两个协同工作的函数来实现猴子补丁的想法:spyOnmockImplementation。以下是 spyOn

Date.now = jest.spyOn(Date, 'now')

spyOn 以作用域和需要跟踪的函数作为参数。请注意,我们在这里需要使用一个字符串作为参数,这并不真的有利于重构——如果我们重命名那个函数,很容易就会错过它。

A.2.2 Jest 间谍

“spy” 这个词比我们在本书中遇到的其他术语要稍微有趣一些,这也是为什么如果可能的话,我不太喜欢过多(或根本不)使用它。不幸的是,这个词是 Jest API 的一个重要部分,所以让我们确保我们理解它。

xUnit 测试模式(Addison-Wesley,2007),由 Gerard Meszaros 编著,在其关于间谍的讨论中提到:“使用测试双用来捕获系统(SUT)对另一个组件进行的间接输出调用,以便稍后由测试进行验证。”间谍与存根或测试双重的唯一区别在于,间谍是调用函数底层的真实实现,并且它只跟踪该函数的输入和输出,我们可以通过测试稍后验证这些输入和输出。存根和测试双重不使用函数的真实实现。

我对“间谍”的改进定义非常接近:在测试期间,通过在“工作单元”的“入口点”和“出口点”上添加一个不可见的跟踪层,而不改变其底层功能,以跟踪其输入和输出。

A.2.3 使用 mockImplementation()spyOn

间谍固有的这种“跟踪而不改变功能”的行为也解释了为什么仅仅使用 spyOn 并不足以让我们伪造 Date.now。它仅用于跟踪,而不是伪造。

要实际伪造 Date.now 函数并将其转换为存根,我们将使用令人困惑的命名 mockImplementation 来替换底层工作单元的功能:

jest.spyOn(Date, 'now').mockImplementation(() => /*return stub time*/);

“mock” 过多

如果我能决定为 mockImplementation 起一个新的名字,我会叫它 fakeImplementation,因为它可以很容易地用来创建返回数据的存根或验证传入参数的模拟。在我们的行业中,“mock” 这个词被过度使用,用来表示任何不真实的东西,而区分这一点可以帮助我们编写更稳健的测试。“mock” 在名称中立即暗示了这是我们稍后会验证的东西,至少当我看的时候,考虑到我在本书中对待模拟和存根的观点。

Jest 中“mock”这个词的使用过于频繁,尤其是在将其 API 与 Sinon.js 这样的隔离框架进行比较时,Sinon.js 使用的是不那么令人惊讶的命名方式,并且避免了在不必要的地方使用“mock”。

下面是如何在我们的代码中看到 spyOnmockImplementation 组合的示例。

列表 A.3 使用 jest.SpyOn() 来 monkey-patch Date.now()

describe('v4 findRecentlyRebooted with jest spyOn', () => {
  afterEach(() => jest.restoreAllMocks());

  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    Date.now = jest.spyOn(Date, 'now')
      .mockImplementation(() => fromDate.getTime());

    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];

你可以看到代码中最后一部分是在afterEach()中。我们使用另一个名为jest.restoreAllMocks的函数,这是 Jest 重置任何被监视的全局状态到其原始实现的方式,没有围绕它的额外模拟层。

注意,尽管我们使用了监视器,但我们并没有验证函数是否实际上被调用。那样做意味着我们将其用作模拟对象,而我们并不是。我们只是将其用作存根。在 Jest 中,我们必须通过“监视器”来存根东西。

我之前列出的所有优缺点在这里仍然适用。当有道理时,我更喜欢使用参数,而不是使用全局函数或变量。

A.3 使用 Jest 忽略整个模块很简单

在本附录中提到的所有技术中,这是最安全的,因为它不涉及被测试单元的内部工作原理。它只是以广泛的方式忽略事物。

如果我们在测试过程中根本不关心模块,我们只想让它从我们的场景中移除,而不从它那里获取任何模拟数据,那么在测试文件顶部简单调用jest.mock('module path')就足够了,无需太多麻烦。

下一个部分有助于你从模拟模块中模拟每个测试的定制数据,这让我们不得不跳过更多的圈子。

A.4 在每个测试中模拟模块行为

模拟模块基本上意味着模拟一个全局对象,当被测试代码首次使用importrequire时,该对象会被加载。根据我们使用的测试框架,模块可能通过内部缓存或通过标准的 Node.js require.cache机制进行缓存。由于这只会发生一次,当我们尝试在同一个文件中对不同测试进行不同行为或数据的模拟时,我们就会遇到一些问题。

为了模拟我们模拟模块的定制行为,我们需要在我们的测试中注意以下事项:从内存中清理所需的模块,替换它,重新导入它,并通过再次导入我们的被测试代码来让被测试代码使用新的模块而不是原始模块。这相当多。我称这种模式为 Clear-Fake-Require-Act (CFRA):

  1. 清晰—在每次测试之前,清除测试运行器内存中所有缓存的或必需的模块。

  2. 在测试的安排部分:

    1. 模拟—模拟将被测试代码中require动作调用的模块。

    2. 导入—在调用之前导入被测试代码。

  3. 行动—调用入口点。

如果我们忘记了这些步骤,或者以错误的顺序执行它们,或者在测试的生命周期中不正确的点执行,当我们执行测试时,会出现很多问号,事情似乎没有正确模拟。更糟糕的是,它们有时可能工作正确。令人毛骨悚然。

让我们来看一个真实示例,从以下代码开始。

列表 A.4 带有依赖关系的测试代码

const { getAllMachines } = require('./my-data-module');     ❶

const daysFrom = (from, to) => {
  const ms = from.getTime() - new Date(to).getTime();
  const diff = (ms / 1000) / 60 / 60 / 24; // secs * min * hrs
  console.log(diff);
  return diff;
};

const findRecentlyRebooted = (maxDays, fromDate) => {
  const machines = getAllMachines();
  return machines.filter(machine => {
    const daysDiff = daysFrom(fromDate, machine.lastBootTime);
    console.log(`${daysDiff} vs ${maxDays}`);
    return daysDiff < maxDays;
  });
};

❶ 模拟的依赖项

第一行包含我们在测试中需要断开依赖的函数getAllMachines,它是从my-data-module解构出来的。因为我们使用的是与其父模块分离的函数,所以我们不能仅仅伪造父模块上的函数并期望测试通过。我们必须在解构过程中获取解构的函数,以便在解构过程中获得伪造的函数,这就是难点所在。

A.4.1 使用纯require.cache伪造模块

在我们使用 Jest 和其他框架伪造整个模块之前,让我们看看我们如何实现这种效果,并探索各种框架中发生的事情。

你可以直接使用require.cache而不使用任何框架来使用 CFRA 模式。

列表 A.5 使用require.cache伪造

const assert = require('assert');
const { check } = require('./custom-test-framework');

const dataModulePath = require.resolve('../my-data-module');

const fakeDataFromModule = fakeData => {
  delete require.cache[dataModulePath];                                ❶
  require.cache[dataModulePath] = {                                    ❷
    id: dataModulePath,
    filename: dataModulePath,
    loaded: true,
    exports: {
      getAllMachines: () => fakeData
    }
  };
  require(dataModulePath);
};

const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');     ❸
  return findRecentlyRebooted(maxDays, fromDate);                      ❹
};

check('given 1 of 2 machines under the threshold, it is found', () => {
  const rebootTwoDaysEarly = new Date(2000,0,1);
  const fromDate = new Date(2000,0,3);
 fakeDataFromModule([
 { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
 { lastBootTime: fromDate, name: 'found' }
 ]);

 const result = requireAndCall_findRecentlyRebooted(1, fromDate);
  assert(result.length === 1);
  assert(result[0].name.includes('found'));
});

❶ 清除

❷ 伪造

❸ 需要

❹ 执行

不幸的是,这段代码与 Jest 不兼容,因为 Jest 忽略require.cache并内部实现了自己的缓存算法。要执行此测试,请直接通过 Node.js 命令行运行它。你会看到我实现了一个小小的check()函数,这样我就不用使用 Jest 的 API。当使用 Jasmine 等框架时,这个测试将正常工作。

记住我们被测试代码中的这一行?

const { getAllMachines } = require('./my-data-module'); 

我们的测试需要在想要返回伪造值时执行这种解构。这意味着我们需要在我们的测试代码中执行被测试单元的 require 或 import,而不是在文件顶部,而是在测试执行的中间部分。你可以在 A.5 列表的以下部分看到这是如何发生的:

const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  return findRecentlyRebooted(maxDays, fromDate);
};

正是因为这种解构代码模式,模块不仅仅是具有属性的普通对象,可以使用正常的猴子补丁技术。我们需要跳过更多的障碍。

让我们将四个 CFRA 步骤映射到 A.5 列表中的代码:

  • 清除—这是在测试期间调用的fakeDataFromModule函数的一部分。

  • 伪造—我们正在告诉require.cache的字典条目返回一个看似代表模块外观的自定义对象,但实际上它有一个自定义实现,返回fakeData

  • 需要—我们在requireAndCall_findRecentlyRebooted()函数中作为部分要求被测试的代码,该函数在测试期间被调用。

  • ACT—这是由测试调用的同一个requireAndCall_findRecentlyRebooted()函数的一部分。

注意,我们没有在这个测试中使用beforeEach()。我们直接从测试中做所有事情,因为每个测试都会从模块中伪造自己的数据。

A.4.2 使用 Jest 伪造自定义模块数据很复杂

我们已经看到了伪造自定义模块数据的“纯”方法。但如果你使用 Jest,这通常不是你会这样做的方式。Jest 包含几个名称相似且非常接近的函数,它们处理清除和伪造模块,包括mockdoMockgenMockFromModuleresetAllMocksclearAllMocksrestoreAllMocksresetModules等。太棒了!

我将推荐的代码在可读性和可维护性方面是 Jest 所有 API 中最干净、最简单的。我在 GitHub 仓库 github.com/royosherove/aout3-samples 以及在 “other-variations” 文件夹 mng.bz/Jddo 中涵盖了它的其他变体。

这是使用 Jest 模拟模块的常见模式:

  1. 在你的测试中引入你想要模拟的模块。

  2. 在测试上方模块中使用 jest.mock(modulename) 熄灭。

在每个测试中,告诉 Jest 通过使用 [modulename].function.mockImplementation()mockImplementationOnce() 来覆盖该模块中某个函数的行为。

以下是一个示例。

列表 A.6 使用 Jest 存根模块

const dataModule = require('../my-data-module');
const { findRecentlyRebooted } = require('../machine-scanner4');

const fakeDataFromModule = (fakeData) =>
    dataModule.getAllMachines.mockImplementation(() => fakeData);

jest.mock('../my-data-module');

describe('findRecentlyRebooted', () => {
  beforeEach(jest.resetAllMocks); //<- the cleanest way

  test('given no machines, returns empty results', () => {
    fakeDataFromModule([]);
    const someDate = new Date(2000,0,1);

    const result = findRecentlyRebooted(0, someDate);

    expect(result.length).toBe(0);
  });

  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    const rebootTwoDaysEarly = new Date(2000,0,1);
 fakeDataFromModule([
 { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
 { lastBootTime: fromDate, name: 'found' }
 ]);
    const result = findRecentlyRebooted(1, fromDate);
    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');
  });

这是你可以如何使用 Jest 接近 CFRA 的每个部分的示例。

清除 jest.resetAllMocks
模拟 jest.mock()+[fake].mockImplementation()
需要 定期在文件顶部
行动 定期

jest.mockjest.resetAllMocks 方法都是关于模拟模块并将模拟实现重置为空的。注意,在 resetAllMocks 之后模块仍然是模拟的。只有其行为被重置为默认的模拟实现。不指定返回值而调用它将产生奇怪的错误。

使用 FromModule 方法,我们在每个测试中用返回我们硬编码值的函数替换默认实现。

我们本可以使用 mockImplementationOnce() 来进行模拟,而不是使用 fakeDataFromModule() 方法,但我发现这可能会创建非常脆弱的测试。使用存根时,我们通常不需要关心它们返回模拟值多少次。如果我们关心它们被调用的次数,我们会将它们用作 模拟 对象,这是第四章的主题。

A.4.3 避免使用 Jest 的手动模拟

Jest 包含了 手动模拟 的概念,但如果可能的话,不要使用它们。这项技术要求你在测试中放置一个特殊的 mocks 文件夹,其中包含基于模块名称的硬编码的模拟模块代码。这会起作用,但当你想要控制模拟数据时,维护成本会很高。可读性成本也很高,因为它将滚动疲劳增加到不必要的水平,需要我们在多个文件之间切换以理解测试。你可以在 Jest 文档中了解更多关于手动模拟的信息:jestjs.io/docs/en/manual-mocks.html

A.4.4 使用 Sinon.js 存根模块

为了比较,并且让你看到 CFRA 的模式在其他框架中重复出现,这里是一个使用 Sinon.js(一个专门用于创建存根的框架)实现相同测试的示例。

列表 A.7 使用 Sinon.js 存根模块

const sinon = require('sinon');
let dataModule;
const fakeDataFromModule = fakeData => {
  sinon.stub(dataModule, 'getAllMachines')
    .returns(fakeData);
};

const resetAndRequireModules = () => {
 jest.resetModules();
 dataModule = require('../my-data-module');
};

const requireAndCall_findRecentlyRebooted = (maxDays, someDate) => {
 const { findRecentlyRebooted } = require('../machine-scanner4');
 return findRecentlyRebooted(maxDays, someDate);
};

describe('4  sinon sandbox findRecentlyRebooted', () => {
  beforeEach(resetAndRequireModules);

  test('given no machines, returns empty results', () => {
    const someDate = new Date('01 01 2000');
    fakeDataFromModule([]);

    const result = requireAndCall_findRecentlyRebooted(2, someDate);

    expect(result.length).toBe(0);
  });

让我们用 Sinon 将相关部分映射出来。

清除 在每个测试之前:jest.resetModules + 重新要求模拟模块
模拟 在每次测试前:sinon.stub(module,'function') .returns(fakeData)
需求(待测试模块) 在调用入口点之前
行动 在重新要求待测试模块后

A.4.5 使用 testdouble 模拟模块

Testdouble 是另一个可以轻松用于模拟事物的隔离框架。由于之前的测试中已经完成的重构,代码更改很小。

列表 A.8 使用 testdouble 模拟模块

let td;

const resetAndRequireModules = () => {
  jest.resetModules();
  td = require('testdouble');
  require('testdouble-jest')(td, jest);
};
const fakeDataFromModule = fakeData => {
  td.replace('../my-data-module', {
    getAllMachines: () => fakeData
  });
};

const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  return findRecentlyRebooted(maxDays, fromDate);
};

这里列出了使用 testdouble 的重要部分。

清晰 在每次测试前:jest.resetModules + require('testdouble'); require('testdouble-jest') (td, jest);
模拟 在每次测试前:Td.replace(module, fake object)
需求(待测试模块) 在调用入口点之前
行动 在重新要求待测试模块后

测试实现与 Sinon 示例完全相同。我们也在使用testdouble-jest,因为它连接到 Jest 模块替换功能。如果我们使用不同的测试框架,则不需要这样做。

这些技术起作用,但我建议除非绝对没有其他方法,否则请远离它们。几乎总是有另一种方法,你可以在第三章中看到其中许多。

posted @ 2025-11-20 09:31  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报