JUnit实战(第3版)

978-7-115-57853-2
作者: [罗马尼亚]克特林·图多塞(Cătălin Tudose)
译者: 沈泽刚王永胜
编辑: 吴晋瑜

图书目录:

详情

本书全面介绍JUnit 5的新特性及其主要应用。全书共22章,分为五部分。第一部分介绍JUnit 的核心、JUnit的体系结构、从JUnit 4向JUnit 5迁移、软件测试原则等内容;第二部分介绍软件测试质量、用stub和mock object进行测试、容器内测试等内容;第三部分介绍用Maven和Gradle工具运行JUnit测试、IDE对JUnit 5的支持、JUnit 5的持续集成等内容;第四部分介绍JUnit 5扩展模型,表示层测试,Spring、Spring Boot和REST API以及数据库应用程序的测试等内容;第五部分介绍使用JUnit 5进行测试驱动开发和行为驱动开发,以及用JUnit 5实现测试金字塔策略等内容。 本书既适合刚接触JUnit框架的Java开发人员阅读,也适合想要了解JUnit 5新特性的、经验丰富的JUnit开发人员学习,尤其适合企业级Java开发人员阅读。本书还可作为高等院校学生“软件测试”课程的参考用书。

图书摘要

版权信息

书名:JUnit实战(第3版)

ISBN:978-7-115-57853-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


版  权

著    [罗马尼亚]克特林·图多塞(Cătălin Tudose)

译    沈泽刚 王永胜

责任编辑 吴晋瑜

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e57853”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

内 容 提 要

本书全面介绍JUnit 5的新特性及其主要应用。全书共22章,分为五部分。第一部分介绍JUnit 的核心、JUnit的体系结构、从JUnit 4向JUnit 5迁移、软件测试原则等内容;第二部分介绍软件测试质量、用stub和mock object进行测试、容器内测试等内容;第三部分介绍用Maven和Gradle工具运行JUnit测试、IDE对JUnit 5的支持、JUnit 5的持续集成等内容;第四部分介绍JUnit 5扩展模型,表示层测试,Spring、Spring Boot和REST API以及数据库应用程序的测试等内容;第五部分介绍使用JUnit 5进行测试驱动开发和行为驱动开发,以及用JUnit 5实现测试金字塔策略等内容。

本书既适合刚接触JUnit框架的Java开发人员阅读,也适合想要了解JUnit 5新特性的、经验丰富的JUnit开发人员学习,尤其适合企业级Java开发人员阅读。本书还可作为高等院校学生“软件测试”课程的参考用书。

前  言

我在IT行业工作了近25年,颇感幸运。最初编程时,我使用的是C++和Delphi。学生时代和职业生涯的头几年,我就是在编程中度过的。得益于在青少年时期学到的数学知识,我步入了计算机科学领域,并一直从事这两方面的研究。2000年,我开始关注Java编程语言。尽管那时的Java还“年纪轻轻”,但是很多人预测其将“前途无量”。我在一个在线游戏开发团队里工作,用的是一种特殊的技术——当时非常流行的applet技术。团队开发程序要花一定的时间,测试程序则要花更多的时间,因为测试主要是手动完成的:团队成员一起在网络中运行程序,各自竭尽全力去发现可能出现的各种问题。那时,我们都没有听说过JUnit或测试驱动开发之类的东西,因为当时这些东西还处在开拓阶段。

2004年以后,我将90%的工作时间都放到Java上。对我来说,2004年这个时间节点是一个新的开端,像代码重构、单元测试和测试驱动开发这样的事情,开始成为我职业生涯的一部分。如今,一个项目(即使是较小的项目)如果不进行自动测试,那是无法想象的,我所在的Luxoft公司的情况就是这样的。我的同事经常谈论如何在当前的工作中进行自动测试、用户的预期是什么、如何度量和提高代码覆盖率,以及如何分析测试质量等。讨论的核心不仅包括单元测试和测试驱动开发,还包括行为驱动开发。在没有可靠的测试的情况下发布能满足市场预期的产品现在来看是无法想象的。这种可靠的测试,实际上像一个由单元测试、集成测试、系统测试和验收测试构成的金字塔。

我为在线教育网站Pluralsight开发过3门关于自动测试的课程,由此幸运地与Manning出版社建立了联系。就本书而言,我认为没有必要从头写起,因为第2版已经是畅销书了,不过那是在2010年针对Junit 4编写的。对IT领域而言,十年就像几个世纪那么长!在本书中,我会介绍JUnit 5这一当今的热门技术及其工作方法。从我开始使用单元测试和JUnit算起,单元测试和JUnit走过了漫长的道路。说起来很轻松,但要实现从Junit 4到JUnit 5的迁移,需要仔细考虑和筹划。对此,本书会用大量实例加以说明。当你在实际操作中遇到新情况不知如何是好时,我希望这些方法能对你有所帮助。

本书内容

本书涉及如何创建安全的应用程序,以及如何极大地提高开发速度并消除调试“噩梦”。这些都可以在JUnit 5(及其新特性)以及与JUnit 5协同运作的其他工具和技术的帮助下完成。

本书首先关注的是JUnit中对谁(who)、什么(what)、为什么(why)以及如何(how)等问题的理解。前几章旨在让你相信JUnit 5的“威力”和功能,后续章节会深入探讨对JUnit 5的有效使用,如从JUnit 4向JUnit 5迁移、测试策略、JUnit 5与各种工具和现代框架的协作,以及根据当前方法学习使用JUnit 5开发应用程序等。

读者对象

本书适合已经能熟练编写Java核心代码,并且有兴趣学习如何开发安全和灵活的应用程序的开发人员。你应熟悉面向对象编程,并且至少使用过Java,还需具备Maven的实用知识,能够构建Maven项目,能够在IntelliJ IDEA中打开Java程序文件、编辑并运行Java程序。对于书中某些内容,你还需了解Spring、Hibernate、REST和Jakarta EE等技术的基础知识。

本书结构

本书共22章,分为五部分。

第一部分(第1~5章)介绍JUnit 5的基础知识。

第1章简单介绍了测试的概念,并帮助你了解如何编写和运行一个非常简单的测试并查看其结果。

第2章详细讨论JUnit,让你一睹JUnit 5的强大功能,并浏览将其付诸实践的代码。

第3章介绍JUnit的体系结构。

第4章讨论如何从JUnit 4迁移到JUnit 5,以及如何在版本间迁移项目。

第5章从整体上讨论测试。该章将描述不同类型的测试及其适用场景,还将讨论不同级别的测试以及运行这些测试的最佳场景。

第二部分(第6~9章)介绍各种测试策略。

第6章分析测试质量。该章将引入一些概念,如代码覆盖率、测试驱动开发、行为驱动开发和突变测试等。

第7章是关于stub的,介绍一种隔离环境和无缝测试的解决方法。

第8章解释mock object,并对如何构造和使用mock object加以概述。

第9章描述一种不同的技术:在容器中运行测试。

第三部分(第10~13章)展示JUnit 5如何与其他工具协同工作。

第10章简要介绍Maven及其术语。

第11章介绍一个称为Gradle的流行工具。

第12章研究使用当前非常流行的IDE(IntelliJ IDEA、Eclipse和NetBeans等)与JUnit 5协作的方法。

第13章专门讨论持续集成工具。强烈推荐这种实践操作,它可以帮助你维护代码存储库并在其上实现自动化构建。

第四部分(第14~19章)展示JUnit 5如何与现代框架协同工作。

第14章介绍JUnit 5扩展的实现,它是JUnit 4的规则和运行器的一种替代。

第15章介绍HtmlUnit和Selenium。你将看到如何使用这些工具测试表示层。

第16章和第17章专门讨论当前非常有用的测试框架之一——Spring。Spring是一个用于Java平台的开源应用程序框架和控制反转容器。它包括几个独立的框架,用于创建可直接运行的应用程序的Spring Boot约定优于配置解决方法。

第18章介绍REST应用程序的测试。REST是一种应用程序接口,使用HTTP的GET、PUT、PATCH、POST和DELETE请求方法来操作(或处理)数据。

第19章讨论测试数据库应用程序的备选方案,包括JDBC、Spring和Hibernate等。

第五部分(第20~22章)展示如何根据现代软件开发方法使用JUnit 5开发应用程序。

第20章讨论如何使用当前主流的开发技术之一——测试驱动开发进行项目开发。

第21章讨论使用行为驱动开发来开发项目。它展示如何创建满足业务需求的应用程序,即不仅要把事情做对,而且要做对的事情。

第22章展示如何使用JUnit 5实现测试金字塔策略。这一策略是从底层(单元测试)到高层(集成测试、系统测试和验收测试等)的测试。

一般来说,需要按顺序阅读本书每章内容。但是,只要掌握第一部分中介绍的基本内容,你就可以直接跳到任何一章进行学习。

关于源代码

本书给出的代码(大部分)普遍较长,而不是短小的代码段。大部分代码都有注解和解释。在某些章中,代码中的注解及其在正文中的解释用数字标记。异步社区将为你提供所有的完整源代码下载。

作者简介

克特林·图多塞(Cătălin Tudose)出生于罗马尼亚阿尔杰什的皮特什蒂。

他于1997年在罗马尼亚布加勒斯特大学获得计算机科学学位,并于2006年获得该专业的博士学位。Cătălin有超过15年的Java开发经验,参与过电信和金融类项目,曾担任高级软件开发人员和技术团队负责人,目前是Luxoft Holding罗马尼亚分公司的Java和Web技术专家。

在罗马尼亚布加勒斯特大学自动化和计算机学院任教期间,Cătălin讲授了超过2000小时的课程。Cătălin在Luxoft公司讲授了超过4000小时的Java课程,其中包括Corporate Junior项目——该项目已经在波兰培养了大约50名Java开发人员。Cătălin还在公司内部开发了以Java为主题的企业课程。

Cătălin在马里兰大学全球校区(University of Maryland Global Campus,UMGC)讲授在线课程,包括Java计算机图形学、Java中级编程、Java高级编程、软件验证与有效性、数据库概念、SQL和高级数据库概念等。

Cătălin为在线教育网站Pluralsight新开设了5门课程,分别是“使用JUnit 5的TDD”“Java:BDD基础”“Java测试金字塔策略的实现”“Spring框架:用Spring AOP进行面向切面编程”“从JUnit 4向JUnit 5迁移的测试平台”。

除了IT领域,Cătălin还对数学、世界文化和足球等感兴趣。Cătălin是家乡球队FC Argeş Piteşti的铁杆儿粉丝。

封面图片简介

《JUnit实战(第3版)》封面上的人物是“Walaque夫人”。Jacques Grasset de Saint-Sauveur(1757—1810)收集了各国的服装图片,集结成书,书名为Costumes de Différents Pays,并于1797年在法国出版。这幅插图取自此书。此书中每幅插图都是精心绘制而成的,皆为手工着色。Saint-Sauveur的藏品丰富多样,以生动的画面提醒我们,200多年前,世界上的城镇和地区在文化上有多么迥异。由于彼此隔绝,人们说着不同的语言或方言。但无论是在城镇还是在乡村,通常只要看人们的衣着,就能很容易地认出他们住在哪里,在什么行业工作或从事什么职业。

从那以后,我们的着装发生了变化,而当时如此丰富的地区多样性也逐渐消失了。现在依据着装对不同大陆的居民进行区分都已很难,更不用说不同的国家、地区和城镇了。也许我们已经用文化的多样性换取了更多样的个人生活——当然是更多样和快节奏的科技生活。

在很难通过外观将计算机相关的图书区分开的情况下,Manning出版社采用两个世纪前丰富多样的地区生活图片作为图书封面,用以赞美计算机行业的发明和创举,使Saint-Sauveur的画作重现。

致  谢

本书的出版得益于Manning出版社团队的帮助,我期望今后还有这样的创作机会。

感谢我的老师和同事多年来对我的支持。感谢参加我线下面授课程或在线课程的人,是他们激励我高质量地完成工作并不断鼓励我加以改进。感谢本书第2版的合著者Petar Tahchiev、Felipe Leme、Vincent Massol以及Gary Gregory。你们撰写的第2版很强大,为我打下了坚实的基础,希望有一天能见到你们。特别感谢我的同事兼朋友Vladimir Sonkin,我们共同走过新技术的研究之路。

我要感谢Manning出版社的所有工作人员,包括组稿编辑Mike Stephens、策划编辑Deirdre Hiam、流程编辑Katie Sposato Johnson、书评编辑Mihaela Batinic、技术开发编辑John Guthrie、技术校对David Cabrero、高级技术开发编辑Al Scherer、文字编辑Tiffany Taylor和校对员Katie Tennant等。

感谢所有对本书予以评论的人:Andy Keffalas、Becky Huett、Burk Hufnagel、Conor Redmond、David Cabrero Souto、Ernesto Arroyo、Ferdinando Santacroce、Gaurav Tuli、Greg Wright、Gualtiero Testa、Gustavo Filipe Ramos Gomes、Hilde Van Gysel、Ivo Alexandre Costa Alves Angélico、Jean-François Morin、Joseph Tingsanchali、Junilu Lacar、Karthikeyarajan Rajendran、Kelum Prabath Senanayake、Kent R. Spillner、Kevin Orr、Paulo Cesar、Dias Lima、Robert Trausmuth、Robert Wenner、Sau Fai Fong、Shawn Ritchie、Sidharth Masaldaan、Simeon Leyzerzon、Srihari Sridharan、Thorsten P.Weber、Vittorio Marino、Vladimír Oraný以及Zorodzayi Mukuya等。在你们的帮助下,本书日臻完善。

资源与支持

本书由异步社区出品,社区(https://www.epubit.com)为您提供相关资源和后续服务。

您还可以扫码右侧二维码, 关注【异步社区】微信公众号,回复“e57853”直接获取,同时可以获得异步社区15天VIP会员卡,近千本电子书免费畅读。

配套资源

本书提供源代码。要获得以上配套资源,请在异步社区本书页面中单击,跳转到下载界面,按提示进行操作即可。注意:为保证购书者的权益,该操作会给出相关提示,要求输入提取码进行验证。

提交勘误

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎读者将发现的问题反馈给我们,帮助我们提升图书的质量。

如果读者发现错误,请登录异步社区,按书名搜索,进入本书页面,输入勘误信息,单击“提交勘误”按钮即可。本书的作者和编辑会对读者提交的勘误进行审核,确认并接受后,将赠予读者异步社区的100 积分(积分可用于在异步社区兑换优惠券、样书或奖品)。

扫码关注本书

扫描下方二维码,读者会在异步社区的微信服务号中看到本书信息及相关的服务提示。

与我们联系

我们的联系邮箱是contact@epubit.com.cn。

如果读者对本书有任何疑问或建议,请发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果读者有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果读者来自学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果读者在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请将怀疑有侵权行为的链接发邮件给我们。这一举动是对作者权益的保护,也是我们持续为读者提供有价值的内容的动力之源。

关于异步社区和异步图书

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT 专业图书的品牌,依托于人民邮电出版社近40年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号

第一部分 JUnit

本书主要介绍JUnit框架的相关内容。JUnit框架由Kent Beck和Erich Gamma于1995年年底着手开发。自此以后,JUnit框架日益受到欢迎,现已成为Java应用程序单元测试事实上的标准。

本书是《JUnit实战(第2版)》的升级版。《JUnit实战(第1版)》是一本畅销书,由Vincent Massol和Ted Husted于2003年编写,其内容是基于JUnit 3.x的。《JUnit实战(第2版)》也是一本畅销书,由Petar Tahchiev、Felipe Leme、Vincent Massol和Gary Gregory于2010年编写,其内容是基于JUnit 4.x的。

本书将基于JUnit 5.x介绍JUnit框架的诸多特性,并会展示一些有趣的细节和测试程序代码的技巧。本书的主要内容包括框架的体系结构、测试质量、mock object、JUnit与其他工具的交互、JUnit扩展以及应用程序测试各层,还包括测试驱动开发和行为驱动开发技术的应用等。

这一部分探讨JUnit本身的一些内容。第1章介绍JUnit测试的概念,也就是你首先应学习的知识。我们将直接引入代码,展示如何编写和运行一个简单的测试并查看其结果。第2章详细介绍JUnit的核心内容,展示JUnit 5的功能,并介绍将其付诸实践的代码。第3章介绍JUnit的体系结构。第4章讨论如何完成从JUnit 4到JUnit 5的迁移,以及项目如何在JUnit框架的不同版本之间迁移。第5章专门介绍软件测试,描述不同类型的测试及其适用的场景,并讨论不同层级的测试以及运行这些测试的最佳场景。

第1章 JUnit起步

本章重点

认识JUnit。

安装JUnit。

编写第一个测试。

运行测试。

在软件开发领域,从未有过“很少的几行代码对大量代码起到至关重要的作用”这样的事情。

——Martin Fowler

所有代码都需要经过测试。在开发期间,我们要做的第一件事就是运行自己的“验收测试”。我们通常会编写代码、编译代码,然后运行代码。运行代码,实际上就是在测试代码。测试可能只是单击一个按钮,看它是否能弹出预期的菜单,或者查看结果并将其与预期的值加以比较。不管怎样,我们每天都会重复“编写代码、编译代码、运行(测试)代码”这样的过程。

测试时,我们经常会发现各种问题,尤其是在程序第一次运行时,然后再去重复上面的过程。

我们中的大多数人很快会形成一套非正式的测试模式:添加记录、查看记录、编辑记录以及删除记录。手动执行这样的操作非常容易,所以我们会不断重复这种模式。

有些开发人员喜欢做这种重复性的测试。在经过思考和艰难的编码之后,这种重复性的测试可以为其带来一段惬意的休息时间。当这种轻松单击鼠标式的测试最终成功时,一种成就感便油然而生——搞定了!我搞定了!

但有些开发人员不喜欢这种重复性的工作。与其手动运行测试,他们宁愿编写一个短小的程序来自动运行测试。编写测试代码是一回事,运行自动测试则是另一回事。

如果你是一名编写测试代码的开发人员,那么本书就是为你准备的。我们将为你展示创建自动测试是多么简单、有效,甚至有趣。

如果你是深受“测试感染(test-infected[1])”影响的开发人员,那么本书也同样适合你。我们将在第一部分介绍JUnit测试的基础知识,然后在第二到第五部分探讨一些棘手的现实问题。

[1] test-infectd是由Erich Gamma和Kent Beck提出的一个术语,参见Test-Infected: Programmers Love Writing Tests,Java Report 3 (7), 37–50, 1998。

1.1 证明程序的可运行性

有些开发人员认为自动测试是开发过程中非常重要的一部分:只有通过一系列全面的测试,才能证明组件是有效的。曾有两位开发人员认为这种类型的单元测试非常重要,甚至认为值得为其编写一个框架。1997年,Erich Gamma和Kent Beck针对Java开发了一个简单、有效的单元测试框架,将其命名为JUnit:在一次长途旅行中,他们有了做这件趣事的机会。Erich想让Kent学习Java,而他自己对Kent之前为Smalltalk编写的SUnit测试框架产生了浓厚兴趣,这次旅行给了他们做这两件事的机会。

定义:

框架(framework)是一个应用程序的半成品[2]。框架提供一个可复用的公共结构,可以在多个应用程序之间共享。开发人员将框架融入他们自己的应用程序中,并对其加以扩展以满足特定需求。框架与工具包的不同之处在于,框架提供了一致的结构,而不是一组简单的工具类。框架定义了一个骨架,应用程序则通过定义自己的特性来填充骨架。开发人员的代码在适当的时候被框架调用。开发人员不用担心设计是否良好,而应更多地关注如何实现特定领域的功能。

[2] Ralph Johnson,Brian Foote, Designning Reuserable Classes, Journal of Objected-Oriented Programming1(2): 22-35, 1988.

如果你对Erich Gamma和Kent Beck这两个人名感到似曾相识,也在情理之中。Erich Gamma是经典作品Design Patterns一书[3]的作者之一;Kent Beck则因他在软件领域的开创性工作(“极限编程”)而闻名。

[3] Erich Gamma et al., Design Patterns (Reading , MA: Addison-Wesley, 1995).

JUnit很快成了Java应用程序单元测试事实上的标准框架。如今,JUnit作为一个开源软件托管在GitHub上,拥有Eclipse公共许可证。底层测试模型xUnit正在成为所有语言的标准框架。xUnit框架可用于ASP、C++、C#、Eiffel、Delphi、Perl、PHP、Python、REBOL、Smalltalk和Visual Basic等,此处不一一列举。

当然,软件测试乃至单元测试并不是JUnit团队的发明。单元测试这个术语最初用于描述检查单个工作单元(一个类或一个方法)行为的测试。随着时间的推移,这个术语的使用范围扩大了。例如,电气电子工程师学会(IEEE)将单元测试定义为“对单个硬件、软件单元或一组相关单元的测试”。[4]

[4] IEEE Standard Computer Dictionary: A Compilation of IEEE Standard Computer Glossaries (New York, IEEE, 1990).

在本书中,“单元测试”这一术语的定义较为狭窄,指“检查独立于其他单元的单个单元”的测试。这里关注的是开发人员在自己的代码中所应用的小型增量测试。有时,我们把这些测试称为开发人员测试(programmer test),以区别于质量保证测试或用户测试。

下面是从本书的角度对典型单元测试做出的一般性描述:“确保方法接收预期范围的输入值,并且该方法对每个输入值返回预期的值”。这个描述要求通过它的接口测试方法的行为。如果给它赋值x,它会返回y吗?如果给它赋值z,它会抛出正确的异常吗?

定义:

单元测试(unit testing)是检验软件不同工作单元的行为测试。工作单元是不直接依赖于其他任何任务的任务。在Java应用程序中,工作单元通常是(但不总是)单个的方法。相比之下,集成测试验收测试要检查多种组件如何交互。

单元测试通常侧重于测试一个方法是否遵守了API契约的有关条款。就像人与人在特定条件下交换某些商品或服务的书面合同一样,API契约是通过方法签名而形成的正式协议。某个方法要求其调用者提供具体的对象引用或基本类型值,并返回一个对象引用或基本类型值。如果该方法不能完成契约,测试应该抛出一个异常,这时我们就说该方法违反了契约。

定义:

API契约是API的视图,是调用者和被调用者之间的一种正式协议。通常,单元测试通过证明预期的行为来帮助定义API契约。API契约的概念源于“Design by Contract”(按契约设计)的实践,因Eiffel编程语言而流行。

本章将从零开始介绍为一个简单的类创建单元测试:首先介绍编写一个测试和最小运行时框架,让你可以了解以前的工作是如何进行的;其次介绍JUnit,展示如何用适当的工具让工作变得更简单。

1.2 从零开始

在本书第一个示例中,我们将创建一个非常简单的计算器类Calculator,以计算两个数相加的结果。用于测试的Calculator类为用户端提供了API,但不包含用户界面,如清单1.1所示。为了测试其功能,我们先创建一个纯Java测试,然后转用JUnit 5。

清单1.1 用于测试的Calculator类

public class Calculator { 
   public double add(double number1, double number2) {
      return number1 + number2;
   }
}

尽管没有给出文档,但是Calculator类的add(double, double)方法显然带有两个double型参数,并返回这两个double型参数之和。虽然编译器能够正确编译这段代码,但是我们还应确保它在运行时能正常工作。单元测试的一个核心原则是“任何没有经过自动测试的程序功能都可以当作不存在[5]”。这个add方法就是这个Calculator类的一个核心功能。我们通过编写代码实现了这个功能,但缺少证明该功能能正常工作的自动测试。

[5] Kent Beck, Extreme Progrmming Explained:Embrace Change(Reading, MA: Addison-Wesley, 1999).

add方法如此简单就不会出错吗?

目前,add方法的实现非常简单,不易出错。如果add不是一个重要的工具方法,可能不会直接对其加以测试。在这种情况下,如果add真的出错,那么使用add方法的测试都将会出错。add方法会被间接地测试,但终究是被测试了。在上述程序的上下文中,add不仅是一个方法,也是一个程序功能(program feature)。为了确保程序正确运行,大多数开发人员会期待有一个针对add方法的自动测试,不管实现看起来有多简单。在某些情况下,你可以通过自动功能测试或自动验收测试来证明程序功能。有关软件测试的更多信息请参阅第5章。

在这个时候进行任何测试看起来都很难,因为我们甚至没有一个用户界面输入一对double值。我们可以编写一个短小的程序,等待用户输入两个double值,然后显示结果。这样一来,就同时测试了输入数字及求和的功能——这比我们想要的还要多,我们只想知道这个工作单元是否能计算两个double型参数之和并返回正确结果,并没想知道测试人员输入的是否是数字。

与此同时,如果想花大力气测试工作成果,那么我们应该尽量使得这一份投入物有所值。在编写代码时就知道add(double, double)方法是否可以正常运行固然好,但实际上你真正应知道的是“在交付应用程序的其余部分或者在任何时候进行后续修改时,该方法是否依然能够正常运行”。如果综合考虑这些需求,我们就会萌生这样一个想法:为add方法编写一个简单的测试程序。

这个测试程序可以将已知值传递给方法,并判断结果是否与我们预期的一致。我们也可以随后再次运行这个测试程序,以确保该方法随应用程序的完善仍能继续正常运行。那么,我们所能编写的最简单的测试程序是什么呢?清单1.2所示的这个CalculatorTest程序怎么样?

清单1.2 一个简单的CalculatorTest程序

public class CalculatorTest {
   public static void main(String[] args) {
      Calculator calculator = new Calculator();
      double result = calculator.add(10, 50);
      if (result != 60) {
         System.out.println("Bad result: " + result);
      }
   }
}

CalculatorTest类非常简单:创建一个Calculator实例,给add方法传递两个数值参数,然后验证结果。若结果不是所预期的,就在标准输出中输出一条消息。

如果现在编译并运行这个程序,测试就会通过,一切似乎都很正常。但是,如果修改代码让其出错,会怎样呢?你必须仔细盯着屏幕上的错误消息。你可能不必提供输入数据,但还是需要检验监控程序输出的能力。需要测试的是代码,而不是你自己!

在Java中,表示错误的传统做法是抛出一个异常。那么,我们就可以抛出一个异常,以表示测试失败。

你或许还想针对Calculator其他尚未编写的方法(如subtract方法或multiply方法)运行测试,那么转向模块化的设计可以让捕获和处理异常变得更加容易,也可以让以后扩展测试程序变得更容易。清单1.3给出了略有改进的CalculatorTest程序。

清单1.3 略有改进的CalculatorTest程序

public class CalculatorTest {
 
   private int nbErrors = 0;
 
   public void testAdd() {  ←--- 
      Calculator calculator = new Calculator();
      double result = calculator.add(10, 50);
      if (result != 60) {
         throw new IllegalStateException("Bad result: " + result);
      }
   }  ←--- 
 
   public static void main(String[] args) {
      CalculatorTest test = new CalculatorTest();
      try {  ←--- 
         test.testAdd();
      }
      catch (Throwable e) {
         test.nbErrors++;
         e.printStackTrace();
      }
      if (test.nbErrors > 0) {  ←--- 
         throw new IllegalStateException("There were " + test.nbErrors
 + " error(s)");
      }
   }
}

处,把测试代码移到testAdd方法中。现在要观察测试做了什么,就变得容易多了。你也可以增加更多的方法,编写更多的单元测试,而不会使main方法变得难以维护。在处,修改了main方法,以便在发生错误时输出栈跟踪信息。最后,如果发生任何错误,就抛出一个总结性的异常使程序结束运行。

现在我们实现了一个简单的应用程序及其测试。你可能会发现,即使是很小的类及其测试也能让你从中获益——这些少量的“骨架代码”是为运行和管理测试结果而创建的。然而,随着应用程序越来越复杂,测试也越来越多,继续构建和维护一个自定义测试框架就成了一种负担。

接下来,我们“退一步”,来看一下单元测试框架的一般情况。

1.2.1 单元测试框架的规则

单元测试框架应遵循以下最佳实践规则。清单1.3所示的CalculatorTest程序中看似微小的改进强调了三大规则(以我们的经验来看),这些规则是单元测试框架都应该遵循的。

每个单元测试必须独立于其他所有单元测试而运行。

框架应该通过一个一个的测试来检测和报告错误。

应该很容易地确定要运行哪个单元测试。

清单1.3所示的略有改进的测试程序基本上遵循了上述规则,但仍存在不足。例如,要使每个单元测试真正独立,就应该在不同的类实例中运行每个单元测试。

1.2.2 添加单元测试

现在我们通过增加一个新的方法并在main方法中增加一个对应的try/catch块来添加新的单元测试。这显然是一个进步,但是在真正的单元测试集中,这样做还远远不够。经验告诉我们,较大的try/catch块会引起一些维护问题,例如,可能很容易遗漏某个单元测试,而我们对此并不知晓!

如果能够添加新的测试方法并继续正常工作,就太好了。但是如果这样做,那么程序如何知道要运行哪些方法呢?应该有一个简单的注册过程。注册方法至少要列出正在运行的测试。

另一种方法是使用Java的反射功能。一个程序可以检查自身,并决定运行任一方法,只要这一方法遵循一定的命名约定即可,例如那些名称以test开头的方法。

要使添加测试变得容易,这似乎成了单元测试框架中又一条规则。要实现这一规则的支持代码(通过注册或反射)并没那么容易,但仍值得一试。你必须预先做大量的工作,但每次添加新测试时,这些努力都会让你从中受益。

幸运的是,JUnit团队解决了上述困扰我们的问题。JUnit框架已经支持发现方法,也支持对每个测试使用一个不同的类实例和类加载器实例,并逐个报告每个测试的所有错误。JUnit团队为框架定义了如下3个不相关的目标。

框架必须有助于编写有用的测试。

框架必须有助于创建具有长久价值的测试。

框架必须有助于通过重用代码以降低编写测试的成本。

在第2章中,我们将进一步讨论这些目标。接下来,让我们看看如何安装JUnit。

1.3 安装JUnit

若用JUnit编写应用程序测试,就要了解JUnit的依赖关系。本书使用JUnit 5,这是我们撰写本书时该框架的最新版本。该版本的测试框架是模块化的,因为我们不能简单地把JAR文件添加到项目编译类路径(classpath)和运行类路径中。实际上,从JUnit 5开始,体系结构不再是单体结构(见第3章),而且随着Java 5注解的引入,JUnit也开始使用注解。JUnit 5很大程度上是基于注解的,这与以前版本中为所有测试类扩展一个基类,并为所有测试方法使用命名约定来匹配textXYZ格式的思想形成了对比。

注意:

如果你熟悉JUnit 4,那么可能想了解新版本中有哪些新内容,为什么增加新内容以及如何使用新内容。JUnit 5是JUnit的新版本,可以使用Java 8引进的编程功能,也能模块化和分层地构建测试,构建的测试也更容易理解、维护和扩展。第4章将讨论如何从JUnit 4迁移到JUnit 5,并展示如何让正在做的项目从JUnit 5的强大特性中获益。你将看到,要顺利地过渡到新版本,只需迈出很小的一步。

为了有效管理JUnit 5的依赖关系,我们应该使用构建工具。本书用的是非常流行的构建工具Maven。我们将在第10章中专门讨论在Maven中运行JUnit测试的主题,现在仅需了解Maven背后的基本思想:通过pom.xml文件配置项目,执行mvn clean install命令,并理解该命令的效果。

注意:

在撰写本书时,Maven的最新版本是3.6.3。

在pom.xml文件中,始终需要的依赖项如清单1.4所示。最初我们只需要junit-jupiter-api和junit-jupiter-engine两个依赖。

清单1.4 JUnit 5的pom.xml依赖

<dependency>
     <groupId>org.junit.jupiter</groupId>
     <artifactId>junit-jupiter-api</artifactId>
     <version>5.6.0</version>
     <scope>test</scope>
</dependency>
<dependency>
     <groupId>org.junit.jupiter</groupId>
     <artifactId>junit-jupiter-engine</artifactId>
     <version>5.6.0</version>
     <scope>test</scope>
</dependency>

要从命令提示符窗口运行测试,请确保pom.xml配置文件中有一个为Maven Surefire插件提供的JUnit程序依赖项,如清单1.5所示。

清单1.5 pom.xml中Maven Surefire插件的配置

<build>
    <plugins>
         <plugin>
             <artifactId>maven-surefire-plugin</artifactId>
             <version>2.22.2</version>
         </plugin>
    </plugins>
</build>

由于Windows是最常用的操作系统之一,这里的示例配置使用最新版本的Windows 10。路径、环境变量和命令提示符窗口等概念在其他操作系统中也有。如果不是在Windows操作系统上运行示例,请参考相关文档。

要运行测试,Maven目录的bin文件夹必须位于操作系统路径上,如图1.1所示。还需要在操作系统上配置JAVA_HOME环境变量,令其指向Java安装文件夹,如图1.2所示。另外,JDK版本必须至少为8,这是JUnit 5所要求的。

图1.1 操作系统路径的配置必须包含Maven目录的bin文件夹

图1.2 JAVA_HOME环境变量的配置

欲获得图1.3所示的结果,需要使用本章的源文件。打开命令提示符窗口,进入项目文件夹(包含pom.xml文件),执行下面的命令:

mvn clean install

执行这个命令将获取Java源代码,编译、测试并将其转换为可执行的Java程序(在该例中是一个JAR文件)。测试的结果如图1.3所示。

有关使用Maven和Gradle运行测试的更多细节参见本书第三部分。

图1.3 测试的结果

1.4 使用JUnit测试

JUnit有许多特性,这些特性使编写、运行测试变得更加容易。本书将介绍以下特性。

针对每个单元测试,分离测试类实例和类加载器实例,以免产生副作用。

使用JUnit注解提供资源初始化和清理方法:@BeforeEach、@BeforeAll、@AfterEach和@AfterAll(从JUnit 5开始),以及@Before、@BeforeClass、@After和@AfterClass(JUnit 4及以下版本支持)。

提供多种断言方法,使检查测试结果变得更容易。

提供与Maven和Gradle等流行工具的集成,以及与Eclipse、NetBeans和IntelliJ等流行的集成开发环境(IDE)的集成。

清单1.6展示了用JUnit编写的简单CalculatorTest程序。

清单1.6 用JUnit编写的简单CalculatorTest程序

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
 
public class CalculatorTest {  ←--- 
 
   @Test  ←--- 
   public void testAdd() {
      Calculator calculator = new Calculator();  ←--- 
      double result = calculator.add(10, 50);   ←--- 
      assertEquals(60, result, 0);   ←--- 
   }
}

该测试非常简单,使用Maven运行该测试可以得到与图1.3类似的结果。处的语句定义了一个测试类——测试类名通常以Test结尾。若使用JUnit 3,则需要扩展TestCase类,但JUnit 4去掉了这个限制条件。另外,在JUnit 4之前,测试类必须是公有的。从JUnit 5开始,顶级测试类可以是公有的,也可以是包私有(package-private)的,并且可以任意命名。

处的语句用@Test注解将一个方法标记为单元测试方法。过去,通常按照testXYZ格式来命名测试方法,这是JUnit 3所要求的,现在就不需要这样了。有些开发人员删除了前缀test并用描述性短语作为方法名。我们可以随意命名方法,只要使用@Test注解,JUnit就会予以运行。JUnit 5的@Test注解属于org.junit.jupiter.api这个新包,而JUnit 4的@Test注解属于org.junit包。除了明确强调的地方(例如,显示从JUnit 4迁移的地方),本书用的都是JUnit 5的功能。

处的语句用于创建Calculator类的一个实例(被测试的对象)并对其进行测试。和前文一样,在处,调用测试方法来运行测试,并向其传递两个已知的值。

JUnit框架开始展示它的“威力”了!为了检查测试结果,我们用处的语句调用assertEquals方法,该方法是在类的第一行中通过静态导入法导入的。assertEquals方法的Javadoc如下所示。

/**
 * Assert that expected and actual are equal within the non-negative delta.
 * Equality imposed by this method is consistent with Double.equals(Object)
 * and Double.compare(double, double). */
public static void assertEquals(
   double expected, double actual, double delta)

在清单1.6中,向assertEquals方法传递了以下参数:

expected = 60
actual = result
delta = 0

这里给calculator的add方法传递了参数10和50,然后告诉assertEquals预期的和为60(为delta传递0,因为这些数字的小数部分是0,所以10和50相加不会出现浮点错误)。调用calculator的add方法时,将返回值保存在一个double型的局部变量resullt中。因此,可将该变量传递给assertEquals方法,以便与预期值(60)进行比较。如果实际值不等于预期值,JUnit会抛出一个未检查的异常,从而导致测试失败。

在大多数情况下,delta参数的值可以是0,也可以忽略。该参数用于非精确计算,包括许多浮点计算。delta用于提供一个误差范围:如果实际值在expected − delta和expected + delta之间,测试就算通过。当运行带有舍入或截断误差的数学计算时,或者在断言文件修改日期的条件时,你就会发现这一点很有用,因为日期的精度取决于操作系统。

对于清单1.6中的CalculatorTest类,最值得一提的是,它的代码比清单1.2或清单1.3所示的CalculatorTest程序更易编写。此外,你还可以用JUnit框架自动运行测试。

当在命令提示符窗口中运行测试时(见图1.3),我们会看到所花的时间和通过测试的单元数量。还有许多方法可以运行测试,使用IDE和不同的构建工具也可以运行测试。这个简单的示例只是让你初步领略一下JUnit和单元测试的强大功能。

我们可以修改Calculator类,给它人为地增加一个bug(例如做减法而不是做加法),再运行测试,这样便可观察到测试失败的结果。

在第2章中,我们将进一步研究JUnit框架的类(注解和断言机制)和功能(嵌套测试和标记测试,以及重复的、参数化的、动态的测试),还将展示如何让JUnit框架类和功能协同工作来使单元测试更高效。学完这些内容,你将了解如何在实践中使用JUnit 5特性,以及JUnit 4和JUnit 5的差异。

1.5 小结

在本章中,我们主要讨论了以下内容。

开发人员为何应该运行某种类型的测试以检查代码是否可以正常运行。使用自动单元测试的开发人员可以按需重复这些测试,以确保新代码能够正常运行,且不会破坏现有的测试。

即便不使用JUnit,也能比较容易地编写简单的单元测试。

随着测试变得越来越多且越来越复杂,编写和维护测试也会变得越来越困难。

JUnit是一个单元测试框架,可以让创建、运行和修改单元测试更容易。

如何逐步完成一个简单的JUnit测试。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e57853”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

第2章 探索JUnit的核心

本章重点

理解JUnit的生命周期。

使用JUnit核心类、方法和注解。

展示JUnit机制。

错误是发现之门。

——James Joyce

在第1章中,我们已经明确说明需要一种可靠的、可重用的方法来测试程序。解决方法是通过编写或复用一个框架来驱动所编写的程序API的测试代码。随着程序代码量的增长,我们会在现有的类中添加一些新类和新方法,这样也会使测试代码量增长。根据经验,类有时会以意想不到的方式交互,因此我们需要确保无论代码怎样修改,都可以在任何时候运行所有测试。但是如何运行多个测试类呢?如何发现哪些测试通过了,哪些测试失败了呢?

在本章中,我们来看看JUnit为解决以上问题提供了哪些功能。本章先简要介绍JUnit的核心概念(测试类、方法和注解等),然后详细介绍JUnit 5的各种测试机制和JUnit生命周期。

本章按照Manning出版社出版的“实战”系列图书的思路编写,主要着眼于新核心特性的使用。如需获得每个类、方法和注解的完整文档,请访问JUnit 5用户指南或JUnit 5的Javadoc文档。

本章提到的Tested Data Systems公司,是一家使用了测试机制的示例公司。这是一家为多个用户运行数个Java项目的外包公司。这些项目使用不同的框架和不同的构建工具,但它们有一些共同之处,那就是都需要测试,以确保编写了高质量的代码。一些较旧的项目使用JUnit 4运行测试,较新的项目已经开始使用JUnit 5运行测试。这家公司的开发人员已打算深入了解JUnit 5,并将其应用到需要从JUnit 4迁移到JUnit 5的项目中。

2.1 核心注解

清单2.1列出了第1章的CalculatorTest程序,其定义了一个测试类,其中包含一个testAdd测试方法。

清单2.1 CalculatorTest程序

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
 
public class CalculatorTest {
 
   @Test
   public void testAdd() {
      Calculator calculator = new Calculator();
      double result = calculator.add(10, 50);
      assertEquals(60, result, 0);
   }
}

下面是一些重要的概念。

测试类可以是顶级类、静态成员类或使用@Nested注解的包含一个或多个测试方法的内部类。测试类不能是抽象的,必须有单一的构造方法。构造方法必须不带参数,或者所带参数能通过依赖注入(详细内容见2.6节)在运行时动态解析。作为可见性的最低要求,测试类允许是包私有的,不再像JUnit 4.x那样要求测试类是公有类。在这里的示例中,因为没有定义其他构造方法,所以也无须定义无参数的构造方法,Java编译器将提供一个无参数的构造方法。

测试方法是用@Test、@RepeatedTest、@ParameterizedTest、@TestFactory或@TestTemplate等注解标注的实例方法。测试方法不能是抽象的,也不能有返回值,即返回值类型应该是void。

生命周期方法是用@BeforeAll、@AfterAll、@BeforeEach或@AfterEach等注解的方法。

测试时,我们需要用从清单2.1中导入的类、方法和注解,需要声明依赖关系。大多数项目使用构建工具(如第1章所述,这里使用Maven。第10章将介绍如何在Maven中运行JUnit测试)管理这些类、方法和注解。

在Maven中,我们只需完成基本任务:配置项目的pom.xml文件、执行mvn clean install命令,并理解命令的效果。清单2.2显示了在Maven的pom.xml配置文件中所使用的JUnit 5最小依赖项。

清单2.2 在Maven的pom.xml配置文件中所使用的JUnit 5最小依赖项

<dependency>
     <groupId>org.junit.jupiter</groupId>
     <artifactId>junit-jupiter-api</artifactId>  ←--- 
     <version>5.6.0</version>
     <scope>test</scope>
</dependency>
<dependency>
     <groupId>org.junit.jupiter</groupId>
     <artifactId>junit-jupiter-engine</artifactId>  ←--- 
     <version>5.6.0</version>
     <scope>test</scope>
</dependency>

清单2.2表明,JUnit 5所需的最小依赖项是junit-jupiter-api(处)和junit-jupiter-engine(处)。

JUnit在调用每个@Test标注的方法之前创建测试类的一个新实例,以确保测试方法的独立性,并防止测试代码中出现意想不到的副作用。另外,测试得到的结果必须与运行顺序无关,这是一个被普遍认可的事实。因为每个测试方法都在测试类的一个新实例上运行,所以不能跨测试方法重用实例变量值。为要运行的每个测试方法创建测试类的一个实例,这是JUnit 5和之前所有版本的默认行为。

如果用@TestInstance(Lifecycle.PER_CLASS)标注测试类,JUnit 5将在同一个测试类实例上运行所有测试方法。使用该注解,我们可为每个测试类创建一个新的测试实例。

清单2.3显示了在lifecycle.SUTTest类中JUnit 5生命周期方法的使用情况。Tested Data Systems公司管理一个项目需要测试一个系统,该系统将启动、接收常规和额外的工作,然后自行关闭。生命周期方法可确保系统在每次有效测试运行之前和之后进行初始化并关闭。测试方法可检查系统是否接收到常规和额外的工作。

清单2.3 在lifecycle.SUTTest类中JUnit 5生命周期方法的使用情况

class SUTTest {
    private static ResourceForAllTests resourceForAllTests;
    private SUT systemUnderTest;
 
    @BeforeAll  ←--- 
    static void setUpClass() {
        resourceForAllTests =
           new ResourceForAllTests("Our resource for all tests");
    }
 
    @AfterAll  ←--- 
    static void tearDownClass() {
        resourceForAllTests.close();
    }
 
    @BeforeEach  ←--- 
    void setUp() {
        systemUnderTest = new SUT("Our system under test");
    }
 
    @AfterEach
    void tearDown() {  ←--- 
        systemUnderTest.close();
    }
 
    @Test  ←--- 
    void testRegularWork() {
        boolean canReceiveRegularWork =
            systemUnderTest.canReceiveRegularWork();
 
        assertTrue(canReceiveRegularWork);
    }
 
    @Test  ←--- 
    void testAdditionalWork() {
        boolean canReceiveAdditionalWork =
                   systemUnderTest.canReceiveAdditionalWork();
 
        assertFalse(canReceiveAdditionalWork);
    }
}

运行完生命周期方法,我们会看到如下情形。

在所有测试运行之前运行一次使用@BeforeAll注解的方法(处)。该方法应是静态的,除非测试类使用@TestInstance(Lifecycle.PER_CLASS)注解。

在每次测试运行之前运行使用@BeforeEach注解的方法(处)。在这个示例中,该方法被运行了两次。

使用@Test注解的两个方法(处)是单独运行的。

在每次测试运行之后运行使用@AfterEach注解的方法(处)。在这个示例中,该方法也被运行了两次。

在所有测试运行之后只运行一次使用@AfterAll注解的方法(处)。该方法应是静态的,除非测试类使用@TestInstance(Lifecycle.PER_Class)注解。

要运行这个测试类,可以使用命令“mvn -Dtest=SUTTest.java clean install”。

2.1.1 @DisplayName注解

@DisplayName注解可用于类和测试方法。该注解可以让Tested Data Systems公司的开发人员为一个测试类或测试方法指定显示名称。通常,该注解用于IDE和构建工具的测试报告中。@DisplayName注解的字符串参数可以包含空格、特殊字符,甚至是表情符号。

清单2.4中的displayname.DisplayNameTest类展示了@DisplayName注解的使用。显示的名称通常是一个完整的短语,给出了有关测试目的的重要信息。

清单2.4 @DisplayName注解

@DisplayName("Test class showing the @DisplayName annotation.")   ←--- 
class DisplayNameTest {
    private SUT systemUnderTest = new SUT();
 
    @Test
    @DisplayName("Our system under test says hello.")   ←--- 
    void testHello() {
        assertEquals("Hello", systemUnderTest.hello());
    }
 
    @Test
    @DisplayName(")  ←--- 
    void testTalking() {
        assertEquals("How are you?", systemUnderTest.talk());
    }
 
    @Test
    void testBye() {
        assertEquals("Bye", systemUnderTest.bye());
    }
}

这个示例实现了以下功能。

显示应用于整个类的名称(处)。

使用普通文本显示名称(处)。

使用表情符号显示名称(处)。

如果一个测试没有指定显示名称,就只显示方法名称。在IntelliJ中,你可以通过右击测试类,然后执行命令运行测试。在IntelliJ IDE中运行该测试类的结果如图2.1所示。

图2.1 在IntelliJ中运行DisplayNameTest的结果

2.1.2 @Disabled注解

@Disabled注解可用于测试类和方法,表示禁用测试类或测试方法不予以运行。开发人员用这个注解给出禁用一个测试的理由,以便团队的其他成员确切地知道为什么要这么做。如果该注解用在一个类上,将禁用该测试类的所有方法。此外,当开发人员在IDE中运行测试时,被禁用的测试及禁用原因在不同的控制台上显示的内容也有所不同。

disabled.DisabledClassTest类和disabled.DisabledMethodsTest类显示了该注解的用法,如清单2.5和清单2.6所示。

清单2.5 在测试类上应用@Disabled注解

@Disabled("Feature is still under construction.")   ←--- 
class DisabledClassTest {
    private SUT systemUnderTest= new SUT("Our system under test");
 
    @Test
    void testRegularWork() {
        boolean canReceiveRegularWork = systemUnderTest.
        canReceiveRegularWork();
 
        assertTrue(canReceiveRegularWork);
    }
 
    @Test
    void testAdditionalWork() {
        boolean canReceiveAdditionalWork =
                systemUnderTest.canReceiveAdditionalWork();
 
        assertFalse(canReceiveAdditionalWork);
    }

这里禁用了整个测试类,并给出了原因(处)。建议使用此技术,以便你和你的同事能够马上得知为什么禁用该测试。

清单2.6 在方法上应用@Disabled注解

class DisabledMethodsTest {
    private SUT systemUnderTest= new SUT("Our system under test");
 
    @Test
    @Disabled  ←--- 
    void testRegularWork() {
       boolean canReceiveRegularWork =
                 systemUnderTest.canReceiveRegularWork ();
 
       assertTrue(canReceiveRegularWork);
    }
 
    @Test
    @Disabled("Feature still under construction.")   ←--- 
    void testAdditionalWork() {
       boolean canReceiveAdditionalWork =
               systemUnderTest.canReceiveAdditionalWork ();
 
       assertFalse(canReceiveAdditionalWork);
    }
}

从清单2.6可以看到:

代码中提供的两个测试方法都被禁用了。

其中一个被禁用的测试方法没有给出原因(处)。

另一个被禁用的测试方法给出了其他开发人员可以理解的信息(处)——这是推荐的方法。

2.2 嵌套测试

内部类(inner class)是另一个类的成员。该类可以访问外部类的所有私有实例变量,因为其实际上是外部类的一部分。典型的用例是当两个类紧密耦合时,从内部类直接访问外部类的所有实例变量是符合逻辑的。例如,可能要对有两种类型的登机乘客的航班加以测试。航班的行为可在外部测试类中进行描述,而每种类型的乘客的行为可在其自己的嵌套类中描述。每种类型的乘客都能与航班交互。让嵌套测试遵循这种业务逻辑,最终就能编写出更清晰的代码,因为开发人员更容易理解这种测试过程。

按照这种紧密耦合的思想,嵌套测试使测试编写人员具备更强的能力去表达测试组之间的关系。内部类可以是包私有的。

Tested Data Systems公司需要与用户合作。每位用户都有性别、名、姓,有时还有中间名,以及他们成为用户的日期(如果日期是已知的话)。因为有些参数可能不存在,所以开发人员使用构建器模式来创建和测试用户。

嵌套测试(在NestedTestsTest类上使用@Nested注解)如清单2.7所示。正在测试的用户是John Michael Smith,他成为用户的日期是已知的。

清单2.7 嵌套测试

public class NestedTestsTest {  ←--- 
    private static final String FIRST_NAME = "John";  ←--- 
    private static final String LAST_NAME = "Smith";
 
    @Nested  ←--- 
    class BuilderTest {
        private String MIDDLE_NAME = "Michael";
 
        @Test  ←--- 
        void customerBuilder() throws ParseException {
            SimpleDateFormat simpleDateFormat =
                  new SimpleDateFormat("MM-dd-yyyy");
            Date customerDate = simpleDateFormat.parse("04-21-2019");
 
            Customer customer = new Customer.Builder(  ←--- 
                                     Gender.MALE, FIRST_NAME, LAST_NAME)
                                    .withMiddleName(MIDDLE_NAME)
                                    .withBecomeCustomer(customerDate)
                                    .build();  ←--- 
            assertAll(() -> {  ←--- 
                assertEquals(Gender.MALE, customer.getGender());
                assertEquals(FIRST_NAME, customer.getFirstName());
                assertEquals(LAST_NAME, customer.getLastName());
                assertEquals(MIDDLE_NAME, customer.getMiddleName());
                assertEquals(customerDate,
                             customer.getBecomeCustomer());
            });  ←--- 
        }
    }
}

NestedTestsTest是主测试类(处),它与嵌套测试类BuilderTest紧密耦合(处)。首先,NestedTestsTest定义了将用于所有嵌套测试的一位用户的名和姓(处)。嵌套测试BuilderTest使用构建器模式(处)验证一个Customer对象的构造(处)。在customerBuilder测试方法的最后对字段值是否相等进行验证(处)。

源代码文件还包含一个CustomerHashCodeTest嵌套类,该类则包含另两个测试。

2.3 标记测试

如果熟悉JUnit 4,你就知道标记测试(tagged test)是JUnit 4分类的一种替代。你可以在类和测试方法上使用@Tag注解标记,然后可以利用这些标记过滤测试的发现和运行。

清单2.8给出了CustomerTest标记类的代码,可用于测试创建Tested Data Systems用户的正确性。清单2.9给出了CustomerRepositoryTest标记类的代码,可用于测试一个存储库中是否存在用户。一个可能的用例是根据业务逻辑和正在进行有效测试的内容,将测试分成几个类别(每个测试类别都有自己的标记)。你可以决定只运行某些测试或在类别之间进行选择,具体取决于目前的需要。

清单2.8 CustomerTest标记类

@Tag("individual")  ←--- 
public class CustomerTest {
    private String CUSTOMER_NAME = "John Smith";
 
    @Test
    void testCustomer() {
        Customer customer = new Customer(CUSTOMER_NAME);
 
        assertEquals("John Smith", customer.getName());
    }
}

其中,@Tag注解被添加在整个CustomerTest类上(处)。

清单2.9 CustomerRepositoryTest标记类

@Tag("repository")  ←--- 
public class CustomersRepositoryTest {
    private String CUSTOMER_NAME = "John Smith";
    private CustomersRepository repository = new CustomersRepository();
 
    @Test
    void testNonExistence() {
        boolean exists = repository.contains("John Smith");
 
        assertFalse(exists);
    }
 
    @Test
    void testCustomerPersistence() {
        repository.persist(new Customer(CUSTOMER_NAME));
 
        assertTrue(repository.contains("John Smith"));
    }
}

类似地,@Tag注解被添加在整个CustomerRepositoryTest类上(处)。清单2.10是用于这些测试的Maven中pom.xml配置文件的内容。

清单2.10 pom.xml配置文件的内容

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
    <!--  ←--- 
    <configuration>
        <groups>individual</groups>
        <excludedGroups>repository</excludedGroups>
    </configuration>
    -->  ←--- 
</plugin>

激活这些标记测试类的方法有两种。

一种方法是在pom.xml配置文件级别上操作。在本示例中,取消对Surefire插件的配置节点的注释(处),然后执行mvn clean install命令就可以了。

另一种方法是在IntelliJ IDEA中选择Run>Run>Edit Configurations>Tags(JUnit 5)作为测试类型,创建一个配置,如图2.2所示。要快速更改在本地运行的测试时,这种方法还是不错的。但是,强烈建议在pom.xml配置文件级别上更改,否则,项目的自动构建都会以失败告终。

图2.2 在IntellJ IDEA中创建一个配置

2.4 断言

要运行测试确认,应该使用JUnit的Assertions类提供的断言方法。正如前面示例所示,我们在测试类中静态导入了这些方法,当然也可以导入Assertions类本身,这取决于个人对静态导入的喜好。表2.1列出了JUnit 5常用的断言方法。

表2.1 JUnit 5常用的断言方法

断言方法

用途

assertAll

重载的方法。该方法断言提供的可运行对象都不会抛出异常。可运行(executable)对象是org.junit.jupiter.api.function.Executable类的一个对象

assertArrayEquals

重载的方法。该方法断言预期的数组和实际的数组相等

assertEquals

重载的方法。该方法断言预期的值和实际的值相等

assertX(..., String message)

如果断言失败,该方法将提供的消息传递给测试框架

assertX(..., Supplier<String> messageSupplier)

如果断言失败,该方法将提供的消息传递给测试框架。断言失败的消息是从所提供的messageSupplier中延迟检索出的

JUnit 5提供了许多重载的断言方法,以及很多从JUnit 4中获得的断言方法,还添加了一些可以使用Java 8 Lambda表达式的断言方法。JUnit Jupiter断言方法都属于org.junit.jupiter.api.Assertions类,并且都是静态方法。与Hamcrest匹配器一起工作的assertThat方法已被移除。在这种情况下,推荐使用Hamcrest的MatcherAssert.assertThat重载方法。该方法更加灵活,并且符合Java 8的“精神”。

定义:

Hamcrest是在JUnit中有助于编写软件测试的框架。它支持创建定制的断言匹配器(Hamcrest是匹配器的变位词),允许以声明的方式定义匹配规则。我们将在本章后续部分讨论Hamcrest的功能。

如前所述,Tested Data Systems公司管理一个项目需要测试一个系统的启动、接收常规和额外的工作,以及自行关闭。执行某些操作后我们需要验证一些条件。在本示例中,我们还将使用Java 8中引入的Lambda表达式。Lambda表达式把功能视为方法参数,并将代码视为数据。我们可以给方法传递一个Lambda表达式,就像传递一个对象一样,并根据需要予以运行。

我们将展示assertions示例包提供的几个测试类。清单2.11显示了重载的assertAll方法的用法。其中,该方法的heading参数允许我们识别assertAll方法中的断言组。assertAll方法的失败消息可以提供关于组中每个特定断言的详细信息。此外,这里使用@DisplayName注解来提供易于理解的信息,以便明确测试的目标。我们的目的是验证与之前介绍的相同的SUT(被测系统)类。

清单2.11 assertAll方法

class AssertAllTest {
    @Test
    @DisplayName(
        "SUT should default to not being under current verification")
    void testSystemNotVerified() {
        SUT systemUnderTest = new SUT("Our system under test");
 
        assertAll("By default,   ←--- 
                   SUT is not under current verification",  ←--- 
                () -> assertEquals("Our system under test",  ←--- 
                      systemUnderTest.getSystemName()),  ←--- 
                () -> assertFalse(systemUnderTest.isVerified())  ←--- 
        );
    }
 
    @Test
    @DisplayName("SUT should be under current verification")
    void testSystemUnderVerification() {
        SUT systemUnderTest = new SUT("Our system under test");
 
        systemUnderTest.verify();
 
        assertAll("SUT under current verification",  ←--- 
                () -> assertEquals("Our system under test",  ←--- 
                      systemUnderTest.getSystemName()),  ←--- 
                () -> assertTrue(systemUnderTest.isVerified())  ←--- 
        );
    }
}

在assertAll方法的heading参数之后,我们还提供了一些参数,作为可运行对象的集合。这是一种更简短、更方便的断言方法,该方法断言所提供的可运行对象不抛出异常。

在清单2.11中,assertAll方法始终检查为自己提供的所有断言,即使其中一些断言失败。如果某个可运行对象断言失败,其余的仍将运行。对于JUnit 4,情况并非如此:如果有多个断言方法,一个位于另一个之下,其中一个断言失败,这将使其他方法停止运行。

在第一个测试中,assertAll方法接收一个消息作为参数,该消息将在提供的一个可运行对象抛出异常时显示出来(处)。然后,该方法接收一个用assertEquals(处)和assertFalse(处)验证的可运行对象。断言条件很简短,因此一目了然。

在第二个测试中,assertAll方法接收一个消息作为参数,该消息将在提供的一个可运行对象抛出异常时显示出来(处)。然后,该方法接收一个用assertEquals(处)和assertTrue(处)验证的可运行对象。与第一个测试一样,断言条件很容易阅读。

清单2.12展示了一些带消息的断言方法。由于使用了Supplier<String>,在断言成功的情况下没有提供创建复杂消息所需的说明。你可以使用Lambda表达式或方法引用验证SUT,从而提高性能。

清单2.12 一些带消息的断言方法

...
@Test
@DisplayName("SUT should be under current verification")
void testSystemUnderVerification() {
    systemUnderTest.verify();
    assertTrue(systemUnderTest.isVerified(),  ←--- 
               () -> "System should be under verification");  ←--- 
}
 
@Test
@DisplayName("SUT should not be under current verification")
void testSystemNotUnderVerification() {
    assertFalse(systemUnderTest.isVerified(),  ←--- 
            () -> "System should not be under verification.");   ←--- 
}
 
@Test
@DisplayName("SUT should have no current job")
void testNoJob() {
    assertNull(systemUnderTest.getCurrentJob(),  ←--- 
               () -> "There should be no current job");  ←--- 
}
...

清单2.12实现了如下操作。

使用assertTrue方法验证条件(处)。如果验证失败,将延迟创建消息(处)。

使用assertFalse方法验证条件(处)。如果验证失败,将延迟创建消息(处)。

使用assertNull方法验证对象的存在性(处)。如果验证失败,将延迟创建消息(处)。

用Lambda表达式作为断言方法参数的优点是,所有方法都是延迟创建的,这样就提高了性能。如果条件被满足(处),表示测试成功,那么对Lambda表达式的调用就不会发生(处)。如果测试是用旧的方式编写的,上述结果是不可能实现的。

在某些情况下,我们可能希望在给定的时间间隔内运行一个测试。在本示例中,用户自然期望被测系统能够快速运行给定的作业。JUnit 5为这种示例提供了一种简洁的解决方法。

清单2.13展示了一些assertTimeout方法。这些方法替代了JUnit 4中的Timeout(超时)规则。这些方法可以检查SUT的性能是否足够好,即SUT是否能在一段给定的超时时间内做完自己的工作。

清单2.13 一些assertTimeout方法

class AssertTimeoutTest {
    private SUT systemUnderTest = new SUT("Our system under test");
 
    @Test
    @DisplayName("A job is executed within a timeout")
    void testTimeout() throws InterruptedException {
        systemUnderTest.addJob(new Job("Job 1"));
        assertTimeout(ofMillis(500), () -> systemUnderTest.run(200));   ←--- 
    }
 
    @Test
    @DisplayName("A job is executed preemptively within a timeout")
    void testTimeoutPreemptively() throws InterruptedException {
        systemUnderTest.addJob(new Job("Job 1"));
        assertTimeoutPreemptively(ofMillis(500),   ←--- 
                                  () -> systemUnderTest.run(200));   ←--- 
    }
}

其中,assertTimeout方法用于等待可运行对象完成(处)。失败消息为“execution exceeded timeout of 500 ms by 193 ms”,即“运行超过500ms,超时时间为193ms”。

AssertTimeoutPreemptively用于超时后停止运行可运行对象(处)。失败消息为“execution timed out after 100 ms”,即“运行在100ms后超时”。

在某些情况下,你可能希望运行测试时抛出异常,这样可以强制测试在不适当的条件下运行或接收不适当的输入。在本示例中,SUT试图在没有为其指定作业的情况下运行,自然会抛出异常。JUnit 5对此也提供了一种简洁的解决方法。

清单2.14展示了一些assertThrows方法,这些方法替代了JUnit 4中的ExpectedException规则和@Test注解的预期属性。所有断言都可以针对返回的Throwable实例运行。这使得测试更具可读性,因为我们正在验证SUT是否抛出异常:预期当前有一个作业,但未找到。

清单2.14 一些assertThrows方法

class AssertThrowsTest {
    private SUT systemUnderTest = new SUT("Our system under test");
 
    @Test
    @DisplayName("An exception is expected")
    void testExpectedException() {
        assertThrows(NoJobException.class, systemUnderTest::run);   ←--- 
    }
 
    @Test
    @DisplayName("An exception is caught")
    void testCatchException() {
        Throwable throwable = assertThrows(NoJobException.class,
                                    () -> systemUnderTest.run(1000));   ←--- 
        assertEquals("No jobs on the execution list!",
                    throwable.getMessage());  ←--- 
    }
}

清单2.14实现了如下操作。

验证对systemUnderTest对象调用run方法时是否会抛出NoJobException异常(处)。

验证对systemUnderTest.run(1000)的调用是否会抛出NoJobException异常,并且用throwable变量保存对抛出异常的引用(处)。

检查保存在throwable异常变量中的消息(处)。

2.5 假设

有时测试失败是由外部环境配置、无法控制的日期或时区等问题导致的。防止在不适当的条件下运行测试是可以实现的。

假设(assumption)用来验证对运行测试所必需的先决条件的满足情况。当继续运行一个给定的测试方法没有意义时,你可以使用假设。在测试报告中,这些测试被标记为中止。

JUnit 5包含一组假设方法,适合与Java 8的Lambda表达式一起使用。JUnit 5中的假设属于org.junit.jupiter.api.Assumptions类的静态方法。message是最后一个参数。

JUnit 4的用户应该知道,JUnit 5并没有给出之前已有的所有假设,也没有给出assumeThat方法——我们可以通过该方法确认匹配器不再是JUnit的一部分。新的assumingThat方法只在假设满足时运行断言。

假设有一个只需在Windows操作系统和Java 8中运行的测试,这些先决条件被转换成JUnit 5假设,而测试只有在假设为真时才运行。清单2.15展示了一些假设方法,且仅在所施加的环境条件(操作系统是Windows,Java版本号是8)下验证SUT。如果这些条件(假设)不满足,就不会进行验证。

清单2.15 一些假设方法

class AssumptionsTest {
    private static String EXPECTED_JAVA_VERSION = "1.8";
    private TestsEnvironment environment = new TestsEnvironment(
            new JavaSpecification(
                System.getProperty("java.vm.specification.version")),
            new OperationSystem(
                System.getProperty("os.name"),
                System.getProperty("os.arch"))
            );
 
    private SUT systemUnderTest = new SUT();
 
    @BeforeEach  ←--- 
    void setUp() {
        assumeTrue(environment.isWindows());  ←--- 
    }
 
    @Test
    void testNoJobToRun() {
        assumingThat(
                () -> environment.getJavaVersion()
                                 .equals(EXPECTED_JAVA_VERSION),   ←--- 
                () -> assertFalse(systemUnderTest.hasJobToRun()));  ←--- 
    }
 
    @Test
    void testJobToRun() {
        assumeTrue(environment.isAmd64Architecture());  ←--- 
        systemUnderTest.run(new Job());  ←--- 
        assertTrue(systemUnderTest.hasJobToRun());  ←--- 
    }
}

清单2.15实现了如下操作。

在每次测试之前运行用@BeforeEach注解的方法。除非“当前环境是Windows系统”这个假设为真,否则测试将不被运行(处)。

第一个测试检查当前Java版本是否符合预期(处)。只有当这个假设为真时,该测试才能验证SUT当前没有运行任何作业(处)。

第二个测试检查当前环境体系结构(处)。只有当这个架构符合预期,该测试才会在SUT上运行一个新作业(处),并验证系统有一个作业要运行(处)。

2.6 JUnit 5的依赖注入

之前版本的JUnit不允许测试带参数的构造方法或普通方法。JUnit 5允许测试带参数的构造方法和普通方法,但是需要通过依赖注入来解析。

ParameterResolver接口在运行时动态解析参数。构造方法或普通方法的一个参数必须在运行时由注册的ParameterResolver解析。你可以按照任何顺序注入任意数量的参数。

JUnit 5现在有3个内置的解析器。你必须通过@ExtendWith注册适当的扩展来显式启用其他参数解析器。接下来,我们讨论自动注册的参数解析器。

2.6.1 TestInfoParameterResolver

如果构造方法或普通方法参数的类型是TestInfo,那么TestInfoParameterResolver将提供该类型的一个实例。TestInfo是一个类,其对象用于将当前运行的测试或容器的信息注入@Test、@BeforeEach、@AfterEach、@BeforeAll和@AfterAll等标注的方法中。然后,TestInfo会获取关于当前测试的信息:显示名称、测试类或方法以及相关的标记。显示名称可以是测试类或测试方法的名称,也可以是由@DisplayName提供的自定义名称。清单2.16展示了如何将TestInfo作为构造方法和带注解的方法的参数。

清单2.16 将TestInfo作为构造方法和带注解的方法的参数

class TestInfoTest {
    TestInfoTest(TestInfo testInfo) {
        assertEquals("TestInfoTest", testInfo.getDisplayName());  ←--- 
    }
 
    @BeforeEach
    void setUp(TestInfo testInfo) {
        String displayName = testInfo.getDisplayName();
        assertTrue(displayName.equals("display name of the method") ||  ←--- 
                   displayName.equals(
                               "testGetNameOfTheMethod(TestInfo)"));   ←--- 
    }
 
    @Test
    void testGetNameOfTheMethod(TestInfo testInfo) {
        assertEquals("testGetNameOfTheMethod(TestInfo)",
                     testInfo.getDisplayName());  ←--- 
    }
 
    @Test
    @DisplayName("display name of the method")
    void testGetNameOfTheMethodWithDisplayNameAnnotation(TestInfo testInfo) {
        assertEquals("display name of the method",
                     testInfo.getDisplayName());  ←--- 
    }
}

对于清单2.16,需要注意的有以下4点。

TestInfo参数被注入构造方法和3个普通方法中。构造方法验证显示名称是否为TestInfoTest,即其自身的名称(处)。这种行为是默认行为,可以用@DisplayName注解加以改变。

在每次测试之前运行带@BeforeEach注解的方法。该方法带有注入的TestInfo参数,以验证显示的名称是否是预期的名称:是方法的名称,还是@DisplayName注解(处)指定的名称。

两个测试方法也都带有注入的TestInfo参数。每个参数用于验证显示的名称是否是预期的名称:是第一个测试(处)中的方法名称,还是第二个测试中的@DisplayName注解指定的名称(处)。

内置的TestInfoParameterResolver提供了一个与当前容器或测试相对应的TestInfo实例,作为构造方法和普通方法的预期参数值。

2.6.2 TestReporterParameterResolver

如果构造方法或普通方法参数的类型是TestReporter,TestReporterParameterResolver将提供该类型的实例。TestReporter是一个函数式接口,因此可以用作Lambda表达式或方法引用的赋值目标。TestReporter有一个publishEntry抽象方法和几个重载的publishEntry默认方法。TestReporter的参数可以注入带有@BeforeEach、@AfterEach和@Test注解的测试类的方法中。TestReporter还可以用来提供有关正在运行的测试的附加信息。清单2.17展示了如何将TestReporter作为@Test标注的方法的参数。

清单2.17 将TestReporter作为@Test标注的方法的参数

class TestReporterTest {
 
    @Test
    void testReportSingleValue(TestReporter testReporter) {
        testReporter.publishEntry("Single value");  ←--- 
    }
 
    @Test
    void testReportKeyValuePair(TestReporter testReporter) {
        testReporter.publishEntry("Key", "Value");  ←--- 
    }
 
    @Test
    void testReportMultipleKeyValuePairs(TestReporter testReporter) {
        Map<String, String> values = new HashMap<>();  ←--- 
        values.put("user", "John");  ←--- 
        values.put("password", "secret");  ←--- 
        testReporter.publishEntry(values);   ←--- 
    }
}

在清单2.17中,TestReporter参数被注入3个方法中。

在第一个方法中,该参数用于发布单个值条目(处)。

在第二个方法中,该参数用于发布键-值对(处)。

在第三个方法中,该参数用于构造一个映射(处),用两个键-值对填充该映射(处),然后用该映射来发布构造的映射(处)。

内置的TestReporterParameterResolver提供需要发布条目的TestReporter的实例。

TestReporterTest的运行结果如图2.3所示。

图2.3 TestReporterTest的运行结果

2.6.3 RepetitionInfoParameterResolver

如果使用了@RepeatedTest、@BeforeEach或@AfterEach等注解的方法中的参数类型为RepetitionInfo,则RepetitionInfoParameterResolver提供该类型的实例。然后,RepetitionInfo会获取一个测试的当前重复次数和总重复次数的信息,而这个测试使用了@RepeatedTest注解。

2.7 重复测试

JUnit 5允许使用@RepeatedTest注解指定一个测试重复运行的次数,该注解需要指定重复的次数作为参数。当从一次测试运行到另一次测试时,测试条件可能改变,此特性将非常有用,例如,一些影响成功测试的数据可能在同一测试的两次运行之间发生改变,而对数据的意外修改将产生一个需要修复的错误。

可以用@RepeatedTest注解的name属性为每次重复测试配置自定义显示名称,该注解支持以下占位符。

{displayName}——带@RepeatedTest注解的方法的显示名称。

{currentRepetition}——当前重复次数。

{totalRepetitions}——总重复次数。

清单2.18展示了重复测试、显示名称占位符和RepetitionInfo参数的用法。第一个测试验证了来自Calculator类的add方法的运行是稳定的,并且总是提供相同的结果。第二个测试验证了集合是否遵循某种适当的行为:列表在每次迭代时接收一个新元素,而一个集合不会得到重复的元素,即使多次尝试插入这样的元素。

清单2.18 重复测试、显示名称占位符和RepetitionInfo参数的用法

public class RepeatedTestsTest {
 
    private static Set<Integer> integerSet = new HashSet<>();
    private static List<Integer> integerList = new ArrayList<>();
 
    @RepeatedTest(value = 5, name =  ←--- 
"{displayName} - repetition {currentRepetition}/{totalRepetitions}")  ←--- 
    @DisplayName("Test add operation")
    void addNumber() {
        Calculator calculator = new Calculator();
        assertEquals(2, calculator.add(1, 1),
                     "1 + 1 should equal 2");
    }
 
    @RepeatedTest(value = 5, name = "the list contains  ←--- 
{currentRepetition} elements(s), the set contains 1 element")  ←--- 
    void testAddingToCollections(TestReporter testReporter,
                                 RepetitionInfo repetitionInfo) {
        integerSet.add(1);
        integerList.add(repetitionInfo.getCurrentRepetition());
 
        testReporter.publishEntry("Repetition number",  ←--- 
            String.valueOf(repetitionInfo.getCurrentRepetition()));  ←--- 
        assertEquals(1, integerSet.size());
        assertEquals(repetitionInfo.getCurrentRepetition(),
                     integerList.size());
    }
}

清单2.18实现了如下操作。

第一个测试重复5次。每次重复输出显示名称、当前重复次数和总重复次数(处)。

第二个测试重复5次。每次重复都会显示列表中的元素数量(当前重复次数),并检查集合是否总是只有一个元素(处)。

每次重复第二个测试时,重复次数都会显示出来,因为重复次数已被注入RepetitionInfo参数中(处)。

运行这些测试的结果如图2.4和图2.5所示。重复测试每次调用的行为都类似于运行完全支持生命周期回调和扩展的常规@Test方法。这就是示例中的列表和集合被声明为静态的原因。

图2.4 在运行期间重复测试的显示名称

图2.5 第二个测试在控制台上显示的消息

2.8 参数化测试

参数化测试(parameterized test)允许使用不同的参数多次运行一个测试。这样做的最大好处是,可以编写测试,然后使用参数来运行测试。这些参数用于检查各种输入数据。参数化测试方法使用@ParameterizedTest注解。必须至少声明一个为每次调用提供参数的源,然后将参数传递给测试方法。

清单2.19所示的@ValueSource注解需要指定一个字面值数组。在运行时,此数组为参数化测试的每次调用提供一个参数。清单2.19所示的测试的目的是检查一些短语(这些短语作为参数提供)中的单词数量。

清单2.19 @ValueSource注解

class ParameterizedWithValueSourceTest {
    private WordCounter wordCounter = new WordCounter();
 
    @ParameterizedTest  ←--- 
    @ValueSource(strings = {"Check three parameters",  ←--- 
                            "JUnit in Action"})  ←--- 
    void testWordsInSentence(String sentence) {
        assertEquals(3, wordCounter.countWords(sentence));
    }
}

清单2.19实现了如下操作。

使用@ParameterizedTest注解将测试方法标记为参数化测试方法(处)。

使用@ValueSource注解指定为测试方法的参数传递的值(处)。运行两次测试方法:将@ValueSource注解提供的每个参数各运行一次。

@EnumSource注解让我们能够使用enum实例,并提供了一个可选的names参数,以指定必须使用或排除哪些实例。默认情况下,使用所有的enum实例。

清单2.20所示的@EnumSource注解用于检查一些句子中单词的数量,这些句子是作为enum实例提供的。

清单2.20 @EnumSource注解

class ParameterizedWithEnumSourceTest {
 private WordCounter wordCounter = new WordCounter();
 
    @ParameterizedTest  ←--- 
    @EnumSource(Sentences.class)   ←--- 
 void testWordsInSentence(Sentences sentence) {
        assertEquals(3, wordCounter.countWords(sentence.value()));
    }
 
    @ParameterizedTest  ←--- 
    @EnumSource(value=Sentences.class,
                names = { "JUNIT_IN_ACTION", "THREE_PARAMETERS" })  ←--- 
 void testSelectedWordsInSentence(Sentences sentence) {
        assertEquals(3, wordCounter.countWords(sentence.value()));
    }
 
    @ParameterizedTest #3
    @EnumSource(value=Sentences.class, mode = EXCLUDE, names =  ←--- 
                { "THREE_PARAMETERS" })  ←--- 
 void testExcludedWordsInSentence(Sentences sentence) {
        assertEquals(3, wordCounter.countWords(sentence.value()));
    }
 enum Sentences {
 JUNIT_IN_ACTION("JUnit in Action"),
        SOME_PARAMETERS("Check some parameters"),
        THREE_PARAMETERS("Check three parameters");
 
 private final String sentence;
 
        Sentences(String sentence) {
 this.sentence = sentence;
        }
 
 public String value() {
 return sentence;
        }
    }
}

这个示例有3个测试,其工作原理如下。

第一个测试标注为参数化。然后,将整个Sentences.class指定为枚举源(处)。因此这个测试运行了3次,对Sentences枚举的每个实例(JUNIT_IN_ACTION、SOME_PARAMETERS和THREE_PARAMETERS)各运行了1次。

第二个测试标注为参数化。然后,将Sentences.class指定为枚举源,但是这里将传递给测试的实例限制为JUNIT_IN_ACTION和THREE_PARAMETERS(处),因此这个测试运行了2次。

第三个测试标注为参数化。然后,将Sentences.class指定为枚举源。但是这里排除了THREE_PARAMETERS实例(处)。因此,这个测试对JUNIT_IN_ACTION和SOME_PARAMETERS运行了2次。

可以使用@CsvSource将参数列表表示为逗号分隔值(CSV),如String文本。如清单2.21所示,用@CsvSource注解来检查某些短语(这些短语作为参数提供)中单词的数量。这次使用了CSV格式。

清单2.21 @CsvSource注解

class ParameterizedWithCsvSourceTest {
    private WordCounter wordCounter = new WordCounter();
 
    @ParameterizedTest  ←--- 
    @CsvSource({"2, Unit testing", "3, JUnit in Action",  ←--- 
                "4, Write solid Java code"})  ←--- 
    void testWordsInSentence(int expected, String sentence) {
        assertEquals(expected, wordCounter.countWords(sentence));
    }
}

本示例有一个参数化测试,其功能如下。

测试被参数化,如相应的注解所示(处)。

传递给测试的参数来自@CsvSource注解中列出的、解析过的CSV字符串(处),因此该测试将运行3次——对CSV文件的每一行运行一次。

解析CSV文件的每一行,将第一个值赋给expected参数,将第二个值赋给sentence参数。

@CsvFileSource允许从类路径中使用CSV文件。参数化测试对CSV文件的每一行运行一次。清单2.22展示了@CsvFileSource注解的用法。清单2.23展示了类路径上的word_counter.csv文件的内容。Maven构建工具会自动将src/test/resources文件夹添加到类路径中。测试用于检查某些短语(这些短语作为参数提供)中单词的数量。这次用CSV格式,并将CSV文件作为输入源。

清单2.22 @CsvFileSource注解的用法

class ParameterizedWithCsvFileSourceTest {
    private WordCounter wordCounter = new WordCounter();
 
    @ParameterizedTest  ←--- 
    @CsvFileSource(resources = "/word_counter.csv")  ←--- 
    void testWordsInSentence(int expected, String sentence) {
        assertEquals(expected, wordCounter.countWords(sentence));
    }
}

清单2.23 word_counter.csv文件的内容

2, Unit testing
3, JUnit in Action
4, Write solid Java code 

这个示例中有一个参数化测试,接收@CsvFileSource注解中指示的行作为参数(处)。因此,该测试将运行3次:对CSV文件的每一行运行一次。解析CSV文件的每一行,将第一个值赋给expected参数,然后将第二个值赋给sentence参数。

2.9 动态测试

JUnit 5中引入了一种新的动态编程模型,可以在运行时生成测试。编写一个工厂方法,在运行时该方法会创建一系列要运行的测试。这样的工厂方法必须使用@TestFactory注解。使用@TestFactory注解的方法不是常规测试,而是一个生成测试的工厂。使用@TestFactory标注的方法必须返回以下内容之一。

DynamicNode(一个抽象类,DynamicContainer和DynamicTest是可实例化的具体类)。

DynamicNode对象数组。

DynamicNode对象流。

DynamicNode对象的集合。

DynamicNode对象的Iterable。

DynamicNode对象的Iterator。

与用@Test标注方法的要求一样,作为可见性的最低要求,@TestFactory标注的方法允许是包私有的,但不能是私有的或静态的,还可以声明由一个ParameterResolver解析的参数。

DynamicTest是在运行时生成的测试用例,由一个显示名称和一个Executable组成。Executable是Java 8的一个函数式接口。动态测试的实现可以作为Lambda表达式或方法引用来提供。

动态测试与用@Test标注的标准测试有不同的生命周期。标注了@BeforeEach和@AfterEach的方法是针对@TestFactory标注的方法来运行的,而不是针对每个动态测试。除了这些方法,没有针对单个动态测试的生命周期回调。@BeforeAll和@AfterAll的行为保持不变,即在所有测试运行之前和所有测试运行之后运行。

清单2.24所示的动态测试旨在针对数值检查一个谓词。为此,我们使用一个工厂来生成要在运行时创建的3个测试:一个为负值、一个为零、一个为正值。编写一个方法,但动态地获得3个测试。

清单2.24 动态测试

class DynamicTestsTest {
 
    private PositiveNumberPredicate predicate = new
     PositiveNumberPredicate();
 
    @BeforeAll  ←--- 
    static void setUpClass() {  ←--- 
        System.out.println("@BeforeAll method");
    }
 
    @AfterAll  ←--- 
    static void tearDownClass() {  ←--- 
        System.out.println("@AfterAll method");
    }
 
    @BeforeEach  ←--- 
    void setUp() {  ←--- 
        System.out.println("@BeforeEach method");
    }
 
    @AfterEach  ←--- 
    void tearDown() {  ←--- 
        System.out.println("@AfterEach method");
    }
 
    @TestFactory  ←--- 
    Iterator<DynamicTest> positiveNumberPredicateTestCases() {  ←--- 
        return asList(
                dynamicTest("negative number",  ←--- 
                             () -> assertFalse(predicate.check(-1))),   ←--- 
                dynamicTest("zero",  ←--- 
                             () -> assertFalse(predicate.check(0))),   ←--- 
                dynamicTest("positive number",  ←--- 
                             () -> assertTrue(predicate.check(1)))   ←--- 
        ).iterator();
    }
}

清单2.24实现了如下操作。

用@BeforeAll(处)和@AfterAll(处)标注的方法按预期运行一次:分别在整个测试列表的开始和结束处运行。

用@BeforeEach(处)和@AfterEach(处)标注的方法分别在@TestFactory(处)标注的方法运行之前和之后运行。

这个工厂方法生成3个测试方法,分别使用“negative number”(处)、“zero”(处)和“positive number”(处)标记。

每个测试的有效行为由Executable给出。Executable是作为DynamicTest方法的第二个参数提供的。

动态测试的运行结果如图2.6所示。

图2.6 动态测试的运行结果

2.10 使用Hamcrest匹配器

统计数据表明,人们很容易受到单元测试理念的影响。当习惯了编写单元测试并看到避免犯错误的感觉有多好时,我们就会惊讶地发现,离开了单元测试,我们会无所适从。

如果编写更多的单元测试和断言,我们就会发现某些断言很庞大而且难以阅读。Tested Data Systems公司正在与用户合作,所生成的数据或许会保存在列表中。开发人员会在列表中填上像“Michael”“John”“Edwin”这样的值,然后他们会搜索像“Oliver”“Jack”“Harry”这样的用户,如清单2.25所示。此测试的目的在于使断言失败并显示断言失败的描述信息。

清单2.25 JUnit笨重的assert方法

[...]
public class HamcrestListTest {
   private List<String> values;
 
   @BeforeEach  ←--- 
      public void setUp () {
          values = new ArrayList< >();
       values.add("Michael");
       values.add("John");
       values.add("Edwin");
      }
 
   @Test  ←--- 
      @DisplayName("List without Hamcrest")
   public void testWithoutHamcrest() {
        assertEquals(3, values.size());
      assertTrue(values.contains("Oliver")  ←--- 
              || values.contains("Jack")
              || values.contains("Harry"));  ←--- 
      }
   }

这个示例构造了一个简单的JUnit测试,就像本章前面描述的那样。

@BeforeEach(处)标注的方法用于为测试初始化一些数据。

使用单一的测试方法(处),这个测试方法产生了一个很长的、难以阅读的断言(处,也许这个断言本身并不难阅读,但乍一看,其作用显然不明显)。

目标是简化在测试方法中做出的断言。

为了解决上述问题,Tested Data Systems公司用一个匹配器Hamcrest来构建测试表达式。Hamcrest匹配器包含许多有用的Matcher对象(也称为约束谓词),这些对象可以移植到多种语言中,如Java、C++、Objective-C、Python和PHP等。

Hamcrest匹配器

Hamcrest本身并不是一个测试框架,但有助于我们以声明的方式指定简单的匹配规则。这些匹配规则可用于多种情况,对单元测试尤其有帮助。

清单2.26给出了与清单2.25相同的测试方法,这次使用Hamcrest匹配器来编写。

清单2.26 使用Hamcrest匹配器

[...]
import static org.hamcrest.CoreMatchers.anyOf;   ←--- 
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;   ←--- 
[...]
 
   @DisplayName("List with Hamcrest")
   public void testListWithHamcrest() {
        assertThat(values, hasSize(3));
        assertThat(values, hasItem(anyOf(equalTo("Oliver"),
                   equalTo("Jack"), equalTo("Harry"))));  ←--- 
  }
[...]

此示例添加了一个测试方法,该方法会导入所需的匹配器和assertThat方法(处),然后构造一个测试方法。测试方法用了匹配器一个非常强大的特性——嵌套(处)。Hamcrest匹配器给了我们标准断言所没有的,那就是“一种可读的对断言失败的描述”。至于使用带或不带Hamcrest匹配器的断言代码,则是一种个人偏好。

清单2.25和清单2.26中的示例以用户“Michael”“John”“Edwin”作为元素构造一个List。之后,代码断言其中是否存在“Oliver”“Jack”“Harry”用户,因此测试有意设计为失败的结局。没有使用Hamcrest匹配器的测试运行结果如图2.7所示,使用Hamcrest匹配器的测试运行结果如图2.8所示。可以看到,使用Hamcrest匹配器的测试运行结果包含更多细节。

图2.7 没有使用Hamcrest匹配器的测试运行结果

图2.8 使用Hamcrest匹配器的测试运行结果

要在项目中使用Hamcrest匹配器,需要向pom.xml文件添加所需的依赖项,如清单2.27所示。

清单2.27 pom.xml中的Hamcrest匹配器依赖

<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest-library</artifactId>
    <version>2.1</version>
    <scope>test</scope>
</dependency>

要在JUnit 4中使用Hamcrest匹配器,必须使用org.junit.Assert类的assertThat方法。但是,正如本章前面所提到的,JUnit 5中移除了assertThat方法。用户指南是这样解释这个决定的:

[...] org.junit.jupiter.api.Assertions类没有提供与JUnit 4的org.junit.Assert类相似的assertThat方法——它接收一个Hamcrest匹配器对象,但提倡开发人员使用第三方断言库提供的内置匹配器支持。

这段内容的含义是“如果我们想使用Hamcrest匹配器,就必须使用org.hamcrest.MatcherAssert类的assertThat方法”。如前面的示例所示,重载的方法带有2个或3个参数。

断言失败时显示的错误消息(可选)。

实际值或对象。

预期值的Matcher对象。

要创建Matcher对象,需要使用org.hamcrest.Matchers类提供的静态工厂方法,如表2.2所示。

表2.2 Hamcrest匹配器常用的静态工厂方法

工厂方法

处理逻辑

anything

绝对匹配。若要使assert语句更具可读性,此方法非常有用

is

仅用于提高语句的可读性

allOf

测试是否与所有包含的匹配器相匹配(相当于&&运算符)

anyOf

测试是否与任一包含的匹配器相匹配(相当于||运算符)

not

与包含的匹配器的含义相反(如Java中的!运算符)

instanceOf

测试对象是否是彼此的实例

sameInstance

测试对象是否是同一实例

nullValue、notNullValue

测试空值或非空值

hasProperty

测试JavaBeans是否具有某个属性

hasEntry、hasKey、hasValue

测试给定映射是否具有给定条目、键或值

hasItem、hasItems

测试给定集合中是否存在一个或多个项

closeTo、greaterThan、greaterThanOrEqualTo、lessThan、lessThanOrEqualTo

测试给定的数字是否接近、大于、大于或等于、小于、小于或等于给定的值

equalToIgnoringCase

测试给定字符串是否等于另一个字符串,忽略大小写

equalToIgnoringWhiteSpace

测试给定字符串是否等于另一个字符串,忽略空白

containsString、endsWith、startsWith

测试给定字符串是否包含特定字符串、以特定字符串开始或结束

这些方法都很容易阅读和使用,还可以组合在一起使用。

对于向用户提供的每项服务,Tested Data Systems公司都要收取一定的费用。清单2.28用了数个Hamcrest匹配器方法来测试用户属性和一些服务的价格。

清单2.28 Hamcrest匹配器的一些静态工厂方法

public class HamcrestMatchersTest {
 
   private static String FIRST_NAME = "John";
   private static String LAST_NAME = "Smith";
   private static Customer customer = new Customer(FIRST_NAME, LAST_NAME);
 
   @Test
   @DisplayName("Hamcrest is, anyOf, allOf")
   public void testHamcrestIs() {
      int price1 = 1, price2 = 1, price3 = 2;
 
      assertThat(1, is(price1));   ←--- 
      assertThat(1, anyOf(is(price2), is(price3)));
      assertThat(1, allOf(is(price1), is(price2)));   ←--- 
   }
 
   @Test
   @DisplayName("Null expected")
   void testNull() {
      assertThat(null, nullValue());  ←--- 
   }
 
   @Test
   @DisplayName("Object expected")
   void testNotNull() {
      assertThat(customer, notNullValue());  ←--- 
   }
 
   @Test
   @DisplayName("Check correct customer properties")
   void checkCorrectCustomerProperties() {
      assertThat(customer, allOf(  ←--- 
            hasProperty("firstName", is(FIRST_NAME)),
            hasProperty("lastName", is(LAST_NAME))
      ));   ←--- 
   }
 
}

从清单2.28中可以看到如下使用了匹配器的方法。

处使用了is、anyOf和allOf方法;处使用了nullValue方法;处使用了notNullValue方法。

使用了assertThat方法(处、处、处和处)。

这里还构造了一个Customer对象,并使用hasProperty方法检查其属性(处)。

最后(但并非不重要的)一点,Hamcrest匹配器具有极强的可扩展性。编写检查特定条件的匹配器很容易:实现Matcher接口和一个命名适当的工厂方法。

在第3章中,我们将分析JUnit 4和JUnit 5的体系结构,并讨论如何迁移到新的体系结构。

2.11 小结

在本章中,我们主要讨论了以下内容。

JUnit 5中与断言和假设有关的核心类。

使用JUnit 5方法和注解:断言和假设类中的方法,以及@Test、@DisplayName和@Disabled之类的注解。

JUnit 5测试的生命周期,并通过@BeforeEach、@AfterEach、@BeforeAll和@AfterAll注解对其加以控制。

应用JUnit 5功能来创建嵌套测试和标记测试(@NestedTest和@Tag注解)。

在带有参数的测试构造方法和普通方法的帮助下实现依赖注入。

通过使用不同的参数解析器(TestInfoParameterResolver和TestReporterParameterResolver)应用依赖注入。

实现重复测试(@RepeatedTest注解),作为依赖注入的另一个应用。

一个非常灵活的测试工具(参数化测试),使用不同的数据集和运行时创建的动态测试(@ParameterizedTest和@TestFactory注解)。

使用Hamcrest匹配器简化断言。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e57853”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

相关图书

Rust游戏开发实战
Rust游戏开发实战
仓颉编程快速上手
仓颉编程快速上手
深入浅出Go语言编程从原理解析到实战进阶
深入浅出Go语言编程从原理解析到实战进阶
Go语言编程指南
Go语言编程指南
Scala速学版(第3版)
Scala速学版(第3版)
Kafka实战
Kafka实战

相关文章

相关课程