精通-JUnit5-软件测试-全-
精通 JUnit5 软件测试(全)
原文:
zh.annas-archive.org/md5/6006963f247d852b0fdc6daf54c18ce5
译者:飞龙
前言
人类并非完美的思考者。在撰写本文时,软件工程师是人类。大多数是。因此,编写高质量、有用的软件是一项非常困难的任务。正如我们将在本书中发现的那样,软件测试是软件工程师(即开发人员、程序员或测试人员)进行的最重要的活动之一,以保证软件的质量和信心水平。
JUnit 是 Java 语言中最常用的测试框架,也是软件工程中最显著的框架之一。如今,JUnit 不仅仅是 Java 的单元测试框架。正如我们将发现的那样,它可以用于实现不同类型的测试(如单元测试、集成测试、端到端测试或验收测试),并使用不同的策略(如黑盒或白盒)。
2017 年 9 月 10 日,JUnit 团队发布了 JUnit 5.0.0。本书主要关注这个 JUnit 的新主要版本。正如我们将发现的那样,JUnit 5 对 JUnit 框架进行了完全的重新设计,改进了重要功能,如模块化(JUnit 5 架构完全模块化)、可组合性(JUnit 5 的扩展模型允许以简单的方式集成第三方框架到 JUnit 5 测试生命周期中)、兼容性(JUnit 5 支持在全新的 JUnit 平台中执行 JUnit 3 和 4 的遗留测试)。所有这些都遵循基于 Java 8 的现代编程模型,并符合 Java 9 的规范。
软件工程涉及一个多学科的知识体系,对变革有着强烈的推动力。本书全面审查了与软件测试相关的许多不同方面,主要是从开源的角度(JUnit 从一开始就是开源的)。在本书中,除了学习 JUnit 外,还可以学习如何在开发过程中使用第三方框架和技术,比如 Spring、Mockito、Selenium、Appium、Cucumber、Docker、Android、REST 服务、Hamcrest、Allure、Jenkins、Travis CI、Codecov 或 SonarCube 等。
本书涵盖的内容
第一章,软件质量和 Java 测试的回顾,对软件质量和测试进行了详细回顾。本章的目标是以易懂的方式澄清这一领域的术语。此外,本章还总结了 JUnit(版本 3 和 4)的历史,以及一些 JUnit 增强器(例如,可以用来扩展 JUnit 的库)。
第二章,JUnit 5 的新功能,首先介绍了创建 JUnit 5 版本的动机。然后,本章描述了 JUnit 5 架构的主要组件,即 Platform、Jupiter 和 Vintage。接下来,我们将了解如何运行 JUnit 测试,例如使用不同的构建工具,如 Maven 或 Gradle。最后,本章介绍了 JUnit 5 的扩展模型,允许任何第三方扩展 JUnit 5 的核心功能。
第三章,JUnit 5 标准测试,详细描述了新的 JUnit 5 编程模型的基本特性。这个编程模型,连同扩展模型,被称为 Jupiter。在本章中,您将了解基本的测试生命周期、断言、标记和过滤测试、条件测试执行、嵌套和重复测试,以及如何从 JUnit 4 迁移。
第四章,使用高级 JUnit 功能简化测试,详细描述了 JUnit 5 的功能,如依赖注入、动态测试、测试接口、测试模板、参数化测试、与 Java 9 的兼容性,以及 JUnit 5.1 的计划功能(在撰写本文时尚未发布)。
第五章,JUnit 5 与外部框架的集成,讨论了 JUnit 5 与现有第三方软件的集成。可以通过不同的方式进行此集成。通常,应使用 Jupiter 扩展模型与外部框架进行交互。这适用于 Mockito(一种流行的模拟框架)、Spring(一个旨在基于依赖注入创建企业应用程序的 Java 框架)、Docker(一个容器平台技术)或 Selenium(用于 Web 应用程序的测试框架)。此外,开发人员可以重用 Jupiter 测试生命周期与其他技术进行交互,例如 Android 或 REST 服务。
第六章,从需求到测试用例,提供了一套旨在帮助软件测试人员编写有意义的测试用例的最佳实践。考虑需求作为软件测试的基础,本章提供了一个全面的指南,以编写测试,避免典型的错误(反模式和代码异味)。
第七章,测试管理,是本书的最后一章,其目标是指导读者了解软件测试活动在一个活跃的软件项目中是如何管理的。为此,本章回顾了诸如持续集成(CI)、构建服务器(Jenkins、Travis)、测试报告或缺陷跟踪系统等概念。为了结束本书,还提供了一个完整的示例应用程序,以及不同类型的测试(单元测试、集成测试和端到端测试)。
您需要为本书做些什么
为了更好地理解本书中提出的概念,强烈建议 fork GitHub 存储库,其中包含本书中提出的代码示例(github.com/bonigarcia/mastering-junit5
)。在作者看来,触摸和玩弄代码对于快速掌握 JUnit 5 测试框架至关重要。正如前面介绍的,本书的最后一章提供了一个完整的应用程序示例,涵盖了本书中一些最重要的主题。这个应用程序(称为Rate my cat!)也可以在 GitHub 上找到,位于存储库github.com/bonigarcia/rate-my-cat
中。
为了运行这些示例,您需要 JDK 8 或更高版本。您可以从 Oracle JDK 的网站下载:www.oracle.com/technetwork/java/javase/downloads/index.html
。此外,强烈建议使用集成开发环境(IDE)来简化开发和测试过程。正如我们将在本书中发现的那样,在撰写本文时,有两个完全符合 JUnit 5 的 IDE,即:
-
Eclipse 4.7+(Oxygen):
eclipse.org/ide/
。 -
IntelliJ IDEA 2016.2+:
www.jetbrains.com/idea/
。
如果您更喜欢从命令行运行 JUnit 5,则可以使用两种可能的构建工具:
-
Maven:
maven.apache.org/
-
Gradle:
gradle.org/
这本书适合谁
本书面向 Java 软件工程师。因此,这部文学作品试图与读者(即 Java)说同样的语言,因此它是由上述公开的 GitHub 存储库上可用的工作代码示例驱动的。
约定
在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“@AfterAll
和@BeforeAll
方法仅执行一次”。
一块代码设置如下:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.api.Test;
class StandardTest {
@Test
void verySimpleTest () {
*assertTrue*(true);
}
}
任何命令行输入或输出都以以下形式编写:
mvn test
新术语和重要词汇显示为粗体,如:“兼容性是产品、系统或组件与其他产品交换信息的程度”。
警告或重要提示会以这样的方式出现在框中。
提示和技巧会出现在这样的情况下。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对本书的看法-您喜欢或不喜欢的内容。读者的反馈对我们很重要,因为它有助于我们开发您真正能从中获益的标题。
要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com
,并在消息主题中提及书名。
如果您在某个专业领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的自豪所有者,我们有一些东西可以帮助您充分利用您的购买。
下载示例代码
您可以从您在www.packtpub.com
的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support
并注册,以便文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册到我们的网站。
-
将鼠标指针悬停在顶部的“支持”选项卡上。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名。
-
选择您要下载代码文件的书籍。
-
从下拉菜单中选择您购买本书的地点。
-
单击“代码下载”。
下载文件后,请确保使用以下最新版本的软件解压缩文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/bonigarcia/mastering-junit5
。我们还有其他丰富书籍和视频代码包可供下载,网址为github.com/PacktPublishing/
。快去看看吧!
勘误
尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在我们的书籍中发现错误-可能是文本或代码中的错误-我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata
报告,选择您的书籍,单击“勘误提交表格”链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的“勘误”部分下的任何现有勘误列表中。
要查看先前提交的勘误,请访问www.packtpub.com/books/content/support
,并在搜索框中输入书名。所需信息将出现在“勘误”部分下。
盗版
互联网上侵犯版权材料的盗版是所有媒体都面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。
请通过copyright@packtpub.com
与我们联系,并附上涉嫌盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,可以通过questions@packtpub.com
与我们联系,我们将尽力解决问题。
第一章:软件质量和 Java 测试的回顾
为了从头开始制作一个苹果派,你必须首先创造宇宙。
- 卡尔·萨根
自 1995 年创立以来,著名的测试框架 JUnit 已经走过了很长的路。2017 年 9 月 10 日,项目生命周期中的一个重要里程碑发生了,即发布了 JUnit 5.0.0。在深入了解 JUnit 5 的细节之前,值得回顾一下软件测试的现状,以便了解我们来自何处,以及我们将要去往何处。为此,本章提供了对软件质量、软件测试和 Java 测试背景的高层次回顾。具体来说,本章由三个部分组成:
-
软件质量:第一部分回顾了质量工程的现状:质量保证、ISO/IEC-2500、验证和验证(V&V)以及软件缺陷(错误)。
-
软件测试:这是最常见的活动,用于保证软件质量并减少软件缺陷的数量。本部分提供了软件测试层次(单元、集成、系统和验收)、方法(黑盒、白盒和非功能性)、自动化和手动软件测试的理论背景。
-
Java 虚拟机的测试框架(JVM):本部分概述了 JUnit 框架的旧版本(即版本 3 和 4)的主要特点。最后,简要描述了替代测试框架和对 JUnit 的增强。
软件质量
软件是为特定客户或一般市场开发的计算机程序、相关数据和相关文档的集合。它是现代世界的重要组成部分,在电信、公用事业、商业、文化、娱乐等领域普遍存在。问题什么是软件质量?可能会得到不同的答案,取决于涉及从业者在软件系统中的角色。在软件产品或服务中涉及两大主要群体:
-
消费者:是使用软件的人。在这个群体中,我们可以区分客户(即负责获取软件产品或服务的人)和用户(即为各种目的使用软件产品或服务的人)。然而,客户和用户的双重角色是非常普遍的。
-
生产者:参与软件产品的开发、管理、维护、营销和服务的人。
消费者的质量期望是软件系统按规定执行有用的功能。对于软件生产商来说,基本的质量问题是通过生产符合服务级别协议(SLA)的软件产品来履行他们的合同义务。著名软件工程师 Roger Pressman 对软件质量的定义包括两个观点:
有效的软件过程以创造有用的产品,并为生产者和使用者提供可衡量的价值。
质量工程
质量工程(也称为质量管理)是一个评估、评价和改进软件质量的过程。在质量工程过程中有三大主要活动组:
-
质量规划:这个阶段通过管理项目成本和预算限制来建立整体质量目标。这个质量计划还包括策略,即选择要执行的活动和适当的质量测量以提供反馈和评估。
-
质量保证(QA):通过规划和执行一系列活动来保证项目生命周期中的软件产品和过程满足其指定的要求,从而提供足够的信心,质量被构建到软件中。主要的 QA 活动是验证和验证,但还有其他活动,如软件质量度量、使用质量标准、配置管理、文档管理或专家意见。
-
质量保证后:这个阶段包括质量量化和改进测量、分析、反馈和后续活动。这些活动的目的是提供产品质量的定量评估和改进机会的识别。
这些阶段在下图中表示:
软件质量工程过程
需求和规范
需求是质量工程领域的关键主题。需求是确定产品或过程需求的能力、物理特征或质量因素的陈述。需求开发(也称为需求工程)是产生和分析客户、产品和产品组件需求的过程。支持需求开发的一系列程序,包括规划、可追溯性、影响分析、变更管理等,被称为需求管理。软件需求有两种类型:
-
功能性需求是产品必须执行的操作,以使其对用户有用。它们源自利益相关者需要做的工作。几乎任何动作,如检查、发布或大多数其他动词都可以是功能性需求。
-
非功能性需求是产品必须具有的属性或特性。例如,它们可以描述性能、可用性或安全性等属性。它们通常被称为质量属性。
另一个与需求密切相关的重要主题是规范,它是一份文件,以完整、精确、可验证的方式规定了系统的需求、设计、行为或其他特征,通常还包括确定这些规定是否得到满足的程序。
质量保证
质量保证(QA)主要关注定义或选择应用于软件开发过程或软件产品的标准。《软件质量保证》(2004)一书的作者丹尼尔·加林将 QA 定义为:
系统化、计划的一系列行动,以提供足够的信心,使软件系统产品的开发和维护过程符合已建立的规范以及保持进度和在预算范围内运作的管理要求。
质量保证(QA)过程选择 V&V 活动、工具和方法来支持所选的质量标准。V&V 是一组活动,其主要目标是如果产品不符合资格,则阻止产品发货。相比之下,QA 旨在通过在开发和维护过程中引入各种活动来最小化质量成本,以防止错误的原因,检测它们,并在开发的早期阶段纠正它们。因此,QA 大大降低了不合格产品的比率。总的来说,V&V 活动只是 QA 活动的一部分。
ISO/IEC-25000
已经提出了各种质量标准以适应这些不同的质量视图和期望。标准ISO/IEC-9126是软件工程界中最有影响力的标准之一。然而,研究人员和实践者发现了该标准的一些问题和弱点。因此,ISO/IEC-9126 国际标准被ISO/IEC-25000系列国际标准软件产品质量要求和评估(SQuaRE)所取代。本节提供了该标准的高级概述。
ISO/IEC-2500 质量参考模型区分了软件质量的不同视图:
-
内部质量:这涉及可以在不执行系统的情况下进行测量的系统属性。
-
外部质量:这涉及可以在执行过程中观察到的系统属性。
-
使用质量:这涉及消费者在操作和维护系统过程中体验到的属性。
理想情况下,开发(过程质量)影响内部质量;然后,内部质量决定外部质量。最后,外部质量决定使用质量。这一链条在下图中描述:
ISO/IEC-2500 产品质量参考模型
ISO/IEC-25000 的质量模型将产品质量模型(即内部和外部属性)分为八个顶层质量特征:功能适用性、性能效率、兼容性、可用性、可靠性、安全性、可维护性和可移植性。以下定义直接从标准中提取:
-
功能适用性:这代表产品或系统在指定条件下使用时提供满足规定和隐含需求的功能程度。
-
性能效率:这代表在规定条件下使用的资源量相对于性能的表现。
-
兼容性:这是产品、系统或组件能够与其他产品、系统或组件交换信息,并/或执行其所需功能的程度,同时共享相同的硬件或软件环境。
-
可用性:这是产品或系统在指定使用环境中由指定用户使用以实现指定目标时的效果、效率和满意度程度。
-
可靠性:这是系统、产品或组件在指定条件下在指定时间内执行指定功能的程度。
-
安全性:这是产品或系统保护信息和数据的程度,使得人员或其他产品或系统能够获得适合其类型和授权级别的数据访问程度。
-
可维护性:这代表产品或系统可以被修改以改进、纠正或适应环境和需求变化的效果和效率程度。
-
可移植性:这是系统、产品或组件能够从一个硬件、软件或其他操作或使用环境转移到另一个环境的效果和效率程度。
另一方面,使用质量的属性可以归类为以下五个特征:
-
有效性:这是用户实现指定目标的准确性和完整性。
-
效率:这是用户实现目标所需的准确性和完整性所耗费的资源。
-
满意度:这是在指定使用环境中使用产品或系统时满足用户需求的程度。
-
免于风险:这是产品或系统减轻对经济状况、人类生命、健康或环境潜在风险的程度。
-
上下文覆盖:这是产品或系统在指定使用环境和初始明确定义的环境以外的环境中能够有效、高效、无风险和满意程度的程度。
验证和验证
验证和验证-也称为软件质量控制-涉及评估正在开发的软件是否满足其规范并提供消费者期望的功能。这些检查过程从需求可用开始,并贯穿开发过程的所有阶段。验证与验证不同,尽管它们经常被混淆。
计算机科学杰出教授 Barry Boehm 在 1979 年就表达了它们之间的区别:
-
验证:我们是否正在正确构建产品?验证的目的是检查软件是否满足其规定的功能和非功能要求(即规范)。
-
验证:我们是否在构建正确的产品?验证的目的是确保软件满足消费者的期望。由于规范并不总是反映消费者的真实愿望或需求,因此它比验证更为普遍。
V&V 活动包括各种 QA 活动。虽然软件测试在 V&V 中起着极其重要的作用,但其他活动也是必要的。在 V&V 过程中,可以使用两大类系统检查和分析技术:
-
软件测试:这是 QA 中最常见的活动。给定一段代码,软件测试(或简单地测试)包括观察一些执行(测试用例)并对其做出裁决。因此,测试是一种基于执行的 QA 活动,因此前提是已实施的软件单元、组件或系统需要进行测试。因此,有时它被称为动态分析。
-
静态分析:这是一种不需要执行软件的 V&V 形式。静态分析是针对软件的源表示进行的:规范、设计或程序的模型。也许最常用的是检查和审查,其中一组人员检查规范、设计或程序。还可以使用其他静态分析技术,例如自动化软件分析(检查程序的源代码是否存在已知潜在错误的模式)。
值得注意的是,关于哪些测试构成验证或验证存在着强烈的分歧意见。一些作者认为所有测试都是验证,而验证是在需求被审查和批准时进行的。其他作者认为单元测试和集成测试是验证,而更高级别的测试(例如系统或用户测试)是验证。为了解决这种分歧,V&V 可以被视为一个单一主题,而不是两个单独的主题。
软件缺陷
V&V 正确性方面的关键是软件缺陷的概念。术语缺陷(也称为错误)指的是一般的软件问题。IEEE 标准 610.12 提出了与软件缺陷相关的以下分类:
- 错误:产生不正确结果的人为行为。错误可以分为两类:
-
语法错误(违反所写语言的一个或多个规则的程序语句)。
-
逻辑错误(不正确的数据字段,超出范围的术语或无效的组合)。
-
故障:软件系统中错误的表现被称为故障。例如,不正确的步骤、过程或数据定义。
-
故障:软件系统无法执行其所需功能被称为(系统)故障。
术语“bug”最早是由软件先驱格雷斯·胡珀在 1946 年创造的,当时一只被困在电机计算机的继电器中的飞蛾导致系统故障。在这十年中,术语“debug”也被引入,作为在系统中检测和纠正缺陷的过程。
除了缺陷的这种细粒度之外,还有一个有趣的事件,即软件消费者感知到的与故障相关的症状。总的来说,错误、故障、故障和事件是软件缺陷的不同方面。这四个缺陷方面之间存在因果关系。错误可能导致故障注入软件中,故障可能在执行软件时导致故障。最后,当最终用户或客户经历故障时,就会发生事件。可以进行不同的质量保证活动来尽量减少软件系统中的缺陷数量。正如杰夫·田在他的书《软件质量工程》(2005)中所定义的那样,这些替代方案可以分为以下三个通用类别:
-
通过错误修复预防缺陷:例如,使用某些流程和产品标准可以帮助最小化将某些类型的故障注入软件中。
-
通过故障检测和修复减少缺陷:传统的测试和静态分析活动就是这一类别的例子。我们将在本章的内容中发现这些机制的具体类型。
-
缺陷控制通过预防故障:这些活动通常超出软件系统的范围。控制的目标是最小化软件系统故障造成的损害(例如,在反应堆故障时用墙壁来包含放射性材料)。
软件缺陷链和相关的质量保证活动
静态分析
对软件片段的静态分析是在不执行代码的情况下进行的。与测试相比,软件分析有几个优点:
-
在测试过程中,错误可能会隐藏其他错误。这种情况在静态分析中不会发生,因为它不涉及错误之间的相互作用。
-
不完整的系统版本可以在不增加额外成本的情况下进行静态分析。在测试中,如果程序不完整,就必须开发测试工具。
-
静态分析可以考虑软件系统的更广泛的质量属性,例如符合标准、可移植性和可维护性。
有不同的方法可以被确定为静态分析:
-
检查(1976 年由迈克尔·法根首次提出)是人员检查软件工件,旨在发现和修复软件系统中的故障。所有类型的软件资产都可能被检查,例如规范、设计模型等。检查存在的主要原因不是等待可执行程序的可用性(例如在测试中)才开始进行检查。
-
审查是一个过程,其中一组人员检查软件及其相关文档,寻找潜在问题和与标准不符合,以及其他潜在问题或遗漏。如今,在将新代码合并到共享源代码存储库之前,通常会进行审查。通常,审查由团队内的不同人员(同行审查)进行。这个过程在时间和精力方面非常昂贵,但另一方面,当正确执行时,它有助于确保高内部代码质量,减少潜在风险。
审查是一种特殊形式的审查。根据 IEEE 软件审查标准,审查是一种软件同行审查形式,其中设计师或程序员带领开发团队成员和其他感兴趣的人员浏览软件产品,参与者提出问题并对可能的错误、违反开发标准和其他问题进行评论。
- 自动化软件分析使用已知潜在危险的模式来评估源代码。这种技术通常以商业或开源工具和服务的形式提供,通常被称为lint或linter。这些工具可以定位许多常见的编程错误,在代码被测试之前分析源代码,并识别潜在问题,以便在它们表现为故障之前重新编码。这种 linting 过程的目的是引起代码阅读者对程序中的错误的注意,比如:
-
数据故障:这可能包括声明但从未使用的变量,两次赋值但在赋值之间从未使用的变量等。
-
控制故障:这可能包括无法到达的代码或无条件进入循环。
-
输入/输出故障:这可能包括变量在没有中间赋值的情况下输出两次。
-
接口故障:这可能包括参数类型不匹配、参数不匹配、函数结果未使用、未调用的函数和过程等。
-
存储管理故障:这可能包括未分配的指针、指针算术等。
在静态分析和动态测试之间,我们发现了一种特殊的软件评估方式,称为形式验证。这种评估提供了检查系统是否按照其正式规范运行的机制。为此,软件被视为一个可以使用逻辑操作证明其正确性的数学实体,结合不同类型的静态和动态评估。如今,由于可扩展性问题,形式方法并不被广泛采用。使用这些技术的项目大多相对较小,比如关键的内核系统。随着系统的增长,开发正式规范和验证所需的工作量也会过分增长。
软件测试
软件测试包括对程序在通常无限执行域中合适选择的有限测试用例的动态评估,以检查其行为是否符合预期。这个定义的关键概念如下所示:
-
动态:被测试系统(SUT)使用特定的输入值来查找其行为中的故障。因此,实际的 SUT 应该确保设计和代码是正确的,还有环境,比如库、操作系统和网络支持等等。
-
有限的:对于大多数真实程序来说,穷举测试是不可能或不切实际的。它们通常对每个操作有大量允许的输入,还有更多无效或意外的输入,操作序列通常也是无限的。测试人员必须选择一定数量的测试,以便在可用时间内运行这些测试。
-
选定的:由于可能的测试集合庞大甚至无限,我们只能运行其中的一小部分,测试的关键挑战在于如何选择最有可能暴露系统故障的测试。
-
预期的:在每次测试执行后,必须决定系统的观察行为是否是故障。
软件测试是一个广泛的术语,涵盖了许多不同的概念。在文献中,并没有所有不同测试形式的通用分类。为了清晰起见,在本书中,我们使用三个轴对不同的测试形式进行分类,即测试级别(单元、集成、系统和验收)、测试方法(黑盒、白盒和非功能测试)和测试类型(手动和自动化)。
接下来的章节将提供关于所有这些概念的更多细节,这些概念在以下图表中进行了总结:
软件测试的分类法分为三类:级别、方法和类型
例如,正如我们将会发现的,根据其功能行为执行类中的方法的 JUnit 测试可以被视为自动化的单元黑盒测试。当最终用户使用软件产品来验证其是否按预期工作时,根据之前的分类,我们可以将其视为手动黑盒验收测试。应该注意的是,并非所有这三个轴的可能组合总是有意义的。例如,非功能测试(例如性能)通常是在系统级别自动进行的(手动或在单元级别进行的可能性非常小)。
测试级别
根据 SUT 的大小和测试的场景,测试可以在不同的级别进行。在本书中,我们将不同的测试级别分类为四个阶段:
-
单元测试:在这里,测试单独的程序单元。单元测试应该专注于对象或方法的功能。
-
集成测试:在这里,单元被组合成复合组件。集成测试应该专注于测试组件和接口。
-
系统测试:在这里,所有组件都被集成,整个系统被测试。
-
验收测试:在这里,消费者决定系统是否准备部署到消费者环境中。它可以被视为由最终用户或客户在系统级进行的高级功能测试。
在许多不同形式的测试中,没有通用的分类。关于测试级别,在本书中,我们使用上述的四个级别分类。然而,文献中还存在其他级别或方法(例如系统集成测试或回归测试)。在本节的最后部分,我们可以找到对不同测试方法的审查。
前三个级别(单元、集成和系统)通常在软件生命周期的开发阶段进行。这些测试通常由软件工程师的不同角色执行(即程序员、测试人员、质量保证团队等)。这些测试的目标是对系统进行验证。另一方面,第四个级别(验收)是一种用户测试,其中通常涉及潜在或真实用户(验证)。以下图片提供了这些概念的图形描述:
测试级别及其与 V&V 的关系
单元测试
单元测试是一种通过测试单个源代码片段来验证该单元的设计和实现是否正确的方法。在单元测试用例中按顺序执行的四个阶段如下:
-
设置:测试用例初始化测试装置,即 SUT 展示预期行为所需的之前图片。
-
执行:测试用例与 SUT 进行交互,从中获得一些结果。SUT 通常查询另一个组件,称为依赖组件(DOC)。
-
验证:测试用例使用断言(也称为谓词)确定是否获得了预期的结果。
-
拆卸:测试用例拆除测试装置,将 SUT 恢复到初始状态。
这些阶段及其与 SUT 和 DOC 的关系如下所示:
单元测试通用结构
单元测试是在单元测试中进行的,即在不与其 DOCs 进行交互的情况下进行。为此,使用测试替身来替换 SUT 所依赖的任何组件。有几种类型的测试替身:
-
虚拟对象只是满足真实对象的 API,但实际上从未被使用。虚拟对象的典型用例是当它们作为参数传递以满足方法签名时,但然后虚拟对象实际上并未被使用。
-
伪造对象用更简单的实现替换真实对象,例如,内存数据库。
-
存根对象替换真实对象,提供硬编码的值作为响应。
-
模拟对象也替换真实对象,但这次是使用编程期望作为响应。
-
间谍对象是部分模拟对象,意味着它的一些方法是使用期望进行编程的,但其他方法使用真实对象的实现。
集成测试
集成测试应该暴露接口中的缺陷,以及集成组件或模块之间的交互。有不同的策略来执行集成测试。这些策略描述了要集成单元的顺序,假设这些单元已经分别进行了测试。常见的集成策略示例包括以下内容:
-
自顶向下集成:这种策略从主要单元(模块)开始,即程序树的根部。任何被主要单元调用的较低级别模块都应该被测试替身替换。一旦测试人员确信主要单元逻辑是正确的,存根将逐渐被实际代码替换。这个过程将重复进行,直到程序树中的其余较低单元。这种方法的主要优点是缺陷更容易被发现。
-
自底向上集成:这种策略从最基本的单元开始测试。较大的子系统是由经过测试的组件组装而成。这种类型的主要优点是不需要测试替身。
-
临时集成:组件按照完成的自然顺序进行集成。它允许对系统进行早期测试。通常需要测试替身。
-
骨干集成:构建组件的骨架,逐渐集成其他组件。这种方法的主要缺点是骨干的创建可能需要大量工作。
文献中常常提到的另一种策略是大爆炸集成。在这种策略中,测试人员等待直到所有或大多数单元都被开发和集成。结果,所有的故障都会同时被发现,使得纠正潜在故障非常困难和耗时。如果可能的话,应该避免使用这种策略。
系统测试
开发过程中的系统测试涉及将组件集成以创建系统的一个版本,并测试集成系统。它验证组件是否兼容,正确地进行交互,并在正确的时间传输正确的数据,通常跨越其用户界面。显然,它与集成测试重叠,但这里的区别在于系统测试应该涉及所有系统组件以及最终用户(通常是模拟的)。
还有一种特殊类型的系统测试称为端到端测试。在这种方法中,最终用户通常被模拟,即使用自动化技术进行模拟。
测试方法
测试方法(或策略)定义了设计测试用例的方式。它们可以基于责任(黑盒),基于实现(白盒),或非功能性。黑盒技术根据被测试项的指定功能设计测试用例。白盒技术依靠源代码分析来开发测试用例。混合技术(灰盒)测试使用基于责任和基于实现的方法设计测试用例。
黑盒测试
黑盒测试(也称为功能或行为测试)是基于需求的,不了解内部程序结构或数据。黑盒测试依赖于正在测试的系统或组件的规范来推导测试用例。系统是一个只能通过研究其输入和相关输出来确定其行为的黑盒。有许多具体的黑盒测试技术;以下是一些最著名的技术:
-
系统化测试:这指的是一种完整的测试方法,其中系统被证明完全符合规范,直到测试假设。它仅在限制意义上生成测试用例,即每个域点都是单例子域。在这个类别中,一些最常执行的是等价类划分和边界值分析,以及基于逻辑的技术,如因果图、决策表或成对测试。
-
随机测试:这实际上是系统化测试的对立面-对整个输入域进行抽样。模糊测试是一种黑盒随机测试,它会随机变异格式良好的输入,并对生成的数据进行测试。它会向系统提供随机顺序和/或结构不良的数据,以查看是否发生故障。
-
图形用户界面(GUI)测试:这是确保具有图形界面的软件与用户进行交互的规范的过程。GUI 测试是事件驱动的(例如,鼠标移动或菜单选择),并通过消息或方法调用向底层应用程序代码提供前端。单元级别的 GUI 测试通常在按钮级别使用。系统级别的 GUI 测试会测试系统的事件驱动特性。
-
基于模型的测试(MBT):这是一种测试策略,其中测试用例部分地源自描述系统下测试对象的模型。MBT 是一种黑盒测试,因为测试是从模型生成的,而模型又源自需求文档。它可以在不同的级别(单元、集成或系统)进行。
-
冒烟测试:这是确保系统关键功能的过程。冒烟测试用例是测试人员在接受构建进行进一步测试之前运行的第一个测试。冒烟测试用例失败意味着软件构建被拒绝。冒烟测试的名称源自电气系统测试,即首次测试是打开开关并查看是否冒烟。
-
理智测试:这是确保系统基本功能的过程。与冒烟测试类似,理智测试是在测试过程开始时执行的,但其目标不同。理智测试旨在确保系统基本功能继续按预期工作(即系统的合理性),然后进行更详尽的测试。
冒烟测试和理智测试通常在软件测试社区中容易混淆。通常认为这两种测试都是为了避免在这些测试失败时浪费精力进行严格的测试,它们的主要区别在于目标(关键功能 vs. 基本功能)。
白盒测试
白盒测试(也称为结构测试)基于对应用程序代码内部逻辑的了解。它确定程序代码结构和逻辑是否有错误。只有当测试人员知道程序应该做什么时,白盒测试用例才是准确的。
黑盒测试仅使用规范来识别用例,而白盒测试使用程序源代码(实现)作为测试用例识别的基础。这两种方法结合使用,应该是选择 SUT 的一组良好测试用例所必需的。以下是一些最重要的白盒技术:
- 代码覆盖定义了已经测试的源代码程度,例如以 LOC 百分比的形式。代码覆盖有几个标准:
-
语句覆盖:代码覆盖粒度。
-
决策(分支)覆盖:控制结构(例如,if-else)覆盖粒度。
-
条件覆盖:布尔表达式(真-假)覆盖粒度。
-
路径覆盖:每个可能的路径覆盖粒度。
-
功能覆盖:程序功能覆盖粒度。
-
入口/出口覆盖:调用和返回的覆盖粒度。
-
故障注入是向软件中注入故障以确定某个 SUT 的表现如何的过程。缺陷可以说是传播的,如果是这种情况,它们的影响会在错误存在的状态之外的程序状态中可见(故障变成了失败)。
-
突变测试通过对包含不同、单一且故意插入更改的 SUT 的多个副本运行测试和它们的数据来验证。突变测试有助于识别代码中的遗漏。
非功能测试
系统的非功能方面可能需要大量的测试工作。在这一组中,可以找到不同的测试手段,例如,性能测试用于评估 SUT 是否符合指定的性能要求。这些要求通常包括有关时间行为和资源使用的约束。性能测试可以通过单个用户对系统进行操作或多个用户对系统进行操作来测量响应时间。负载测试侧重于增加系统的负载到某个规定或暗示的最大负载,以验证系统能够处理定义的系统边界。体积测试通常被认为是负载测试的同义词,但体积测试侧重于数据。压力测试超出正常操作能力的范围,以至系统失败,识别系统破裂的实际边界。压力测试的目的是观察系统如何失败以及瓶颈在哪里。
安全测试试图确保以下概念:机密性(保护信息不被泄露),完整性(确保信息的正确性),认证(确保用户的身份),授权(确定用户是否被允许接收服务或执行操作),可用性(确保系统在需要时执行其功能),不可否认性(确保否认某个动作发生)。评估系统基础设施安全性的授权尝试通常被称为渗透测试。
可用性测试侧重于发现可能使软件难以使用或导致用户误解输出的用户界面问题。可访问性测试是确保产品符合可访问性(访问系统功能的能力)的技术。
测试类型
有两种主要的软件测试方法:
-
手动测试:这是由人类进行的评估 SUT 的过程,通常是软件工程师或最终用户。在这种类型的测试中,我们可以找到所谓的探索性测试,这是一种人工测试,人类测试人员通过调查和自由评估系统使用其个人感知来评估系统。
-
自动化测试:这是评估 SUT 的过程,其中测试过程(测试执行、报告等)是通过专门的软件和基础设施进行的。Elfriede Dustin 在她的书Implementing Automated Software Testing: How to Save Time and Lower Costs While Raising Quality(2009)*中定义了自动化软件测试(AST)为:
应用和实施软件技术贯穿整个软件测试生命周期,目标是提高效率和效果。
AST 的主要好处是:预期的成本节约、缩短的测试持续时间、提高测试的彻底性、提高测试的准确性、改进结果报告以及统计处理,以及随后的报告。
自动化测试通常在构建服务器上在持续集成(CI)过程的上下文中执行。关于这方面的更多细节在第七章中提供,测试管理。
AST 在框架内实施时效果最好。测试框架可以被定义为一组抽象概念、过程、程序和环境,其中自动化测试将被设计、创建和实施。这个框架定义包括用于测试创建和实施的物理结构,以及这些组件之间的逻辑交互。
严格来说,框架的定义与我们对库的理解并没有太大的区别。为了更清楚地区分,考虑一下著名的软件工程专家马丁·福勒的以下引用:
库本质上是一组可以调用的函数,这些天通常组织成类。每次调用都会执行一些工作并将控制返回给客户端。框架包含了一些抽象设计,并内置了更多的行为。为了使用它,您需要将您的行为插入到框架的各个位置,要么通过子类化,要么通过插入您自己的类。然后框架的代码在这些点调用您的代码。
库和框架之间的视觉解释
框架在现代软件开发中变得越来越重要。它们提供了软件密集型系统中非常需要的可重用性能力。这样,大型应用程序最终将由相互合作的框架层组成。
其他测试方法
正如本节开头介绍的,对于不同形式的测试并没有一个通用的定义。在本节中,我们回顾了一些文献中常见的测试种类,例如当测试过程用于确定系统是否符合其规格时,它被称为一致性测试。当向系统引入新功能或功能(我们可以称之为构建)时,测试这个新功能的方式被称为渐进测试。此外,为了检查新引入的更改不会影响系统其余部分的正确性,现有的测试用例被执行。这种方法通常被称为回归测试。
当系统与任何外部或第三方系统进行交互时,可以进行另一种称为系统集成测试的测试。这种测试验证系统是否正确地集成到任何外部系统中。
用户或客户测试 是测试过程中的一个阶段,在该阶段用户或客户提供系统测试的输入和建议。验收测试 是用户测试的一种类型,但也可以有不同类型的用户测试:
-
Alpha 测试:这在开发者的站点进行,与软件的消费者一起工作,然后才发布给外部用户或客户。
-
Beta 测试:这在客户的站点进行,涉及由一组客户对系统进行测试,他们在自己的位置使用系统并提供反馈,然后系统才会发布给其他客户。
-
运行测试:这是由最终用户在其正常操作环境中执行的测试。
最后,发布测试 指的是由开发团队之外的一个独立团队对系统的特定发布进行测试的过程。发布测试的主要目标是说服系统的供应商系统足够好以供使用。
JVM 的测试框架
JUnit 是一个允许创建自动化测试的测试框架。JUnit 的开发始于 1995 年底,由 Kent Beck 和 Erich Gamma 发起。自那时起,该框架的流行度一直在增长。如今,它被广泛认为是测试 Java 应用程序的事实标准。
JUnit 旨在成为一个单元测试框架。然而,它不仅可以用于实现单元测试,还可以用于其他类型的测试。正如我们将在本书的内容中发现的那样,根据测试逻辑如何对受测试软件进行测试,使用 JUnit 实现的测试用例可以被视为单元、集成、系统,甚至验收测试。总的来说,我们可以将 JUnit 视为 Java 的多用途测试框架。
JUnit 3
自 JUnit 3 的早期版本以来,该框架可以与 Java 2 及更高版本一起使用。JUnit3 是开源软件,根据Common Public License(CPL)版本 1.0 发布,并托管在 SourceForge(sourceforge.net/projects/junit/
)上。JUnit 3 的最新版本是 JUnit 3.8.2,于 2007 年 5 月 14 日发布。JUnit 在测试框架的世界中引入的主要要求如下:
-
应该很容易定义哪些测试将运行。
-
框架应该能够独立运行所有其他测试。
-
框架应该能够逐个测试检测和报告错误。
JUnit 3 中的标准测试
在 JUnit 3 中,为了创建测试用例,我们需要扩展类 junit.framework.TestCase
。这个基类包括 JUnit 需要自动运行测试的框架代码。然后,我们只需确保方法名遵循 testXXX()
模式。这个命名约定使得框架清楚地知道该方法是一个单元测试,并且可以自动运行。
测试生命周期由 setup()
和 tearDown()
方法控制。TestCase
在运行每个测试之前调用 setup()
,然后在每个测试完成时调用 teardown()
。将多个测试方法放入同一个测试用例的原因之一是共享相同的测试装置。
最后,为了在测试用例中实现验证阶段,JUnit 3 在名为 junit.framework.Assert
的实用类中定义了几个断言方法。以下表总结了该类提供的主要断言:
方法 | 描述 |
---|---|
assertTrue |
断言条件为真。如果不是,方法将抛出带有给定消息的 AssertionFailedError (如果有的话)。 |
assertFalse |
断言条件为假。如果不是,方法将抛出带有给定消息的 AssertionFailedError (如果有的话)。 |
assertEquals |
断言两个对象相等。如果它们不相等,方法将抛出带有给定消息的 AssertionFailedError (如果有的话)。 |
assertNotNull |
断言对象不为空。如果为空,方法将抛出带有消息的 AssertionFailedError (如果有的话)。 |
assertNull |
断言对象为空。如果不是,则该方法将抛出带有给定消息的AssertionFailedError (如果有)。 |
assertSame |
断言两个对象引用同一个对象。如果不是,则该方法将抛出带有给定消息的AssertionFailedError (如果有)。 |
assertNotSame |
断言两个对象不引用同一个对象。如果是,则该方法将抛出带有给定消息的AssertionFailedError (如果有)。 |
fail |
使测试失败(抛出AssertionFailedError ),并附上给定的消息(如果有)。 |
下面的类显示了使用 JUnit 3.8.2 实现的简单测试。正如我们所看到的,这个测试用例包含两个测试。在每个测试之前,框架将调用setUp()
方法,并且在每个测试执行之后,也将调用tearDown()
方法。这个例子已经编码,使得第一个名为testSuccess()
的测试正确完成,而第二个名为testFailure()
的测试以错误结束(断言抛出异常):
package io.github.bonigarcia;
import junit.framework.TestCase;
public class TestSimple extends TestCase {
// Phase 1: Setup (for each test)
protected void setUp() throws Exception {
System.*out*.println("<Setup>");
}
// Test 1: This test is going to succeed
public void testSuccess() {
// Phase 2: Simulation of exercise
int expected = 60;
int real = 60;
System.*out*.println("** Test 1 **");
// Phase 3: Verify
*assertEquals*(expected + " should be equals to "
+ real, expected, real);
}
// Test 2: This test is going to fail
public void testFailure() {
// Phase 2: Simulation of exercise
int expected = 60;
int real = 20;
System.*out*.println("** Test 2 **");
// Phase 3: Verify
*assertEquals*(expected + " should be equals to "
+ real, expected, real);
}
// Phase 4: Teardown (for each test)
protected void tearDown() throws Exception {
System.*out*.println("</Ending>");
}
}
本书中解释的所有代码示例都可以在 GitHub 存储库github.com/bonigarcia/mastering-junit5
上找到。
JUnit 3 中的测试执行
JUnit 3 允许通过称为测试运行器的 Java 应用程序运行测试用例。JUnit 3.8.2 提供了三种不同的测试运行器:两种图形化(基于 Swing 和 AWT)和一种可以从命令行使用的文本运行器。JUnit 框架为每个测试提供单独的类加载器,以避免测试之间的副作用。
构建工具(如 Ant 或 Maven)和集成开发环境-IDE-(如 Eclipse 和 IntelliJ)实现了自己的 JUnit 测试运行器是一种常见做法。
下面的图片显示了当我们使用 JUnit Swing 运行器以及使用 Eclipse 运行相同的测试用例时,先前的测试是什么样子的。
使用图形化 Swing 测试运行器和 Eclipse 测试运行器执行 JUnit 3 测试用例
当 JUnit 中的测试未成功时,可能有两个原因:失败或错误。一方面,失败是由未满足的断言(Assert
类)引起的。另一方面,错误是测试中未预期的条件,例如被测试软件中的常规异常。
JUnit 3 的另一个重要贡献是测试套件的概念,这是一种方便的方式来分组相关的测试。测试套件是通过 JUnit 类junit.framework.TestSuite
实现的。这个类,与TestCase
一样,实现了框架接口junit.framework.Test
。
下面的图表显示了 JUnit 3 的主要类和方法:
核心 JUnit 3 类
TestSuite object, and then add single test cases using the method addTestSuite():
package io.github.bonigarcia;
import junit.framework.Test;
import junit.framework.TestSuite;
public class TestAll {
public static Test suite() {
TestSuite suite = new TestSuite("All tests");
suite.addTestSuite(TestSimple.class);
suite.addTestSuite(TestMinimal.class);
return suite;
}
}
稍后可以使用测试运行器执行此测试套件。例如,我们可以使用命令行测试运行器(junit.textui.TestRunner
)和命令行,如下所示:
使用文本测试运行器和命令行执行的测试套件
JUnit 4
JUnit 4 仍然是一个开源框架,尽管许可证与 JUnit 3 相比发生了变化,从 CPL 更改为Eclipse Public License(EPL)版本 1.0。JUnit 4 的源代码托管在 GitHub 上(github.com/junit-team/junit4/
)。
2006 年 2 月 18 日,发布了 JUnit 4.0。它遵循与 JUnit 3 相同的高级指导方针,即轻松定义测试,框架独立运行测试,并且框架检测并报告测试中的错误。
JUnit 4 相对于 JUnit 3 的主要区别之一是 JUnit 4 允许定义测试的方式。在 JUnit 4 中,使用 Java 注解标记方法为测试。因此,JUnit 4 只能用于 Java 5 或更高版本。正如 2006 年 JUnit 4.0 的文档所述:
JUnit 4.0 的架构与早期版本有着很大的不同。现在,不再通过将测试类标记为子类化junit.framework.TestCase
和通过以'test'开头的名称标记测试方法,而是使用@Test
注解来标记测试方法。
JUnit 4 中的标准测试
在 JUnit 4 中,@Test
注解(包含在org.junit
包中)表示一个测试。任何公共方法都可以用@Test
注解来标记为测试方法。
为了设置测试装置,JUnit 4 提供了@Before
注解。这个注解可以在任何公共方法中使用。同样,任何使用@After
注解标记的公共方法在每次测试方法执行后执行。JUnit 4 还提供了两个注解来增强测试生命周期:@BeforeClass
和@AfterClass
。它们只在每个测试类中执行一次,分别在所有测试之前和之后执行。以下图片描述了 JUnit 4 测试用例的生命周期:
JUnit 4 测试生命周期
@Before
和@After
可以应用于任何公共 void 方法。@AfterClass
和@BeforeClass
只能应用于公共静态 void 方法。
以下表格总结了迄今为止在 JUnit 3 和 JUnit 4 中看到的主要区别:
特性 | JUnit 3 | JUnit 4 |
---|---|---|
测试定义 | testXXX 模式 |
@Test 注解 |
在第一个测试之前运行 | 不支持 | @BeforeClass 注解 |
在所有测试之后运行 | 不支持 | @AfterClass 注解 |
在每个测试之前运行 | 重写setUp() 方法 |
@Before 注解 |
在每个测试之后运行 | 重写tearDown() 方法 |
@After 注解 |
忽略测试 | 不支持 | @Ignore 注解 |
org.junit.Assert
类提供了执行断言(谓词)的静态方法。以下是最有用的断言方法:
-
assertTrue
:如果条件变为 false,则断言失败并抛出AssertionError
。 -
assertFalse
:如果条件变为 true,则断言失败并抛出AssertionError
。 -
assertNull
:这检查参数是否为空,否则如果参数不为空则抛出AssertionError
。 -
assertNotNull
:这检查参数是否不为空;否则,它会抛出AssertionError
。 -
assertEquals
:这比较两个对象或原始类型。此外,如果实际值与期望值不匹配,则会抛出AssertionError
。 -
assertSame
:这仅支持对象,并使用==
运算符检查对象引用。 -
assertNotSame
:这是assertSame
的相反。
以下代码片段提供了 JUnit 4 测试用例的简单示例。正如我们所看到的,这是与前一节中看到的等效测试用例相同,这次使用 JUnit 4 编程模型,即使用@Test
注解来标识测试和其他注解(@AfterAll
,@After
,@BeforeAll
,@Before
)来实现测试生命周期(设置和拆卸测试装置):
package io.github.bonigarcia;
import static org.junit.Assert.*assertEquals*;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
public class TestSimple {
// Phase 1.1: Setup (for all tests)
@BeforeClass
public static void setupAll() {
System.*out*.println("<Setup Class>");
}
// Phase 1.2: Setup (for each test)
@Before
public void setupTest() {
System.*out*.println("<Setup Test>");
}
// Test 1: This test is going to succeed
@Test
public void testSuccess() {
// Phase 2: Simulation of exercise
int expected = 60;
int real = 60;
System.*out*.println("** Test 1 **");
// Phase 3: Verify
*assertEquals*(expected + " should be equals to "
+ real, expected, real);
}
// Test 2: This test is going to fail
@Test
public void testFailure() {
// Phase 2: Simulation of exercise
int expected = 60;
int real = 20;
System.*out*.println("** Test 2 **");
// Phase 3: Verify
*assertEquals*(expected + " should be equals to "
+ real, expected, real);
}
// Phase 4.1: Teardown (for each test)
@After
public void teardownTest() {
System.*out*.println("</Ending Test>");
}
// Phase 4.2: Teardown (for all test)
@AfterClass
public static void teardownClass() {
System.*out*.println("</Ending Class>");
}
}
JUnit 4 中的测试执行
测试运行器的概念在 JUnit 4 中也存在,但与 JUnit 3 相比略有改进。在 JUnit 4 中,测试运行器是一个用于管理测试生命周期的 Java 类:实例化,调用设置和拆卸方法,运行测试,处理异常,发送通知等等。默认的 JUnit 4 测试运行器称为BlockJUnit4ClassRunner
,它实现了 JUnit 4 标准测试用例类模型。
在 JUnit 4 测试用例中使用的测试运行器可以通过简单地使用@RunWith
注解来更改。JUnit 4 提供了一系列内置的测试运行器,允许更改测试类的性质。在本节中,我们将回顾最重要的运行器。
- 为了运行一组测试(即测试套件),JUnit 4 提供了
Suite
运行器。除了运行器,Suite.SuiteClasses
类还允许定义属于套件的单个测试类。例如:
package io.github.bonigarcia;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@RunWith(Suite.class)
@Suite.SuiteClasses({ TestMinimal1.class, TestMinimal2.class })
public class MySuite {
}
- 参数化测试用于指定将在相同测试逻辑中使用的不同输入数据。为了实现这种类型的测试,JUnit 4 提供了
Parameterized
运行器。要在此类型的测试中定义数据参数,我们需要使用注解@Parameters
对类的静态方法进行注释。此方法应返回提供测试输入参数的二维数组的Collection
。现在,将有两种选项将输入数据注入到测试中:
-
使用构造函数类。
-
使用注解
@Parameter
对类属性进行注释。
以下代码片段显示了后者的示例:
package io.github.bonigarcia;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
@RunWith(Parameterized.class)
public class TestParameterized {
@Parameter(0)
public int input1;
@Parameter(1)
public int input2;
@Parameter(2)
public int sum;
@Parameters(name = "{index}: input1={0} input2={1} sum={2}?")
public static Collection<Object[]> data() {
return Arrays.*asList*(
new Object[][] { { 1, 1, 2 }, { 2, 2, 4 }, { 3, 3, 9 } });
}
@Test
public void testSum() {
*assertTrue*(input1 + "+" + input2 + " is not " + sum,
input1 + input2 == sum);
}
}
在 Eclipse 上执行此测试将如下所示:
在 Eclipse 中执行参数化测试
- JUnit 理论是 JUnit 参数化测试的一种替代方法。JUnit 理论预期对所有数据集都为真。因此,在 JUnit 理论中,我们有一个提供数据点的方法(即用于测试的输入值)。然后,我们需要指定一个带有
@Theory
注解的方法,该方法带有参数。类中的理论将使用数据点的每种可能组合执行:
package io.github.bonigarcia;
import static org.junit.Assert.assertTrue;
import org.junit.experimental.theories.DataPoints;
import org.junit.experimental.theories.Theories;
import org.junit.experimental.theories.Theory;
import org.junit.runner.RunWith;
@RunWith(Theories.class)
public class MyTheoryTest {
@DataPoints
public static int[] positiveIntegers() {
return new int[] { 1, 10, 100 };
}
@Theory
public void testSum(int a, int b) {
System.out.println("Checking " + a + "+" + b);
*assertTrue*(a + b > a);
*assertTrue*(a + b > b);
}
}
再次在 Eclipse 中查看此示例的执行:
在 Eclipse 中执行 JUnit 4 理论
JUnit 4 的高级功能
在 JUnit 4 中引入的最重要的创新之一是使用规则。规则允许灵活地添加或重新定义测试类中每个测试方法的行为。通过使用注解@Rule
将规则包含在测试用例中。此属性的类型应继承 JUnit 接口org.junit.rulesTestRule
。JUnit 4 中提供了以下规则:
-
ErrorCollector
:此规则允许在发现第一个问题后继续执行测试 -
ExpectedException
:此规则允许验证测试是否引发特定异常 -
ExternalResource
:此规则为在测试之前设置外部资源(文件、套接字、服务器、数据库连接等)并保证在之后拆除的规则提供了一个基类 -
TestName
:此规则使当前测试名称在测试方法内部可用 -
TemporaryFolder
:此规则允许创建在测试方法完成时应删除的文件和文件夹 -
Timeout
:此规则将相同的超时应用于类中的所有测试方法 -
TestWatcher
:这是一个记录每个通过和失败测试的规则的基类
JUnit 4 的另一个先进功能允许:
-
使用注解
@FixMethodOrder
按给定顺序执行测试。 -
使用 Assume 类创建假设。该类提供许多静态方法,例如
assumeTrue(condition)
、assumeFalse(condition)
、assumeNotNull(condition)
和assumeThat(condition)
。在执行测试之前,JUnit 会检查测试中的假设。如果其中一个假设失败,JUnit 运行器将忽略具有失败假设的测试。 -
JUnit 在
@Test
注解中提供了一个超时值(以毫秒为单位),以确保如果测试运行时间超过指定值,则测试失败。 -
使用测试运行器
Categories
对测试进行分类,并使用注解Category
对测试方法进行标注以识别测试的类型。
在 GitHub 存储库中可以找到每个先前提到的功能的有意义的示例(github.com/bonigarcia/mastering-junit5
)。
JUnit 生态系统
JUnit 是 JVM 中最受欢迎的测试框架之一,被认为是软件工程中最有影响力的框架之一。我们可以找到几个库和框架,它们在 JUnit 的基础上提供了额外的功能。这些生态系统增强器的一些示例是:
-
Mockito(
site.mockito.org/
):这是一个模拟框架,可以与 JUnit 一起使用。 -
AssertJ(
joel-costigliola.github.io/assertj/
):这是 Java 的流畅断言库。 -
Hamcrest(
hamcrest.org/
):这是具有匹配器的库,可以组合以创建灵活且可读的断言。 -
Cucumber(
cucumber.io/
):这是允许以行为驱动开发(BDD)风格编写的自动化验收测试的测试框架。 -
FitNesse(
www.fitnesse.org/
):这是旨在通过支持系统功能的详细可读描述来支持验收测试的测试框架。
虽然 JUnit 是 JVM 上最大的测试框架,但并非唯一的测试框架。JVM 上还有几个其他测试框架可用。一些例子包括:
-
TestNG(
testng.org/
):这是受到 JUnit 和 NUnit 启发的测试框架。 -
Spock(
spockframework.org/
):这是 Java 和 Groovy 应用程序的测试和规范框架。 -
Jtest(
www.parasoft.com/product/jtest/
):这是由 Parasoft 公司制作和分发的自动化 Java 测试和静态分析框架。 -
Scalatest(
www.scalatest.org/
):这是 Scala、Scala.js(JavaScript)和 Java 应用程序的测试框架。
由于 JUnit,测试已经成为编程的核心部分。因此,在 JVM 边界之外,JUnit 实现的基础测试模型已被移植到所谓的 xUnit 家族的一系列测试框架中。在这个模型中,我们找到了测试用例、运行器、固定装置、套件、测试执行、报告和断言的概念。举几个例子,考虑以下框架。所有这些都属于 xUnit 家族:
-
Google Test(
github.com/google/googletest
):Google 的 C++测试框架。 -
JSUnit(
www.jsunit.net/
):JavaScript 的单元测试框架。 -
Mocha(
mochajs.org/
):在 Node.js 上运行的单元测试框架。 -
NUnit(
www.nunit.org/
):用于 Microsoft.NET 的单元测试框架。 -
PHPUnit(
phpunit.de/
):PHP 的单元测试框架。 -
SimplyVBUnit(
simplyvbunit.sourceforge.net/
):VB.NET 的单元测试框架。 -
Unittest(
docs.python.org/3/library/unittest.html
):Python 的单元测试框架。
总结
软件质量是软件工程中的关键概念,因为它决定了软件系统满足其要求和用户期望的程度。验证和验证是一组旨在评估软件系统的活动的名称。V&V 的目标是确保软件的质量,同时减少缺陷的数量。V&V 中的两个核心活动是软件测试(评估运行中的软件)和静态分析(评估软件构件而不执行)。
自动化软件测试在过去几十年中取得了最大的进步。在这个领域,JUnit 框架占据着重要的地位。JUnit 旨在成为 JVM 的单元框架。如今,事实上 JUnit 是 Java 社区中最流行的测试框架,提供了一个全面的编程模型来创建和执行测试用例。在下一节中,我们将了解框架的新版本 JUnit 5 提供的功能和能力。
第二章:JUnit 5 的新功能
那些能够想象任何事情的人,可以创造不可能的事情。
- 艾伦·图灵
JUnit 是 JVM 中最重要的测试框架,也是软件工程中最有影响力的框架之一。JUnit 5 是 JUnit 的下一代,其第一个正式版本(5.0.0)于 2017 年 9 月 10 日发布。正如我们将了解的那样,JUnit 5 相对于 JUnit 4 来说是一次小革命,提供了全新的架构、编程和扩展模型。本章内容包括以下内容:
-
通往 JUnit 5:在第一节中,我们将了解创建 JUnit 的新主要版本的动机(即 JUnit 4 的限制),指导 JUnit 5 开发的设计原则,以及 JUnit 5 开源社区的详细信息。
-
JUnit 5 架构:JUnit 5 是一个由三个主要组件组成的模块化框架,分别是 Platform、Jupiter 和 Vintage。
-
在 JUnit 5 中运行测试:我们将了解如何使用流行的构建工具(如 Maven 或 Gradle)以及 IDE(如 IntelliJ 或 Eclipse)运行 JUnit 5 测试。
-
JUnit 5 的扩展模型:扩展模型允许第三方库和框架通过它们自己的添加来扩展 JUnit 5 的编程模型。
通往 JUnit 5
自 2006 年 JUnit 4 首次发布以来,软件测试发生了很大变化。自那时起,不仅 Java 和 JVM 发生了变化,我们的测试需求也变得更加成熟。我们不再只编写单元测试。除了验证单个代码片段外,软件工程师和测试人员还要求其他类型的测试,如集成测试和端到端测试。
此外,我们对测试框架的期望已经增长。如今,我们要求这些框架具有高级功能,比如可扩展性或模块化等。在本节中,我们将了解 JUnit 4 的主要限制,JUnit 5 的愿景以及支持其开发的社区。
JUnit 5 的动机
根据多项研究,JUnit 4 是 Java 项目中使用最多的库。例如,《GitHub 上排名前 100 的 Java 库》是 OverOps(@overopshq)发布的一份知名报告,OverOps 是一家专注于大规模 Java 和 Scala 代码库的软件分析公司。
在 2017 年的报告中,分析了 GitHub 上排名前 1000 的 Java 项目(按星级)使用的独特 Java 库的导入语句。根据结果,JUnit 4 是 Java 库的无可争议的王者:org.junit
和org.junit.runner
包的导入分别位列第一和第二。
GitHub 上排名前 20 的 Java 库
尽管事实如此,JUnit 4 是十多年前创建的一个框架,存在着一些重要的限制,这些限制要求对框架进行完全重新设计。
模块化
首先,JUnit 4 不是模块化的。如下图所示,JUnit 4 的架构完全是单片的。JUnit 4 的所有功能都由junit.jar
依赖提供。因此,JUnit 4 中的不同测试机制,如测试发现和执行,在 JUnit 4 中是紧密耦合的。
JUnit 4 的架构
约翰内斯·林克(Johannes Link)是 JUnit 5 核心团队成员之一,他在 2015 年 8 月 13 日接受 Jax 杂志采访时总结了这个问题(在 JUnit 5 开始时):
JUnit 作为一个平台的成功阻碍了它作为测试工具的发展。我们要解决的基本问题是通过分离足够强大和稳定的 API 来执行测试用例。
JUnit 4 运行器
JUnit 4 的运行器 API 也有一个重要的威慑作用。正如在第一章中所描述的,“关于软件质量和 Java 测试的回顾”,在 JUnit 4 中,运行器是用于管理测试生命周期的 Java 类。JUnit 4 中的运行器 API 非常强大,但是有一个重要的缺点:运行器不可组合,也就是说,我们一次只能使用一个运行器。
例如,参数化测试无法与 Spring 测试支持结合使用,因为两个测试都会使用自己的运行器实现。在 Java 中,每个测试用例都使用自己独特的@RunWith
注解。第一个使用Parameterized
运行器。
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
@RunWith(Parameterized.class)
public class MyParameterizedTest {
@Test
public void myFirstTest() {
// my test code
}
}
虽然这个第二个例子使用了SpringJUnit4ClassRunner
运行器,但由于 JUnit 4 的限制(运行器不可组合),它不能与前一个例子结合使用:
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
public class MySpringTest {
@Test
public void yetAnotherTest() {
// my test code
}
}
JUnit 4 规则
由于 JUnit 4 中对同一测试类中 JUnit 4 运行器的唯一性的严格限制,JUnit 的 4.7 版本引入了方法级规则的概念,这些规则是测试类中带有@Rule
注解的字段。这些规则允许通过在执行测试之前和之后执行一些代码来添加或重新定义测试行为。JUnit 4.9 还包括类级别规则的概念,这些规则是在类中的所有测试之前和之后执行的规则。通过使用@ClassRule
注解静态字段来标识这些规则,如下例所示:
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
public class MyRuleTest {
@ClassRule
public static TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void anotherTest() {
// my test code
}
}
虽然规则更简单且大多可组合,但它们也有其他缺点。在使用 JUnit 4 规则进行复杂测试时的主要不便之处在于,我们无法使用单个规则实体来进行方法级和类级的测试。归根结底,这对自定义生命周期管理(在之前/之后的行为)施加了限制。
JUnit 5 的开始
尽管 JUnit 4 是全球数百万 Java 开发人员的默认测试框架,但没有一位活跃的 JUnit 维护者受雇于其雇主从事这项工作。因此,为了克服 JUnit 4 的缺点,Johannes Link 和 Marc Philipp 于 2015 年 7 月在 Indiegogo(国际众筹网站)上启动了 JUnit Lambda 众筹活动(junit.org/junit4/junit-lambda-campaign.html
):
JUnit Lambda 众筹活动
JUnit Lambda 是该项目的名称,它是当前 JUnit 5 框架的种子。在项目名称中加入 lambda 一词强调了从项目一开始就使用 Java 8 的想法。引用 JUnit Lambda 项目网站:
目标是在 JVM 上为开发人员测试创建一个最新的基础。这包括专注于 Java 8 及以上,以及启用许多不同的测试风格。
JUnit Lambda 众筹活动从 2015 年 7 月持续到 10 月。这是一个成功的活动,从全球 474 个个人和公司筹集了 53,937 欧元。从这一点开始,JUnit 5 的启动团队成立了,加入了来自 Eclipse、Gradle、IntelliJ 或 Spring 的人员。
JUnit Lambda 项目成为 JUnit 5,并且指导开发过程的设计原则如下:
-
模块化:如前所述,JUnit 4 不是模块化的,这会导致一些问题。从一开始,JUnit 5 的架构就是完全模块化的,允许开发人员使用他们需要的框架的特定部分。
-
具有重点在可组合性上的强大扩展模型:可扩展性对于现代测试框架是必不可少的。因此,JUnit 5 应该提供与第三方框架(如 Spring 或 Mockito 等)的无缝集成。
-
API 分离:将测试发现和执行与测试定义分离。
-
与旧版本的兼容性:支持在新的 JUnit 5 平台中执行旧版 Java 3 和 Java 4。
-
用于编写测试的现代编程模型(Java 8):如今,越来越多的开发人员使用 Java 8 的新功能编写代码,如 lambda 表达式。JUnit 4 是基于 Java 5 构建的,但 JUnit 5 是使用 Java 8 从头开始创建的。
JUnit 5 社区
JUnit 5 的源代码托管在 GitHub 上(github.com/junit-team/junit5
)。JUnit 5 框架的所有模块都已根据开源许可证 EPL v1.0 发布。有一个例外,即名为junit-platform-surefire-provider
的模块(稍后描述)已使用 Apache License v2.0 发布。
JUnit 开发路线图(github.com/junit-team/junit5/wiki/Roadmap
)以及不同发布和里程碑的定义和状态(github.com/junit-team/junit5/milestones/
)在 GitHub 上是公开的。以下表格总结了这个路线图:
阶段 | 日期 | 发布 |
---|---|---|
0. 众筹 | 2015 年 7 月至 2015 年 10 月 | - |
1. 启动 | 2015 年 10 月 20 日至 22 日 | - |
2. 第一个原型 | 2015 年 10 月 23 日至 2015 年 11 月底 | - |
3. Alpha 版本 | 2016 年 2 月 1 日 | 5.0 Alpha |
4. 第一个里程碑 | 2016 年 7 月 9 日 | 5.0 M1:稳定的、有文档的面向 IDE 的 API(启动器 API 和引擎 SPI),动态测试 |
5. 额外的里程碑 | 2016 年 7 月 23 日(5.0 M2)2016 年 11 月 30 日(5.0 M3)2017 年 4 月 1 日(5.0 M4)2017 年 7 月 5 日(5.0 M5)2017 年 7 月 16 日(5.0 M6) | 5.0 M2:错误修复和小的改进发布 5.0 M3:JUnit 4 互操作性,额外的发现选择器 5.0 M4:测试模板,重复测试和参数化测试 5.0 M5:动态容器和小的 API 更改 5.0 M6:Java 9 兼容性,场景测试,JUnit Jupiter 的额外扩展 API |
6. 发布候选(RC) | 2017 年 7 月 30 日 2017 年 7 月 30 日 2017 年 8 月 23 日 | 5.0 RC1:最终错误修复和文档改进 5.0 RC2:修复 Gradle 对junit-jupiter-engine的使用 5.0 RC3:配置参数和错误修复 |
7. 正式发布(GA) | 2017 年 9 月 10 日 | 5.0 GA:第一个稳定版本发布 |
JUnit 5 的贡献者不仅仅是开发人员。贡献者还是测试人员、维护者和沟通者。在撰写本文时,GitHub 上最多的 JUnit 5 贡献者是:
-
Sam Brannen(@sam_brannen):Spring Framework 和 JUnit 5 的核心贡献者。Swiftmind 的企业 Java 顾问。Spring 和 JUnit 培训师。会议发言人。
-
Marc Philipp(@marcphilipp):LogMeIn 的高级软件工程师,JUnit 或 Usus 等开源项目的活跃贡献者。会议发言人。
-
Johannes Link(@johanneslink):程序员和软件治疗师。JUnit 5 支持者。
-
Matthias Merdes:德国海德堡移动有限公司的首席开发人员。
GitHub 上最多的 JUnit 5 贡献者
以下列表提供了一些在线 JUnit 5 资源:
-
官方网站(
junit.org/junit5/
)。 -
JUnit 5 开发者指南(
junit.org/junit5/docs/current/user-guide/
)。参考文档。 -
JUnit 团队的 Twitter(
twitter.com/junitteam
)。通常,关于 JUnit 5 的推文都标有#JUnit5
(twitter.com/hashtag/JUnit5
)。 -
问题(
github.com/junit-team/junit5/issues
)。GitHub 上的问题或对额外功能的建议。 -
Stack Overflow 上的问题(
stackoverflow.com/questions/tagged/junit5
)。Stack Overflow 是一个流行的计算机编程问答网站。标签junit5
应该用于询问关于 JUnit 5 的问题。 -
JUnit 5 JavaDoc(
junit.org/junit5/docs/current/api/
)。 -
JUnit 5 Gitter(
gitter.im/junit-team/junit5
),这是一个即时通讯和聊天室系统,用于与 JUnit 5 团队成员和其他从业者直接讨论。 -
JVM 的开放测试联盟(
github.com/ota4j-team/opentest4j
)。这是 JUnit 5 团队发起的一个倡议,其目标是为 JVM 上的测试库(JUnit、TestNG、Spock 等)和第三方断言库(Hamcrest、AssertJ 等)提供一个最小的共同基础。其想法是使用一组通用的异常,以便 IDE 和构建工具可以在所有测试场景中以一致的方式支持(到目前为止,JVM 上还没有测试的标准,唯一的共同构建块是 Java 异常java.lang.AssertionError
)。
JUnit 5 架构
JUnit 5 框架已经被设计为可以被不同的编程客户端消费。第一组客户端是 Java 测试。这些测试可以基于 JUnit 4(使用旧的测试编程模型的测试)、JUnit 5(使用全新的编程模型的测试)甚至其他类型的 Java 测试(第三方)。第二组客户端是构建工具(如 Maven 或 Gradle)和 IDE(如 IntelliJ 或 Eclipse)。
为了以松散耦合的方式实现所有这些部分的集成,JUnit 5 被设计为模块化的。如下图所示,JUnit 5 框架由三个主要组件组成,称为 Platform、Jupiter 和 Vintage:
JUnit 5 架构:高级组件
JUnit 5 架构的高级组件列举如下:
-
第一个高级组件称为Jupiter。它提供了 JUnit 5 框架全新的编程和扩展模型。
-
在 JUnit 5 的核心中,我们找到了 JUnit Platform。这个组件旨在成为 JVM 中执行任何测试框架的基础。换句话说,它提供了运行 Jupiter 测试、传统的 JUnit 4 以及第三方测试(例如 Spock、FitNesse 等)的机制。
-
JUnit 5 架构的最后一个高级组件称为Vintage。该组件允许在 JUnit 平台上直接运行传统的 JUnit 测试。
让我们更仔细地查看每个组件的细节,以了解它们的内部模块:
JUnit 5 架构:模块
如前图所示,有三种类型的模块:
-
测试 API:这些是面向用户(即软件工程师和测试人员)的模块。这些模块为特定的测试引擎提供了编程模型(例如,
junit-jupiter-api
用于 JUnit 5 测试,junit
用于 JUnit 4 测试)。 -
测试引擎:这些模块允许在 JUnit 平台内执行一种测试(Jupiter 测试、传统的 JUnit 4 或其他 Java 测试)。它们是通过扩展通用的Platform Engine(
junit-platform-engine
)创建的。 -
测试启动器:这些模块为外部构建工具和 IDE 提供了在 JUnit 平台内进行测试发现的能力。这个 API 被工具如 Maven、Gradle、IntelliJ 等所使用,使用
junit-platform-launcher
模块。
由于这种模块化架构,JUnit 框架暴露了一组接口:
-
API(应用程序编程接口)用于编写测试,Jupiter API。这个 API 的详细描述就是所谓的 Jupiter 编程模型,它在本书的第三章JUnit 5 标准测试和第四章使用高级 JUnit 功能简化测试中有详细描述。
-
SPI(服务提供者接口)用于发现和执行测试,Engine SPI。这个 SPI 通常由测试引擎扩展,最终提供编写测试的编程模型。
-
用于测试发现和执行的 API,Launcher API。这个 API 通常由编程客户端(IDE 和构建工具)消耗。
API 和 SPI 都是软件工程师用于特定目的的一组资产(通常是类和接口)。不同之处在于 API 是调用,而 SPI 是扩展。
测试引擎 SPI
测试引擎 SPI 允许在 JVM 之上创建测试执行器。在 JUnit 5 框架中,有两个测试引擎实现:
-
junit-vintage-engine
:这允许在 JUnit 平台中运行 JUnit 3 和 4 的测试。 -
junit-jupiter-engine
:这允许在 JUnit 平台中运行 JUnit 5 的测试。
此外,第三方测试库(例如 Spock、TestNG 等)可以通过提供自定义测试引擎来插入 JUnit 平台。为此,这些框架应该通过扩展 JUnit 5 接口org.junit.platform.engine.TestEngine
来创建自己的测试引擎。为了扩展这个接口,必须重写三个强制性方法:
-
getId
:测试引擎的唯一标识符。 -
discover
:查找和过滤测试的逻辑。 -
execute
:运行先前找到的测试的逻辑。
以下示例提供了自定义测试引擎的框架:
package io.github.bonigarcia;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.ExecutionRequest;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.TestEngine;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.support.descriptor.EngineDescriptor;
public class MyCustomEngine implements TestEngine {
public static final String *ENGINE_ID* = "my-custom-engine";
@Override
public String getId() {
return *ENGINE_ID*;
}
@Override
public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest,
UniqueId uniqueId) {
// Discover test(s) and return a TestDescriptor object
TestDescriptor testDescriptor = new EngineDescriptor(uniqueId,
"My test");
return testDescriptor;
}
@Override
public void execute(ExecutionRequest request) {
// Use ExecutionRequest to execute TestDescriptor
TestDescriptor rootTestDescriptor =
request.getRootTestDescriptor();
request.getEngineExecutionListener()
.executionStarted(rootTestDescriptor);
}
}
社区在 JUnit 5 团队的 GitHub 网站上的维基中维护了一份现有测试引擎的列表(例如 Specsy、Spek 等):github.com/junit-team/junit5/wiki/Third-party-Extensions
。
测试启动器 API
JUnit 5 的目标之一是使 JUnit 与其编程客户端(构建工具和 IDE)之间的接口更加强大和稳定。为此目的,已经实现了测试启动器 API。这个 API 被 IDE 和构建工具用于发现、过滤和执行测试。
仔细查看此 API 的细节,我们会发现LauncherDiscoveryRequest
类,它公开了一个流畅的 API,用于选择测试的位置(例如类、方法或包)。这组测试可以进行过滤,例如使用匹配模式:
import static org.junit.platform.engine.discovery.ClassNameFilter.includeClassNamePatterns;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectPackage;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.TestPlan;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
// Discover and filter tests
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder
.*request*()
.*selectors*(*selectPackage*("io.github.bonigarcia"),
selectClass(MyTest.class))
.*filters*(i*ncludeClassNamePatterns*(".*Test")).build();
Launcher launcher = LauncherFactory.create();
TestPlan plan = launcher.discover(request);
之后,可以使用TestExecutionListener
类执行生成的测试套件。这个类也可以用于获取反馈和接收事件:
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
// Executing tests
TestExecutionListener listener = new SummaryGeneratingListener();
launcher.registerTestExecutionListeners(listener);
launcher.execute(request);
在 JUnit 5 中运行测试
在撰写本文时,Jupiter 测试可以通过多种方式执行:
-
使用构建工具:Maven(在模块
junit-plaform-surefire-provider
中实现)或 Gradle(在模块junit-platform-gradle-plugin
中实现)。 -
使用控制台启动器:一个命令行 Java 应用程序,允许从控制台启动 JUnit 平台。
-
使用 IDE:IntelliJ(自 2016.2 版)和 Eclipse(自 4.7 版,Oxygen)。
由于我们将要发现,并且由于 JUnit 5 的模块化架构,我们需要在我们的项目中包含三个依赖项:一个用于测试 API(实现测试),另一个用于测试引擎(运行测试),最后一个用于测试启动器(发现测试)。
使用 Maven 进行 Jupiter 测试
为了在 Maven 项目中运行 Jupiter 测试,我们需要正确配置pom.xml
文件。首先,我们需要将junit-jupiter-api
模块作为依赖项包含进去。这是为了编写我们的测试,通常使用测试范围:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
一般来说,建议使用最新版本的依赖项。为了检查该版本,我们可以在 Maven 中央仓库(search.maven.org/
)上进行检查。
然后,必须声明maven-surefire-plugin
。在内部,此插件需要两个依赖项:测试启动器(junit-platform-surefire-provider
)和测试引擎(junit-jupiter-engine
):
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>${junit.platform.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
本书的所有源代码都可以在 GitHub 存储库github.com/bonigarcia/mastering-junit5
上公开获取。
最后但同样重要的是,我们需要创建一个 Jupiter 测试用例。到目前为止,我们还没有学习如何实现 Jupiter 测试(这部分在第三章中有介绍,JUnit 5 标准测试)。然而,我们在这里执行的测试是演示 JUnit 5 框架执行的最简单的测试。Jupiter 测试在其最小表达形式中只是一个 Java 类,其中的一个(或多个)方法被注释为@Test
(包org.junit.jupiter.api
)。以下代码段提供了一个示例:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class MyFirstJUnit5Test {
@Test
void myFirstTest() {
String message = "1+1 should be equal to 2";
System.*out*.println(message);
*assertEquals*(2, 1 + 1, message);
}
}
JUnit 在运行时需要 Java 8(或更高版本)。但是,我们仍然可以测试使用先前版本的 Java 编译的代码。
如下图所示,可以使用命令mvn test
执行此测试:
使用 Maven 运行 Jupiter 测试
使用 Gradle 运行 Jupiter 测试
现在,我们将研究相同的示例,但这次使用 Gradle 执行。因此,我们需要配置build.gradle
文件。在此文件中,我们需要定义:
-
Jupiter API 的依赖项(
junit-jupiter-api
)。 -
测试引擎的依赖项(
junit-jupiter-engine
)。 -
测试启动器的插件(
junit-platform-gradle-plugin
)。
build.gradle
的完整源代码如下:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.junit.platform:junit-platform-gradle-plugin:${junitPlatformVersion}")
}
}
repositories {
mavenCentral()
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.junit.platform.gradle.plugin'
compileTestJava {
sourceCompatibility = 1.8
targetCompatibility = 1.8
options.compilerArgs += '-parameters'
}
dependencies {
testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
}
我们使用命令gradle test
来从命令行使用 Gradle 运行我们的 Jupiter 测试:
使用 Gradle 运行 Jupiter 测试
使用 Maven 运行传统测试
以下是我们想要在 JUnit 平台内运行传统测试(在本例中为 JUnit 4)的图像:
package io.github.bonigarcia;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class LegacyJUnit4Test {
@Test
public void myFirstTest() {
String message = "1+1 should be equal to 2";
System.*out*.println(message);
*assertEquals*(message, 2, 1 + 1);
}
}
为此,在 Maven 中,我们首先需要在pom.xml
中包含旧的 JUnit 4 依赖项,如下所示:
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
然后,我们需要包含maven-surefire-plugin
,使用以下插件的依赖项:测试引擎(junit-vintage-engine
)和测试启动器(junit-platform-surefire-provider
):
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>${junit.platform.version}</version>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>${junit.vintage.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
从命令行执行也将使用命令mvn test
:
使用 Maven 运行传统测试
使用 Gradle 运行传统测试
如果我们想要执行之前示例中提到的相同测试(io.github.bonigarcia.LegacyJUnit4Test
),但这次使用 Gradle,我们需要在build.gradle
文件中包含以下内容:
-
JUnit 4.12 的依赖项。
-
测试引擎的依赖项(
junit-vintage-engine
)。 -
测试启动器的插件(
junit-platform-gradle-plugin
)。
因此,build.gradle
的完整源代码如下:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.junit.platform:junit-platform-gradle-plugin:${junitPlatformVersion}")
}
}
repositories {
mavenCentral()
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.junit.platform.gradle.plugin'
compileTestJava {
sourceCompatibility = 1.8
targetCompatibility = 1.8
options.compilerArgs += '-parameters'
}
dependencies {
testCompile("junit:junit:${junitLegacy}")
testRuntime("org.junit.vintage:junit-vintage-engine:${junitVintageVersion}")
}
从命令行执行如下:
使用 Gradle 运行传统测试
控制台启动器
ConsoleLauncher
是一个命令行 Java 应用程序,允许从控制台启动 JUnit 平台。例如,它可以用于从命令行运行 Vintage 和 Jupiter 测试。
包含所有依赖项的可执行 JAR 已发布在中央 Maven 仓库的junit-platform-console-standalone
工件下。独立的控制台启动器可以如下执行:
java -jar junit-platform-console-standalone-version.jar <Options>
示例 GitHub 存储库junit5-console-launcher包含了 Console Launcher 的简单示例。如下图所示,在 Eclipse 中创建了一个运行配置项,运行主类org.junit.platform.console.ConsoleLauncher
。然后,使用选项--select-class
和限定类名(在本例中为io.github.bonigarcia.EmptyTest
)作为参数传递测试类名。之后,我们可以运行应用程序,在 Eclipse 的集成控制台中获取测试结果:
在 Eclipse 中使用 ConsoleLauncher 的示例
在 JUnit 4 中的 Jupiter 测试
JUnit 5 被设计为向前和向后兼容。一方面,Vintage 组件支持在 JUnit 3 和 4 上运行旧代码。另一方面,JUnit 5 提供了一个 JUnit 4 运行器,允许在支持 JUnit 4 但尚未直接支持新的 JUnit Platform 5 的 IDE 和构建系统中运行 JUnit 5。
让我们看一个例子。假设我们想在不支持 JUnit 5 的 IDE 中运行 Jupiter 测试,例如,一个旧版本的 Eclipse。在这种情况下,我们需要用@RunWith(JUnitPlatform.class)
注解我们的 Jupiter 测试。JUnitPlatform
运行器是一个基于 JUnit 4 的运行器,它可以在 JUnit 4 环境中运行任何编程模型受支持的测试。因此,我们的测试结果如下:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.junit.platform.runner.JUnitPlatform;
import org.junit.runner.RunWith;
@RunWith(JUnitPlatform.class)
public class JUnit5CompatibleTest {
@Test
void myTest() {
String message = "1+1 should be equal to 2";
System.*out*.println(message);
*assertEquals*(2, 1 + 1, message);
}
}
如果这个测试包含在一个 Maven 项目中,我们的pom.xml
应该包含以下依赖项:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-runner</artifactId>
<version>${junit.platform.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
另一方面,对于 Gradle 项目,我们的build.gradle
如下:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.junit.platform:junit-platform-gradle-plugin:${junitPlatformVersion}")
}
}
repositories {
mavenCentral()
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.junit.platform.gradle.plugin'
compileTestJava {
sourceCompatibility = 1.8
targetCompatibility = 1.8
options.compilerArgs += '-parameters'
}
dependencies {
testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
testCompile("org.junit.platform:junit-platform-runner:${junitPlatformVersion}")
}
IntelliJ
IntelliJ 2016.2+是第一个原生支持执行 Jupiter 测试的 IDE。如下图所示,可以使用 IDE 的集成功能执行任何 Jupiter 测试:
在 IntelliJ 2016.2+中运行 Jupiter 测试
Eclipse
Eclipse 4.7(Oxygen)支持 JUnit 5 的 beta 版本。由于这个原因,Eclipse 提供了直接在 Eclipse 中运行 Jupiter 测试的能力,如下面的截图所示:
在 Eclipse 4.7+中运行 Jupiter 测试
此外,Eclipse 4.7(Oxygen)提供了一个向导,可以简单地创建 Jupiter 测试,如下面的图片所示:
在 Eclipse 中创建 Jupiter 测试的向导
JUnit 5 的扩展模型
如前所述,Jupiter 是 JUnit 5 的新编程模型的名称,详细描述在第三章中,JUnit 5 标准测试和第四章,使用高级 JUnit 功能简化测试,以及扩展模型。扩展模型允许使用自定义添加扩展 Jupiter 编程模型。由于这一点,第三方框架(如 Spring 或 Mockito 等)可以无缝地与 JUnit 5 实现互操作性。这些框架提供的扩展将在第五章中进行研究,JUnit 5 与外部框架的集成。在当前部分,我们分析扩展模型的一般性能以及 JUnit 5 中提供的扩展。
与 JUnit 4 中以前的扩展点相比(即测试运行器和规则),JUnit 5 的扩展模型由一个单一的、连贯的概念组成:扩展 API。这个 API 允许任何第三方(工具供应商、开发人员等)扩展 JUnit 5 的核心功能。我们需要了解的第一件事是,Jupiter 中的每个新扩展都实现了一个名为Extension
的接口。这个接口是一个标记接口,也就是说,它是一个没有字段或方法的 Java 接口:
package org.junit.jupiter.api.extension;
import static org.apiguardian.api.API.Status.STABLE;
import org.apiguardian.api.API;
/**
* Marker interface for all extensions.
*
* @since 5.0
*/
@API(status = STABLE, since = "5.0")
public interface Extension {
}
为了简化 Jupiter 扩展的创建,JUnit 5 提供了一组扩展点,允许在测试生命周期的不同部分执行自定义代码。下表包含了 Jupiter 中的扩展点摘要,其详细信息将在下一节中介绍:
扩展点 | 由想要实现的扩展 |
---|---|
TestInstancePostProcessor |
在测试实例化后提供额外行为 |
BeforeAllCallback |
在测试容器中所有测试被调用之前提供额外行为 |
BeforeEachCallback |
在每个测试被调用前为测试提供额外行为 |
BeforeTestExecutionCallback |
在每个测试执行前立即为测试提供额外行为 |
TestExecutionExceptionHandler |
处理测试执行期间抛出的异常 |
AfterAllCallback |
在所有测试被调用后,为测试容器提供额外行为 |
AfterEachCallback |
在每个测试被调用后为测试提供额外行为 |
AfterTestExecutionCallback |
在每个测试执行后立即为测试提供额外行为 |
ExecutionCondition |
在运行时条件化测试执行 |
ParameterResolver |
在运行时解析参数 |
一旦我们创建了一个扩展,为了使用它,我们需要使用注解 ExtendWith
。这个注解可以用来注册一个或多个扩展。它可以声明在接口、类、方法、字段,甚至其他注解中:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
public class MyTest {
@ExtendWith(MyExtension.class)
@Test
public void test() {
// My test logic
}
}
测试生命周期
有一组旨在控制测试生命周期的扩展点。首先,TestInstancePostProcessor
可以用于在测试实例化后执行一些逻辑。之后,有不同的扩展来控制测试前阶段:
-
BeforeAllCallback
在所有测试之前定义要执行的逻辑。 -
BeforeEachCallback
在测试方法之前定义要执行的逻辑。 -
BeforeTestExecutionCallback
在测试方法之前定义要执行的逻辑。
同样,还有控制测试后阶段的扩展:
-
AfterAllCallback
在所有测试之后定义要执行的逻辑。 -
AfterEachCallback
在测试方法之后定义要执行的逻辑。 -
AfterTestExecutionCallback
在测试方法之后定义要执行的逻辑。
在 Before*
和 After*
回调之间,有一个提供收集异常的扩展:TestExecutionExceptionHandler
。
所有这些回调及其在测试生命周期中的顺序如下图所示:
扩展回调的生命周期
让我们看一个例子。我们创建了一个名为 IgnoreIOExceptionExtension
的扩展,它实现了 TestExecutionExceptionHandler
。在这个例子中,扩展检查异常是否是 IOException
。如果是,异常就被丢弃:
package io.github.bonigarcia;
import java.io.IOException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
public class IgnoreIOExceptionExtension
implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(ExtensionContext context,
Throwable throwable) throws Throwable {
if (throwable instanceof IOException) {
return;
}
throw throwable;
}
}
考虑以下测试类,其中包含两个测试(@Test
)。第一个用 @ExtendWith
和我们自定义的扩展(IgnoreIOExceptionExtension
)进行了注释:
package io.github.bonigarcia;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
public class ExceptionTest {
@ExtendWith(IgnoreIOExceptionExtension.class)
@Test
public void firstTest() throws IOException {
throw new IOException("IO Exception");
}
@Test
public void secondTest() throws IOException {
throw new IOException("My IO Exception");
}
}
在执行这个测试类时,第一个测试成功了,因为 IOException
已经被我们的扩展内部处理了。另一方面,第二个测试会失败,因为异常没有被处理。
可以在控制台中看到这个测试类的执行结果。请注意,我们使用 Maven 命令 mvn test -Dtest=ExceptionTest
选择要执行的测试:
忽略异常示例的输出
条件扩展点
为了创建根据给定条件激活或停用测试的扩展,JUnit 5 提供了一个条件扩展点,称为 ExecutionCondition
。下面的代码片段显示了这个扩展点的声明:
package org.junit.jupiter.api.extension;
import static org.apiguardian.api.API.Status.STABLE;
import org.apiguardian.api.API;
@FunctionalInterface
@API(status = STABLE, since = "5.0")
public interface ExecutionCondition extends Extension {
ConditionEvaluationResult evaluateExecutionCondition
ExtensionContext context);
}
该扩展可以用于停用容器中的所有测试(可能是一个类)或单个测试(可能是一个测试方法)。该扩展的示例在第三章的C 条件测试执行部分中提供,JUnit 5 标准测试。
依赖注入
ParameterResolver
扩展提供了方法级别的依赖注入。在这个例子中,我们可以看到如何使用名为MyParameterResolver
的ParameterResolver
的自定义实现来在测试方法中注入参数。在代码后面,我们可以看到这个解析器将简单地注入硬编码的字符串参数,值为my parameter
:
package io.github.bonigarcia;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
public class MyParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext)
throws ParameterResolutionException {
return true;
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext)
throws ParameterResolutionException {
return "my parameter";
}
}
然后,这个参数解析器可以像往常一样在测试中使用,声明为@ExtendWith
注解:
package io.github.bonigarcia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
public class DependencyInjectionTest {
@ExtendWith(MyParameterResolver.class)
@Test
public void test(Object parameter) {
System.*out*.println("My parameter " + parameter);
}
}
最后,如果我们执行这个测试(例如使用 Maven 和命令行),我们可以看到注入的参数被记录在标准输出中:
依赖注入扩展示例的输出
第三方扩展
SpringExtension:
package org.springframework.test.context.junit.jupiter;
import org.junit.jupiter.api.extension.*;
public class SpringExtension implements BeforeAllCallback,
AfterAllCallback,
TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback,
BeforeTestExecutionCallback, AfterTestExecutionCallback,
ParameterResolver {
@Override
public void afterTestExecution(TestExtensionContext context)
throws Exception {
// implementation
}
// Rest of methods
}
JUnit 5 的现有扩展列表(例如 Spring,Selenium,Docker 等)由社区在 JUnit 5 团队的 GitHub 网站的 wiki 中维护:github.com/junit-team/junit5/wiki/Third-party-Extensions
。其中一些也在第五章中有详细介绍,JUnit 5 与外部框架的集成。
总结
本章概述了 JUnit 5 测试框架。由于 JUnit 4 的限制(单片架构,无法组合测试运行器,以及测试规则的限制),需要一个新的主要版本的框架。为了进行实现,JUnit Lambda 项目在 2015 年发起了一场众筹活动。结果,JUnit 5 开发团队诞生了,并于 2017 年 9 月 10 日发布了该框架的 GA 版本。
JUnit 5 被设计为现代化(即从一开始就使用 Java 8 和 Java 9 兼容),并且是模块化的。JUnit 5 内的三个主要组件是:Jupiter(新的编程和扩展模型),Platform(在 JVM 中执行任何测试框架的基础),以及 Vintage(与传统的 JUnit 3 和 4 测试集成)。在撰写本文时,JUnit 5 测试可以使用构建工具(Maven 或 Gradle)以及 IDE(IntelliJ 2016.2+或 Eclipse 4.7+)来执行。
JUnit 5 的扩展模型允许任何第三方扩展其核心功能。为了创建 JUnit 5 扩展,我们需要实现一个或多个 JUnit 扩展点(如BeforeAllCallback
,ParameterResolver
或ExecutionCondition
等),然后使用@ExtendWith
注解在我们的测试中注册扩展。
在接下来的第三章中,JUnit 5 标准测试,我们将学习 Jupiter 编程模型的基础知识。换句话说,我们将学习如何创建标准的 JUnit 5 测试。
第三章:JUnit 5 标准测试
言语是廉价的。给我看代码。
- Linus Torvalds
JUnit 5 提供了一个全新的编程模型,称为 Jupiter。我们可以将这个编程模型看作是软件工程师和测试人员的 API,允许创建 JUnit 5 测试。这些测试随后在 JUnit 平台上执行。正如我们将要发现的那样,Jupiter 编程模型允许创建许多不同类型的测试。本章介绍了 Jupiter 的基础知识。为此,本章结构如下:
-
测试生命周期:在本节中,我们分析了 Jupiter 测试的结构,描述了在 JUnit 5 编程模型中管理测试生命周期的注解。然后,我们了解如何跳过测试,以及如何为测试添加自定义显示名称的注解。
-
断言:在本节中,首先我们简要介绍了称为断言(也称为谓词)的验证资产。其次,我们研究了 Jupiter 中如何实现这些断言。最后,我们介绍了一些关于断言的第三方库,提供了一些 Hamcrest 的示例。
-
标记和过滤测试:在本节中,首先我们将学习如何为 Jupiter 测试创建标签,即如何在 JUnit 5 中创建标签。然后,我们将学习如何使用 Maven 和 Gradle 来过滤我们的测试。最后,我们将分析如何使用 Jupiter 创建元注解。
-
条件测试执行:在本节中,我们将学习如何根据给定条件禁用测试。之后,我们将回顾 Jupiter 中所谓的假设,这是 Jupiter 提供的一个机制,只有在某些条件符合预期时才运行测试。
-
嵌套测试:本节介绍了 Jupiter 如何允许表达一组测试之间的关系,称为嵌套测试。
-
重复测试:本节回顾了 Jupiter 如何提供重复执行指定次数的测试的能力。
-
从 JUnit 4 迁移到 JUnit 5:本节提供了一组关于 JUnit 5 和其直接前身 JUnit 4 之间主要区别的提示。然后,本节介绍了 Jupiter 测试中对几个 JUnit 4 规则的支持。
测试生命周期
正如我们在第一章中所看到的,一个单元测试用例由四个阶段组成:
-
设置(可选):首先,测试初始化测试夹具(在 SUT 的图片之前)。
-
练习:其次,测试与 SUT 进行交互,从中获取一些结果。
-
验证:第三,将来自被测试系统的结果与预期值进行比较,使用一个或多个断言(也称为谓词)。因此,创建了一个测试判决。
-
拆卸(可选):最后,测试释放测试夹具,将 SUT 恢复到初始状态。
在 JUnit 4 中,有不同的注解来控制这些测试阶段。JUnit 5 遵循相同的方法,即使用 Java 注解来标识 Java 类中的不同方法,实现测试生命周期。在 Jupiter 中,所有这些注解都包含在org.junit.jupiter.api
包中。
JUnit 的最基本注解是@Test
,它标识了必须作为测试执行的方法。因此,使用org.junit.jupiter.api.Test
注解的 Java 方法将被视为测试。这个注解与 JUnit 4 的@Test
的区别有两个方面。一方面,Jupiter 的@Test
注解不声明任何属性。在 JUnit 4 中,@Test
可以声明测试超时(作为长属性,以毫秒为单位的超时时间),另一方面,在 JUnit 5 中,测试类和测试方法都不需要是 public(这是 JUnit 4 中的要求)。
看一下下面的 Java 类。可能,这是我们可以用 Jupiter 创建的最简单的测试用例。它只是一个带有@Test
注解的方法。测试逻辑(即前面描述的练习和验证阶段)将包含在myTest
方法中。
package io.github.bonigarcia;
import org.junit.jupiter.api.Test;
class SimpleJUnit5Test {
@Test
void mySimpleTest() {
// My test logic here
}
}
Jupiter 注解(也位于包org.junit.jupiter.api
中)旨在控制 JUnit 5 测试中的设置和拆卸阶段,如下表所述:
JUnit 5 注解 | 描述 | JUnit 4 的等效 |
---|---|---|
@BeforeEach |
在当前类中的每个@Test 之前执行的方法 |
@Before |
@AfterEach |
在当前类中的每个@Test 之后执行的方法 |
@After |
@BeforeAll |
在当前类中的所有@Test 之前执行的方法 |
@BeforeClass |
@AfterAll |
在当前类中的所有@Test 之后执行的方法 |
@AfterClass |
这些注解(@BeforeEach
,@AfterEach
,@AfterAll
和@BeforeAll
)注解的方法始终会被继承。
下图描述了这些注解在 Java 类中的执行顺序:
控制测试生命周期的 Jupiter 注解
让我们回到本节开头看到的测试的通用结构。现在,我们能够将 Jupiter 注解映射到测试用例的不同部分,以控制测试生命周期。如下图所示,我们通过使用@BeforeAll
和@BeforeEach
注解的方法进行设置阶段。然后,我们在使用@Test
注解的方法中进行练习和验证阶段。最后,我们在使用@AfterEach
和@AfterAll
注解的方法中进行拆卸过程。
单元测试阶段与 Jupiter 注解之间的关系
让我们看一个简单的例子,它在一个单独的 Java 类中使用了所有这些注解。这个例子定义了两个测试(即,使用@Test
注解的两个方法),并且我们使用@BeforeAll
,@BeforeEach
,@AfterEach
和@AfterAll
注解为测试生命周期的其余部分定义了额外的方法:
package io.github.bonigarcia;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class LifecycleJUnit5Test {
@BeforeAll
static void setupAll() {
System.*out*.println("Setup ALL TESTS in the class");
}
@BeforeEach
void setup() {
System.*out*.println("Setup EACH TEST in the class");
}
@Test
void testOne() {
System.*out*.println("TEST 1");
}
@Test
void testTwo() {
System.*out*.println("TEST 2");
}
@AfterEach
void teardown() {
System.*out*.println("Teardown EACH TEST in the class");
}
@AfterAll
static void teardownAll() {
System.*out*.println("Teardown ALL TESTS in the class");
}
}
如果我们运行这个测试类,首先会执行@BeforeAll
。然后,两个测试方法将按顺序执行,即先执行第一个,然后执行另一个。在每次执行中,测试之前使用@BeforeEach
注解的设置方法将在测试之前执行,然后执行@AfterEach
方法。以下截图显示了使用 Maven 和命令行执行测试的情况:
控制其生命周期的 Jupiter 测试的执行
测试实例生命周期
为了提供隔离的执行,JUnit 5 框架在执行实际测试(即使用@Test
注解的方法)之前创建一个新的测试实例。这种每方法的测试实例生命周期是 Jupiter 测试和其前身(JUnit 3 和 4)的行为。作为新功能,这种默认行为可以在 JUnit 5 中通过简单地使用@TestInstance(Lifecycle.PER_CLASS)
注解来改变。使用这种模式,测试实例将每个类创建一次,而不是每个测试方法创建一次。
这种每类的行为意味着可以将@BeforeAll
和@AfterAll
方法声明为非静态的。这对于与一些高级功能一起使用非常有益,比如嵌套测试或默认测试接口(在下一章中解释)。
总的来说,考虑到扩展回调(如第二章JUnit 5 中的新功能中所述的JUnit 5 的扩展模型),用户代码和扩展的相对执行顺序如下图所示:
用户代码和扩展的相对执行顺序
跳过测试
Jupiter 注释@Disabled
(位于包org.junit.jupiter.api
中)可用于跳过测试。它可以在类级别或方法级别使用。以下示例在方法级别使用注释@Disabled
,因此强制跳过测试:
package io.github.bonigarcia;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
class DisabledTest {
@Disabled
@Test
void skippedTest() {
}
}
如下截图所示,当我们执行此示例时,测试将被视为已跳过:
禁用测试方法的控制台输出
在这个例子中,注释@Disabled
放置在类级别,因此类中包含的所有测试都将被跳过。请注意,通常可以在注释中指定自定义消息,通常包含禁用的原因:
package io.github.bonigarcia;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@Disabled("All test in this class will be skipped")
class AllDisabledTest {
@Test
void skippedTestOne() {
}
@Test
void skippedTestTwo() {
}
}
以下截图显示了在执行测试用例时(在此示例中使用 Maven 和命令行)跳过测试案例的情况:
禁用测试类的控制台输出
显示名称
JUnit 4 基本上通过使用带有@Test
注释的方法的名称来识别测试。这对测试名称施加了限制,因为这些名称受到在 Java 中声明方法的方式的限制。
为了解决这个问题,Jupiter 提供了声明自定义显示名称(与测试名称不同)的能力。这是通过注释@DisplayName
完成的。此注释为测试类或测试方法声明了自定义显示名称。此名称将由测试运行器和报告工具显示,并且可以包含空格、特殊字符,甚至表情符号。
看看以下示例。我们使用@DisplayName
为测试类和类中声明的三个测试方法注释了自定义测试名称:
package io.github.bonigarcia;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("A special test case")
class DisplayNameTest {
@Test
@DisplayName("Custom test name containing spaces")
void testWithDisplayNameContainingSpaces() {
}
@Test
@DisplayName("(╯°Д°)╯")
void testWithDisplayNameContainingSpecialCharacters() {
}
@Test
@DisplayName("")
void testWithDisplayNameContainingEmoji() {
}
}
因此,当在符合 JUnit 5 的 IDE 中执行此测试时,我们会看到这些标签。以下图片显示了在 IntelliJ 2016.2+上执行示例的情况:
在 IntelliJ 中使用@DisplayName执行测试案例
另一方面,显示名称也可以在 Eclipse 4.7(Oxygen)或更新版本中看到:
在 Eclipse 中使用@DisplayName执行测试案例
断言
我们知道,测试案例的一般结构由四个阶段组成:设置、执行、验证和拆卸。实际测试发生在第二和第三阶段,当测试逻辑与被测试系统交互时,从中获得某种结果。这个结果在验证阶段与预期结果进行比较。在这个阶段,我们找到了我们所谓的断言。在本节中,我们将更仔细地研究它们。
断言(也称为谓词)是一个boolean
语句,通常用于推理软件的正确性。从技术角度来看,断言由三部分组成(见列表后的图像):
-
首先,我们找到预期值,这些值来自我们称之为测试预言的东西。测试预言是预期输出的可靠来源,例如,系统规范。
-
其次,我们找到真正的结果,这是由测试对 SUT 进行的练习阶段产生的。
-
最后,这两个值使用一些逻辑比较器进行比较。这种比较可以通过许多不同的方式进行,例如,我们可以比较对象的身份(相等或不相等),大小(更高或更低的值),等等。结果,我们得到一个测试结论,最终将定义测试是否成功或失败。
断言的示意图
Jupiter 断言
让我们继续讨论 JUnit 5 编程模型。Jupiter 提供了许多断言方法,例如 JUnit 4 中的方法,并且还添加了一些可以与 Java 8 lambda 一起使用的方法。所有 JUnit Jupiter 断言都是位于org.junit.jupiter
包中的Assertions
类中的静态方法。
以下图片显示了这些方法的完整列表:
Jupiter 断言的完整列表(类org.junit.jupiter.Assertions)
以下表格回顾了 Jupiter 中不同类型的基本断言:
断言 | 描述 |
---|---|
fail |
以给定的消息和/或异常失败测试 |
assertTrue |
断言提供的条件为真 |
assertFalse |
断言提供的条件为假 |
assertNull |
断言提供的对象为 null |
assertNotNull |
断言提供的对象不是 null |
assertEquals |
断言两个提供的对象相等 |
assertArrayEquals |
断言两个提供的数组相等 |
assertIterableEquals |
断言两个可迭代对象深度相等 |
assertLinesMatch |
断言两个字符串列表相等 |
assertNotEquals |
断言两个提供的对象不相等 |
assertSame |
断言两个对象相同,使用 == 进行比较 |
assertNotSame |
断言两个对象不同,使用 != 进行比较 |
对于表中包含的每个断言,都可以提供一个可选的失败消息(String)。这个消息始终是断言方法中的最后一个参数。这与 JUnit 4 有一点小区别,因为在 JUnit 4 中,这个消息是方法调用中的第一个参数。
以下示例显示了一个使用 assertEquals
、assertTrue
和 assertFalse
断言的测试。请注意,我们在类的开头导入了静态断言方法,以提高测试逻辑的可读性。在示例中,我们找到了 assertEquals
方法,这里比较了两种原始类型(也可以用于对象)。其次,assertTrue
方法评估一个 boolean
表达式是否为真。第三,assertFalse
方法评估一个布尔表达式是否为假。在这种情况下,请注意消息是作为 Lamdba 表达式创建的。这样,断言消息会被懒惰地评估,以避免不必要地构造复杂的消息:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class StandardAssertionsTest {
@Test
void standardAssertions() {
*assertEquals*(2, 2);
*assertTrue*(true,
"The optional assertion message is now the last parameter");
*assertFalse*(false, () -> "Really " + "expensive " + "message"
+ ".");
}
}
本节的以下部分将回顾 Jupiter 提供的高级断言:assertAll
、assertThrows
、assertTimeout
和 assertTimeoutPreemptively
。
断言组
一个重要的 Jupiter 断言是 assertAll
。这个方法允许同时对不同的断言进行分组。在分组断言中,所有断言都会被执行,任何失败都将一起报告。
方法 assertAll
接受 lambda 表达式(Executable…
)的可变参数或这些表达式的流(Stream<Executable>
)。可选地,assertAll
的第一个参数可以是一个用于标记断言组的字符串消息。
让我们看一个例子。在以下测试中,我们使用 lambda 表达式对一对 assertEquals
进行分组:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class GroupedAssertionsTest {
@Test
void groupedAssertions() {
Address address = new Address("John", "Smith");
// In a grouped assertion all assertions are executed, and any
// failures will be reported together.
*assertAll*("address", () -> *assertEquals*("John",
address.getFirstName()),
() -> *assertEquals*("User", address.getLastName()));
}
}
在执行这个测试时,将评估组中的所有断言。由于第二个断言失败(lastname
不匹配),在最终的判决中报告了一个失败,如下截图所示:
分组断言示例的控制台输出
断言异常
另一个重要的 Jupiter 断言是 assertThrows
。这个断言允许验证在一段代码中是否引发了给定的异常。为此,assertThrows
方法接受两个参数。首先是预期的异常类,其次是可执行对象(lambda 表达式),其中应该发生异常:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class ExceptionTest {
@Test
void exceptionTesting() {
Throwable exception =
*assertThrows*(IllegalArgumentException.class,
() -> {
throw new IllegalArgumentException("a message");});
*assertEquals*("a message", exception.getMessage());
}
}
这里期望抛出 IllegalArgumentException
,而这实际上是在这个 lambda 表达式中发生的。下面的截图显示了测试实际上成功了:
assertThrows 示例的控制台输出
断言超时
为了评估 JUnit 5 测试中的超时,Jupiter 提供了两个断言:assertTimeout
和 assertTimeoutPreemptively
。一方面,assertTimeout
允许我们验证给定操作的超时。在这个断言中,使用标准 Java 包 java.time
的 Duration
类定义了预期时间。
我们将看到几个运行示例,以阐明这个断言方法的使用。在下面的类中,我们找到两个使用assertTimeout
的测试。第一个测试旨在成功,因为我们期望给定操作的持续时间少于 2 分钟,而我们在那里什么也没做。另一方面,第二个测试将失败,因为我们期望给定操作的持续时间最多为 10 毫秒,而我们强制它持续 100 毫秒。
package io.github.bonigarcia;
import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import org.junit.jupiter.api.Test;
class TimeoutExceededTest {
@Test
void timeoutNotExceeded() {
*assertTimeout*(*ofMinutes*(2), () -> {
// Perform task that takes less than 2 minutes
});
}
@Test
void timeoutExceeded() {
*assertTimeout*(*ofMillis*(10), () -> {
Thread.*sleep*(100);
});
}
}
当我们执行这个测试时,第二个测试被声明为失败,因为超时已经超过了 90 毫秒:
assertTimeout第一个示例的控制台输出
让我们看看使用assertTimeout
的另外两个测试。在第一个测试中,assertTimeout
在给定的超时时间内将代码作为 lambda 表达式进行评估,获取其结果。在第二个测试中,assertTimeout
在给定的超时时间内评估一个方法,获取其结果:
package io.github.bonigarcia;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import org.junit.jupiter.api.Test;
class TimeoutWithResultOrMethodTest {
@Test
void timeoutNotExceededWithResult() {
String actualResult = *assertTimeout*(*ofMinutes*(1), () -> {
return "hi there";
});
*assertEquals*("hi there", actualResult);
}
@Test
void timeoutNotExceededWithMethod() {
String actualGreeting = *assertTimeout*(*ofMinutes*(1),
TimeoutWithResultOrMethodTest::*greeting*);
*assertEquals*("hello world!", actualGreeting);
}
private static String greeting() {
return "hello world!";
}
}
在这两种情况下,测试所花费的时间都少于预期,因此它们都成功了:
assertTimeout第二个示例的控制台输出
另一个 Jupiter 断言超时的方法称为assertTimeoutPreemptively
。与assertTimeout
相比,assertTimeoutPreemptively
的区别在于assertTimeoutPreemptively
不会等到操作结束,当超过预期的超时时,执行会被中止。
在这个例子中,测试将失败,因为我们模拟了一个持续 100 毫秒的操作,并且我们定义了 10 毫秒的超时:
package io.github.bonigarcia;
import static java.time.Duration.ofMillis;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import org.junit.jupiter.api.Test;
class TimeoutWithPreemptiveTerminationTest {
@Test
void timeoutExceededWithPreemptiveTermination() {
*assertTimeoutPreemptively*(*ofMillis*(10), () -> {
Thread.*sleep*(100);
});
}
}
在这个例子中,当达到 10 毫秒的超时时,测试立即被声明为失败:
assertTimeoutPreemptively示例的控制台输出
第三方断言库
正如我们所见,Jupiter 提供的内置断言已经足够满足许多测试场景。然而,在某些情况下,可能需要更多的额外功能,比如匹配器。在这种情况下,JUnit 团队建议使用以下第三方断言库:
-
Hamcrest(
hamcrest.org/
):一个断言框架,用于编写允许以声明方式定义规则的匹配器对象。 -
AssertJ(
joel-costigliola.github.io/assertj/
):用于 Java 的流畅断言。 -
Truth(
google.github.io/truth/
):一个用于使测试断言和失败消息更易读的断言 Java 库。
在本节中,我们将简要回顾一下 Hamcrest。这个库提供了断言assertThat
,它允许创建可读性高且高度可配置的断言。方法assertThat
接受两个参数:第一个是实际对象,第二个是Matcher
对象。这个匹配器实现了接口org.hamcrest.Matcher
,并允许对期望进行部分或完全匹配。Hamcrest 提供了不同的匹配器实用程序,比如is
,either
,or
,not
和hasItem
。匹配器方法使用了构建器模式,允许组合一个或多个匹配器来构建一个匹配器链。
为了使用 Hamcrest,首先我们需要在项目中导入依赖项。在 Maven 项目中,这意味着我们必须在pom.xml
文件中包含以下依赖项:
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>${hamcrest.version}</version>
<scope>test</scope>
</dependency>
如果我们使用 Gradle,我们需要在build.gradle
文件中添加相应的配置:
dependencies {
testCompile("org.hamcrest:hamcrest-core:${hamcrest}")
}
通常情况下,建议使用最新版本的 Hamcrest。我们可以在 Maven 中央网站上检查它(search.maven.org/
)。
以下示例演示了如何在 Jupiter 测试中使用 Hamcrest。具体来说,这个测试使用了断言assertThat
,以及匹配器containsString
,equalTo
和notNullValue
:
package io.github.bonigarcia;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import org.junit.jupiter.api.Test;
class HamcrestTest {
@Test
void assertWithHamcrestMatcher() {
*assertThat*(2 + 1, *equalTo*(3));
*assertThat*("Foo", *notNullValue*());
*assertThat*("Hello world", *containsString*("world"));
}
}
如下截图所示,这个测试执行时没有失败:
使用 Hamcrest 断言库的示例的控制台输出
标记和过滤测试
在 JUnit 5 编程模型中,可以通过注解@Tag
(包org.junit.jupiter.api
)为测试类和方法打标签。这些标签可以后来用于过滤测试的发现和执行。在下面的示例中,我们看到了在类级别和方法级别使用@Tag
的情况:
package io.github.bonigarcia;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("simple")
class SimpleTaggingTest {
@Test
@Tag("taxes")
void testingTaxCalculation() {
}
}
从 JUnit 5 M6 开始,标记测试的标签应满足以下语法规则:
-
标签不能为空或空白。
-
修剪的标签(即去除了前导和尾随空格的标签)不得包含空格。
-
修剪的标签不得包含 ISO 控制字符,也不得包含以下保留字符:
,
,(
,)
,&
,|
和!
。
使用 Maven 过滤测试
正如我们已经知道的,我们需要在 Maven 项目中使用maven-surefire-plugin
来执行 Jupiter 测试。此外,该插件允许我们以多种方式过滤测试执行:通过 JUnit 5 标签进行过滤,还可以使用maven-surefire-plugin
的常规包含/排除支持。
为了按标签过滤,应该使用maven-surefire-plugin
配置的属性includeTags
和excludeTags
。让我们看一个示例来演示如何。考虑同一个 Maven 项目中包含的以下测试。一方面,这个类中的所有测试都被标记为functional
。
package io.github.bonigarcia;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("functional")
class FunctionalTest {
@Test
void testOne() {
System.*out*.println("Functional Test 1");
}
@Test
void testTwo() {
System.*out*.println("Functional Test 2");
}
}
另一方面,第二个类中的所有测试都被标记为non-functional
,每个单独的测试也被标记为更多的标签(performance
,security
,usability
等):
package io.github.bonigarcia;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("non-functional")
class NonFunctionalTest {
@Test
@Tag("performance")
@Tag("load")
void testOne() {
System.*out*.println("Non-Functional Test 1 (Performance/Load)");
}
@Test
@Tag("performance")
@Tag("stress")
void testTwo() {
System.*out*.println("Non-Functional Test 2 (Performance/Stress)");
}
@Test
@Tag("security")
void testThree() {
System.*out*.println("Non-Functional Test 3 (Security)");
}
@Test
@Tag("usability")
void testFour() {
System.*out*.println("Non-Functional Test 4 (Usability)"); }
}
如前所述,我们在 Maven 的pom.xml
文件中使用配置关键字includeTags
和excludeTags
。在这个例子中,我们包含了带有标签functional
的测试,并排除了non-functional
:
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<properties>
<includeTags>functional</includeTags>
<excludeTags>non-functional</excludeTags>
</properties>
</configuration>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>${junit.platform.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
结果是,当我们尝试执行项目中的所有测试时,只有两个测试会被执行(带有标签functional
的测试),其余的测试不被识别为测试:
通过标签过滤的 Maven 执行
Maven 常规支持
Maven 插件的常规包含/排除支持仍然可以用于选择由maven-surefire-plugin
执行的测试。为此,我们使用关键字includes
和excludes
来配置插件执行时用于过滤的测试名称模式。请注意,对于包含和排除,可以使用正则表达式来指定测试文件名的模式:
<configuration>
<includes>
<include>**/Test*.java</include>
<include>**/*Test.java</include>
<include>**/*TestCase.java</include>
</includes>
</configuration>
<configuration>
<excludes>
<exclude>**/TestCircle.java</exclude>
<exclude>**/TestSquare.java</exclude>
</excludes>
</configuration>
这三个模式,即包含单词Test或以TestCase结尾的 Java 文件,默认情况下由maven-surefire 插件包含。
使用 Gradle 过滤测试
现在让我们转到 Gradle。正如我们已经知道的,我们也可以使用 Gradle 来运行 JUnit 5 测试。关于过滤过程,我们可以根据以下选择要执行的测试:
-
测试引擎:使用关键字引擎,我们可以包含或排除要使用的测试引擎(即
junit-jupiter
或junit-vintage
)。 -
Jupiter 标签:使用关键字
tags
。 -
Java 包:使用关键字
packages
。 -
类名模式:使用关键字
includeClassNamePattern
。
默认情况下,测试计划中包含所有引擎和标签。只应用包含单词Tests
的类名。让我们看一个工作示例。我们在前一个 Maven 项目中重用相同的测试,但这次是在一个 Gradle 项目中:
junitPlatform {
filters {
engines {
include 'junit-jupiter'
exclude 'junit-vintage'
}
tags {
include 'non-functional'
exclude 'functional'
}
packages {
include 'io.github.bonigarcia'
exclude 'com.others', 'org.others'
}
includeClassNamePattern '.*Spec'
includeClassNamePatterns '.*Test', '.*Tests'
}
}
请注意,我们包含标签non-functional
并排除functional
,因此我们执行了四个测试:
通过标签过滤的 Gradle 执行
元注解
本节的最后部分是关于元注释的定义。JUnit Jupiter 注释可以在其他注释的定义中使用(即可以用作元注释)。这意味着我们可以定义自己的组合注释,它将自动继承其元注释的语义。这个特性非常方便,可以通过重用 JUnit 5 注释@Tag
来创建我们自定义的测试分类。
让我们看一个例子。考虑测试用例的以下分类,其中我们将所有测试分类为功能和非功能,然后在非功能测试下再进行另一级分类:
测试的示例分类(功能和非功能)
有了这个方案,我们将为树结构的叶子创建我们自定义的元注释:@Functional
,@Security
,@Usability
,@Accessiblity
,@Load
和@Stress
。请注意,在每个注释中,我们使用一个或多个@Tag
注释,具体取决于先前定义的结构。首先,我们可以看到@Functional
的声明:
package io.github.bonigarcia;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
@Target({ ElementType.***TYPE**, ElementType.**METHOD** })* @Retention(RetentionPolicy.***RUNTIME**)* @Tag("functional")
public @interface Functional {
}
然后,我们使用标签non-functional
和security
定义注释@Security
:
package io.github.bonigarcia;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
@Target({ ElementType.***TYPE**, ElementType.**METHOD** })* @Retention(RetentionPolicy.***RUNTIME**)* @Tag("non-functional")
@Tag("security")
public @interface Security {
}
同样,我们定义注释@Load
,但这次标记为non-functional
,performance
和load
:
package io.github.bonigarcia;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
@Target({ ElementType.***TYPE**, ElementType.**METHOD** })* @Retention(RetentionPolicy.***RUNTIME**)* @Tag("non-functional")
@Tag("performance")
@Tag("load")
public @interface Load {
}
最后,我们创建注释@Stress
(带有标签non-functional
,performance
和stress
):
package io.github.bonigarcia;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
@Target({ ElementType.***TYPE**, ElementType.**METHOD** })* @Retention(RetentionPolicy.***RUNTIME**)* @Tag("non-functional")
@Tag("performance")
@Tag("stress")
public @interface Stress {
}
现在,我们可以使用我们的注释来标记(以及稍后过滤)测试。例如,在以下示例中,我们在类级别使用注释@Functional
:
package io.github.bonigarcia;
import org.junit.jupiter.api.Test;
@Functional
class FunctionalTest {
@Test
void testOne() {
System.*out*.println("Test 1");
}
@Test
void testTwo() {
System.*out*.println("Test 2");
}
}
我们还可以在方法级别使用注释。在以下测试中,我们使用不同的注释(@Load
,@Stress
,@Security
和@Accessibility
)对不同的测试(方法)进行注释:
package io.github.bonigarcia;
import org.junit.jupiter.api.Test;
class NonFunctionalTest {
@Test
@Load
void testOne() {
System.*out*.println("Test 1");
}
@Test
@Stress
void testTwo() {
System.*out*.println("Test 2");
}
@Test
@Security
void testThree() {
System.*out*.println("Test 3");
}
@Test
@Usability
void testFour() {
System.*out*.println("Test 4"); }
}
总之,我们可以通过简单地更改包含的标签来过滤测试。一方面,我们可以按标签functional
进行过滤。请注意,在这种情况下,只有两个测试被执行。以下代码片段显示了使用 Maven 进行此类过滤的输出:
使用 Maven 和命令行按标签(功能)过滤测试
另一方面,我们也可以使用不同的标签进行过滤,例如non-functional
。以下图片显示了这种类型的过滤示例,这次使用 Gradle。和往常一样,我们可以通过分叉 GitHub 存储库(github.com/bonigarcia/mastering-junit5
)来玩这些示例:
使用 Gradle 和命令行按标签(非功能)过滤测试
条件测试执行
为了为测试执行建立自定义条件,我们需要使用 JUnit 5 扩展模型(在第二章中介绍,JUnit 5 的新功能,在JUnit 5 的扩展模型部分引入)。具体来说,我们需要使用名为ExecutionCondition
的条件扩展点。此扩展可以用于停用类中的所有测试或单个测试。
我们将看到一个工作示例,其中我们创建一个自定义注释来基于操作系统禁用测试。首先,我们创建一个自定义实用枚举来选择一个操作系统(WINDOWS
,MAC
,LINUX
和OTHER
):
package io.github.bonigarcia;
public enum Os {
***WINDOWS***, ***MAC***, ***LINUX***, ***OTHER***;
public static Os determine() {
Os out = ***OTHER***;
String myOs = System.*getProperty*("os.name").toLowerCase();
if (myOs.contains("win")) {
out = ***WINDOWS***;
}
else if (myOs.contains("mac")) {
out = ***MAC***;
}
else if (myOs.contains("nux")) {
out = ***LINUX***;
}
return out;
}
}
然后,我们创建ExecutionCondition
的扩展。在这个例子中,通过检查自定义注释@DisabledOnOs
是否存在来进行评估。当存在注释@DisabledOnOs
时,操作系统的值将与当前平台进行比较。根据该条件的结果,测试将被禁用或启用。
package io.github.bonigarcia;
import java.lang.reflect.AnnotatedElement;
import java.util.Arrays;
import java.util.Optional;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.platform.commons.util.AnnotationUtils;
public class OsCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(
ExtensionContext context) {
Optional<AnnotatedElement> element = context.getElement();
ConditionEvaluationResult out = ConditionEvaluationResult
.*enabled*("@DisabledOnOs is not present");
Optional<DisabledOnOs> disabledOnOs = AnnotationUtils
.*findAnnotation*(element, DisabledOnOs.class);
if (disabledOnOs.isPresent()) {
Os myOs = Os.*determine*();
if(Arrays.asList(disabledOnOs.get().value())
.contains(myOs)) {
out = ConditionEvaluationResult
.*disabled*("Test is disabled on " + myOs);
}
else {
out = ConditionEvaluationResult
.*enabled*("Test is not disabled on " + myOs);
}
}
System.*out*.println("--> " + out.getReason().get());
return out;
}
}
此外,我们需要创建我们的自定义注释@DisabledOnOs
,该注释也使用@ExtendWith
进行注释,指向我们的扩展点。
package io.github.bonigarcia;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
@Target({ ElementType.*TYPE*, ElementType.*METHOD* })
@Retention(RetentionPolicy.*RUNTIME*)
@ExtendWith(OsCondition.class)
public @interface DisabledOnOs {
Os[] value();
}
最后,我们在 Jupiter 测试中使用我们的注释@DisabledOnOs
。
import org.junit.jupiter.api.Test;
import static io.github.bonigarcia.Os.*MAC*;
import static io.github.bonigarcia.Os.*LINUX*;
class DisabledOnOsTest {
@DisabledOnOs({ *MAC*, *LINUX* })
@Test
void conditionalTest() {
System.*out*.println("This test will be disabled on MAC and LINUX");
}
}
如果我们在 Windows 机器上执行此测试,则测试不会被跳过,如下面的快照所示:
条件测试示例的执行
假设
在本节的这一部分是关于所谓的假设。假设允许我们仅在某些条件符合预期时运行测试。所有 JUnit Jupiter 假设都是位于org.junit.jupiter
包内的Assumptions
类中的静态方法。以下截图显示了该类的所有方法:
org.junit.jupiter.Assumptions类的方法
一方面,assumeTrue
和assumeFalse
方法可用于跳过未满足前提条件的测试。另一方面,assumingThat
方法用于条件测试中的一部分的执行:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*fail*;
import static org.junit.jupiter.api.Assumptions.*assumeFalse*;
import static org.junit.jupiter.api.Assumptions.*assumeTrue*;
import static org.junit.jupiter.api.Assumptions.*assumingThat*;
import org.junit.jupiter.api.Test;
class AssumptionsTest {
@Test
void assumeTrueTest() {
*assumeTrue*(false);
*fail*("Test 1 failed");
}
@Test
void assumeFalseTest() {
*assumeFalse*(this::getTrue);
*fail*("Test 2 failed");
}
private boolean getTrue() {
return true;
}
@Test
void assummingThatTest() {
*assumingThat*(false, () -> *fail*("Test 3 failed"));
}
}
请注意,在这个示例中,前两个测试(assumeTrueTest
和assumeFalseTest
)由于假设条件不满足而被跳过。然而,在assummingThatTest
测试中,只有测试的这一部分(在这种情况下是一个 lambda 表达式)没有被执行,但整个测试并没有被跳过:
假设测试示例的执行
嵌套测试
嵌套测试使测试编写者能够更多地表达一组测试中的关系和顺序。JUnit 5 使得嵌套测试类变得轻而易举。我们只需要用@Nested
注解内部类,其中的所有测试方法也将被执行,从顶级类中定义的常规测试到每个内部类中定义的测试。
我们需要考虑的第一件事是,只有非静态嵌套类(即内部类)才能作为@Nested
测试。嵌套可以任意深入,并且每个测试的设置和拆卸(即@BeforeEach
和@AfterEach
方法)都会在嵌套测试中继承。然而,内部类不能定义@BeforeAll
和@AfterAll
方法,因为 Java 不允许内部类中有静态成员。然而,可以使用@TestInstance(Lifecycle.PER_CLASS)
注解在测试类中避免这种限制。正如本章节中的测试实例生命周期部分所描述的,该注解强制每个类实例化一个测试实例,而不是每个方法实例化一个测试实例(默认行为)。这样,@BeforeAll
和@AfterAll
方法就不需要是静态的,因此可以在嵌套测试中使用。
让我们看一个由一个 Java 类组成的简单示例,该类有两个级别的内部类,即,该类包含两个嵌套的内部类,这些内部类带有@Nested
注解。正如我们所看到的,该类的三个级别都有测试。请注意,顶级类定义了一个设置方法(@BeforeEach
),并且第一个嵌套类(在示例中称为InnerClass1
)也是如此。在顶级类中,我们定义了一个单一的测试(称为topTest
),并且在每个嵌套类中,我们找到另一个测试(分别称为innerTest1
和innerTest2
):
package io.github.bonigarcia;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class NestTest {
@BeforeEach
void setup1() {
System.*out*.println("Setup 1");
}
@Test
void topTest() {
System.*out*.println("Test 1");
}
@Nested
class InnerClass1 {
@BeforeEach
void setup2() {
System.*out*.println("Setup 2");
}
@Test
void innerTest1() {
System.*out*.println("Test 2");
}
@Nested
class InnerClass2 {
@Test
void innerTest2() {
System.*out*.println("Test 3");
}
}
}
}
如果我们执行这个示例,我们可以通过简单地查看控制台跟踪来追踪嵌套测试的执行。请注意,顶级@BeforeEach
方法(称为setup1
)总是在每个测试之前执行。因此,在实际测试执行之前,控制台中始终存在Setup 1
的跟踪。每个测试也会在控制台上写一行。正如我们所看到的,第一个测试记录了Test 1
。之后,执行了内部类中定义的测试。第一个内部类执行了测试innerTest1
,但在此之后,顶级类和第一个内部类的设置方法被执行(分别记录了Setup 1
和Setup 2
)。
最后,执行了最后一个内部类中定义的测试(innerTest2
),但通常情况下,在测试之前会执行一系列的设置方法:
嵌套测试示例的控制台输出
嵌套测试可以与显示名称(即注解@DisplayName
)一起使用,以帮助生成易读的测试输出。以下示例演示了如何使用。这个类包含了测试栈实现的结构,即后进先出(LIFO)集合。该类首先设计了在栈刚实例化时进行测试(方法isInstantiatedWithNew
)。之后,第一个内部类(WhenNew
)应该测试栈作为空集合(方法isEmpty
,throwsExceptionWhenPopped
和throwsExceptionWhenPeeked
)。最后,第二个内部类应该测试栈不为空时的情况(方法isNotEmpty
,returnElementWhenPopped
和returnElementWhenPeeked
):
package io.github.bonigarcia;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@DisplayName("A stack test")
class StackTest {
@Test
@DisplayName("is instantiated")
void isInstantiated() {
}
@Nested
@DisplayName("when empty")
class WhenNew {
@Test
@DisplayName("is empty")
void isEmpty() {
}
@Test
@DisplayName("throws Exception when popped")
void throwsExceptionWhenPopped() {
}
@Test
@DisplayName("throws Exception when peeked")
void throwsExceptionWhenPeeked() {
}
@Nested
@DisplayName("after pushing an element")
class AfterPushing {
@Test
@DisplayName("it is no longer empty")
void isNotEmpty() {
}
@Test
@DisplayName("returns the element when popped")
void returnElementWhenPopped() {
}
@Test
@DisplayName("returns the element when peeked")
void returnElementWhenPeeked() {
}
}
}
}
这种类型的测试的目的是双重的。一方面,类结构为测试的执行提供了顺序。另一方面,使用@DisplayName
提高了测试执行的可读性。我们可以看到,当测试在 IDE 中执行时,特别是在 IntelliJ IDEA 中。
在 Intellij IDEA 上使用@DisplayName执行嵌套测试
重复测试
JUnit Jupiter 提供了通过简单地使用@RepeatedTest
方法对测试进行指定次数的重复的能力,指定所需的总重复次数。每次重复的测试行为与常规的@Test
方法完全相同。此外,每次重复的测试都保留相同的生命周期回调(@BeforeEach
,@AfterEach
等)。
以下 Java 类包含一个将重复五次的测试:
package io.github.bonigarcia;
import org.junit.jupiter.api.RepeatedTest;
class SimpleRepeatedTest {
@RepeatedTest(5)
void test() {
System.*out*.println("Repeated test");
}
}
由于这个测试只在标准输出中写了一行(Repeated test
),当在控制台中执行这个测试时,我们会看到这个迹象出现五次:
在控制台中执行重复测试
除了指定重复次数外,还可以通过@RepeatedTest
注解的 name 属性为每次重复配置自定义显示名称。显示名称可以是由静态文本和动态占位符组成的模式。目前支持以下内容:
-
{displayName}
:这是@RepeatedTest
方法的名称。 -
{currentRepetition}
:这是当前的重复次数。 -
{totalRepetitions}
:这是总的重复次数。
以下示例显示了一个类,其中有三个重复测试,其中显示名称使用了@RepeatedTest
的属性名称:
package io.github.bonigarcia;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.TestInfo;
class TunningDisplayInRepeatedTest {
@RepeatedTest(value = 2, name = "{displayName}
{currentRepetition}/{totalRepetitions}")
@DisplayName("Repeat!")
void customDisplayName(TestInfo testInfo) {
System.*out*.println(testInfo.getDisplayName());
}
@RepeatedTest(value = 2, name = RepeatedTest.*LONG_DISPLAY_NAME*)
@DisplayName("Test using long display name")
void customDisplayNameWithLongPattern(TestInfo testInfo) {
System.*out*.println(testInfo.getDisplayName());
}
@RepeatedTest(value = 2, name = RepeatedTest.*SHORT_DISPLAY_NAME*)
@DisplayName("Test using short display name")
void customDisplayNameWithShortPattern(TestInfo testInfo) {
System.*out*.println(testInfo.getDisplayName());
}
}
在这个测试中,这些重复测试的显示名称将如下所示:
-
对于测试
customDisplayName
,显示名称将遵循长显示格式: -
重复 1 次,共 2 次
。 -
重复 2 次,共 2 次
。 -
对于测试
customDisplayNameWithLongPattern
,显示名称将遵循长显示格式: -
重复!1/2
。 -
重复!2/2
。 -
对于测试
customDisplayNameWithShortPattern
,此测试中的显示名称将遵循短显示格式: -
使用长显示名称的测试::重复 1 次,共 2 次
。 -
使用长显示名称的测试::重复 2 次,共 2 次
。
在与@DisplayName结合使用的重复测试示例中执行
从 JUnit 4 迁移到 JUnit 5
JUnit 5 不支持 JUnit 4 的功能,比如规则和运行器。然而,JUnit 5 通过 JUnit Vintage 测试引擎提供了一个渐进的迁移路径,允许我们在 JUnit 平台上执行传统的测试用例(包括 JUnit 4 和 JUnit 3)。
以下表格可用于总结 JUnit 4 和 5 之间的主要区别:
功能 | JUnit 4 | JUnit 5 |
---|---|---|
注解包 | org.junit |
org.junit.jupiter.api |
声明测试 | @Test |
@Test |
所有测试的设置 | @BeforeClass |
@BeforeAll |
每个测试的设置 | @Before |
@BeforeEach |
每个测试的拆卸 | @After |
@AfterEach |
所有测试的拆卸 | @AfterClass |
@AfterAll |
标记和过滤 | @Category |
@Tag |
禁用测试方法或类 | @Ignore |
@Disabled |
嵌套测试 | 不适用 | @Nested |
重复测试 | 使用自定义规则 | @Repeated |
动态测试 | 不适用 | @TestFactory |
测试模板 | 不适用 | @TestTemaplate |
运行器 | @RunWith |
此功能已被扩展模型 (@ExtendWith ) 取代 |
规则 | @Rule 和 @ClassRule |
此功能已被扩展模型 (@ExtendWith ) 取代 |
Jupiter 中的规则支持
如前所述,Jupiter 不原生支持 JUnit 4 规则。然而,JUnit 5 团队意识到 JUnit 4 规则如今在许多测试代码库中被广泛采用。为了实现从 JUnit 4 到 JUnit 5 的无缝迁移,JUnit 5 团队实现了 junit-jupiter-migrationsupport
模块。如果要在项目中使用这个模块,应该导入模块依赖。Maven 的示例在这里:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-migrationsupport</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
这个依赖的 Gradle 声明是这样的:
dependencies {
testCompile("org.junit.jupiter:junit-jupiter-
migrationsupport:${junitJupiterVersion}")
}
JUnit 5 中的规则支持仅限于与 Jupiter 扩展模型在语义上兼容的规则,包括以下规则:
-
junit.rules.ExternalResource
(包括org.junit.rules.TemporaryFolder
)。 -
junit.rules.Verifier
(包括org.junit.rules.ErrorCollector
)。 -
junit.rules.ExpectedException
。
为了在 Jupiter 测试中启用这些规则,测试类应该用类级别的注解 @EnableRuleMigrationSupport
进行注解(位于包 org.junit.jupiter.migrationsupport.rules
中)。让我们看几个例子。首先,以下测试用例在 Jupiter 测试中定义并使用了 TemporaryFolder
JUnit 4 规则:
package io.github.bonigarcia;
import java.io.IOException;
import org.junit.Rule;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport;
import org.junit.rules.TemporaryFolder;
@EnableRuleMigrationSupport
class TemporaryFolderRuleTest {
@Rule
TemporaryFolder temporaryFolder = new TemporaryFolder();
@BeforeEach
void setup() throws IOException {
temporaryFolder.create();
}
@Test
void test() {
System.*out*.println("Temporary folder: " +
temporaryFolder.getRoot());
}
@AfterEach
void teardown() {
temporaryFolder.delete();
}
}
在执行这个测试时,临时文件夹的路径将被记录在标准输出中:
使用 JUnit 4 的 TemporaryFolder 规则执行 Jupiter 测试
以下测试演示了在 Jupiter 测试中使用 ErrorCollector
规则。请注意,收集器规则允许在发现一个或多个问题后继续执行测试:
package io.github.bonigarcia;
import static org.hamcrest.CoreMatchers.equalTo;
import org.junit.Rule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport;
import org.junit.rules.ErrorCollector;
@EnableRuleMigrationSupport
class ErrorCollectorRuleTest {
@Rule
public ErrorCollector collector = new ErrorCollector();
@Test
void test() {
collector.checkThat("a", *equalTo*("b"));
collector.checkThat(1, *equalTo*(2));
collector.checkThat("c", *equalTo*("c"));
}
}
这些问题将在测试结束时一起报告:
使用 JUnit 4 的 ErrorCollector 规则执行 Jupiter 测试
最后,ExpectedException
规则允许我们配置测试以预期在测试逻辑中抛出给定的异常:
package io.github.bonigarcia;
import org.junit.Rule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport;
import org.junit.rules.ExpectedException;
@EnableRuleMigrationSupport
class ExpectedExceptionRuleTest {
@Rule
ExpectedException thrown = ExpectedException.*none*();
@Test
void throwsNothing() {
}
@Test
void throwsNullPointerException() {
thrown.expect(NullPointerException.class);
throw new NullPointerException();
}
}
在这个例子中,即使第二个测试引发了 NullPointerException
,由于预期到了这个异常,测试将被标记为成功。
使用 JUnit 4 的 ExpectedException 规则执行 Jupiter 测试
总结
在本章中,我们介绍了 JUnit 5 框架全新编程模型 Jupiter 的基础知识。这个编程模型提供了丰富的 API,可以被从业者用来创建测试用例。Jupiter 最基本的元素是注解 @Test
,它标识 Java 类中作为测试的方法(即对 SUT 进行测试和验证的逻辑)。此外,还有不同的注解可以用来控制测试生命周期,即 @BeforeAll
、@BeforeEach
、@AfterEach
和 @AfterAll
。其他有用的 Jupiter 注解包括 @Disabled
(跳过测试)、@DisplayName
(提供测试名称)、@Tag
(标记和过滤测试)。
Jupiter 提供了丰富的断言集,这些断言是 Assertions
类中的静态方法,用于验证从 SUT 获取的结果是否与某个预期值相对应。我们可以通过多种方式对测试执行施加条件。一方面,我们可以使用 Assumptions
仅在某些条件符合预期时运行测试(或其中的一部分)。
我们已经学习了如何使用@Nested
注解简单地创建嵌套测试,这可以用来按照嵌套类的关系顺序执行测试。我们还学习了使用 JUnit 5 编程模型创建重复测试的简便方法。@RepeatedTest
注解用于此目的,可以重复执行指定次数的测试。最后,我们看到 Jupiter 为几个传统的 JUnit 4 测试规则提供了支持,包括ExternalResource
、Verifier
和ExpectedException
。
在第四章中,使用高级 JUnit 功能简化测试,我们继续探索 JUnit 编程模型。具体来说,我们回顾了 JUnit 5 的高级功能,包括依赖注入、动态测试、测试接口、测试模板、参数化测试、JUnit 5 与 Java 9 的兼容性。最后,我们回顾了 JUnit 5.1 中计划的一些功能,这些功能在撰写本文时尚未实现。
第四章:使用高级 JUnit 功能简化测试
简单是终极的复杂。
- 列奥纳多·达·芬奇
到目前为止,我们已经了解了 Jupiter 的基础知识,这是 JUnit 5 框架提供的全新编程模型。此外,Jupiter 提供了丰富的可能性,可以创建不同类型的测试用例。在本章中,我们将回顾这些高级功能。为此,本章结构如下:
-
依赖注入:本节首先介绍了测试类中构造函数和方法的依赖注入。然后,它回顾了 Jupiter 中提供的三个参数解析器。这些解析器允许在测试中注入
TestInfo
、RepetitionInfo
和TestReporter
对象。 -
动态测试:本节讨论了如何在 JUnit 5 中实现动态测试,使用
dynamicTest
和stream
方法。 -
测试接口:本节介绍了可以在测试接口和默认方法上声明的 Jupiter 注解。
-
测试模板:JUnit 5 引入了测试用例的模板概念。这些模板将根据调用上下文多次调用。
-
参数化测试:与 JUnit 4 一样,JUnit 5 提供了创建由不同输入数据驱动的测试的功能,即参数化测试。我们将发现,对这种测试的支持在 Jupiter 编程模型中得到了显着增强。
-
Java 9:2017 年 9 月 21 日,Java 9 发布。正如我们将发现的那样,JUnit 5 已经实现为与 Java 9 兼容,特别强调了 Java 9 的模块化特性。
依赖注入
在以前的 JUnit 版本中,不允许测试构造函数和方法带有参数。JUnit 5 的一个主要变化是现在允许测试构造函数和方法都包含参数。这个特性使得构造函数和方法可以进行依赖注入。
正如本书的第二章中介绍的,JUnit 5 的扩展模型具有一个扩展,为 Jupiter 测试提供依赖注入,称为ParameterResolver
,它定义了希望在运行时动态解析参数的测试扩展的 API。
如果测试构造函数或方法带有@Test
、@TestFactory
、@BeforeEach
、@AfterEach
、@BeforeAll
或@AfterAll
注解,并接受一个参数,那么这个参数将由解析器(具有父类ParameterResolver
的对象)在运行时解析。在 JUnit 5 中,有三个内置的解析器自动注册:TestInfoParameterResolver
、RepetitionInfoParameterResolver
和TestReporterParameterResolver
。我们将在本节中回顾这些解析器中的每一个。
TestInfoParameterResolver
给定一个测试类,如果方法参数的类型是TestInfo
,则 JUnit 5 解析器TestInfoParameterResolver
会提供一个与声明的参数对应的TestInfo
实例,该实例对应于当前测试。TestInfo
对象用于检索有关当前测试的信息,例如测试显示名称、测试类、测试方法或关联的标签。
TestInfo
充当 JUnit 4 的TestName
规则的替代品。
TestInfo
类位于org.junit.jupiter.api
包中,并提供以下 API:
-
String getDisplayName()
:这会返回测试或容器的显示名称。 -
Set<String> getTags()
:这会获取当前测试或容器的所有标签集。 -
Optional<Class<?>> getTestClass()
:如果可用,这会获取与当前测试或容器关联的类。 -
Optional<Method> getTestMethod()
:如果可用,这会获取与当前测试关联的方法。
TestInfo API
让我们看一个例子。请注意,在以下类中,使用@BeforeEach
和@Test
注解的两个方法都接受TestInfo
参数。这个参数是由TestInfoParameterResolver
注入的:
package io.github.bonigarcia;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
class TestInfoTest {
@BeforeEach
void init(TestInfo testInfo) {
String displayName = testInfo.getDisplayName();
System.*out*.printf("@BeforeEach %s %n", displayName);
}
@Test
@DisplayName("My test")
@Tag("my-tag")
void testOne(TestInfo testInfo) {
System.*out*.println(testInfo.getDisplayName());
System.*out*.println(testInfo.getTags());
System.*out*.println(testInfo.getTestClass());
System.*out*.println(testInfo.getTestMethod());
}
@Test
void testTwo() {
}
}
因此,在每个方法的主体中,我们能够在运行时使用TestInfo
API 来获取测试信息,如下面的屏幕截图所示:
TestInfo对象的依赖注入的控制台输出
RepetitionInfoParameterResolver
JUnit 5 中提供的第二个内置解析器称为RepetitionInfoParameterResolver
。给定一个测试类,如果@RepeatedTest
、@BeforeEach
或@AfterEach
方法的方法参数是RepetitionInfo
类型,RepetitionInfoParameterResolver
将提供RepetitionInfo
的实例。
RepetitionInfo
可用于检索有关当前重复和相应@RepeatedTest
的总重复次数的信息。RepetitionInfo
的 API 提供了两种方法,如列表后的屏幕截图所示:
-
int getCurrentRepetition()
: 获取相应@RepeatedTest
方法的当前重复次数 -
int getTotalRepetitions()
: 获取相应@RepeatedTest
方法的总重复次数
RepetitionInfo API
这里的类包含了对RepetitionInfo
的简单示例:
package io.github.bonigarcia;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;
class RepetitionInfoTest {
@RepeatedTest(2)
void test(RepetitionInfo repetitionInfo) {
System.*out*.println("** Test " +
repetitionInfo.getCurrentRepetition()
+ "/" + repetitionInfo.getTotalRepetitions());
}
}
正如在测试输出中所看到的,我们能够在运行时读取有关重复测试的信息:
RepetitionInfo对象的依赖注入的控制台输出。
TestReporterParameterResolver
JUnit 5 中的最后一个内置解析器是TestReporterParameterResolver
。同样,给定一个测试类,如果方法参数的类型是TestReporter
,TestReporterParameterResolver
将提供TestReporter
的实例。
TestReporter
用于发布有关测试执行的附加数据。数据可以通过reportingEntryPublished
方法进行消耗,然后可以被 IDE 请求或包含在测试报告中。每个TestReporter
对象都将信息存储为一个映射,即键值对集合:
TestReporter API
这个测试提供了TestReporter
的一个简单示例。正如我们所看到的,我们使用注入的testReporter
对象使用键值对添加自定义信息:
package io.github.bonigarcia;
import java.util.HashMap;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestReporter;
class TestReporterTest {
@Test
void reportSingleValue(TestReporter testReporter) {
testReporter.publishEntry("key", "value");
}
@Test
void reportSeveralValues(TestReporter testReporter) {
HashMap<String, String> values = new HashMap<>();
values.put("name", "john");
values.put("surname", "doe");
testReporter.publishEntry(values);
}
}
动态测试
我们知道,在 JUnit 3 中,我们通过解析方法名称并检查它们是否以单词 test 开头来识别测试。然后,在 JUnit 4 中,我们通过收集带有@Test
注解的方法来识别测试。这两种技术共享相同的方法:测试在编译时定义。这个概念就是我们所说的静态测试。
静态测试被认为是一种有限的方法,特别是对于同一个测试应该针对各种输入数据执行的常见情况。在 JUnit 4 中,这个限制以几种方式得到解决。解决这个问题的一个非常简单的解决方案是循环输入测试数据并练习相同的测试逻辑(这里是 JUnit 4 的示例)。按照这种方法,一个测试会一直执行,直到第一个断言失败:
package io.github.bonigarcia;
import org.junit.Test;
public class MyTest {
@Test
public void test() {
String[] input = { "A", "B", "C" };
for (String s : input) {
exercise(s);
}
}
private void exercise(String s) {
System.*out.*println*(s);
* }
}
更详细的解决方案是使用 JUnit 4 支持参数化测试,使用参数化运行器。这种方法也不会在运行时创建测试,它只是根据参数多次重复相同的测试:
package io.github.bonigarcia;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
@RunWith(Parameterized.class)
public class ParameterizedTest {
@Parameter(0)
public Integer input1;
@Parameter(1)
public String input2;
@Parameters(name = "My test #{index} -- input data: {0} and {1}")
public static Collection<Object[]> data() {
return Arrays
.*asList*(new Object[][] { { 1, "hello" }, { 2, "goodbye" } });
}
@Test
public void test() {
System.*out*.println(input1 + " " + input2);
}
}
我们可以在 Eclipse IDE 中看到前面示例的执行:
在 Eclipse 中执行 JUnit 4 的参数化测试
另一方面,JUnit 5 允许通过一个使用@TestFactory
注释的工厂方法在运行时生成测试。与@Test
相比,@TestFactory
方法不是一个测试,而是一个工厂。@TestFactory
方法必须返回DynamicTest
实例的Stream
、Collection
、Iterable
或Iterator
。这些DynamicTest
实例是惰性执行的,可以动态生成测试用例。
为了创建动态测试,我们可以使用位于org.junit.jupiter.api
包中的DynamicTest
类的静态方法dynamicTest
。如果我们检查这个类的源代码,我们可以看到DynamicTest
由一个字符串形式的显示名称和一个可执行对象组成,可以提供为 lambda 表达式或方法引用。
让我们看一些动态测试的例子。在下面的例子中,第一个动态测试将失败,因为我们没有返回预期的DynamicTests
集合。接下来的三个方法是非常简单的例子,演示了DynamicTest
实例的Collection
、Iterable
和Iterator
的生成:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
class CollectionTest {
// Warning: this test will raise an exception
@TestFactory
List<String> dynamicTestsWithInvalidReturnType() {
return Arrays.*asList*("Hello");
}
@TestFactory
Collection<DynamicTest> dynamicTestsFromCollection() {
return Arrays.*asList*(
*dynamicTest*("1st dynamic test", () ->
*assertTrue*(true)),
*dynamicTest*("2nd dynamic test", () -> *assertEquals*(4, 2
* 2)));
}
@TestFactory
Iterable<DynamicTest> dynamicTestsFromIterable() {
return Arrays.*asList*(
*dynamicTest*("3rd dynamic test", () ->
*assertTrue*(true)),
*dynamicTest*("4th dynamic test", () -> *assertEquals*(4, 2
* 2)));
}
@TestFactory
Iterator<DynamicTest> dynamicTestsFromIterator() {
return Arrays.*asList*(
*dynamicTest*("5th dynamic test", () ->
*assertTrue*(true)),
*dynamicTest*("6th dynamic test", () -> *assertEquals*(4, 2
* 2))).iterator();
}
}
这些示例并没有真正展示动态行为,而只是演示了支持的返回类型。请注意,第一个测试将由于JUnitException
而失败:
第一个动态测试示例的控制台输出
以下示例演示了为给定的输入数据集生成动态测试有多么容易:
package io.github.bonigarcia;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
class DynamicExampleTest {
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
Stream<String> inputStream = Stream.*of*("A", "B", "C");
return inputStream.*map*(
input -> *dynamicTest*("Display name for input " + input,
() -> {
System.*out*.println("Testing " + input);
}));
}
}
请注意,最终执行了三个测试,并且这三个测试是由 JUnit 5 在运行时创建的:
第二个动态测试示例的控制台输出
在 JUnit 5 中,还有另一种创建动态测试的可能性,使用DynamicTest
类的stream
静态方法。这个方法需要一个输入生成器,一个根据输入值生成显示名称的函数,以及一个测试执行器。
让我们看另一个例子。我们创建一个测试工厂,提供输入数据作为Iterator
,使用 lambda 表达式作为显示名称函数,最后,使用另一个 lambda 表达式实现的测试执行器。在这个例子中,测试执行器基本上断言输入的整数是偶数还是奇数:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.stream;
import java.util.Arrays;
import java.util.Iterator;
import java.util.function.Function;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.function.ThrowingConsumer;
class StreamExampleTest {
@TestFactory
Stream<DynamicTest> streamTest() {
// Input data
Integer array[] = { 1, 2, 3 };
Iterator<Integer> inputGenerator = Arrays.*asList*(array).iterator();
// Display names
Function<Integer, String> displayNameGenerator = (
input) -> "Data input:" + input;
// Test executor
ThrowingConsumer<Integer> testExecutor = (input) -> {
System.*out*.println(input);
*assertTrue*(input % 2 == 0);
};
// Returns a stream of dynamic tests
return *stream*(inputGenerator, displayNameGenerator,
testExecutor);
}
}
奇数输入的测试将失败。我们可以看到,三个测试中有两个会失败:
动态测试执行的控制台输出(第三个示例)
测试接口
在 JUnit 5 中,有关 Java 接口中注解使用的规则是不同的。首先,我们需要意识到@Test
、@TestFactory
、@BeforeEach
和@AfterEach
可以在接口默认方法上声明。
默认方法是 Java 8 版本引入的一个特性。这些方法(使用保留关键字default
声明)允许在 Java 接口中为给定方法定义默认实现。这种能力对于与现有接口的向后兼容性可能很有用。
关于 JUnit 5 和接口的第二条规则是,@BeforeAll
和@AfterAll
可以在测试接口中的static
方法上声明。此外,如果实现给定接口的测试类被注解为@TestInstance(Lifecycle.PER_CLASS)
,则接口上声明的@BeforeAll
和@AfterAll
方法不需要是static
,而是default
方法。
关于 JUnit 5 中接口的第三条和最后一条规则是,可以在测试接口上声明@ExtendWith
和@Tag
来配置扩展和标签。
让我们看一些简单的例子。在下面的类中,我们创建的是一个接口,而不是一个类。在这个接口中,我们使用了注解@BeforeAll
、@AfterAll
、@BeforeEach
和@AfterEach
。一方面,我们将@BeforeAll
、@AfterAll
定义为静态方法。另一方面,我们将@BeforeEach
和@AfterEach
定义为 Java 8 默认方法:
package io.github.bonigarcia;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public interface TestLifecycleLogger {
static final Logger ***log*** = LoggerFactory
.getLogger(TestLifecycleLogger.class.getName());
@BeforeAll
static void beforeAllTests() {
***log***.info("beforeAllTests");
}
@AfterAll
static void afterAllTests() {
***log***.info("afterAllTests");
}
@BeforeEach
default void beforeEachTest(TestInfo testInfo) {
***log***.info("About to execute {}", testInfo.getDisplayName());
}
@AfterEach
default void afterEachTest(TestInfo testInfo) {
***log***.info("Finished executing {}", testInfo.getDisplayName());
}
}
在这个例子中,我们使用了 Simple Logging Facade for Java (SLF4J)库。请查看 GitHub 上的代码(github.com/bonigarcia/mastering-junit5
)以获取有关依赖声明的详细信息。
在这个例子中,我们使用注解TestFactory
来定义 Java 接口中的默认方法:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.Arrays;
import java.util.Collection;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
interface TestInterfaceDynamicTestsDemo {
@TestFactory
default Collection<DynamicTest> dynamicTestsFromCollection() {
return Arrays.*asList*(
*dynamicTest*("1st dynamic test in test interface",
() -> *assertTrue*(true)),
*dynamicTest*("2nd dynamic test in test interface",
() -> *assertTrue*(true)));
}
}
最后,我们在另一个接口中使用注解@Tag
和@ExtendWith
:
package io.github.bonigarcia;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.extension.ExtendWith;
@Tag("timed")
@ExtendWith(TimingExtension.class)
public interface TimeExecutionLogger {
}
总的来说,我们可以在我们的 Jupiter 测试中使用这些接口:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import org.junit.jupiter.api.Test;
class TestInterfaceTest implements TestLifecycleLogger,
TimeExecutionLogger,
TestInterfaceDynamicTestsDemo {
@Test
void isEqualValue() {
*assertEquals*(1, 1);
}
}
在这个测试中,实现所有先前定义的接口将提供默认方法中实现的日志记录功能:
实现多个接口的测试的控制台输出
测试模板
@TestTemplate
方法不是一个常规的测试用例,而是测试用例的模板。像这样注释的方法将根据注册的提供程序返回的调用上下文多次调用。因此,测试模板与注册的 TestTemplateInvocationContextProvider
扩展一起使用。
@TestTemplate, and also declaring an extension of the type MyTestTemplateInvocationContextProvider:
package io.github.bonigarcia;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
class TemplateTest {
@TestTemplate
@ExtendWith(MyTestTemplateInvocationContextProvider.class)
void testTemplate(String parameter) {
System.*out*.println(parameter);
}
}
所需的提供程序实现了 Jupiter 接口 TestTemplateInvocationContextProvider
。检查这个类的代码,我们可以看到如何为测试模板提供了两个 String
参数(在这种情况下,这些参数的值为 parameter-1
和 parameter-2
):
package io.github.bonigarcia;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;
public class MyTestTemplateInvocationContextProvider
implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return true;
}
@Override
public Stream<TestTemplateInvocationContext>
provideTestTemplateInvocationContexts(
ExtensionContext context) {
return Stream.*of*(invocationContext("parameter-1"),
invocationContext("parameter-2"));
}
private TestTemplateInvocationContext invocationContext(String parameter) {
return new TestTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return parameter;
}
@Override
public List<Extension> getAdditionalExtensions() {
return Collections.singletonList(new ParameterResolver() {
@Override
public boolean supportsParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext) {
return parameterContext.getParameter().getType()
.equals(String.class);
}
@Override
public Object resolveParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext) {
return parameter;
}
});
}
};
}
}
当测试被执行时,测试模板的每次调用都会像常规的 @Test
一样行为。在这个例子中,测试只是将参数写入标准输出。
测试模板示例的控制台输出
参数化测试
参数化测试是一种特殊类型的测试,其中数据输入被注入到测试中,以便重用相同的测试逻辑。这个概念在 JUnit 4 中已经讨论过,如 第一章 关于软件质量和 Java 测试的回顾 中所解释的。正如我们所期望的,参数化测试也在 JUnit 5 中实现了。
首先,为了在 Jupiter 中实现参数化测试,我们需要将 junit-jupiter-params
添加到我们的项目中。在使用 Maven 时,这意味着添加以下依赖项:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
通常情况下,建议使用最新版本的构件。我们可以通过 Maven 中央仓库 (search.maven.org/
) 找到最新版本。
在使用 Gradle 时,可以声明 junit-jupiter-params
依赖项如下:
dependencies {
testCompile("org.junit.jupiter:junit-jupiter-
params:${junitJupiterVersion}")
}
然后,我们需要使用注解 @ParameterizedTest
(位于包 org.junit.jupiter.params
中)来声明一个 Java 类中的方法作为参数化测试。这种类型的测试行为与常规的 @Test
完全相同,意味着所有的生命周期回调(@BeforeEach
、@AfterEach
等)和扩展都会以相同的方式工作。
然而,使用 @ParameterizedTest
还不足以实现参数化测试。与 @ParameterizedTest
一起,我们需要至少指定一个参数提供程序。正如我们将在本节中发现的那样,JUnit 5 实现了不同的注解,以从不同的来源提供数据输入(即测试的参数)。这些参数提供程序(在 JUnit 5 中作为注解实现)总结在下表中(这些注解中的每一个都位于包 org.junit.jupiter.params.provider
中):
参数提供程序注解 | 描述 |
---|---|
@ValueSource |
用于指定 String 、int 、long 或 double 的字面值数组 |
@EnumSource |
用于指定指定枚举类型(java.lang.Enum )的常量的参数源 |
@MethodSource |
提供对声明此注解的类的静态方法返回的值的访问权限 |
@CsvSource |
从其属性读取逗号分隔值(CSV)的参数源 |
@CsvFileSource |
用于从一个或多个类路径资源加载 CSV 文件的参数源 |
@ArgumentsSource |
用于指定自定义参数提供程序(即实现接口 org.junit.jupiter.params.provider.ArgumentsProvider 的 Java 类) |
@ValueSource
@ValueSource
注解与@ParameterizedTest
结合使用,用于指定一个参数化测试,其中参数源是String
,int
,long
或double
的文字值数组。这些值在注解中指定,使用strings
,ints
,longs
或doubles
元素。考虑以下示例:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ValueSourceStringsParameterizedTest {
@ParameterizedTest
@ValueSource(strings = { "Hello", "World" })
void testWithStrings(String argument) {
System.*out*.println("Parameterized test with (String) parameter: "
+ argument);
*assertNotNull*(argument);
}
}
此类的方法(testWithStrings
)定义了一个参数化测试,其中指定了一个 String 数组。由于在@ValueSource
注解中指定了两个 String 参数(在本例中为"Hello"
和"World"
),测试逻辑将被执行两次,每次一个值。这些数据通过方法的参数注入到测试方法中,这里是通过名为 argument 的String
变量。总的来说,当执行此测试类时,输出将如下所示:
使用@ValueSource和 String 参数提供程序执行参数化测试
我们还可以在@ValueSource
注解中使用整数原始类型(int
,long
和double
)。以下示例演示了如何使用。此示例类的方法(命名为testWithInts
,testWithLongs
和testWithDoubles
)使用@ValueSource
注解以整数值的形式定义参数,分别使用原始类型 int,long 和 double。为此,需要指定@ValueSource
的ints
,longs
和doubles
元素:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ValueSourcePrimitiveTypesParameterizedTest {
@ParameterizedTest
@ValueSource(ints = { 0, 1 })
void testWithInts(int argument) {
System.*out*.println("Parameterized test with (int) argument: " +
argument);
*assertNotNull*(argument);
}
@ParameterizedTest
@ValueSource(longs = { 2L, 3L })
void testWithLongs(long argument) {
System.*out*.println(
"Parameterized test with (long)
argument: " + argument);
*assertNotNull*(argument);
}
@ParameterizedTest
@ValueSource(doubles = { 4d, 5d })
void testWithDoubles(double argument) {
System.*out*.println("Parameterized test with (double)
argument: " + argument);
*assertNotNull*(argument);
}
}
正如图中所示,每个测试都会执行两次,因为在每个@ValueSource
注解中,我们指定了两个不同的输入参数(类型为int
,long
和double
)。
使用@ValueSource和原始类型执行参数化测试
@EnumSource
@EnumSource
注解允许指定一个参数化测试,其中参数源是一个 Java 枚举类。默认情况下,枚举的每个值将被用来提供参数化测试,依次进行测试。
例如,在以下测试类中,方法testWithEnum
使用@ParameterizedTest
与@EnumSource
一起进行注释。正如我们所看到的,此注解的值是TimeUnit.class
,这是一个标准的 Java 注解(java.util.concurrent 包),用于表示时间持续。此枚举中定义的可能值是NANOSECONDS
,MICROSECONDS
,MILLISECONDS
,SECONDS
,MINUTES
,HOURS
和DAYS
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
class EnumSourceParameterizedTest {
@ParameterizedTest
@EnumSource(TimeUnit.class)
void testWithEnum(TimeUnit argument) {
System.*out*.println("Parameterized test with (TimeUnit)
argument: " + argument);
*assertNotNull*(argument);
}
}
因此,此测试的执行将进行七次,即每个TimeUnit
枚举值一次。在执行测试时,可以在输出控制台的跟踪中检查到这一点:
使用@EnumSource和TimeUnit.class执行参数化测试
此外,@EnumSource
注解允许以多种方式过滤枚举的成员。为了实现这种选择,可以在@EnumSource
注解中指定以下元素:
-
mode
:常量值,确定过滤的类型。这在内部类org.junit.jupiter.params.provider.EnumSource.Mode
中定义为枚举,并且可能的值是: -
INCLUDE
:用于选择那些名称通过names
元素提供的值。这是默认选项。 -
EXCLUDE
:用于选择除了通过names
元素提供的所有值之外的所有值。 -
MATCH_ALL
:用于选择那些名称与names
元素中的模式匹配的值。 -
MATCH_ANY
:用于选择那些名称与names
元素中的任何模式匹配的值。 -
names
:字符串数组,允许选择一组enum
常量。包含/排除的标准与 mode 的值直接相关。此外,该元素还允许定义正则表达式来选择要匹配的enum
常量的名称。
考虑以下例子。在这个类中,有三个参数化测试。第一个,名为testWithFilteredEnum
,使用TimeUnit
类来提供@EnumSource
参数提供程序。此外,枚举常量集使用元素名称进行过滤。正如我们所看到的,只有常量"DAYS"
和"HOURS"
将用于提供这个测试(请注意,默认模式是INCLUDE
):
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import static org.junit.jupiter.params.provider.EnumSource.Mode.*EXCLUDE*;
import static org.junit.jupiter.params.provider.EnumSource.Mode.*MATCH_ALL*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
class EnumSourceFilteringParameterizedTest {
@ParameterizedTest
@EnumSource(value = TimeUnit.class, names = { "DAYS", "HOURS" })
void testWithFilteredEnum(TimeUnit argument) {
System.*out*.println("Parameterized test with some (TimeUnit)
argument: "+ argument);
*assertNotNull*(argument);
}
@ParameterizedTest
@EnumSource(value = TimeUnit.class, mode = *EXCLUDE*, names = {
"DAYS", "HOURS" })
void testWithExcludeEnum(TimeUnit argument) {
System.*out*.println("Parameterized test with excluded (TimeUnit)
argument: " + argument);
*assertNotNull*(argument);
}
@ParameterizedTest
@EnumSource(value = TimeUnit.class, mode = *MATCH_ALL*, names =
"^(M|N).+SECONDS$")
void testWithRegexEnum(TimeUnit argument) {
System.*out*.println("Parameterized test with regex filtered
(TimeUnit) argument: " + argument);
*assertNotNull*(argument);
}
}
因此,在控制台中执行这个类时,我们得到的输出如下。关于第一个测试,我们可以看到只有"DAYS"
和"HOURS"
的迹象:
使用@EnumSource使用过滤功能执行参数化测试
现在考虑第二个测试方法,名为testWithExcludeEnum
。这个测试与之前完全相同,唯一的区别是这里的模式是EXCLUSION
(而不是之前测试中默认选择的INCLUSION
)。总的来说,在执行中(见之前的截图)我们可以看到这个测试被执行了五次,每次都是使用一个不同于DAYS
和HOURS
的枚举常量。要检查这一点,可以跟踪带有句子“使用排除(TimeUnit)参数的参数化测试”的迹象。
这个类的第三个也是最后一个方法(名为testWithRegexEnum
)定义了一个包含模式,MATCH_ALL
,使用正则表达式来过滤枚举(在这种情况下,也是TimeUnit
)。在这个例子中使用的具体正则表达式是^(M|N).+SECONDS$
,这意味着只有以M
或N
开头并以SECONDS
结尾的枚举常量将被包含在内。正如在执行截图中可以看到的,有三个TimeUnit
常量符合这些条件:NANOSECONDS
、MICROSECONDS
和MILISECONDS
。
@MethodSource
注解@MethodSource
允许定义静态方法的名称,该方法提供测试的参数作为 Java 8 的Stream
。例如,在下面的例子中,我们可以看到一个参数化测试,其中参数提供程序是一个名为stringProvider
的静态方法。在这个例子中,这个方法返回一个String
的Stream
,因此测试方法的参数(名为testWithStringProvider
)接受一个String
参数:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class MethodSourceStringsParameterizedTest {
static Stream<String> stringProvider() {
return Stream.*of*("hello", "world");
}
@ParameterizedTest
@MethodSource("stringProvider")
void testWithStringProvider(String argument) {
System.*out*.println("Parameterized test with (String) argument: "
+ argument);
*assertNotNull*(argument);
}
}
在运行示例时,我们可以看到测试被执行两次,每次都是使用Stream
中包含的String
。
使用@MethodSource和 String 参数提供程序执行参数化测试
Stream
中包含的对象的类型不需要是String
。实际上,这种类型可以是任何类型。让我们考虑另一个例子,其中@MethodSource
与一个静态方法关联,该方法返回自定义对象的Stream
。在这个例子中,这种类型被命名为Person
,并且在这里它被实现为一个内部类,具有两个属性(name
和surname
)。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class MethodSourceObjectsParameterizedTest {
static Stream<Person> personProvider() {
Person john = new Person("John", "Doe");
Person jane = new Person("Jane", "Roe");
return Stream.*of*(john, jane);
}
@ParameterizedTest
@MethodSource("personProvider")
void testWithPersonProvider(Person argument) {
System.*out*.println("Parameterized test with (Person) argument: " +
argument);
*assertNotNull*(argument);
}
static class Person {
String name;
String surname;
public Person(String name, String surname) {
this.name = name;
this.surname = surname;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSurname() {
return surname;
}
public void setSurname(String surname) {
this.surname = surname;
}
@Override
public String toString() {
return "Person [name=" + name + ", surname=" + surname + "]";
}
}
}
正如下面的截图所示,在执行这个例子时,参数化测试被执行两次,每次都是使用Stream
中包含的Person
对象("John Doe"
和``"Jane Roe"`)。
使用@MethodSource和自定义对象参数提供程序执行参数化测试
我们还可以使用@MethodSource
来指定包含整数原始类型的参数提供程序,具体来说是int
、double
和long
。以下的类包含了一个例子。我们可以看到三个参数化测试。第一个(名为testWithIntProvider
)使用注解@MethodSource
与静态方法intProvider
关联。在这个方法的主体中,我们使用标准的 Java 类IntStream
来返回一个int
值的Stream
。第二个和第三个测试(名为testWithDoubleProvider
和testWithLongProvider
)非常相似,但分别使用double
和long
值的Stream
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import java.util.stream.LongStream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class MethodSourcePrimitiveTypesParameterizedTest {
static IntStream intProvider() {
return IntStream.*of*(0, 1);
}
@ParameterizedTest
@MethodSource("intProvider")
void testWithIntProvider(int argument) {
System.*out*.println("Parameterized test with (int) argument: " +
argument);
*assertNotNull*(argument);
}
static DoubleStream doubleProvider() {
return DoubleStream.*of*(2d, 3d);
}
@ParameterizedTest
@MethodSource("doubleProvider")
void testWithDoubleProvider(double argument) {
System.*out*.println(
"Parameterized test with (double) argument: " + argument);
*assertNotNull*(argument);
}
static LongStream longProvider() {
return LongStream.*of*(4L, 5L);
}
@ParameterizedTest
@MethodSource("longProvider")
void testWithLongProvider(long argument) {
System.*out*.println(
"Parameterized test with (long) argument: " + argument);
*assertNotNull*(argument);
}
}
因此,在执行这个类时,将执行六个测试(三个参数化测试,每个测试有两个参数)。
在下面的截图中,我们可以通过跟踪每个测试写入标准输出的迹象来检查这一点:
使用@MethodSource和原始类型参数提供程序执行参数化测试
最后,关于@MethodSource
参数化测试,值得知道的是,方法提供程序允许返回不同类型(对象或原始类型)的流。这对于真实世界的测试用例非常方便。例如,下面的类实现了一个参数化测试,其中参数提供程序是一个返回混合类型(String
和int
)参数的方法。这些参数作为方法参数(在示例中称为 first 和 second)注入到测试中。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotEquals*;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class MethodSourceMixedTypesParameterizedTest {
static Stream<Arguments> stringAndIntProvider() {
return Stream.*of*(Arguments.*of*("Mastering", 10),
Arguments.*of*("JUnit 5", 20));
}
@ParameterizedTest
@MethodSource("stringAndIntProvider")
void testWithMultiArgMethodSource(String first, int second) {
System.*out*.println("Parameterized test with two arguments:
(String) " + first + " and (int) " + second);
*assertNotNull*(first);
*assertNotEquals*(0, second);
}
}
通常情况下,测试执行将作为流中包含的条目。在这种情况下,有两个条目:"Mastertering"和 10,然后是"JUnit 5"和 20。
使用@MethodSource执行参数化测试,使用不同类型的参数
@CsvSource 和@CsvFileSource
使用逗号分隔的值(CSV)指定参数化测试参数的另一种方法。这可以通过注解@CsvSource
来实现,它允许将 CSV 内容嵌入到注解的值中作为字符串。
考虑以下示例。它包含了一个 Jupiter 参数化测试(名为testWithCsvSource
),该测试使用了注解@CsvSource
。该注解包含一个字符串数组。在数组的每个元素中,我们可以看到由逗号分隔的不同值。
CSV 的内容会自动转换为字符串和整数。要了解 JUnit 5 在参数中进行的隐式类型转换的更多信息,请查看本章节中的参数转换部分。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotEquals*;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class CsvSourceParameterizedTest {
@ParameterizedTest
@CsvSource({ "hello, 1", "world, 2", "'happy, testing', 3" })
void testWithCsvSource(String first, int second) {
System.*out*.println("Parameterized test with (String) " + first
+ " and (int) " + second);
*assertNotNull*(first);
*assertNotEquals*(0, second);
}
}
总的来说,当执行这个测试类时,将会有三个单独的测试,每个测试对应数组中的一个条目。每次执行都会传递两个参数给测试。第一个参数名为first
,类型为String
,第二个参数名为second
,类型为int
。
使用@CsvSource执行参数化测试
如果 CSV 数据量很大,使用注解@CsvFileSource
可能更方便。该注解允许使用项目类路径中的 CSV 文件来为参数化测试提供数据。在下面的示例中,我们使用文件input.csv
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotEquals*;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
class CsvFileSourceParameterizedTest {
@ParameterizedTest
@CsvFileSource(resources = "/input.csv")
void testWithCsvFileSource(String first, int second) {
System.*out*.println("Yet another parameterized test with
(String) " + first + " and (int) " + second);
*assertNotNull*(first);
*assertNotEquals*(0, second);
}
}
在内部,注解@CsvFileSource
使用标准 Java 类java.lang.Class
的getResourceAsStream()
方法来定位文件。因此,文件的路径被解释为相对于我们调用它的包类的本地路径。由于我们的资源位于类路径的根目录(在示例中位于文件夹src/test/resources
中),我们需要将其定位为/input.csv
。
在@CsvFileSource示例中的 input.csv 的位置和内容
下面的截图显示了使用 Maven 执行测试时的输出。由于 CSV 有三行数据,因此有三个测试执行,每个执行有两个参数(第一个为String
,第二个为int
):
使用@CsvFileSource执行参数化测试
@ArgumentsSource
JUnit 5 中用于指定参数化测试参数来源的最后一个注解是@ArgumentsSource
。使用此注解,我们可以指定一个自定义的(并且可以在不同测试中重用)类,该类将包含测试的参数。该类必须实现接口org.junit.jupiter.params.provider.ArgumentsProvider
。
让我们看一个例子。下面的类实现了一个 Jupiter 参数化测试,其中参数来源将在类CustomArgumentsProvider1
中定义:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
class ArgumentSourceParameterizedTest {
@ParameterizedTest
@ArgumentsSource(CustomArgumentsProvider1.class)
void testWithArgumentsSource(String first, int second) {
System.*out*.println("Parameterized test with (String) " + first
+ " and (int) " + second);
*assertNotNull*(first);
*assertTrue*(second > 0);
}
}
这个类(名为CustomArgumentsProvider1
)已经在我们这边实现了,由于它实现了ArgumentsProvider
接口,必须重写provideArguments
方法,在这个方法中实现了测试参数的实际定义。从例子的代码中可以看出,这个方法返回一个Arguments
的Stream
。在这个例子中,我们返回了一个Stream
中的一对条目,每个条目分别有两个参数(String
和int
):
package io.github.bonigarcia;
import java.util.stream.Stream;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
public class CustomArgumentsProvider1 implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(
ExtensionContext context) {
System.*out*.println("Arguments provider to test "
+ context.getTestMethod().get().getName());
return Stream.*of*(Arguments.*of*("hello", 1),
Arguments.*of*("world", 2));
}
}
还要注意,这个参数有一个ExtensionContext
类型的参数(包org.junit.jupiter.api.extension
)。这个参数非常有用,可以知道测试执行的上下文。正如这里的截图所示,ExtensionContext
API 提供了不同的方法来找出测试实例的不同属性(测试方法名称、显示名称、标签等)。
在我们的例子(CustomArgumentsProvider1
)中,上下文被用来将测试方法名称写入标准输出:
ExtensionContext API
因此,当执行这个例子时,我们可以看到两个测试被执行。此外,我们可以通过ExtensionContext
对象内部的ArgumentsProvider
实例来检查测试方法的日志跟踪:
使用@ArgumentsSource执行参数化测试
多个参数来源可以应用于同一个参数化测试。事实上,在 Jupiter 编程模型中可以通过两种不同的方式来实现这一点:
-
使用多个
@ArgumentsSource
注解与相同的@ParameterizedTest
。这可以通过@ArgumentsSource
是一个java.lang.annotation.Repeatable
注解来实现。 -
使用注解
@ArgumentsSources
(注意这里的来源是复数)。这个注解只是一个容器,用于一个或多个@ArgumentsSource
。下面的类展示了一个简单的例子:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
import org.junit.jupiter.params.provider.ArgumentsSources;
class ArgumentSourcesParameterizedTest {
@ParameterizedTest
@ArgumentsSources({
@ArgumentsSource(CustomArgumentsProvider1.class),
@ArgumentsSource(CustomArgumentsProvider2.class) })
void testWithArgumentsSource(String first, int second) {
System.*out*.println("Parameterized test with (String) " + first
+ " and (int) " + second);
*assertNotNull*(first);
*assertTrue*(second > 0);
}
}
假设第二个参数提供程序(CustomArgumentsProvider2.**class**
)指定了两个或更多组参数,当执行测试类时,将有四个测试执行:
使用@ArgumentsSources执行参数化测试
参数转换
为了支持@CsvSource
和@CsvFileSource
等用例,Jupiter 提供了一些内置的隐式转换器。此外,这些转换器可以根据特定需求实现显式转换器。本节涵盖了两种类型的转换。
隐式转换
在内部,JUnit 5 处理了一组规则,用于将参数从String
转换为实际的参数类型。例如,如果@ParameterizedTests
声明了一个TimeUnit
类型的参数,但声明的来源是一个String
,那么这个String
将被转换为TimeUnit
。下表总结了 JUnit 5 中参数化测试参数的隐式转换规则:
目标类型 | 示例 |
---|---|
boolean/Boolean |
"false" -> false |
byte/Byte |
"1" -> (byte) 1 |
char/Character |
"a" -> 'a' |
short/Short |
"2" -> (short) 2 |
int/Integer |
"3" -> 3 |
long/Long |
"4" -> 4L |
float/Float |
"5.0" -> 5.0f |
double/Double |
"6.0" -> 6.0d |
Enum 子类 |
"SECONDS" -> TimeUnit.SECONDS |
java.time.Instant |
"1970-01-01T00:00:00Z" -> Instant.ofEpochMilli(0) |
java.time.LocalDate |
"2017-10-24" -> LocalDate.of(2017, 10, 24) |
java.time.LocalDateTime |
"2017-03-14T12:34:56.789" -> LocalDateTime.of(2017, 3, 14, 12, 34, 56, 789_000_000) |
java.time.LocalTime |
"12:34:56.789" -> LocalTime.of(12, 34, 56, 789_000_000) |
java.time.OffsetDateTime |
"2017-03-14T12:34:56.789Z" -> OffsetDateTime.of(2017, 3, 14, 12, 34, 56, 789_000_000, ZoneOffset.UTC) |
java.time.OffsetTime |
"12:34:56.789Z" -> OffsetTime.of(12, 34, 56, 789_000_000, ZoneOffset.UTC) |
java.time.Year |
"2017" -> Year.of(2017) |
java.time.YearMonth |
"2017-10" -> YearMonth.of(2017, 10) |
java.time.ZonedDateTime |
"2017-10-24T12:34:56.789Z" -> ZonedDateTime.of(2017, 10, 24, 12, 34, 56, 789_000_000, ZoneOffset.UTC) |
下面的例子展示了隐式转换的几个例子。第一个测试(testWithImplicitConversionToBoolean
)声明了一个String
源为"true"
,但预期的参数类型是Boolean
。类似地,第二个测试("testWithImplicitConversionToInteger"
)对String
进行了隐式转换为Integer
。第三个测试(testWithImplicitConversionToEnum
)将输入的String
转换为TimeUnit
(枚举),最后第四个测试(testWithImplicitConversionToLocalDate
)进行了转换为LocalDate
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import java.time.LocalDate;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ImplicitConversionParameterizedTest {
@ParameterizedTest
@ValueSource(strings = "true")
void testWithImplicitConversionToBoolean(Boolean argument) {
System.*out*.println("Argument " + argument + " is a type of "
+ argument.getClass());
*assertTrue*(argument);
}
@ParameterizedTest
@ValueSource(strings = "11")
void testWithImplicitConversionToInteger(Integer argument) {
System.*out*.println("Argument " + argument + " is a type of "
+ argument.getClass());
*assertTrue*(argument > 10);
}
@ParameterizedTest
@ValueSource(strings = "SECONDS")
void testWithImplicitConversionToEnum(TimeUnit argument) {
System.*out*.println("Argument " + argument + " is a type of "
+ argument.getDeclaringClass());
*assertNotNull*(argument.name());
}
@ParameterizedTest
@ValueSource(strings = "2017-07-25")
void testWithImplicitConversionToLocalDate(LocalDate argument) {
System.*out*.println("Argument " + argument + " is a type of "
+ argument.getClass());
*assertNotNull*(argument);
}
}
我们可以在控制台中检查参数的实际类型。每个测试都会在标准输出中写入一行,显示每个参数的值和类型:
使用隐式参数转换执行参数化测试
显式转换
如果 JUnit 5 提供的隐式转换不足以满足我们的需求,我们可以使用显式转换功能。有了这个功能,我们可以指定一个类来对参数类型进行自定义转换。这个自定义转换器使用@ConvertWith
注释进行标识,指定要进行转换的参数。考虑下面的例子。这个参数化测试为其测试方法参数声明了一个自定义转换器:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.provider.EnumSource;
class ExplicitConversionParameterizedTest {
@ParameterizedTest
@EnumSource(TimeUnit.class)
void testWithExplicitArgumentConversion(
@ConvertWith(CustomArgumentsConverter.class) String
argument) {
System.*out*.println("Argument " + argument + " is a type of "
+ argument.getClass());
*assertNotNull*(argument);
}
}
我们的自定义转换器是一个扩展了 JUnit 5 的SimpleArgumentConverter
的类。这个类重写了 convert 方法,在这个方法中进行了实际的转换。在这个例子中,我们简单地将任何参数源转换为String
。
package io.github.bonigarcia;
import org.junit.jupiter.params.converter.SimpleArgumentConverter;
public class CustomArgumentsConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
return String.*valueOf*(source);
}
}
总的来说,当测试被执行时,TimeUnit
中定义的七个枚举常量将作为参数传递给测试,然后在CustomArgumentsConverter
中转换为String
:
使用显式参数转换执行参数化测试
自定义名称
JUnit 5 中与参数化测试相关的最后一个特性与每次测试执行的显示名称有关。正如我们所学到的,参数化测试通常被执行为多个单独的测试。因此,为了追踪性,将每个测试执行与参数源关联起来是一个好的做法。
为此,注释@ParameterizedTest
接受一个名为 name 的元素,在其中我们可以为测试执行指定自定义名称(String
)。此外,在这个字符串中,我们可以使用几个内置的占位符,如下表所述:
占位符 | 描述 |
---|---|
{index} |
当前调用索引(第一个为 1,第二个为 2,...) |
{arguments} |
逗号分隔的参数完整列表 |
{0}, {1}, … |
一个单独参数的值(第一个为 0,第二个为 2,...) |
让我们看一个简单的例子。下面的类包含一个参数化测试,其参数是使用@CsvSource
注释定义的。测试方法接受两个参数(String
和int
)。此外,我们使用占位符为注释@ParameterizedTest
的元素名称指定了一个自定义消息,用于当前测试调用的占位符({index}
)以及每个参数的值:第一个({0}
)和第二个({1}
):
package io.github.bonigarcia;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class CustomNamesParameterizedTest {
@DisplayName("Display name of test container")
@ParameterizedTest(name = "[{index}] first argument=\"{0}\", second
argument={1}")
@CsvSource({ "mastering, 1", "parameterized, 2", "tests, 3" })
void testWithCustomDisplayNames(String first, int second) {
System.*out*.println("Testing with parameters: " + first + " and " +
second);
}
}
在 IDE(如下面的 IntelliJ 截图)中执行此测试时,我们可以看到每个测试执行的显示名称是不同的:
在 IntelliJ IDE 中使用自定义名称执行参数化测试
Java 9
Java 9 于 2017 年 9 月 21 日发布为通用可用性(GA)。Java 9 中有许多新功能。其中,模块化是 Java 9 的主要特性。
到目前为止,Java 中存在模块化问题,特别是对于大型代码库来说非常重要。每个公共类都可以被类路径中的任何其他类访问,导致意外使用类。此外,类路径存在潜在问题,例如无法知道是否存在重复的 JAR。为了解决这些问题,Java 9 提供了 Java 平台模块系统,允许创建模块化的 JAR 文件。这种类型的模块包含一个额外的模块描述符,称为module-info.java
。这些文件的内容非常简单:它使用关键字 requires 声明对其他模块的依赖,并使用关键字exports
导出自己的包。所有未导出的包默认情况下都被模块封装,例如:
module mymodule {
exports io.github.bonigarcia;
requires mydependency;
}
我们可以表示这些模块之间的关系如下:
Java 9 中模块之间的关系示例
Java 9 的其他新功能总结如下:
-
使用模块允许创建一个针对特定应用程序进行了优化的最小运行时 JDK,而不是使用完整的 JDK 安装。这可以通过使用 JDK 9 附带的工具jlink来实现。
-
Java 9 提供了一个交互式环境,可以直接从 shell 中执行 Java 代码。这种类型的实用程序通常被称为Read-Eval-Print-Loop(REPL),在 JDK 9 中称为JShell。
-
集合工厂方法,Java 9 提供了创建集合(例如列表或集合)并在一行中填充它们的能力:
Set<Integer> ints = Set.of(1, 2, 3);
List<String> strings = List.*of*("first", "second");
-
Stream API 改进:流是在 Java 8 中引入的,它们允许在集合上创建声明性转换管道。在 Java 9 中,Stream API 添加了
dropWhile
、takeWhile
和ofNullable
方法。 -
私有接口方法:Java 8 在接口上提供了默认方法。到目前为止,Java 8 中默认方法的限制是默认方法必须是公共的。现在,在 Java 9 中,这些默认方法也可以是私有的,有助于更好地结构化它们的实现。
-
HTTP/2:Java 9 支持开箱即用的 HTTP 版本 2 和 WebSockets。
-
多版本 JAR:此功能允许根据执行 JAR 的 JRE 版本创建类的替代版本。为此,在文件夹
META-INF/versions/<java-version>
下,我们可以指定不同版本的已编译类,仅当 JRE 版本与该版本匹配时才使用。 -
改进的 Javadoc:最后但并非最不重要的是,Java 9 允许创建具有集成搜索功能的 HTML5 兼容 Javadoc。
JUnit 5 和 Java 9 的兼容性
自 M5 以来,所有 JUnit 5 构件都附带了为 Java 9 编译的模块描述符,在其 JAR 清单(文件MANIFEST.MF
)中声明。例如,构件junit-jupiter-api
M6 的清单内容如下:
Manifest-Version: 1.0
Implementation-Title: junit-jupiter-api
Automatic-Module-Name: org.junit.jupiter.api
Build-Date: 2017-07-18
Implementation-Version: 5.0.0-M6
Built-By: JUnit Team
Specification-Vendor: junit.org
Specification-Title: junit-jupiter-api
Implementation-Vendor: junit.org
Build-Revision: 3e6482ab8b0dc5376a4ca4bb42bef1eb454b6f1b
Build-Time: 21:26:15.224+0200
Created-By: 1.8.0_131 (Oracle Corporation 25.131-b11)
Specification-Version: 5.0.0
关于 Java 9,有趣的是声明Automatic-Module-Name
。这允许测试模块通过将以下行添加到其模块描述符文件(module-info.java
)来要求 JUnit 5 模块:
module foo.bar {
requires org.junit.jupiter.api;
}
JUnit 5.0 之后
JUnit 5.0 GA(正式版本)于 2017 年 9 月 10 日发布。此外,JUnit 是一个不断发展的项目,新功能计划在下一个版本 5.1 中发布(目前尚未安排发布日程)。JUnit 5 的下一个版本的待办事项可以在 GitHub 上查看:github.com/junit-team/junit5/milestone/3
。其中,计划为 JUnit 5.1 添加以下功能:
-
场景测试:这个功能涉及在一个类中对不同的测试方法进行排序的能力。为了做到这一点,计划使用以下注释:
-
@ScenarioTest
:用于表示测试类包含组成单个场景测试的步骤的类级别注释。 -
@Step
:用于表示测试方法是场景测试中的单个步骤的方法级别注释。 -
支持并行测试执行:并发是 JUnit 5.1 中需要改进的主要方面之一,因此计划支持开箱即用的并发测试执行。
-
提前终止动态测试的机制:这是对 JUnit 5.0 对动态测试的增强支持,引入了超时以在测试自行终止之前停止执行(以避免不受控制的非确定性执行)。
-
测试报告方面的几项改进,比如捕获
stdout
/stderr
并包含在测试报告中,提供了可靠的方式来获取执行测试方法的类(类名),或者在测试报告中指定测试的顺序,等等。
总结
本章包含了一个全面的摘要,介绍了编写丰富的 Jupiter 测试的先进能力。首先,我们已经了解到参数可以被注入到测试类的构造函数和方法中。JUnit 5 提供了三个参数解析器,分别是用于类型为TestInfo
的参数(用于检索有关当前测试的信息),用于类型为RepetitionInfo
的参数(用于检索有关当前重复的信息),以及用于类型为TestReporter
的参数(用于发布有关当前测试运行的附加数据)。
Jupiter 中实现的另一个新功能是动态测试的概念。到目前为止,在 JUnit 3 和 4 中,测试是在编译时定义的(即静态测试)。Jupiter 引入了@TestFactory
注解,允许在运行时生成测试。Jupiter 编程模型提供的另一个新概念是测试模板。这些模板使用@TestTemplate
注解定义,不是常规的测试用例,而是测试用例的模板。
JUnit 5 实现了对参数化测试的增强支持。为了实现这种类型的测试,必须使用@ParameterizedTest
注解。除了这个注解,还应该指定一个参数提供者。为此,Jupiter 提供了几个注解:@ValueSource
、@EnumSource
、@MethodSource
、@CsvSource
、@CsvFileSource
和@ArgumentSource
。
在第五章中,JUnit 5 与外部框架的集成,我们将学习 JUnit 5 如何与外部框架交互。具体来说,我们将回顾几个 JUnit 5 扩展,它们提供了使用 Mockito、Spring、Selenium、Cucumber 或 Docker 的能力。此外,我们还介绍了一个 Gradle 插件,允许在 Android 项目中执行测试。最后,我们将了解如何使用几个 REST 库(例如 REST Assured 或 WireMock)来测试 RESTful 服务。
第五章:JUnit 5 与外部框架的集成
如果我看得比别人更远,那是因为我站在巨人的肩膀上。
- 艾萨克·牛顿
正如在第二章中描述的,JUnit 5 的扩展模型允许我们通过第三方(工具供应商、开发人员等)扩展 JUnit 5 的核心功能。在 Jupiter 扩展模型中,扩展点是扩展实现的回调接口,然后在 JUnit 5 框架中注册(激活)。正如我们将在本章中发现的那样,JUnit 5 的扩展模型可以用于与现有第三方框架提供无缝集成。具体来说,在本章中,我们将审查 JUnit 5 与以下技术的扩展:
-
Mockito:模拟(测试替身)单元测试框架。
-
Spring:用于构建企业应用程序的 Java 框架。
-
Selenium:用于自动化导航和评估 Web 应用程序的测试框架。
-
Cucumber:测试框架,允许我们按照行为驱动开发(BDD)风格编写验收测试。
-
Docker:一种软件技术,允许我们将任何应用程序打包并作为轻量级和可移植的容器运行。
此外,我们发现 JUnit 5 扩展模型并不是与外部世界集成的唯一方式。具体来说,我们研究了 JUnit 5 如何与以下内容一起使用:
-
Android(基于 Linux 的移动操作系统):我们可以使用 JUnit 5 的 Gradle 插件在 Android 项目中运行 Jupiter 测试。
-
REST(用于设计分布式系统的架构风格):我们可以简单地使用第三方库(如 REST Assured 或 WireMock)与 REST 服务进行交互和验证,或者使用 Spring 的完全集成方法(测试与服务实现一起)。
Mockito
Mockito (site.mockito.org/
)是一个用于 Java 的开源模拟单元测试框架,于 2008 年 4 月首次发布。当然,Mockito 并不是 Java 的唯一模拟框架;还有其他的,比如以下这些:
-
EasyMock (
easymock.org/
). -
JMock (
www.jmock.org/
). -
PowerMock (
powermock.github.io/
). -
JMockit (
jmockit.org/
).
我们可以说,在撰写本文时,Mockito 是大多数开发人员和测试人员在 Java 测试中首选的模拟框架。为了证明这一点,我们使用了以下截图,显示了 Google 趋势(trends.google.com/
)中 Mockito、EasyMock、JMock、PowerMock 和 JMockit 从 2004 年到 2017 年的发展。在这段时期的开始,我们可以看到 EasyMock 和 JMock 受到了很大的关注;然而,与其他框架相比,Mockito 更受欢迎:
Google 趋势演变的 Mockito、EasyMock、JMock、PowerMock 和 JMockit
Mockito 简介
正如在第一章中介绍的,软件测试有不同的级别,如单元测试、集成测试、系统测试或验收测试。关于单元测试,它们应该在单个软件部分(例如单个类)的隔离环境中执行。在这个测试级别,目标是验证单元的功能,而不是它的依赖关系。
换句话说,我们想要测试所谓的被测系统(SUT),而不是它的依赖组件(DOCs)。为了实现这种隔离,我们通常使用测试替身来替换这些 DOCs。模拟对象是一种测试替身,它们被编程为对真实 DOC 的期望。
简而言之,Mockito 是一个允许创建模拟对象、存根和验证的测试框架。为此,Mockito 提供了一个 API 来隔离 SUT 及其 DOC。一般来说,使用 Mockito 涉及三个不同的步骤:
-
模拟对象:为了隔离我们的 SUT,我们使用 Mockito API 来创建其关联 DOC 的模拟对象。这样,我们确保 SUT 不依赖于其真实的 DOC,我们的单元测试实际上是专注于 SUT。
-
设置期望:与其他测试替身(如存根)相比,模拟对象的差异性在于可以根据单元测试的需要编程自定义期望。在 Mockito 的术语中,这个过程被称为存根方法,这些方法属于模拟对象。默认情况下,模拟对象模仿真实对象的行为。在实际操作中,这意味着模拟对象返回适当的虚拟值,例如布尔类型的 false,对象的 null,整数或长整数返回类型的 0,等等。Mockito 允许我们使用丰富的 API 更改这种行为,该 API 允许存根在调用方法时返回特定值。
当模拟对象没有编程任何期望(即,它没有存根方法),从技术上讲,它不是模拟对象,而是虚拟对象(请参阅第一章,软件质量和 Java 测试的回顾,以获取定义)。
- 验证:归根结底,我们正在创建测试,因此,我们需要为 SUT 实现某种验证。Mockito 提供了一个强大的 API 来进行不同类型的验证。通过这个 API,我们评估与 SUT 和 DOC 的交互,验证模拟对象的调用顺序,或捕获和验证传递给存根方法的参数。此外,Mockito 的验证能力可以与 JUnit 的内置断言能力或使用第三方断言库(例如 Hamcrest、AssertJ 或 Truth)相结合。请参阅第三章中的断言部分,JUnit 5 标准测试。
下表总结了按前述阶段分组的 Mockito API:
Mockito API | 描述 | 阶段 |
---|---|---|
@Mock |
此注解标识要由 Mockito 创建的模拟对象。这通常用于 DOC。 | 1.模拟对象 |
@InjectMocks |
此注解标识要注入模拟对象的对象。这通常用于我们要测试的单元,也就是我们的 SUT。 | 1.模拟对象 |
@Spy |
除了模拟对象,Mockito 还允许我们创建间谍对象(即部分模拟实现,因为它们在非存根方法中使用真实实现)。 | 1.模拟对象 |
Mockito.when(x).thenReturn(y)``Mockito.doReturn(y).when(x) |
这些方法允许我们指定给定模拟对象的存根方法(x )应返回的值(y )。 |
2.设置期望(存根方法) |
Mockito.when(x).thenThrow(e)``Mockito.doThrow(e).when(x) |
这些方法允许我们指定在调用给定模拟对象的存根方法(x )时应抛出的异常(e )。 |
2.设置期望(存根方法) |
Mockito.when(x).thenAnswer(a)``Mockito.doAnswer(a).when(x) |
与返回硬编码值不同,当调用模拟对象的给定方法(x )时,将执行动态用户定义的逻辑(Answer a )。 |
2.设置期望(存根方法) |
Mockito.when(x).thenCallRealMethod()``Mockito.doCallRealMethod().when(x) |
此方法允许我们调用实际方法而不是模拟方法。 | 2.设置期望(存根方法) |
Mockito.doNothing().when(x) |
在使用 spy 时,默认行为是调用对象的真实方法。为了避免执行void 方法x ,使用此方法。 |
2.设置期望(存根方法) |
BDDMockito.given(x).willReturn(y)``BDDMockito.given(x).willThrow(e)``BDDMockito.given(x).willAnswer(a)``BDDMockito.given(x).willCallRealMethod() |
行为驱动开发是一种测试方法,其中测试以场景的形式指定,并作为给定(初始上下文)、当(事件发生)和然后(确保某些结果)实现。Mockito 通过BDDMockito 类支持这种类型的测试。存根方法(x )的行为等同于Mockito.when(x) 。 |
2.设置期望(存根方法) |
| Mockito.verify()
| 此方法验证模拟对象的调用。可以使用以下方法选择性地增强此验证:
-
times(n)
: 调用存根方法n
次。 -
never()
: 存根方法从未被调用。 -
atLeastOnce()
: 存根方法至少被调用一次。 -
atLeast(n)
: 存根方法至少被调用 n 次。 -
atMost(n)
: 存根方法最多调用 n 次。 -
only()
: 如果在模拟对象上调用了任何其他方法,则模拟失败。 -
timeout(m)
: 在最多m
毫秒内调用此方法。
3.验证 |
---|
Mockito.verifyZeroInteractions()``Mockito.verifyNoMoreInteractions() |
@Captor |
Mockito.inOrder |
在表格之前描述的不同注释的使用(@Mock
,@InjectMocks
,@Spy
和@Captor
)是可选的,尽管出于测试可读性的考虑是值得推荐的。换句话说,有多种使用不同 Mockito 类的注释的替代方法。例如,为了创建一个Mock
,我们可以使用注释@Mock
,如下所示:
@Mock
MyDoc docMock;
这个的替代方法是使用Mockito.mock
方法,如下所示:
MyDoc docMock = Mockito.*mock*(MyDoc.class)
以下部分包含了在 Jupiter 测试中使用前面表格中描述的 Mockito API 的全面示例。
Mockito 的 JUnit 5 扩展
在撰写本文时,尚无官方的 JUnit 5 扩展来在 Jupiter 测试中使用 Mockito。尽管如此,JUnit 5 团队提供了一个简单易用的 Java 类,实现了一个简单但有效的 Mockito 扩展。这个类可以在 JUnit 5 用户指南中找到(junit.org/junit5/docs/current/user-guide/
),其代码如下:
import static org.mockito.Mockito.*mock*;
import java.lang.reflect.Parameter;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
public class MockitoExtension
implements TestInstancePostProcessor, ParameterResolver {
@Override
public void postProcessTestInstance(Object testInstance,
ExtensionContext context) {
MockitoAnnotations.*initMocks*(testInstance);
}
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return
parameterContext.getParameter().isAnnotationPresent(Mock.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return getMock(parameterContext.getParameter(), extensionContext);
}
private Object getMock(Parameter parameter,
ExtensionContext extensionContext) {
Class<?> mockType = parameter.getType();
Store mocks = extensionContext
.getStore(Namespace.*create*(MockitoExtension.class,
mockType));
String mockName = getMockName(parameter);
if (mockName != null) {
return mocks.getOrComputeIfAbsent(mockName,
key -> *mock*(mockType, mockName));
} else {
return mocks.getOrComputeIfAbsent(mockType.getCanonicalName(),
key -> *mock*(mockType));
}
}
private String getMockName(Parameter parameter) {
String explicitMockName =
parameter.getAnnotation(Mock.class).name()
.trim();
if (!explicitMockName.isEmpty()) {
return explicitMockName;
} else if (parameter.isNamePresent()) {
return parameter.getName();
}
return null;
}
}
这个扩展(以及其他扩展)计划在开源项目 JUnit Pioneer(junit-pioneer.org/
)中发布。该项目由 Java 开发人员 Nicolai Parlog 维护,他还是博客 CodeFX(blog.codefx.org/
)的作者。
检查前面的类,我们可以看到它只是 Jupiter 扩展模型的一个用例(在本书的第二章中描述了 Jupiter 扩展模型),它实现了扩展回调TestInstancePostProcessor
和ParameterResolver
。由于第一个,在测试用例实例化后,将调用postProcessTestInstance
方法,并且在此方法的主体中,将进行模拟的初始化:
MockitoAnnotations.*initMocks*(testInstance)
这与在 JUnit 4 中使用 Mockito 的运行器的效果相同:@RunWith(MockitoJUnitRunner.class)
。
此外,这个扩展还实现了接口ParameterResolver
。这意味着在测试中,注册了这个扩展(@ExtendWith(MockitoExtension.class)
)的情况下,将允许在方法级别进行依赖注入。特别是,这个注解将为用@Mock
注解的测试参数注入模拟对象(位于org.mockito
包中)。
让我们看一些例子来澄清这个扩展与 Mockito 一起使用的情况。像往常一样,我们可以在 GitHub 仓库github.com/bonigarcia/mastering-junit5
上找到这些例子的源代码。前面的扩展(MockitoExtension
)的副本包含在项目junit5-mockito
中。为了指导这些例子,我们在软件应用程序中实现了一个典型的用例:用户在软件系统中的登录。
在这个用例中,我们假设用户与由三个类组成的系统进行交互:
-
LoginController
:接收用户的请求,并返回响应的类。这个请求被分派到LoginService
组件。 -
LoginService
:这个类实现了用例的功能。为了实现这个目的,它需要确认用户是否在系统中得到了认证。为了做到这一点,它需要读取LoginRepository
类中实现的持久化层。 -
LoginRepository
:这个类允许访问系统的持久化层,通常是通过数据库实现的。这个类也可以被称为数据访问对象(DAO)。
在组合方面,这三个类的关系如下:
登录用例类图(类之间的组合关系)
用例中涉及的两个基本操作(登录和注销)的序列图如下图所示:
登录用例序列图
我们使用几个简单的 Java 类来实现这个例子。首先,LoginController
通过组合使用LoginService
:
package io.github.bonigarcia;
public class LoginController {
public LoginService loginService = new LoginService();
public String login(UserForm userForm) {
System.*out*.println("LoginController.login " + userForm);
try {
if (userForm == null) {
return "ERROR";
} else if (loginService.login(userForm)) {
return "OK";
} else {
return "KO";
}
} catch (Exception e) {
return "ERROR";
}
}
public void logout(UserForm userForm) {
System.*out*.println("LoginController.logout " + userForm);
loginService.logout(userForm);
}
}
UserForm
对象是一个简单的 Java 类,有时被称为普通的 Java 对象(POJO),有两个属性 username 和 password:
package io.github.bonigarcia;
public class UserForm {
public String username;
public String password;
public UserForm(String username, String password) {
this.username = username;
this.password = password;
}
// Getters and setters
@Override
public String toString() {
return "UserForm [username=" + username + ", password=" + password
+ "]";
}
}
然后,服务依赖于LoginRepository
进行数据访问。在这个例子中,服务还使用 Java 列表实现了用户注册,其中存储了经过认证的用户:
package io.github.bonigarcia;
import java.util.ArrayList;
import java.util.List;
public class LoginService {
private LoginRepository loginRepository = new LoginRepository();
private List<String> usersLogged = new ArrayList<>();
public boolean login(UserForm userForm) {
System.*out*.println("LoginService.login " + userForm);
// Preconditions
checkForm(userForm);
// Same user cannot be logged twice
String username = userForm.getUsername();
if (usersLogged.contains(username)) {
throw new LoginException(username + " already logged");
}
// Call to repository to make logic
boolean login = loginRepository.login(userForm);
if (login) {
usersLogged.add(username);
}
return login;
}
public void logout(UserForm userForm) {
System.*out*.println("LoginService.logout " + userForm);
// Preconditions
checkForm(userForm);
// User should be logged beforehand
String username = userForm.getUsername();
if (!usersLogged.contains(username)) {
throw new LoginException(username + " not logged");
}
usersLogged.remove(username);
}
public int getUserLoggedCount() {
return usersLogged.size();
}
private void checkForm(UserForm userForm) {
assert userForm != null;
assert userForm.getUsername() != null;
assert userForm.getPassword() != null;
}
}
最后,LoginRepository
如下。为了简单起见,这个组件实现了一个映射,而不是访问真实的数据库,其中存储了系统中假设用户的凭据(其中key
= username,value
=password):
package io.github.bonigarcia;
import java.util.HashMap;
import java.util.Map;
public class LoginRepository {
Map<String, String> users;
public LoginRepository() {
users = new HashMap<>();
users.put("user1", "p1");
users.put("user2", "p3");
users.put("user3", "p4");
}
public boolean login(UserForm userForm) {
System.*out*.println("LoginRepository.login " + userForm);
String username = userForm.getUsername();
String password = userForm.getPassword();
return users.keySet().contains(username)
&& users.get(username).equals(password);
}
}
现在,我们将使用 JUnit 5 和 Mockito 来测试我们的系统。首先,我们测试控制器组件。由于我们正在进行单元测试,我们需要将LoginController
登录与系统的其余部分隔离开来。为了做到这一点,我们需要模拟它的依赖关系,在这个例子中,是LoginService
组件。使用在开头解释的 SUT/DOC 术语,在这个测试中,我们的 SUT 是LoginController
类,它的 DOC 是LoginService
类。
为了使用 JUnit 5 实现我们的测试,首先我们需要使用@ExtendWith
注册MockitoExtension
。然后,我们用@InjectMocks
(类LoginController
)声明 SUT,用@Mock
(类LoginService
)声明它的 DOC。我们实现了两个测试(@Test
)。第一个测试(testLoginOk
)指定了当调用模拟loginService
的 login 方法时,这个方法应该返回 true。之后,SUT 被实际执行,并且它的响应被验证(在这种情况下,返回的字符串必须是OK
)。此外,Mockito API 再次被用来评估与模拟LoginService
的交互是否没有更多。第二个测试(testLoginKo
)是等价的,但是将 login 方法的存根设为返回 false,因此 SUT(LoginController
)的响应在这种情况下必须是KO
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import static org.mockito.Mockito.*verify*;
import static org.mockito.Mockito.*verifyNoMoreInteractions*;
import static org.mockito.Mockito.*verifyZeroInteractions*;
import static org.mockito.Mockito.*when*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginControllerLoginTest {
// Mocking objects
@InjectMocks
LoginController loginController;
@Mock
LoginService loginService;
// Test data
UserForm userForm = new UserForm("foo", "bar");
@Test
void testLoginOk() {
// Setting expectations (stubbing methods)
*when*(loginService.login(userForm)).thenReturn(true);
// Exercise SUT
String reseponseLogin = loginController.login(userForm);
// Verification
*assertEquals*("OK", reseponseLogin);
*verify*(loginService).login(userForm);
*verifyNoMoreInteractions*(loginService);
}
@Test
void testLoginKo() {
// Setting expectations (stubbing methods)
*when*(loginService.login(userForm)).thenReturn(false);
// Exercise SUT
String reseponseLogin = loginController.login(userForm);
// Verification
*assertEquals*("KO", reseponseLogin);
*verify*(loginService).login(userForm);
*verifyZeroInteractions*(loginService);
}
}
如果我们执行这个测试,简单地检查标准输出上的跟踪,我们可以检查 SUT 是否实际执行了。此外,我们确保验证阶段在两个测试中都成功了,因为它们都通过了:
使用 JUnit 5 和 Mockito 执行LoginControllerLoginTest的单元测试
现在让我们转到另一个例子,在这个例子中,我们测试了LoginController
组件的负面情况(即错误情况)。以下类包含两个测试,第一个(testLoginError
)旨在评估系统的响应(应该是ERROR
),当使用空表单时。在第二个测试(testLoginException
)中,我们编写了模拟loginService
的方法,当首次使用任何表单时引发异常。然后,我们执行 SUT(LoginController
)并评估响应是否实际上是ERROR
:
请注意,当设置模拟方法的期望时,我们使用了参数匹配器 any(Mockito 默认提供)。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import static org.mockito.ArgumentMatchers.*any*;
import static org.mockito.Mockito.*when*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginControllerErrorTest {
@InjectMocks
LoginController loginController;
@Mock
LoginService loginService;
UserForm userForm = new UserForm("foo", "bar");
@Test
void testLoginError() {
// Exercise
String response = loginController.login(null);
// Verify
*assertEquals*("ERROR", response);
}
@Test
void testLoginException() {
// Expectation
*when*(loginService.login(*any*(UserForm.class)))
.thenThrow(IllegalArgumentException.class);
// Exercise
String response = loginController.login(userForm);
// Verify
*assertEquals*("ERROR", response);
}
}
同样,在 shell 中运行测试时,我们可以确认两个测试都正确执行,SUT 也被执行了:
使用 JUnit 5 和 Mockito 执行LoginControllerErrorTest的单元测试
让我们看一个使用 BDD 风格的例子。为此,使用了BDDMockito
类。请注意,该类的静态方法 given 在示例中被导入。然后,实现了四个测试。实际上,这四个测试与之前的例子(LoginControllerLoginTest
和LoginControllerErrorTest
)完全相同,但这次使用了 BDD 风格和更紧凑的风格(一行命令)。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import static org.mockito.ArgumentMatchers.*any*;
import static org.mockito.BDDMockito.*given*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginControllerBDDTest {
@InjectMocks
LoginController loginController;
@Mock
LoginService loginService;
UserForm userForm = new UserForm("foo", "bar");
@Test
void testLoginOk() {
*given*(loginService.login(userForm)).willReturn(true);
*assertEquals*("OK", loginController.login(userForm));
}
@Test
void testLoginKo() {
*given*(loginService.login(userForm)).willReturn(false);
*assertEquals*("KO", loginController.login(userForm));
}
@Test
void testLoginError() {
*assertEquals*("ERROR", loginController.login(null));
}
@Test
void testLoginException() {
*given*(loginService.login(*any*(UserForm.class)))
.willThrow(IllegalArgumentException.class);
*assertEquals*("ERROR", loginController.login(userForm));
}
}
执行这个测试类意味着执行了四个测试。如下截图所示,它们全部通过了:
使用 JUnit 5 和 Mockito 执行LoginControllerBDDTest的单元测试
现在让我们转到系统的下一个组件:LoginService
。在下面的例子中,我们旨在对该组件进行单元测试,因此首先使用注解@InjectMocks
将 SUT 注入到我们的测试中。然后,使用注解@Mock
对 DOC(LoginRepository
)进行模拟。该类包含三个测试。第一个(testLoginOk
)旨在验证当接收到正确的表单时 SUT 的响应。第二个测试(testLoginKo
)验证相反的情况。最后,第三个测试还验证了系统的错误情况。该服务的实现保留了已登录用户的注册表,并且不允许同一用户登录两次。因此,我们实现了一个测试(testLoginTwice
),用于验证当同一用户尝试两次登录时是否引发了异常LoginException
:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertFalse*;
import static org.junit.jupiter.api.Assertions.*assertThrows*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import static org.mockito.ArgumentMatchers.*any*;
import static org.mockito.Mockito.*atLeast*;
import static org.mockito.Mockito.*times*;
import static org.mockito.Mockito.*verify*;
import static org.mockito.Mockito.*when*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginServiceTest {
@InjectMocks
LoginService loginService;
@Mock
LoginRepository loginRepository;
UserForm userForm = new UserForm("foo", "bar");
@Test
void testLoginOk() {
*when*(loginRepository.login(*any*(UserForm.class))).thenReturn(true);
*assertTrue*(loginService.login(userForm));
*verify*(loginRepository, *atLeast*(1)).login(userForm);
}
@Test
void testLoginKo() {
*when*(loginRepository.login(*any*(UserForm.class))).thenReturn(false);
*assertFalse*(loginService.login(userForm));
*verify*(loginRepository, *times*(1)).login(userForm);
}
@Test
void testLoginTwice() {
*when*(loginRepository.login(userForm)).thenReturn(true);
*assertThrows*(LoginException.class, () -> {
loginService.login(userForm);
loginService.login(userForm);
});
}
}
像往常一样,在 shell 中执行测试可以让我们了解事情的进展。我们可以检查登录服务已经被执行了四次(因为在第三个测试中,我们执行了两次)。但由于预期到了LoginException
,该测试是成功的(其他两个也是):
使用 JUnit 5 和 Mockito 执行LoginServiceTest的单元测试
以下类提供了一个简单的例子,用于捕获模拟对象的参数。我们定义了一个类型为ArgumentCaptor<UserForm>
的类属性,并用@Captor
进行了注释。然后,在测试的主体中,执行了 SUT(在本例中是LoginService
)并捕获了方法 login 的参数。最后,评估了这个参数的值:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import static org.mockito.Mockito.*verify*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginServiceChaptorTest {
@InjectMocks
LoginService loginService;
@Mock
LoginRepository loginRepository;
@Captor
ArgumentCaptor<UserForm> argCaptor;
UserForm userForm = new UserForm("foo", "bar");
@Test
void testArgumentCaptor() {
loginService.login(userForm);
*verify*(loginRepository).login(argCaptor.capture());
*assertEquals*(userForm, argCaptor.getValue());
}
}
再次,在控制台中,我们检查 SUT 是否被执行,并且测试被声明为成功:
使用 JUnit 5 和 Mockito 执行LoginServiceChaptorTest的单元测试
我们在本章中看到的最后一个与 Mockito 相关的示例与使用 spy 有关。如前所述,默认情况下,spy 在非存根方法中使用真实实现。因此,如果我们在 spy 对象中不存根方法,我们在测试中得到的是真实对象。这就是下一个示例中发生的情况。正如我们所看到的,我们正在使用LoginService
作为我们的 SUT,然后我们对对象LoginRepository
进行了监视。由于在测试的主体中,我们没有在 spy 对象中编程期望,我们在测试中评估了真实系统。
总的来说,测试数据准备好了,以获得正确的登录(使用用户名user
和密码p1
,这些值在LoginRepository
的实际实现中是固定的),然后一些虚拟值用于无法登录:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertFalse*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LoginServiceSpyTest {
@InjectMocks
LoginService loginService;
@Spy
LoginRepository loginRepository;
UserForm userOk = new UserForm("user1", "p1");
UserForm userKo = new UserForm("foo", "bar");
@Test
void testLoginOk() {
*assertTrue*(loginService.login(userOk));
}
@Test
void testLoginKo() {
*assertFalse*(loginService.login(userKo));
}
}
在 shell 中,我们可以检查两个测试是否正确执行,而且在这种情况下,实际组件(LoginService
和LoginRepository
)确实被执行:
使用 JUnit 5 和 Mockito 执行LoginServiceSpyTest的单元测试
这些示例展示了 Mockito 的几种功能,但当然不是全部。有关更多信息,请访问官方 Mockito 参考网站site.mockito.org/
。
Spring
Spring (spring.io/
)是一个用于构建企业应用程序的开源 Java 框架。它最初是由 Rod Johnson 在 2002 年 10 月与他的书Expert One-on-One J2EE Design and Development一起编写的。Spring 最初的动机是摆脱 J2EE 的复杂性,提供一个轻量级的基础设施,旨在使用简单的 POJO 作为构建块来简化企业应用程序的开发。
Spring 简介
Spring 框架的核心技术被称为控制反转(IoC),这是在实际使用这些对象的类之外实例化对象的过程。这些对象在 Spring 行话中被称为 bean 或组件,并且默认情况下被创建为单例对象。负责创建 bean 的实体称为 Spring IoC 容器。这是通过依赖注入(DI)实现的,它是提供对象的依赖关系而不是自己构造它们的过程。
IoC 和 DI 经常可以互换使用。然而,正如前面的段落所描述的,这些概念并不完全相同(IoC 是通过 DI 实现的)。
正如本节的下一部分所描述的,Spring 是一个模块化的框架。Spring 的核心功能(即 IoC)在spring-context
模块中提供。该模块提供了创建应用程序上下文的能力,即 Spring 的 DI 容器。在 Spring 中有许多不同的定义应用程序上下文的方式。最重要的两种类型是以下两种:
AnnotationConfigApplicationContext
:应用程序上下文,接受带注释的类来标识要在容器中执行的 Spring bean。在这种类型的上下文中,通过使用注释@Component
对普通类进行注释来标识 bean。这不是唯一将类声明为 Spring bean 的方法。还有进一步的原型注释:@Controller
(用于表示层的原型,在 Web 模块中使用,MVC),@Repository
(用于持久层的原型,在数据访问模块中使用,称为 Spring Data),和@Service
(用于服务层)。这三个注释用于分离应用程序的各个层。最后,使用@Configuration
注释的类允许通过使用@Bean
注释方法来定义 Spring bean(这些方法返回的对象将成为容器中的 Spring bean):
用于定义 bean 的 Spring 原型
ClassPathXmlApplicationContext
:应用程序上下文,接受在项目类路径中声明的 XML 文件中的 bean 定义。
基于注解的上下文配置是在 Spring 2.5 中引入的。Spring IoC 容器与实际编写配置元数据(即 bean 定义)的格式完全解耦。如今,许多开发人员选择基于注解的配置而不是基于 XML 的配置。因此,在本书中,我们将只在示例中使用基于注解的上下文配置。
让我们看一个简单的例子。首先,我们需要在项目中包含spring-context
依赖项。例如,作为 Maven 依赖项:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring-context.version}</version>
</dependency>
然后,我们创建一个可执行的 Java 类(即带有 main 方法)。请注意,在这个类中有一个类级别的注解:@ComponentScan
。这是 Spring 中非常重要的一个注解,因为它允许声明 Spring 将在其中查找注解形式的 bean 定义的包。如果没有定义特定的包(就像在示例中一样),扫描将从声明此注解的类的包中进行(在示例中是包io.github.bonigarcia
)。在 main 方法的主体中,我们使用AnnotationConfigApplicationContext
创建 Spring 应用程序上下文。从该上下文中,我们获取其类为MessageComponent
的 Spring 组件,并将其getMessage()
方法的结果写入标准输出:
package io.github.bonigarcia;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan
public class MySpringApplication {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context = new
AnnotationConfigApplicationContext(
MySpringApplication.class)) {
MessageComponent messageComponent = context
.getBean(MessageComponent.class);
System.*out*.println(messageComponent.getMessage());
}
}
}
bean MessageComponent
在以下类中定义。请注意,它只是在类级别使用@Component
注解声明为 Spring 组件。然后,在这个例子中,我们使用类构造函数注入另一个名为MessageService
的 Spring 组件:
package io.github.bonigarcia;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MessageComponent {
private MessageService messageService;
public MessageComponent(MessageService messageService) {
this.messageService = messageService;
}
public String getMessage() {
return messageService.getMessage();
}
}
在这一点上,值得回顾一下进行 Spring 组件依赖注入的不同方式:
-
字段注入:注入的组件是一个带有
@Autowired
注解的类字段,就像之前的例子一样。作为一个好处,这种类型的注入消除了杂乱的代码,比如 setter 方法或构造函数参数。 -
Setter 注入:注入的组件在类中声明为字段,然后为该字段创建一个带有
@Autowired
注解的 setter。 -
构造函数注入:依赖项被注入到带有
@Autowired
注解的类构造函数中(图中的 3-a)。这是前面示例中展示的方式。从 Spring 4.3 开始,不再需要使用@Autowired
注解构造函数来进行注入(3-b)。
最新的注入方式(3-b)有很多好处,比如促进了无需反射机制的可测试性(例如,通过模拟库实现)。此外,它可以让开发人员思考类的设计,因为许多注入的依赖项意味着许多构造函数参数,这应该被避免(上帝对象反模式)。
Spring 中依赖注入(Autowired)的不同方式
我们示例中的最后一个组件名为MessageService
。请注意,这也是一个 Spring 组件,这次使用@Service
注解来强调其服务性质(从功能角度来看,这与使用@Component
注解类是一样的):
package io.github.bonigarcia;
import org.springframework.stereotype.Service;
@Service
public class MessageService {
public String getMessage() {
return "Hello world!";
}
}
现在,如果我们执行这个示例的主类(称为MySpringApplication
,请参见源代码),我们将使用 try with resources 创建一个基于注解的应用程序上下文(这样应用程序上下文将在最后关闭)。Spring IoC 容器将创建两个 bean:MessageService
和MessageComponet
。使用应用程序上下文,我们寻找 beanMessageComponet
并调用其方法getMessage
,最终将其写入标准输出:
package io.github.bonigarcia;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan
public class MySpringApplication {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context = new
AnnotationConfigApplicationContext(
MySpringApplication.class)) {
MessageComponent messageComponent = context
.getBean(MessageComponent.class);
System.*out*.println(messageComponent.getMessage());
}
}
}
Spring 模块
Spring 框架是模块化的,允许开发人员只使用框架提供的所需模块。这些模块的完整列表可以在spring.io/projects
上找到。以下表格总结了一些最重要的模块:
Spring 项目 | 标志 | 描述 |
---|---|---|
Spring 框架 | ![]() |
提供了对 DI,事务管理,Web 应用程序(Spring MCV),数据访问,消息传递等的核心支持。 |
Spring IO 平台 | ![]() |
将核心 Spring API 整合成一个具有连贯性和版本化的基础平台,用于现代应用程序。 |
Spring Boot | ![]() |
简化了独立的,生产级的基于 Spring 的应用程序的创建,最小化配置。它遵循约定优于配置的方法。 |
Spring 数据 | ![]() |
通过全面的 API 简化数据访问,以处理关系数据库,NoSQL,映射-减少算法等。 |
Spring Cloud | ![]() |
提供了一组库和常见模式,用于构建和部署分布式系统和微服务。 |
Spring 安全 | ![]() |
为基于 Spring 的应用程序提供可定制的身份验证和授权功能。 |
Spring 集成 | ![]() |
为基于 Spring 的应用程序提供了基于轻量级 POJO 的消息传递,以与外部系统集成。 |
Spring 批处理 | ![]() |
提供了一个轻量级框架,旨在实现企业系统操作的稳健批处理应用程序的开发。 |
Spring 测试简介
Spring 有一个名为spring-test
的模块,支持对 Spring 组件进行单元测试和集成测试。除其他功能外,该模块提供了创建用于测试目的的 Spring 应用程序上下文或创建模拟对象以隔离测试代码的能力。有不同的注解支持这些测试功能。最重要的注解列表如下:
-
@ContextConfiguration
:此注解用于确定如何为集成测试加载和配置ApplicationContext
。例如,它允许从注释类(使用元素类)加载应用程序上下文,或者从 XML 文件中声明的 bean 定义(使用元素位置)加载应用程序上下文。 -
@ActiveProfiles
:此注解用于指示容器在应用程序上下文加载期间应激活哪些定义配置文件(例如,开发和测试配置文件)。 -
@TestPropertySource
:此注解用于配置属性文件的位置和要添加的内联属性。 -
@WebAppConfiguration
:此注解用于指示 Spring 上下文加载的ApplicationContext
是WebApplicationContext
。
此外,spring-test
模块提供了几种功能,用于执行测试中通常需要的不同操作,即:
-
Spring 的
org.springframework.mock.web
包含一组 Servlet API 模拟对象,用于测试 web 上下文。例如,MockMvc
对象允许执行 HTTP 请求(POST
,GET
,PUT
,DELETE
等),并验证响应(状态码,内容类型或响应主体)。 -
org.springframework.mock.jndi
包含Java 命名和目录接口(JNDI)SPI 的实现,可用于为测试设置简单的 JNDI 环境。例如,使用SimpleNamingContextBuilder
类,我们可以在测试中提供 JNDI 数据源。 -
org.springframework.test.jdbc
包含JdbcTestUtils
类,这是一组旨在简化标准数据库访问的 JDBC 实用函数。 -
org.springframework.test.util
包含ReflectionTestUtils
类,这是一组实用方法,用于在测试应用程序代码时设置非公共字段或调用私有/受保护的 setter 方法。
测试 Spring Boot 应用程序
如前所述,Spring Boot 是 Spring 系列项目的一个项目,旨在简化 Spring 应用程序的开发。使用 Spring Boot 的主要好处总结如下:
-
Spring Boot 应用程序只是一个使用主要约定优于配置的 Spring
ApplicationContext
。由于这一点,使用 Spring 进行开发变得更快。 -
@SpringBootApplication
注解用于标识 Spring Boot 项目中的主类。 -
Spring Boot 提供了一系列开箱即用的非功能特性:嵌入式 servlet 容器(Tomcat、Jetty 和 Undertow)、安全性、度量、健康检查或外部化配置。
-
创建独立运行的应用程序,只需使用命令
java -jar
即可(即使是 Web 应用程序)。 -
Spring Boot 命令行界面(CLI)允许运行 Groovy 脚本,快速原型化 Spring。
-
Spring Boot 的工作方式与任何标准的 Java 库相同,也就是说,要使用它,我们只需要在项目类路径中添加适当的
spring-boot-*.jar
(通常使用构建工具如 Maven 或 Gradle)。Spring Boot 提供了许多starters,旨在简化将不同的库添加到类路径的过程。以下表格包含了其中几个起始器:
名称 | 描述 |
---|---|
spring-boot-starter |
核心起始器,包括自动配置支持和日志记录 |
spring-boot-starter-batch |
用于使用 Spring Batch 的起始器 |
spring-boot-starter-cloud-connectors |
用于使用 Spring Cloud Connectors 的起始器,简化了连接到云平台(如 Cloud Foundry 和 Heroku)中的服务 |
spring-boot-starter-data-jpa |
用于使用 Hibernate 的 Spring Data JPA 的起始器 |
spring-boot-starter-integration |
用于使用 Spring Integration 的起始器 |
spring-boot-starter-jdbc |
用于使用 Tomcat JDBC 连接池的 JDBC 的起始器 |
spring-boot-starter-test |
用于使用库测试 Spring Boot 应用程序,包括 JUnit、Hamcrest 和 Mockito |
spring-boot-starter-thymeleaf |
用于使用 Thymeleaf 视图构建 MVC Web 应用程序的起始器 |
spring-boot-starter-web |
用于构建 Web 应用程序,包括 REST 的起始器,使用 Tomcat 作为默认的嵌入式容器 |
spring-boot-starter-websocket |
用于使用 Spring 框架的 WebSocket 支持构建 WebSocket 应用程序的起始器 |
有关 Spring Boot 的完整信息,请访问官方参考:projects.spring.io/spring-boot/.
Spring Boot 提供了不同的功能来简化测试。例如,它提供了@SpringBootTest
注解,该注解用于测试类的类级别。此注解将为这些测试创建ApplicationContext
(类似于@ContextConfiguration
,但适用于基于 Spring Boot 的应用程序)。正如我们在前一节中所看到的,在spring-test
模块中,我们使用注解@ContextConfiguration(classes=… )
来指定要加载哪个 bean 定义(Spring @Configuration
)。在测试 Spring Boot 应用程序时,通常不需要这样做。Spring Boot 的测试注解将自动搜索主配置,如果没有明确定义,则会从包含测试的包开始向上搜索,直到找到一个带有@SpringBootApplication
注解的类。
Spring Boot 还简化了对 Spring 组件的模拟使用。为此,提供了@MockBean
注解。此注解允许在我们的ApplicationContext
中为 bean 定义一个 Mockito 模拟。它可以是新的 bean,也可以替换单个现有的 bean 定义。模拟 bean 在每个测试方法后会自动重置。这种方法通常被称为容器内测试,与容器外测试相对应,在容器外测试中,使用模拟库(例如 Mockito)来单元测试 Spring 组件,而无需 Spring ApplicationContext
。下一节中显示了 Spring 应用程序的两种类型的单元测试示例。
用于 Spring 的 JUnit 5 扩展
为了将spring-test
的功能集成到 JUnit 5 的 Jupiter 编程模型中,开发了SpringExtension
。这个扩展是 Spring 5 的spring-test
模块的一部分。让我们看看几个 Junit 5 和 Spring 5 一起的例子。
假设我们想对前一部分描述的 Spring 应用程序进行容器内集成测试,由三个类组成:MySpringApplication
,MessageComponent
和MessageService
。正如我们所学的,为了对这个应用程序实施 Jupiter 测试,我们需要采取以下步骤:
-
用
@ContextConfiguration
注释我们的测试类,以指定需要加载哪个ApplicationContext
。 -
使用
@ExtendWith(SpringExtension.class)
注释我们的测试类,以启用spring-test
进入 Jupiter。 -
在我们的测试类中注入我们想要评估的 Spring 组件。
-
实现我们的测试(
@Test
)。
例如:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = { MySpringApplication.class })
class SimpleSpringTest {
@Autowired
public MessageComponent messageComponent;
@Test
public void test() {
*assertEquals*("Hello world!", messageComponent.getMessage());
}
}
这是一个非常简单的例子,其中评估了名为MessageComponent
的 Spring 组件。当启动此测试时,我们的ApplicationContext
被初始化,并且所有 Spring 组件都在其中。在这个例子中,bean MessageComponent
被注入到测试中,通过调用方法getMessage()
并验证其响应来进行评估。
值得回顾一下这个测试需要哪些依赖项。使用 Maven 时,这些依赖项如下:
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
另一方面,如果我们使用 Gradle,依赖关系子句将如下所示:
dependencies {
compile("org.springframework:spring-context:${springVersion}")
testCompile("org.springframework:spring-test:${springVersion}")
testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
}
请注意,在这两种情况下,实现应用程序需要spring-context
依赖项,然后我们需要spring-test
和junit-jupiter
来测试它。为了实现等效的应用程序和测试,但这次使用 Spring Boot,首先我们需要更改我们的pom.xml
(使用 Maven 时):
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.bonigarcia</groupId>
<artifactId>junit5-spring-boot</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.M3</version>
</parent>
<properties>
<junit.jupiter.version>5.0.0</junit.jupiter.version>
<junit.platform.version>1.0.0</junit.platform.version>
<java.version>1.8</java.version>
<maven.compiler.target>${java.version}</maven.compiler.target>
<maven.compiler.source>${java.version}</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>${junit.platform.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/libs-milestone</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</pluginRepository>
</pluginRepositories>
</project>
或者我们的build.gradle
(使用 Gradle 时):
buildscript {
ext {
springBootVersion = '2.0.0.M3'
junitPlatformVersion = '1.0.0'
}
repositories {
mavenCentral()
maven {
url 'https://repo.spring.io/milestone'
}
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("org.junit.platform:junit-platform-gradle-plugin:${junitPlatformVersion}")
}
}
repositories {
mavenCentral()
maven {
url 'https://repo.spring.io/libs-milestone'
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'org.junit.platform.gradle.plugin'
jar {
baseName = 'junit5-spring-boot'
version = '1.0.0'
}
compileTestJava {
sourceCompatibility = 1.8
targetCompatibility = 1.8
options.compilerArgs += '-parameters'
}
dependencies {
compile('org.springframework.boot:spring-boot-starter')
testCompile("org.springframework.boot:spring-boot-starter-test")
testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
}
为了将我们的原始 Spring 应用程序转换为 Spring Boot,我们的组件(在示例中称为MessageComponent
和MessageService
)将完全相同,但我们的主类会有一些变化(见此处)。请注意,我们在类级别使用@SpringBootApplication
注释,使用 Spring Boot 的典型引导机制实现主方法。仅用于记录目的,我们正在实现一个用@PostConstruct
注释的方法。这个方法将在启动应用程序上下文之前触发:
package io.github.bonigarcia;
import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MySpringBootApplication {
final Logger log = LoggerFactory.*getLogger*(MySpringBootApplication.class);
@Autowired
public MessageComponent messageComponent;
@PostConstruct
private void setup() {
log.info("*** {} ***", messageComponent.getMessage());
}
public static void main(String[] args) throws Exception {
new SpringApplication(MySpringBootApplication.class).run(args);
}
}
测试的实现将是直接的。我们需要做的唯一更改是用@SpringBootTest
注释测试,而不是@ContextConfiguration
(Spring Boot 自动查找并启动我们的ApplicationContext
):
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@SpringBootTest
class SimpleSpringBootTest {
@Autowired
public MessageComponent messagePrinter;
@Test
public void test() {
*assertEquals*("Hello world!", messagePrinter.getMessage());
}
}
在控制台执行测试,我们可以看到实际上应用程序在测试之前启动(请注意开头的不可错过的 spring ASCII 横幅)。
之后,我们的测试使用ApplicationContext
来验证一个 Spring 组件,测试结果成功:
使用 Spring Boot 执行测试
结束这一部分时,我们看到一个使用 Spring Boot 实现的简单的 Web 应用程序。关于依赖项,我们需要做的唯一更改是包含启动的spring-boot-starter-web
(而不是通用的spring-boot-starter
)。就是这样,我们可以开始实现基于 Spring 的 Web 应用程序。
我们将实现一个非常简单的@Controller
,即处理来自浏览器的请求的 Spring bean。在我们的例子中,控制器映射的唯一 URL 是默认资源/
:
package io.github.bonigarcia;
import static org.springframework.web.bind.annotation.RequestMethod.*GET*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class WebController {
@Autowired
private PageService pageService;
@RequestMapping(value = "/", method = *GET*)
public String greeting() {
return pageService.getPage();
}
}
这个组件注入了一个名为PageService
的服务,负责返回响应/
请求加载的实际页面。这个服务的内容也非常简单:
package io.github.bonigarcia;
import org.springframework.stereotype.Service;
@Service
public class PageService {
public String getPage() {
return "/index.html";
}
}
按照约定(我们在这里使用 Spring Boot),基于 Spring 的 Web 应用程序的静态资源位于项目类路径中的一个名为static
的文件夹中。根据 Maven/Gradle 项目的结构,这个文件夹位于src/main/resources
路径下(见下面的截图)。请注意,这里有两个页面(我们在测试中从一个页面切换到另一个页面,敬请关注):
示例项目junit5-spring-boot-web的内容
让我们继续进行有趣的部分:测试。在这个项目中,我们正在实现三个 Jupiter 测试。第一个测试旨在验证对页面/index.html
的直接调用。如前所述,这个测试需要使用 Spring 扩展(@ExtendWith(SpringExtension.class)
)并声明为 Spring Boot 测试(@SpringBootTest
)。为了执行对 Web 应用程序的请求,我们使用MockMvc
的一个实例,以多种方式验证响应(HTTP 响应代码、内容类型和响应内容主体)。这个实例是使用 Spring Boot 注解@AutoConfigureMockMvc
自动配置的。
在 Spring Boot 之外,可以使用一个名为MockMvcBuilders
的构建器类来创建MockMvc
对象,而不是使用@AutoConfigureMockMvc
。在这种情况下,应用程序上下文被用作该构建器的参数。
package io.github.bonigarcia;
import static org.hamcrest.core.StringContains.*containsString*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*get*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*content*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*status*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
class IndexTest {
@Autowired
MockMvc mockMvc;
@Test
void testIndex() throws Exception {
mockMvc.perform(*get*("/index.html")).andExpect(*status*().isOk())
.andExpect(*content*().contentType("text/html")).andExpect(
*content*().string(*containsString*("This is index
page")));
}
}
再次,在 shell 中运行这个测试,我们可以确认应用程序实际上被执行了。默认情况下,嵌入式 Tomcat 监听端口8080
。之后,测试成功执行:
在容器内第一次测试的控制台输出
第二个测试类似,但作为一个差异因素,它使用了测试能力@MockBean
来通过模拟覆盖一个 spring 组件(在这个例子中,PageService
)。在测试的主体中,我们首先对模拟对象的getPage
方法进行存根处理,以改变组件的默认响应为redirect:/page.html
。结果,当使用MockMvc
对象在测试中请求资源/
时,我们将获得一个 HTTP 302 响应(重定向)到资源/page.html
(实际上是一个存在的页面,如项目截图所示):
package io.github.bonigarcia;
import static org.mockito.Mockito.*doReturn*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*get*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*redirectedUrl*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*status*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
class RedirectTest {
@MockBean
PageService pageService;
@Autowired
MockMvc mockMvc;
@Test
void test() throws Exception {
*doReturn*("redirect:/page.html").when(pageService).getPage();
mockMvc.perform(*get*("/")).andExpect(*status*().isFound())
.andExpect(*redirectedUrl*("/page.html"));
}
}
同样,在 shell 中,我们可以确认测试启动了 Spring 应用程序,然后正确执行了:
在容器内第二次测试的控制台输出
这个项目中的最后一个测试是一个“容器外”测试的示例。在前面的测试示例中,Spring 上下文在测试中被使用。另一方面,下面的测试完全依赖 Mockito 来执行系统组件,这次不启动 Spring 应用程序上下文。请注意,我们在这里使用MockitoExtension
扩展,使用组件WebController
作为我们的 SUT(@InjectMocks
)和组件PageService
作为 DOC(@Mock
):
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import static org.mockito.Mockito.*times*;
import static org.mockito.Mockito.*verify*;
import static org.mockito.Mockito.*when*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OutOfContainerTest {
@InjectMocks
private WebController webController;
@Mock
private PageService pageService;
@Test
void test() {
*when*(pageService.getPage()).thenReturn("/my-page.html");
*assertEquals*("/my-page.html", webController.greeting());
*verify*(pageService, *times*(1)).getPage();
}
}
这一次,在测试的执行中,我们没有看到 Spring 的痕迹,因为在执行测试之前没有启动应用程序容器:
在容器外测试的控制台输出
Selenium
Selenium(www.seleniumhq.org/
)是一个开源的 Web 测试框架,自 2008 年成立以来,已经成为事实上的 Web 自动化库。在接下来的部分中,我们将回顾 Selenium 的主要特性以及如何在 JUnit 5 测试中使用它。
Selenium 简介
Selenium 由不同的项目组成。首先,我们找到了 Selenium IDE。它是一个 Firefox 插件,实现了用于 Web 应用程序的“录制和回放”(R&P)模式。因此,它允许记录与 Firefox 的手动交互,并以自动化方式回放该录制。
第二个项目被命名为Selenium Remote Control(RC)。这个组件能够使用不同的编程语言(如 Java、C#、Python、Ruby、PHP、Perl 或 JavaScript)自动驱动不同类型的浏览器。这个组件在 SUT 中注入了一个名为 Selenium Core 的 JavaScript 库。这个库由一个名为 Selenium RC Server 的中间组件控制,该组件接收来自测试代码的请求(见下图)。由于同源策略,Selenium RC 存在重要的安全问题。
因此,它在 2016 年被弃用,以支持 Selenium WebDriver:
Selenium RC 架构
我们回顾 Selenium RC 只是为了介绍 Selenium WebDriver。如今,Selenium RC 已经被弃用,强烈不建议使用。
从功能角度来看,Selenium WebDriver 等同于 RC(即允许使用代码控制浏览器)。作为差异化方面,Selenium WebDriver 使用每个浏览器的本机支持自动化来调用浏览器。由 Selenium WebDriver 提供的语言绑定(在下图中标记为 Test)与特定于浏览器的二进制文件通信,这个二进制文件充当真实浏览器之间的桥梁。例如,这个二进制文件对于 Chrome 被称为chromedriver(sites.google.com/a/chromium.org/chromedriver/
),对于 Firefox 被称为geckodriver(github.com/mozilla/geckodriver
)。测试与驱动程序之间的通信是通过 HTTP 上的 JSON 消息使用所谓的 JSON Wire Protocol 完成的。
这个机制最初由 WebDriver 团队提出,在 W3C WebDriver API 中得到了标准化(www.w3.org/TR/webdriver/
):
Selenium WebDriver 架构
Selenium 组合中的最后一个项目称为 Selenium Grid。它可以被看作是 Selenium WebDriver 的扩展,因为它允许在远程机器上分发浏览器执行。有许多节点,每个节点在不同的操作系统上运行,并且具有不同的浏览器。Hub 服务器跟踪这些节点,并将请求代理给它们(见下图):
Selenium Grid 架构
以下表格总结了 WebDriver API 的主要特性:
WebDriver 特性和描述 | 示例 |
---|---|
创建 WebDriver 对象:它允许创建 WebDriver 实例,这些实例从测试代码中远程控制浏览器。 |
WebDriver driver = new FirefoxDriver();
WebDriver driver = new ChromeDriver();
WebDriver driver = new OperaDriver();
|
导航:它允许导航到给定的 URL。 |
---|
driver.get("http://junit.org/junit5/");
|
定位元素:它允许使用不同的策略(按 id、名称、类名、CSS 选择器、链接文本、标签名或 XPath)来识别网页上的元素(WebElement)。 |
---|
WebElement webElement = driver.findElement(By.*id("id"));* driver.findElement(By.*name("name"));* driver.findElement(By.*className("class"));* driver.findElement(By.*cssSelector("cssInput"));* driver.findElement(By.*linkText("text"));* driver.findElement(By.*tagName("tag name"));* driver.findElement(By.*xpath("/html/body/div[4]"));*
|
与元素交互:从给定的 WebElement,我们可以进行不同类型的自动交互,比如点击元素、输入文本或清除输入字段、读取属性等。 |
---|
webElement.click();
webElement.sendKeys("text");
webElement.clear();
String text = webElement.getText();
String href = webElement.getAttribute("href");
String css = webElement.getCssValue("css");
Dimension dim = webElement.getSize();
boolean enabled = webElement.isEnabled();
boolean selected = webElement.isSelected();
boolean displayed = webElement.isDisplayed();
|
处理等待:WebDriver 可以处理显式和隐式的等待。 |
---|
// Explicit
WebDriverWait wait = new WebDriverWait(driver, 30);
wait.until(ExpectedConditions);
// Implicit wait
driver.manage().timeouts().implicitlyWait(30, ***SECONDS***);
|
XPath(XML Path Language)是一种用于构建表达式以解析和处理类似 XML 的文档(例如 HTML)的语言。
用于 Selenium 的 JUnit 5 扩展
为了简化在 JUnit 5 中使用 Selenium WebDriver,可以使用名为selenium-jupiter
的开源 JUnit 5 扩展。这个扩展是使用 JUnit 5 扩展模型提供的依赖注入功能构建的。由于这个特性,不同类型的对象可以作为参数注入到 JUnit 5 的@Test
方法中。具体来说,selenium-jupiter
允许注入WebDriver
接口的子类型(例如ChromeDriver
、FirefoxDriver
等)。
使用selenium-jupiter
非常容易。首先,我们需要在项目中导入依赖项(通常作为测试依赖项)。在 Maven 中,可以按照以下步骤完成:
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>selenium-jupiter</artifactId>
<version>${selenium-jupiter.version}</version>
<scope>test</scope>
</dependency>
selenium-jupiter
依赖于几个库,这些库作为传递性依赖项
添加到我们的项目中,即:
-
Selenium-java
(org.seleniumhq.selenium:selenium-java
):Selenium WebDriver 的 Java 库。 -
WebDriverManager(
io.github.bonigarcia:webdrivermanager
):用于在 Java 运行时自动管理 Selenium WebDriver 二进制文件的 Java 库(github.com/bonigarcia/webdrivermanager
)。 -
Appium(
io.appium:java-client
):Appium 的 Java 客户端,这是一个测试框架,扩展了 Selenium 以自动化测试原生、混合和移动 Web 应用程序(appium.io/
)。
一旦在我们的项目中包含了selenium-jupiter
,我们需要在我们的 JUnit 5 测试中声明selenium-jupiter
扩展,只需用@ExtendWith(SeleniumExtension.class)
进行注释。然后,在我们的@Test
方法中需要包含一个或多个参数,其类型实现了 WebDriver 接口,selenium-jupiter
在内部控制 WebDriver 对象的生命周期。selenium-jupiter
支持的 WebDriver 子类型如下:
-
ChromeDriver
:这用于控制 Google Chrome 浏览器。 -
FirefoxDriver
:这用于控制 Firefox 浏览器。 -
EdgeDriver
:这用于控制 Microsoft Edge 浏览器。 -
OperaDriver
:这用于控制 Opera 浏览器。 -
SafariDriver
:这用于控制 Apple Safari 浏览器(仅在 OSX El Capitan 或更高版本中可能)。 -
HtmlUnitDriver
:这用于控制 HtmlUnit(无头浏览器,即没有 GUI 的浏览器)。 -
PhantomJSDriver
:这用于控制 PhantomJS(另一个无头浏览器)。 -
InternetExplorerDriver
:这用于控制 Microsoft Internet Explorer。尽管该浏览器受支持,但 Internet Explorer 已被弃用(支持 Edge),强烈不建议使用。 -
RemoteWebDriver
:这用于控制远程浏览器(Selenium Grid)。 -
AppiumDriver
:这用于控制移动设备(Android 和 iOS)。
考虑以下类,它使用selenium-jupiter
,即使用@ExtendWith(SeleniumExtension.**class**)
声明 Selenium 扩展。这个例子定义了三个测试,将使用本地浏览器执行。第一个(名为testWithChrome
)使用 Chrome 作为浏览器。为此,借助于selenium-jupiter
的依赖注入功能,方法只需要声明一个使用ChromeDriver
类型的方法参数。然后,在测试的主体中,调用了该对象中的WebDriver
API。请注意,这个测试只是打开一个网页,并断言标题是否符合预期。接下来的测试(testWithFirefoxAndOpera
)类似,但这次同时使用两个不同的浏览器:Firefox(使用FirefoxDriver
的实例)和 Opera(使用OperaDriver
的实例)。第三个也是最后一个测试(testWithHeadlessBrowsers
)声明并使用了两个无头浏览器(HtmlUnit
和PhantomJS
):
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertNotNull*;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import org.openqa.selenium.opera.OperaDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;
@ExtendWith(SeleniumExtension.class)
public class LocalWebDriverTest {
@Test
public void testWithChrome(ChromeDriver chrome) {
chrome.get("https://bonigarcia.github.io/selenium-jupiter/");
*assertTrue*(chrome.getTitle().startsWith("selenium-jupiter"));
}
@Test
public void testWithFirefoxAndOpera(FirefoxDriver firefox,
OperaDriver opera) {
firefox.get("http://www.seleniumhq.org/");
opera.get("http://junit.org/junit5/");
*assertTrue*(firefox.getTitle().startsWith("Selenium"));
*assertTrue*(opera.getTitle().equals("JUnit 5"));
}
@Test
public void testWithHeadlessBrowsers(HtmlUnitDriver htmlUnit,
PhantomJSDriver phantomjs) {
htmlUnit.get("https://bonigarcia.github.io/selenium-jupiter/");
phantomjs.get("https://bonigarcia.github.io/selenium-jupiter/");
*assertTrue*(htmlUnit.getTitle().contains("JUnit 5 extension"));
*assertNotNull*(phantomjs.getPageSource());
}
}
为了正确执行这个测试类,应该在运行之前安装所需的浏览器(Chrome、Firefox 和 Opera)。另一方面,无头浏览器(HtmlUnit 和 PhantomJS)作为 Java 依赖项使用,因此无需手动安装它们。
让我们看另一个例子,这次使用远程浏览器(即 Selenium Grid)。再次,这个类使用selenium-jupiter
扩展。测试(testWithRemoteChrome
)声明了一个名为remoteChrome
的单个参数,类型为RemoteWedbrider
。这个参数用@DriverUrl
和@DriverCapabilities
进行注释,分别指定了 Selenium 服务器(或 Hub)的 URL 和所需的能力。关于能力,我们正在配置使用 Chrome 浏览器版本 59:
为了正确运行这个测试,Selenium 服务器应该在本地主机上运行,并且需要在 Hub 中注册一个节点(Chrome 59)。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.remote.RemoteWebDriver;
@ExtendWith(SeleniumExtension.class)
public class RemoteWebDriverTest {
@Test
void testWithRemoteChrome(
@DriverUrl("http://localhost:4444/wd/hub")
@DriverCapabilities(capability = {
@Capability(name = "browserName", value ="chrome"),
@Capability(name = "version", value = "59") })
RemoteWebDriver remoteChrome)
throws InterruptedException {
remoteChrome.get("https://bonigarcia.github.io/selenium-
jupiter/");
*assertTrue*(remoteChrome.getTitle().contains("JUnit 5
extension"));
}
}
在本节的最后一个示例中,我们使用了AppiumDriver
。具体来说,我们设置了使用 Android 模拟设备中的 Chrome 浏览器的能力(@DriverCapabilities
)。同样,这个模拟器需要在运行测试的机器上提前启动和运行:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertTrue*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.remote.DesiredCapabilities;
import io.appium.java_client.AppiumDriver;
@ExtendWith(SeleniumExtension.class)
public class AppiumTest {
@DriverCapabilities
DesiredCapabilities capabilities = new DesiredCapabilities();
{
capabilities.setCapability("browserName", "chrome");
capabilities.setCapability("deviceName", "Android");
}
@Test
void testWithAndroid(AppiumDriver<WebElement> android) {
String context = android.getContext();
android.context("NATIVE_APP");
android.findElement(By.*id*("com.android.chrome:id/terms_accept"))
.click();
android.findElement(By.*id*("com.android.chrome:id/negative_button"))
.click();
android.context(context);
android.get("https://bonigarcia.github.io/selenium-jupiter/");
*assertTrue*(android.getTitle().contains("JUnit 5 extension"));
}
}
有关Selenium-Jupiter
的更多示例,请访问bonigarcia.github.io/selenium-jupiter/
。
Cucumber
Cucumber(cucumber.io/
)是一个旨在自动化接受测试的测试框架,遵循行为驱动开发(BDD)风格编写。Cucumber 是用 Ruby 编写的,尽管其他语言的实现(包括 Java、JavaScript 和 Python)也是可用的。
Cucumber 简介
Cucumber 执行以 Gherkin 语言编写的测试。这是一种纯文本自然语言(例如英语或 Cucumber 支持的其他 60 多种语言之一),具有给定的结构。Gherkin 旨在供非程序员使用,通常是客户、业务分析师、经理等。
Gherkin 文件的扩展名是*.feature*
。
在 Gherkin 文件中,非空行可以以关键字开头,后面是自然语言的文本。主要关键字如下:
-
Feature:要测试的软件功能的高级描述。它可以被视为用例描述。
-
Scenario:说明业务规则的具体示例。场景遵循相同的模式:
-
描述初始上下文。
-
描述一个事件。
-
描述预期的结果。
这些操作在 Gherkin 行话中被称为步骤,主要是Given、When或Then:
有两个额外的步骤:And(用于逻辑和不同步骤)和But(用于And的否定形式)。
-
Given:测试开始前的前提条件和初始状态。
-
When:测试期间用户执行的操作。
-
Then:在When子句中执行的操作的结果。
-
Background:为了避免在不同场景中重复步骤,关键字 background 允许声明这些步骤,这些步骤在后续场景中被重用。
-
Scenario Outline:在这些场景中,步骤标有变量(使用符号
**<**
和**>**
)。 -
Examples:场景大纲声明总是后跟一个或多个示例部分,这是一个包含Scenario Outline中声明的变量值的容器表。
当一行不以关键字开头时,Cucumber 不会解释该行。它用于自定义描述。
一旦我们定义了要测试的功能,我们需要所谓的步骤定义,它允许将纯文本 Gherkin 转换为实际执行我们的 SUT 的操作。在 Java 中,可以通过注解来轻松地实现这一点,用于注释步骤实现的方法:@Given
、@Then
、@When
、@And
和@But
。每个步骤的字符串值可以包含正则表达式,这些正则表达式被映射为方法中的字段。在下一节中看一个例子。
Cucumber 的 JUnit 5 扩展
最新版本的 Cucumber Java 工件包含了 Cucumber 的 JUnit 5 扩展。本节包含了一个在 Gherkin 中定义的功能的完整示例,以及使用 Cucumber 执行它的 JUnit 5。和往常一样,这个示例的源代码托管在 GitHub 上(github.com/bonigarcia/mastering-junit5
)。
包含此示例的项目结构如下:
JUnit 5 与 Cucumber 项目结构和内容
首先,我们需要创建我们的 Gherkin 文件,这个文件旨在测试一个简单的计算器系统。这个计算器将是我们的 SUT 或测试对象。我们的功能文件的内容如下:
Feature: Basic Arithmetic
Background: A Calculator
*Given* a calculator I just turned on
Scenario: Addition
*When* I add 4 and 5
*Then* the result is 9
Scenario: Substraction
*When* I substract 7 to 2
*Then* the result is 5
Scenario Outline: Several additions
*When* I add *<a>* and *<b>
* *Then* the result is *<c>
* Examples: Single digits
| a | b | c |
| 1 | 2 | 3 |
| 3 | 7 | 10 |
然后,我们需要实现我们的步骤定义。如前所述,我们使用注解和正则表达式将 Gherkin 文件中包含的文本映射到 SUT 的实际练习,具体取决于步骤:
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
public class CalculatorSteps {
private Calculator calc;
@Given("^a calculator I just turned on$")
public void setup() {
calc = new Calculator();
}
@When("^I add (\\d+) and (\\d+)$")
public void add(int arg1, int arg2) {
calc.push(arg1);
calc.push(arg2);
calc.push("+");
}
@When("^I substract (\\d+) to (\\d+)$")
public void substract(int arg1, int arg2) {
calc.push(arg1);
calc.push(arg2);
calc.push("-");
}
@Then("^the result is (\\d+)$")
public void the_result_is(double expected) {
*assertEquals*(expected, calc.value());
}
}
当然,我们仍然需要实现我们的 JUnit 5 测试。为了实现 Cucumber 和 JUnit 5 的集成,需要通过@ExtendWith(CucumberExtension.**class**)
在我们的类中注册 Cucumber 扩展。在内部,CucumberExtension
实现了 Jupiter 扩展模型的ParameterResolver
回调。其目标是将 Cucumber 功能的相应测试注入为 Jupiter DynamicTest
对象。请注意示例中如何使用@TestFactory
。
可选地,我们可以使用@CucumberOptions
注释我们的测试类。此注释允许配置测试的 Cucumber 设置。此注释的允许元素为:
-
plugin
:内置格式:pretty、progress、JSON、usage 等。默认值:{}
。 -
dryRun
:检查是否所有步骤都有定义。默认值:false
。 -
features
:功能文件的路径。默认值:{}
。 -
glue
:步骤定义的路径。默认值:{}
。 -
tags
:要执行的功能中的标签。默认值{}
。 -
monochrome
:以可读的方式显示控制台输出。默认值:false
。 -
format
:要使用的报告格式。默认值:{}
。 -
strict
:如果有未定义或挂起的步骤,则失败。默认值:false
。
package io.github.bonigarcia;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.extension.ExtendWith;
import cucumber.api.CucumberOptions;
import cucumber.api.junit.jupiter.CucumberExtension;
@CucumberOptions(plugin = { "pretty" })
@ExtendWith(CucumberExtension.class)
public class CucumberTest {
@TestFactory
public Stream<DynamicTest> runCukes(Stream<DynamicTest> scenarios) {
List<DynamicTest> tests = scenarios.collect(Collectors.*toList*());
return tests.stream();
}
}
此时,我们可以使用 JUnit 5 执行我们的 Cucumber 套件。在下面的示例中,我们看到了使用 Gradle 运行测试时的输出:
使用 Gradle 使用 Cucumber 执行 JUnit 5
Docker
Docker(www.docker.com/
)是一种开源软件技术,允许将任何应用程序打包并作为轻量级和便携式容器运行。它提供了一个命令行程序,一个后台守护程序和一组远程服务,简化了容器的生命周期。
Docker 简介
在历史上,UNIX 风格的操作系统使用术语"jail"来描述修改后的隔离运行时环境。Linux 容器(LXC)项目始于 2008 年,汇集了 cgroups、内核命名空间或 chroot(等等)以提供完全隔离的执行。LXC 的问题在于难度,因此 Docker 技术应运而生。
Docker 隐藏了 Linux 内核的上述资源隔离功能(cgroups、内核命名空间等)的底层复杂性,以允许独立的容器在单个 Linux 实例中运行。Docker 提供了一个高级 API,允许将任何应用程序打包、分发和作为容器运行。
在 Docker 中,容器包含应用程序及其依赖项。多个容器可以在同一台机器上运行,并与其他容器共享相同的操作系统内核。每个容器都作为用户空间中的隔离进程运行。
与虚拟机(VM)不同,在 Docker 容器中不需要使用虚拟化程序,虚拟化程序是允许创建和运行 VM 的软件(例如:VirtualBox、VMware、QEMU 或 Virtual PC)。
VM 和容器的架构如下图所示:
虚拟机与容器
Docker 平台有两个组件:Docker 引擎负责创建和运行容器;Docker Hub(hub.docker.com/
)是一个用于分发容器的云服务。Docker Hub 提供了大量的公共容器镜像供下载。Docker 引擎是一个由三个主要组件组成的客户端服务器应用程序:
-
作为守护进程实现的服务器(
dockerd
命令)。 -
一个 REST API,指定程序可以用来与守护进程通信并指示其要执行的接口。
-
一个命令行界面(CLI)客户端(
docker
命令)。
Docker 的 JUnit 5 扩展
如今,容器正在改变我们开发、分发和运行软件的方式。这对于持续集成(CI)测试环境尤其有趣,其中与 Docker 的融合直接影响效率的提高。
关于 JUnit 5,在撰写本文时,有一个名为 JUnit5-Docker 的开源 JUnit 5 扩展,用于 Docker(faustxvi.github.io/junit5-docker/
)。该扩展充当 Docker 引擎的客户端,并允许在运行类的测试之前启动一个 Docker 容器(从 Docker Hub 下载)。该容器在测试结束时停止。为了使用 JUnit5-Docker,首先需要在项目中添加依赖。在 Maven 中:
<dependency>
<groupId>com.github.faustxvi</groupId>
<artifactId>junit5-docker</artifactId>
<version>${junit5-docker.version}</version>
<scope>test</scope>
</dependency>
在 Gradle 中:
dependencies {
testCompile("com.github.faustxvi:junit5-docker:${junitDockerVersion}")
}
使用 JUnit5-Docker 非常简单。我们只需要用@Docker
注解标记我们的测试类。此注解中可用的元素如下:
-
image
:要启动的 Docker 镜像。 -
ports
:Docker 容器的端口映射。这是必需的,因为至少一个端口必须对容器可见才能有用。 -
environments
:要传递给 Docker 容器的可选环境变量。默认值:{}
。 -
waitFor
:在运行测试之前等待的可选日志。默认值:@WaitFor(NOTHING)
。 -
newForEachCase
:布尔标志,确定是否应为每个测试用例重新创建容器。如果应该仅为测试类创建一次,则该值将为 false。默认值:true
。
考虑以下示例。这个测试类使用@Docker
注解在每个测试开始时启动一个 MySql 容器(容器镜像 MySQL)。内部容器端口是3306
,将映射到主机端口8801
。然后,定义了几个环境属性(MySql 根密码、默认数据库、用户名和密码)。测试的执行将在容器日志中出现mysqld: ready for connections的迹象之前不会开始(这表明 MySql 实例已经启动)。在测试的主体中,我们对在容器中运行的 MySQL 实例进行 JDBC 连接。
这个测试是在 Windows 机器上执行的。因此,JDBC URL 的主机是 192.168.99.100,这是 Docker Machine 的 IP。这是一个工具,允许在虚拟主机上安装 Docker Engine,比如 Windows 或 Mac(docs.docker.com/machine/
)。在 Linux 机器上,这个 IP 可能是 127.0.0.1(本地主机)。
package io.github.bonigarcia;
import static org.junit.jupiter.api.Assertions.*assertFalse*;
import java.sql.Connection;
import java.sql.DriverManager;
import org.junit.jupiter.api.Test;
import com.github.junit5docker.Docker;
import com.github.junit5docker.Environment;
import com.github.junit5docker.Port;
import com.github.junit5docker.WaitFor;
@Docker(image = "mysql", ports = @Port(exposed = 8801, inner = 3306), environments = {
@Environment(key = "MYSQL_ROOT_PASSWORD", value = "root"),
@Environment(key = "MYSQL_DATABASE", value = "testdb"),
@Environment(key = "MYSQL_USER", value = "testuser"),
@Environment(key = "MYSQL_PASSWORD", value = "secret"), },
waitFor = @WaitFor("mysqld: ready for connections"))
public class DockerTest {
@Test
void test() throws Exception {
Class.*forName*("com.mysql.jdbc.Driver");
Connection connection = DriverManager.*getConnection*(
"jdbc:mysql://192.168.99.100:8801/testdb", "testuser",
"secret");
*assertFalse*(connection.isClosed());
connection.close();
}
}
在 Docker Windows 终端中执行此测试的过程如下:
使用 JUnit5-Docker 扩展执行测试
Android
Android (www.android.com/
)是一个基于修改版 Linux 的开源移动操作系统。它最初由一家名为 Android 的初创公司开发,于 2005 年被 Google 收购和支持。
根据美国 IT 研究和咨询公司 Gartner Inc.的报告,2017 年 Android 和 iOS 占全球智能手机销量的 99%以上,如下图所示:
智能手机操作系统市场。图片由 www.statista.com 创建。
Android 简介
Android 是一个基于 Linux 的软件堆栈,分为几个层。这些层,从下到上分别是:
-
Linux 内核:这是 Android 平台的基础。该层包含 Android 设备各种硬件组件的低级设备驱动程序。
-
硬件抽象层(HAL):该层提供标准接口,将硬件功能暴露给更高级别的 Java API 框架。
-
Android 运行时(ART):它为
.dex
文件提供运行时环境,这是一种设计用于最小内存占用的字节码格式。ART 是 Android 5.0 上的第一个版本(见下表)。在该版本之前,Dalvik 是 Android 运行时。 -
本地 C/C++库:该层包含用 C 和 C++编写的本地库,如 OpenGL ES,用于高性能 2D 和 3D 图形处理。
-
Java API 框架:Android 的整个功能集通过用 Java 编写的 API 可供开发人员使用。这些 API 是创建 Android 应用程序的构建块,例如:视图系统(用于应用程序 UI)、资源管理器(用于国际化、图形、布局)、通知管理器(用于状态栏中的自定义警报)、活动管理器(用于管理应用程序的生命周期)或内容提供程序(用于使应用程序能够访问其他应用程序的数据,如联系人等)。
-
应用程序:Android 带有一组核心应用程序,如电话、联系人、浏览器等。此外,还可以从 Google Play(以前称为 Android Market)下载和安装许多其他应用程序:
Android 分层架构
自首次发布以来,Android 经历了许多更新,如下表所述:
Android 版本 | 代号 | API 级别 | Linux 内核版本 | 发布日期 |
---|---|---|---|---|
1.5 | Cupcake | 3 | 2.6.27 | 2009 年 4 月 30 日 |
1.6 | Donut | 4 | 2.6.29 | 2009 年 9 月 15 日 |
2.0, 2.1 | Eclair | 5, 6, 7 | 2.6.29 | 2009 年 10 月 26 日 |
2.2 | Froyo | 8 | 2.6.32 | 2010 年 5 月 20 日 |
2.3 | Gingerbread | 9, 10 | 2.6.35 | 2010 年 12 月 6 日 |
3.0, 3.1, 3.2 | Honeycomb | 11, 12, 13 | 2.6.36 | 2011 年 2 月 22 日 |
4.0 | Ice Cream Sandwich | 14, 15 | 3.0.1 | 2011 年 10 月 18 日 |
4.1, 4.2, 4.3 | Jelly Bean | 16, 17, 18 | 3.0.31, 3.0.21, 3.4.0 | 2012 年 7 月 9 日 |
4.4 | KitKat | 19, 20 | 3.10 | 2013 年 10 月 31 日 |
5.0, 5.1 | Lollipop | 21, 22 | 3.16.1 | 2014 年 11 月 12 日 |
6.0 | Marshmallow | 23 | 3.18.10 | 2015 年 10 月 5 日 |
7.0, 7.1 | Nougat | 24, 25 | 4.4.1 | 2016 年 8 月 22 日 |
8.0 | Android O | 26 | 待定 | 待定 |
从开发者的角度来看,Android 提供了丰富的应用程序框架,可以为移动设备构建应用程序。Android 应用程序是用 Java 编程语言编写的。Android 软件开发工具包(SDK)将 Java 代码与任何数据和资源文件编译成一个.apk
(Android 包)文件,该文件可以安装在 Android 设备上,如智能手机、平板电脑、智能电视或智能手表。
有关 Android 开发的完整信息,请访问developer.android.com/
。
Android Studio 是 Android 开发的官方 IDE。它是基于 IntelliJ IDEA 构建的。在 Android Studio 中,Android 项目的构建过程由 Gradle 构建系统管理。在安装 Android Studio 时,还可以安装两个附加工具:
-
Android SDK:其中包含开发 Android 应用程序所需的所有软件包和工具。SDK 管理器允许下载和安装不同版本的 SDK(请参见上表)。
-
Android 虚拟设备(AVD):这是一个允许我们模拟实际设备的仿真器。AVD 管理器允许下载和安装不同的仿真 Android 虚拟设备,分为四类:手机、平板电脑、电视和手表。
Android 项目中的 JUnit 5 的 Gradle 插件
在撰写本文时,Android 项目中尚无对 JUnit 5 的官方支持。为解决这个问题,创建了一个名为android-junit5
的开源 Gradle 插件(github.com/aurae/android-junit5
)。要在项目中使用此插件,首先需要在我们的build.gradle
文件中指定适当的依赖项:
buildscript {
dependencies {
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.0.0"
}
}
为了在我们的项目中使用此插件,我们需要在我们的build.gradle
文件中使用apply plugin
子句扩展我们的项目功能:
apply plugin: "com.android.application"
apply plugin: "de.mannodermaus.android-junit5"
dependencies {
testCompile junitJupiter()
}
android-junit5
插件配置了junitPlatform
任务,在测试执行阶段自动附加了 Jupiter 和 Vintage 引擎。例如,考虑以下项目示例,通常托管在 GitHub 上(github.com/bonigarcia/mastering-junit5/tree/master/junit5-android
)。以下是 Android Studio 中导入此项目的屏幕截图:
在 IntelliJ 上兼容 JUnit 5 的 Android 项目
现在,我们将在 Android Studio 中创建一个 Android JUnit 运行配置。如屏幕截图所示,我们使用All in package
选项来引用包含测试的包(在本例中为io.github.bonigarcia.myapplication
):
Android JUnit 运行配置
如果我们启动上述的运行配置,项目中的所有测试都将被执行。这些测试可以无缝地使用 JUnit 4 编程模型(Vintage)甚至 JUnit 5(Jupiter):
在 IntelliJ 中的 Android 项目中执行 Jupiter 和 Vintage 测试
REST
Roy Fielding 是一位 1965 年出生的美国计算机科学家。他是 HTTP 协议的作者之一,也是 Apache Web 服务器的合著者。在 2000 年,Fielding 在他的博士论文《Architectural Styles and the Design of Network-based Software Architecture》中创造了 REST(REpresentational State Transfer)一词。REST 是一种用于设计分布式系统的架构风格。它不是一个标准,而是一组约束。REST 通常与 HTTP 一起使用。一方面,严格遵循 REST 原则的实现通常被称为 RESTful。另一方面,那些遵循这些原则的宽松实现被称为 RESTlike。
REST 简介
REST 遵循客户端-服务器架构。服务器负责处理一组服务,监听客户端发出的请求。客户端和服务器之间的通信必须是无状态的,这意味着服务器不会存储来自客户端的任何记录,因此客户端发出的每个请求都必须包含服务器处理所需的所有信息。
REST 架构的构建块被称为资源。资源定义了将要传输的信息的类型。资源应该以唯一的方式进行标识。在 HTTP 中,访问资源的方式是提供其完整的 URL,也称为 API 端点。每个资源都有一个表示,这是资源当前状态的机器可读解释。如今,表示通常使用 JSON,但也可以使用其他格式,如 XML 或 YAML。
一旦我们确定了资源和表示格式,我们需要指定可以对它们进行的操作,也就是动作。动作可能是任何东西,尽管有一组任何面向资源的系统都应该提供的常见动作:CRUD(创建、检索、更新和删除)动作。REST 动作可以映射到 HTTP 方法(所谓的动词),如下所示:
-
GET
:读取资源。 -
POST
:向服务器发送新资源。 -
PUT
:更新给定资源。 -
DELETE
:删除资源。 -
PATCH
:部分更新资源。 -
HEAD
:询问给定资源是否存在,而不返回任何表示。 -
OPTIONS
:检索给定资源上可用动词的列表。
在 REST 中,幂等性的概念很重要。例如,GET
、DELETE
或PUT
被认为是幂等的,因为这些请求的效果无论发送一次还是多次都应该是相同的。另一方面,POST
不是幂等的,因为每次请求都会创建一个不同的资源。
REST,基于 HTTP 时可以受益于标准的 HTTP 状态码。在 REST 中经常重用的典型 HTTP 状态码有:
-
200 OK
:请求成功,返回了请求的内容。通常用于 GET 请求。 -
201 Created
:资源已创建。在响应 POST 或 PUT 请求时很有用。 -
204 No content
:操作成功,但没有返回内容。对于不需要响应主体的操作(例如 DELETE)很有用。 -
301 Moved permanently
:此资源已移至另一个位置,并返回该位置。 -
400 Bad request
:发出的请求有问题(例如,缺少一些必需的参数)。 -
401 Unauthorized
:在请求的资源对用户不可访问时进行身份验证时很有用。 -
403 Forbidden
:资源不可访问,但与 401 不同,身份验证不会影响响应。 -
404 Not found
:提供的 URL 未标识任何资源。 -
405 Method not allowed. 对资源使用的 HTTP 动词不允许。(例如,对只读资源进行 PUT 操作)。
-
500 Internal server error
:服务器端发生意外情况时的通用错误代码。
以下图片显示了 REST 的客户端-服务器交互示例。HTTP 消息的主体在请求和响应中都使用 JSON:
REST 序列图示例
使用 Jupiter 的 REST 测试库
REST API 如今变得越来越普遍。因此,对 REST 服务进行评估的适当策略是可取的。在本节中,我们将学习如何在我们的 JUnit 5 测试中使用多个测试库。
首先,我们可以使用开源库 REST Assured(rest-assured.io/
)。REST Assured 允许通过受 Ruby 或 Groovy 等动态语言启发的流畅 API 验证 REST 服务。要在我们的测试项目中使用 REST Assured,我们只需要在 Maven 中添加适当的依赖项:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
或者在 Gradle 中:
dependencies {
testCompile("io.rest-assured:rest-assured:${restAssuredVersion}")
}
之后,我们可以使用 REST Assured API。以下类包含两个测试示例。首先向免费在线 REST 服务echo.jsontest.com/
发送请求。然后验证响应代码和主体内容是否符合预期。第二个测试使用另一个免费在线 REST 服务(services.groupkt.com/
),并验证响应:
package io.github.bonigarcia;
import static io.restassured.RestAssured.*given*;
import static org.hamcrest.Matchers.*equalTo*;
import org.junit.jupiter.api.Test;
public class PublicRestServicesTest {
@Test
void testEchoService() {
String key = "foo";
String value = "bar";
given().when().get("http://echo.jsontest.com/" + key + "/" + value)
.then().assertThat().statusCode(200).body(key,
equalTo(value));
}
@Test
void testCountryService() {
*given*().when()
.get("http://services.groupkt.com/country/get/iso2code/ES")
.then().assertThat().statusCode(200)
.body("RestResponse.result.name", *equalTo*("Spain"));
}
}
在控制台中使用 Maven 运行此测试,我们可以检查两个测试都成功:
使用 REST Assured 执行测试
在第二个示例中,除了测试,我们还将实现服务器端,即 REST 服务实现。为此,我们将使用在本章中介绍的 Spring MVC 和 Spring Boot(请参见Spring部分)。
在 Spring 中实现 REST 服务非常简单。首先,我们只需要使用@RestController
注解一个 Java 类。在这个类的主体中,我们需要添加使用@RequestMapping
注解的方法。这些方法将监听我们的 REST API 中实现的不同 URL(端点)。@RequestMapping
的可接受元素有:
-
value
:这是路径映射 URL。 -
method
:找到要映射到的 HTTP 请求方法。 -
params
:找到映射请求的参数,缩小主要映射。 -
headers
:找到映射请求的标头。 -
consumes
:找到映射请求的可消耗媒体类型。 -
produces
:找到映射请求的可生产媒体类型。
如下类的代码所示,我们的服务示例实现了三种不同的操作:GET /books
(读取系统中的所有书籍),GET /book/{index}
(根据其标识符读取书籍),以及POST /book
(创建书籍)。
package io.github.bonigarcia;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyRestController {
@Autowired
private LibraryService libraryService;
@RequestMapping(value = "/books", method = RequestMethod.*GET*)
public List<Book> getBooks() {
return libraryService.getBooks();
}
@RequestMapping(value = "/book/{index}", method = RequestMethod.*GET*)
public Book getTeam(@PathVariable("index") int index) {
return libraryService.getBook(index);
}
@RequestMapping(value = "/book", method = RequestMethod.*POST*)
public ResponseEntity<Boolean> addBook(@RequestBody Book book) {
libraryService.addBook(book);
return new ResponseEntity<Boolean>(true, HttpStatus.*CREATED*);
}
}
由于我们正在为 Spring 实现 Jupiter 测试,我们需要使用SpringExtension
和SpringBootTest
注解。作为新功能,我们将注入spring-test
提供的测试组件,名为TestRestTemplate
。
这个组件是标准 Spring 的RestTemplate
对象的包装器,可以无缝地实现 REST 客户端。在我们的测试中,它请求我们的服务(在执行测试之前启动),并使用响应来验证结果。
注意,对象MockMvc
(在Spring部分中解释)也可以用于测试 REST 服务。与TestRestTemplate
相比,前者用于从客户端测试(即响应代码、主体、内容类型等),而后者用于从服务器端测试服务。例如,在这个例子中,对服务调用(getForEntity
和postForEntity
)的响应是 Java 对象,其范围仅限于服务器端(在客户端,此信息被序列化为 JSON)。
package io.github.bonigarcia;
import static org.junit.Assert.*assertEquals*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.*RANDOM_PORT*;
import static org.springframework.http.HttpStatus.*CREATED*;
import static org.springframework.http.HttpStatus.*OK*;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = *RANDOM_PORT*)
class SpringBootRestTest {
@Autowired
TestRestTemplate restTemplate;
@Test
void testGetAllBooks() {
ResponseEntity<Book[]> responseEntity = restTemplate
.getForEntity("/books", Book[].class);
*assertEquals*(*OK*, responseEntity.getStatusCode());
*assertEquals*(3, responseEntity.getBody().length);
}
@Test
void testGetBook() {
ResponseEntity<Book> responseEntity = restTemplate
.getForEntity("/book/0", Book.class);
*assertEquals*(*OK*, responseEntity.getStatusCode());
*assertEquals*("The Hobbit", responseEntity.getBody().getName());
}
@Test
void testPostBook() {
Book book = new Book("I, Robot", "Isaac Asimov",
LocalDate.*of*(1950, 12, 2));
ResponseEntity<Boolean> responseEntity = restTemplate
.postForEntity("/book", book, Boolean.class);
*assertEquals*(*CREATED*, responseEntity.getStatusCode());
*assertEquals*(true, responseEntity.getBody());
ResponseEntity<Book[]> responseEntity2 = restTemplate
.getForEntity("/books", Book[].class);
*assertEquals*(responseEntity2.getBody().length, 4);
}
}
如下面的截图所示,我们的 Spring 应用在运行测试之前启动,测试成功执行:
使用 TestRestTemplate 输出 Jupiter 测试的结果以验证 REST 服务。
最后,我们看到一个示例,其中使用了 WireMock 库(wiremock.org/
)。这个库允许模拟 REST 服务,即所谓的 HTTP 模拟服务器。这个模拟服务器捕获对服务的传入请求,并提供存根化的响应。这种能力对于测试一个消费 REST 服务的系统非常有用,但在测试期间服务不可用(或者我们可以测试调用服务的组件)。
像往常一样,我们看到一个示例来演示其用法。假设我们有一个系统,它消费远程 REST 服务。为了实现该服务的客户端,我们使用 Retrofit 2(square.github.io/retrofit/
),这是一个高度可配置的 Java HTTP 客户端。我们定义了用于消费此服务的接口,如下面的类所示。请注意,该服务公开了三个端点,旨在读取远程文件(打开文件、读取流和关闭流):
package io.github.bonigarcia;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.POST;
import retrofit2.http.Path;
public interface RemoteFileApi {
@POST("/api/v1/paths/{file}/open-file")
Call<ResponseBody> openFile(@Path("file") String file);
@POST("/api/v1/streams/{streamId}/read")
Call<ResponseBody> readStream(@Path("streamId") String streamId);
@POST("/api/v1/streams/{streamId}/close")
Call<ResponseBody> closeStream(@Path("streamId") String streamId);
}
然后我们实现了消费 REST 服务的类。在这个例子中,它是一个简单的 Java 类,根据构造函数参数传递的 URL 连接到远程服务:
package io.github.bonigarcia;
import java.io.IOException;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory;
import retrofit2.converter.gson.GsonConverterFactory;
public class RemoteFileService {
private RemoteFileApi remoteFileApi;
public RemoteFileService(String baseUrl) {
Retrofit retrofit = new Retrofit.Builder()
.addCallAdapterFactory(RxJavaCallAdapterFactory.*create*())
.addConverterFactory(GsonConverterFactory.*create*())
.baseUrl(baseUrl).build();
remoteFileApi = retrofit.create(RemoteFileApi.class);
}
public byte[] getFile(String file) throws IOException {
Call<ResponseBody> openFile = remoteFileApi.openFile(file);
Response<ResponseBody> execute = openFile.execute();
String streamId = execute.body().string();
System.*out*.println("Stream " + streamId + " open");
Call<ResponseBody> readStream = remoteFileApi.readStream(streamId);
byte[] content = readStream.execute().body().bytes();
System.*out*.println("Received " + content.length + " bytes");
remoteFileApi.closeStream(streamId).execute();
System.*out*.println("Stream " + streamId + " closed");
return content;
}
}
最后,我们实现了一个 JUnit 5 测试来验证我们的服务。请注意,我们正在创建模拟服务器(**new** WireMockServer
)并在测试的设置中使用 WireMock 提供的stubFor(...)
静态方法来存根 REST 服务调用(@BeforeEach
)。由于在这种情况下,SUT 非常简单且没有文档,我们直接在每个测试的设置中实例化RemoteFileService
类,使用模拟服务器 URL 作为构造函数参数。最后,我们测试我们的服务(使用模拟服务器)简单地执行名为wireMockServer
的对象,例如通过调用getFile
方法并评估其输出。
package io.github.bonigarcia;
import static com.github.tomakehurst.wiremock.client.WireMock.*aResponse*;
import static com.github.tomakehurst.wiremock.client.WireMock.*configureFor*;
import static com.github.tomakehurst.wiremock.client.WireMock.*post*;
import static com.github.tomakehurst.wiremock.client.WireMock.*stubFor*;
import static com.github.tomakehurst.wiremock.client.WireMock.*urlEqualTo*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.*options*;
import static org.junit.jupiter.api.Assertions.*assertEquals*;
import java.io.IOException;
import java.net.ServerSocket;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.github.tomakehurst.wiremock.WireMockServer;
public class RemoteFileTest {
RemoteFileService remoteFileService;
WireMockServer wireMockServer;
// Test data
String filename = "foo";
String streamId = "1";
String contentFile = "dummy";
@BeforeEach
void setup() throws Exception {
// Look for free port for SUT instantiation
int port;
try (ServerSocket socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}
remoteFileService = new RemoteFileService("http://localhost:" +
port);
// Mock server
wireMockServer = new WireMockServer(*options*().port(port));
wireMockServer.start();
*configureFor*("localhost", wireMockServer.port());
// Stubbing service
*stubFor*(*post*(*urlEqualTo*("/api/v1/paths/" + filename + "/open-
file"))
.willReturn(*aResponse*().withStatus(200).withBody(streamId)));
*stubFor*(*post*(*urlEqualTo*("/api/v1/streams/" + streamId +
"/read"))
.willReturn(*aResponse*().withStatus(200).withBody(contentFile)));
*stubFor*(*post*(*urlEqualTo*("/api/v1/streams/" + streamId + /close"))
.willReturn(*aResponse*().withStatus(200)));
}
@Test
void testGetFile() throws IOException {
byte[] fileContent = remoteFileService.getFile(filename);
*assertEquals*(contentFile.length(), fileContent.length);
}
@AfterEach
void teardown() {
wireMockServer.stop();
}
}
在控制台中执行测试时,我们可以看到内部 HTTP 服务器由 WireMock 控制在测试执行之前启动。然后,测试执行了三个 REST 操作(打开流、读取字节、关闭流),最后处理了模拟服务器:
使用 WireMock 执行模拟 REST 服务器的测试
总结
本节详细介绍了如何将 JUnit 5 与第三方框架、库和平台结合使用。由于 Jupiter 扩展模型,开发人员可以创建扩展,实现与外部框架对 JUnit 5 的无缝集成。首先,我们看到了MockitoExtension,这是 JUnit 5 团队提供的一个扩展,用于在 Jupiter 测试中使用 Mockito(Java 中臭名昭著的模拟框架)。然后,我们使用了SpringExtension,这是 Spring Framework 5 版本中提供的官方扩展。该扩展将 Spring 集成到 JUnit 5 编程模型中,使我们能够在测试中使用 Spring 的应用程序上下文(即 Spring 的 DI 容器)。
我们还审查了由selenium-jupiter实施的SeleniumExtension,这是一个为 Selenium WebDriver(用于 Web 应用程序的测试框架)提供 JUnit 5 扩展的开源项目。借助这个扩展,我们可以使用不同的浏览器自动与 Web 应用程序和模拟的移动设备(使用 Appium)进行交互。然后,我们看到了CucumberExtension,它允许使用 Gherkin 语言指定 JUnit 5 验收测试,遵循 BDD 风格。最后,我们看到了如何使用开源的 JUnit5-Docker 扩展在执行 JUnit 5 测试之前启动 Docker 容器(从 Docker Hub 下载镜像)。
此外,我们发现扩展模型并不是与 JUnit 测试交互的唯一方式。例如,为了在 Android 项目中运行 Jupiter 测试,我们可以使用android-junit5
插件。另一方面,即使没有专门用于使用 JUnit 5 评估 REST 服务的自定义扩展,与这些库的集成也是直截了当的:我们只需在项目中包含适当的依赖项,并在测试中使用它(例如,REST Assured、Spring 或 WireMock)。
第六章:从需求到测试用例
程序测试可以用来显示错误的存在,但永远不能用来显示错误的不存在!- Edsger Dijkstra
本章提供了一些知识基础,旨在帮助软件工程师编写有意义的测试用例。这个过程的起点是理解正在测试的系统的要求。没有这些信息,设计和实施有价值的测试是不可行的。在实际编写测试之前,可能会执行几个动作,即测试计划和测试设计。一旦我们开始测试编码过程,我们需要牢记一套编写正确代码的原则,以及一套要避免的反模式和坏味道。所有这些信息都以以下部分的形式在本章中提供:
-
要求的重要性:本节概述了软件开发过程,从提出需要由软件系统满足的一些需求开始,然后经过几个阶段,通常包括分析、设计、实施和测试。
-
测试计划:在软件项目开始时可以生成一个名为测试计划的文档。本节根据 IEEE 829 测试文档标准审查了测试计划的结构。正如我们将发现的那样,测试计划的完整陈述是一个非常细粒度的过程,特别适用于团队之间的沟通对项目成功至关重要的大型项目。
-
测试设计:在开始编写测试代码之前,考虑这些测试的蓝图总是一个好的做法。在本节中,我们回顾了设计我们的测试时需要考虑的主要方面。我们强调测试数据(预期结果),这些数据为测试断言提供支持。在这方面,我们回顾了一些黑盒数据生成技术(等价分区和边界分析)和白盒(测试覆盖)。
-
软件测试原则:本节提供了一组可以帮助我们编写测试的最佳实践。
-
测试反模式:最后,还审查了相反的一面:在编写我们的测试用例时要避免的模式和代码坏味道。
要求的重要性
软件系统是为满足一组消费者(最终用户或客户)的某种需求而构建的。理解这些需求是软件工程中最具挑战性的问题之一,因为消费者的需求通常是模糊的(特别是在项目的早期阶段)。此外,这些需求在项目的整个生命周期中也经常发生深刻的变化。弗雷德·布鲁克斯(Fred Brooks),一位著名的软件工程师和计算机科学家,在他的开创性著作《神话般的程序员月度(1975)》中定义了这个问题:
构建软件系统中最困难的部分是准确决定要构建什么。概念工作中没有其他部分像确立详细的技术要求那样困难……如果做错了,没有其他部分会像这样严重地瘫痪最终的系统。后来纠正这一点也是最困难的。
无论如何,消费者的需求都是任何软件项目的试金石。从这些需求中,可以得出一系列功能。我们将功能定义为软件系统功能的高级描述。从每个功能中,应该派生出一个或多个要求(功能性和非功能性)。要求是关于软件的一切,以满足消费者的期望。场景(真实生活的例子而不是抽象描述)对于为要求描述添加细节是有用的。软件系统的要求组和/或功能列表通常被称为规范。
在软件工程中,定义需求的阶段被称为需求引出。在这个阶段,软件工程师需要澄清他们试图解决的问题是什么。在这个阶段结束时,开始对系统进行建模是一种常见做法。为此,通常会使用建模语言(通常是 UML)来创建一组图表。UML 图表,通常适用于引出阶段的是用例图(系统功能的模型及其与涉及的参与者的关系)。
并不是所有的软件项目都会进行建模。例如,敏捷方法更多地基于素描原则,而不是正式的建模策略。
需求在分析阶段应该进行细化。在这个阶段,已经陈述的需求被分析,以解决不完整、模糊或矛盾的问题。因此,在这个阶段很可能会继续建模,例如使用高级类图,尚未与任何特定技术相关联。一旦分析清楚(也就是系统的“是什么”),我们需要找出“如何”来实现它。这个阶段被称为设计。在设计阶段,项目的指导方针应该被建立。为此,软件系统的架构通常是从需求中派生出来的。建模技术再次被广泛应用于设计的不同方面。在这一点上可以使用一系列 UML 图,包括结构图(组件、部署、对象、包和配置文件图)和行为图(活动、通信、序列或状态图)。从设计开始,实际的实现(即编码)可以开始了。
在设计阶段进行的建模量因不同因素而异,包括生产软件的公司类型和规模(跨国公司、中小企业、政府等)、开发过程(瀑布、螺旋、原型、敏捷等)、项目类型(企业、开源等)、软件类型(定制软件、商业现成软件等)甚至参与人员的背景(经验、职业等)。总的来说,设计需要被理解为软件工程师在项目中参与的不同角色之间的沟通方式。通常情况下,项目越大,基于不同建模图的细粒度设计就越必要。
关于测试,为了制定适当的测试计划(有关详细信息,请参见下一节),我们需要再次使用需求引出的数据,即需求和/或功能列表。换句话说,为了验证我们的系统,我们需要事先知道我们对它有什么期望。除了验证,进行一些验证也是可取的(根据 Boehm 的说法:我们是否在构建正确的产品?)。这是必要的,因为有时候规定的内容(功能和需求)与消费者的实际需求之间存在差距。因此,验证是一种高级别的评估方法,为了进行验证,最终消费者可以参与其中(在部署软件系统后验证软件系统)。所有这些想法都在下图中描述:
软件工程通用开发过程
迄今为止,所提出的术语(沟通、需求引出、分析、设计、实施/测试和部署)没有通用的工作流程。在前面的图表中,它遵循线性流程,然而,在实践中,它可以遵循迭代、演进或并行的工作流程。
为了说明软件工程不同阶段可能涉及的潜在问题(分析、设计、实施等),值得回顾一下经典卡通《项目真正的运作方式》。这张图片的原始来源不详(有追溯到 1960 年代的版本)。2007 年,一个名为《项目卡通》的网站出现了(www.projectcartoon.com/
),允许定制原始卡通的新场景。以下图表是该网站提供的卡通的 1.5 版本:
项目的真正运作方式,版本 1.5(由www.projectcartoon.com创建的插图)
如果我们思考这张图片,我们会发现问题的根源来自需求,客户在开始时解释得很糟糕,而项目负责人理解得更糟糕。从那时起,整个软件工程过程就变成了“传话游戏”。解决所有这些问题超出了本书的范围,但作为一个良好的开始,我们需要特别关注需求,它指导整个过程,当然也包括测试。
测试计划
测试路径的第一步可以是生成一个名为测试计划的文档,这是进行软件测试的蓝图。这份文件描述了测试工作的目标、范围、方法、重点和分配。准备这样的文件的过程是思考软件系统验证需求的一个有用方式。同样,当 SUT 的规模和涉及的团队很大时,这份文件尤其有用,因为不同角色的工作分离使得沟通成为项目成功的潜在障碍。
创建测试计划的一种方法是遵循 IEEE 829 测试文档标准。尽管这个标准对大多数软件项目来说可能太过正式,但值得审查这个标准提出的指南,并在我们的软件项目中使用需要的部分(如果有的话)。IEEE 829 提出的步骤如下:
-
分析产品:这部分强调了从消费者需求中提取系统需求的理解。正如已经解释的那样,如果没有关于软件的信息,就不可能测试软件。
-
设计测试策略:计划的这一部分包括几个部分,包括:
-
定义测试范围,即要测试的系统组件(在范围内)和不测试的部分(超出范围)。正如后面所解释的,全面的测试是不可行的,我们需要仔细选择要测试的内容。这不是一个简单的选择,它可以由不同的因素决定,例如精确的客户要求、项目预算和时间安排,以及涉及的软件工程师的技能。
-
确定测试类型,即应该进行哪些级别的测试(单元、集成、系统、验收)以及哪种测试策略(黑盒、白盒、非功能性)。
-
记录风险,即可能在项目中引起不同问题的潜在问题。
-
定义测试目标:在计划的这一部分中,列出了要测试的功能列表,以及测试每个功能的目标。
-
定义测试标准:这些标准通常由两部分组成,即:
-
暂停标准,例如在多少失败的测试中,新功能的开发将暂停,直到团队解决所有失败。
-
退出标准,例如应通过的关键测试的百分比,以便继续进行下一阶段的开发。
-
资源规划:计划的这一部分致力于总结进行测试活动所需的资源。这可能是人员、设备或基础设施。
-
计划测试环境:它由将要执行测试的软件和硬件设置组成。
-
日程安排和估算:在这个阶段,经理们应该将整个项目分解为小任务,估算工作量(人月)。
-
确定测试交付物:确定必须维护以支持测试活动的所有文档。
可以看出,测试计划是一个复杂的任务,通常由经理在大型项目中执行。在本章的其余部分,我们将继续探讨如何编写测试用例,但从最接近实际测试编码的角度来看。
测试设计
为了正确设计测试,我们需要具体定义需要实现的内容。为此,重要的是要记住测试的通用结构,已在第一章中解释过,关于软件质量和 Java 测试的回顾。因此,对于每个测试,我们需要定义:
-
测试装置是什么,也就是 SUT 中进行测试所需的状态?这是在测试的开始阶段称为设置。在测试结束时,测试装置可能在拆卸阶段被释放。
-
SUT 是什么,如果我们正在进行单元测试,它的 DOC(s)是什么?单元测试应该是独立的,因此我们需要为 DOC(s)定义测试替身(通常是模拟对象或间谍)。
-
断言是什么?这是测试的关键部分。没有断言,我们无法声称测试实际上已经完成。为了设计断言,值得回顾一下它的通用结构。简而言之,断言包括比较一些预期值(测试数据)和从 SUT 获得的实际结果。如果任何一个断言是负面的,测试将被宣布为失败(测试判决):
测试用例和断言的一般模式
测试数据在测试过程中起着至关重要的作用。测试数据的来源通常被称为测试预言,通常可以从需求中提取。然而,还有一些其他常用的测试预言来源,例如:
-
产生预期输出的不同程序(反向关系)。
-
提供近似结果的启发式或统计预言。
-
基于人类专家经验的价值观。
此外,测试数据可以根据底层测试技术进行推导。当使用黑盒测试时,也就是说,使用一些输入来执行特定的基于需求的测试,并期望得到一些输出时,可以采用不同的技术,例如等价分区或边界分析。另一方面,如果我们使用白盒测试,结构将是我们测试的基础,因此测试覆盖率将是选择最大化这些覆盖率的测试输入的关键。在接下来的章节中,将对这些技术进行审查。
等价分区
等价分区(也称为等价类分区)是一种黑盒技术(即,它依赖于系统的需求),旨在减少应该针对 SUT 执行的测试数量。这项技术最早由 Glenford Myers 于 1978 年定义为:
“将程序的输入域划分为有限数量的类[集合]的技术,然后确定一组精心选择的最小测试用例来代表这些类。”
换句话说,等价类划分提供了一个标准来回答问题我们需要多少测试?*。其思想是将所有可能的输入测试数据(通常是大量的组合)划分为一组我们假定 SUT 以相同方式处理的值。我们称这些值的集合为等价类。其思想是测试等价类中的一个代表值就足够了,因为假定所有值都是以相同方式被 SUT 处理的。
通常,对于给定的 SUT,等价类可以分为两种类型:有效和无效的输入。等价类划分测试理论确保只需要一个每个分区的测试用例来评估程序对相关分区的行为(有效和无效类)。以下过程描述了如何系统地进行给定 SUT 的等价类划分:
-
首先,我们确定 SUT 的所有可能有效输入的域。要找出这些值,我们依赖于规范(特性或功能需求)。我们假定 SUT 能够正确处理这些值(有效的等价类)。
-
如果我们的规范规定等价类的某些元素被不同方式处理,它们应该分配到另一个等价类。
-
这个域之外的值可以被视为另一个等价类,这次是无效输入。
-
对于每个等价类,选择一个代表值。这个决定是一个启发式过程,通常基于测试人员的经验。
-
对于每个测试输入,还选择适当的测试输出,有了这些值,我们就能完成我们的测试用例(测试练习和断言)。
边界分析
任何程序员都知道,错误经常出现在等价类的边界上(例如,数组的初始值,给定范围的最大值等)。边界值分析是一种方法,它通过查看测试输入的边界来补充等价类划分。它是由国家标准与技术研究所(NIST)在 1981 年定义的:
“一种选择技术,其中测试数据被选择为位于输入域[或输出范围]类、数据结构和过程参数的‘边界’上”。
总之,要在我们的测试中应用边界值分析,我们需要准确评估我们的 SUT 在等价类的边界上。因此,通常使用这种方法派生两个测试用例:等价类的上界和下界。
测试覆盖
测试覆盖是对 SUT 中为任何测试所执行的代码的比例。测试覆盖对于发现 SUT 中未经测试的部分非常有用。因此,它可以作为完美的白盒技术(结构性)来补充黑盒(功能性)。一般规定,80%或以上的测试覆盖率被认为是合理的。
有不同的 Java 库,可以简单地进行测试覆盖,例如:
-
Cobertura(
cobertura.github.io/cobertura/
):这是一个开源的报告工具,可以使用 Ant、Maven 或直接使用命令行执行。 -
EclEmma(
www.eclemma.org/
):这是一个用于 Eclipse 的开源代码覆盖工具。从 Eclipse 4.7(Oxygen)开始,EclEmma 已经集成在 IDE 中。以下截图显示了 EclEmma 在 Eclipse 中如何突出显示 Java 类的代码覆盖率:
Eclipse 4.7(Oxygen)中使用 EclEmma 进行测试覆盖
-
JaCoCo(
www.jacoco.org/jacoco/
):这是一个由 EclEmma 团队基于另一个名为 EMMA(emma.sourceforge.net/
)的旧覆盖库创建的开源代码覆盖库。JaCoCo 可以作为 Maven 依赖使用。 -
Codecov (
codecov.io/
):这是一个提供友好的代码覆盖率网络仪表板的云解决方案。对于开源项目来说是免费的。
软件测试原则
详尽测试是指一种测试方法,它使用所有可能的测试输入组合来验证软件系统。这种方法只适用于微小的软件系统或具有有限数量可能操作和允许数据的组件。在大多数软件系统中,验证每种可能的排列和输入组合是不可行的,因此详尽测试只是一种理论方法。
因此,有人说软件系统中的缺陷无法被证明。这是由计算机科学先驱 Edsger W. Dijkstra 所说的(见本章开头的引用)。因此,测试最多只是抽样,它必须在任何软件项目中进行,以减少系统故障的风险(参见第一章,关于软件质量和 Java 测试的回顾,回顾软件缺陷分类)。由于我们无法测试所有内容,我们需要进行适当的测试。在本节中,我们将回顾一系列编写有效和高效测试用例的最佳实践,即:
-
测试应该简单:编写测试的软件工程师(称之为测试人员、程序员、开发人员或其他)应该避免尝试测试自己的程序。在测试方面,对于问题“谁监视守夜人?”的正确答案应该是没有人。我们的测试逻辑应该足够简单,以避免任何形式的元测试,因为这将导致逻辑之外的递归问题。间接地,如果我们保持测试简单,我们还会获得另一个理想的特性:测试将更容易维护。
-
不要实现简单的测试:制作简单的测试是一回事,实现 getter 或 setter 等虚拟代码是另一回事。如前所述,测试最多只是抽样,我们不能浪费宝贵的时间评估我们代码库的这种部分。
-
易于阅读:第一步是为我们的测试方法提供一个有意义的名称。此外,由于 JUnit 5 的
@DisplayName
注解,我们可以提供丰富的文本描述,定义测试的目标,而不受 Java 命名约束的限制。 -
单一责任原则:这是计算机编程的一个通用原则,规定每个类应该负责单一功能。它与内聚性的度量密切相关。在编写测试时,实现这一原则非常重要:单个测试应该只涉及特定的系统需求。
-
测试数据是关键:如前所述,从 SUT 得到的预期结果是测试的核心部分。正确管理这些数据对于创建有效的测试至关重要。幸运的是,JUnit 5 提供了丰富的工具箱来处理测试数据(参见第四章,使用高级 JUnit 功能简化测试中的参数化测试一节)。
-
单元测试应该执行得非常快:对于单元测试持有的一个普遍接受的经验法则是,单元测试的持续时间最多应该是一秒。为了实现这一目标,还需要单元测试适当地隔离 SUT,正确地加倍其 DOCs。
-
测试必须可重复:缺陷应该被重现多次,以便开发人员找到错误的原因。这是理论,但不幸的是这并不总是适用。例如,在多线程 SUT(实时或服务器端软件系统)中,可能会发生竞争条件。在这些情况下,可能会出现非确定性的缺陷(通常称为heisenbugs)。
-
我们应该测试正面和负面的情况:这意味着我们需要编写测试,以评估预期结果的输入条件,但我们也需要验证程序不应该执行的操作。除了满足其要求,程序还必须经过测试,以避免不需要的副作用。
-
测试不能仅仅为了覆盖率而进行:仅仅因为代码的所有部分都被一些测试触及,我们不能保证这些部分已经得到了彻底的测试。要想成为真实,测试必须以降低风险的方式进行分析。
测试的心理学
从心理学角度来看,测试的目标应该是执行软件系统,以发现缺陷。理解这一主张的动机可以在我们测试的成功中产生巨大的差异。
人类往往是以目标为导向的。如果我们进行测试以证明程序没有错误,我们往往会选择测试数据,这些数据很少引起程序故障的可能性。另一方面,如果目标是证明程序存在错误,我们将增加发现错误的可能性,为程序增加更多的价值。因此,测试通常被认为是一个破坏性的过程,因为测试人员应该证明 SUT 存在错误。
此外,试图证明软件中存在错误是一个可行的目标,而试图证明它们的不存在,正如前面所解释的,是不可能的。再次,心理学研究告诉我们,当人们知道一个任务是不可行的时,他们的表现会很差。
测试反模式
在软件设计中,模式是解决重复问题的可重用解决方案。其中有很多,包括单例、工厂、构建器、外观、代理、装饰器或适配器等。反模式也是模式,但是不受欢迎的。关于测试,了解一些这些反模式是值得的,以避免它们在我们的测试中出现:
-
二等公民:测试代码包含大量重复的代码,使其难以维护。
-
免费搭车(也称为搭便车):不是编写一个新的方法来验证另一个特性/要求,而是在现有的测试中添加一个新的断言。
-
快乐路径:只验证预期结果,而不测试边界和异常。
-
当地英雄:一个依赖于特定本地环境的测试。这种反模式可以用短语“在我的机器上可以运行”来概括。
-
隐藏的依赖:在测试运行之前需要一些现有数据填充的测试。
-
链式测试:必须按特定顺序运行的测试,例如,将 SUT 更改为下一个预期状态。
-
嘲弄:一个单元测试包含太多的测试替身,以至于 SUT 根本没有被测试,而是从测试替身中返回数据。
-
无声接收器:即使发生意外异常,测试也能通过的测试。
-
检查员:一种违反封装的测试,对 SUT 的任何重构都需要在测试中反映这些变化。
-
过度设置:需要大量设置才能开始执行阶段的测试。
-
肛门探测器:一种测试,必须使用不健康的方式来执行其任务,比如使用反射读取私有字段。
-
没有名称的测试:测试方法的名称没有清晰地指示正在测试什么(例如,在错误跟踪工具中的标识符)。
-
慢吞吞:持续时间超过几秒的单元测试。
-
闪烁的测试:测试中包含竞争条件,使其不时失败。
-
等待观察:一个需要等待特定时间(例如
Thread.sleep()
)才能验证某些预期行为的测试。 -
不恰当的共享装置:测试使用测试装置,甚至不需要设置/拆卸。
-
巨人:一个包含大量测试方法的测试类(上帝对象)。
-
湿地:创建持久数据的测试,但在完成时没有清理。
-
布谷鸟:一个单元测试在实际测试之前建立某种固定装置,但随后测试以某种方式丢弃了这个固定装置。
-
秘密捕手:一个测试没有进行任何断言,依赖于抛出异常并由测试框架报告为失败。
-
环境破坏者:一个测试需要使用给定的环境变量(例如,一个允许同时执行的自由端口号)。
-
分身:将被测试的代码部分复制到一个新的类中,以便测试可见。
-
母鸡:一个不仅仅满足测试需求的固定装置。
-
测试一切:不应该违反单一职责原则的测试。
-
线击手:一个没有对 SUT 进行任何真正验证的测试。
-
连体双胞胎:被称为“单元测试”但实际上是集成测试,因为 SUT 和 DOC 之间没有隔离。
-
说谎者:一个测试并不测试原本应该测试的内容。
代码异味
代码异味(在软件中称为“坏味道”)是源代码中不希望出现的症状。代码异味本身并不是问题,但它们可能表明附近存在某种问题。
如前所述,测试应该简单易读。因此,代码异味在任何情况下都不应该存在于我们的测试中。总的来说,通用的代码异味在我们的测试中可能会被避免。一些最常见的代码异味包括以下内容:
-
重复的代码:克隆的代码在软件中总是一个坏主意,因为它违反了“不要重复自己”的原则(DRY)。在测试中,这个问题甚至更糟,因为测试逻辑必须非常清晰。
-
高复杂度:太多的分支或循环可能被潜在地简化为更小的部分。
-
长方法:一个变得过于庞大的方法总是有问题的,当这个方法是一个测试时,这是一个非常糟糕的症状。
-
不合适的命名约定:变量、类和方法的名称应该简洁。使用非常长的标识符被认为是一种坏味道,但过度使用短(或无意义)的标识符也是如此。
总结
测试设计的起点应该是需求列表。如果这些需求尚未被正式引出,至少我们需要了解 SUT 功能,这反映了软件的需求。从这一点出发,可以采取几种策略。通常情况下,达到我们的目标没有唯一的路径,最终目标应该是降低项目的风险。
本章回顾了一个旨在创建有效和高效测试用例的过程。这个过程涉及需求分析、测试计划的定义、测试用例的设计,最后编写测试用例。我们应该意识到,尽管软件测试是技术任务,但它涉及一些重要的人类心理因素。软件工程师和测试人员应该了解这些因素,以便遵循最佳实践,并避免常见的错误。
在第七章“测试管理”中,我们将了解在一个活跃的软件项目中如何管理软件测试活动。首先,我们将回顾在常见的软件开发过程中(如瀑布、螺旋、迭代、敏捷或测试驱动开发)何时以及如何进行测试。然后,我们将审查旨在在 JUnit 5 的上下文中自动化软件开发过程的服务器端基础设施(如 Jenkins 或 Travis)。最后,我们将学习如何使用所谓的问题跟踪系统和测试报告库跟踪 Jupiter 测试发现的缺陷。
第七章:测试管理
重要的是不停地质疑。
- 阿尔伯特·爱因斯坦
这是本书的最后一章,其目标是指导如何理解软件测试活动在一个活跃的软件项目中是如何管理的。为了达到这个目的,本章分为以下几个部分:
-
软件开发过程:在本节中,我们研究了在不同方法论中何时执行测试:行为驱动开发(BDD)、测试驱动开发(TDD)、先测试开发(TFD)和最后测试开发(TLD)。
-
持续集成(CI):在本节中,我们将了解持续集成,这是软件开发实践,其中构建、测试和集成的过程是持续进行的。这个过程的常见触发器通常是向源代码库(例如 GitHub)提交新更改(补丁)。此外,在本节中,我们将学习如何扩展持续集成,回顾持续交付和持续部署的概念。最后,我们介绍了目前两个最重要的构建服务器:Jenkins 和 Travis CI。
-
测试报告:在本节中,我们将首先了解 xUnit 框架通常报告测试执行的 XML 格式。这种格式的问题在于它不易阅读。因此,有一些工具可以将这个 XML 转换成更友好的格式,通常是 HTML。我们回顾了两种替代方案:Maven Surefire Report 和 Allure。
-
缺陷跟踪系统:在本节中,我们回顾了几个问题跟踪系统:JIRA、Bugzilla、Redmine、MantisBT 和 GitHub 问题。
-
静态分析:在本节中,一方面我们回顾了几种自动化分析工具(linters),如 Checkstyle、FindBugs、PMD 和 SonarQube。另一方面,我们描述了几种同行评审工具,如 Collaborator、Crucible、Gerrit 和 GitHub 拉取请求审查。
-
将所有部分整合在一起:为了结束本书,在最后一节中,我们展示了一个完整的示例应用程序,在这个应用程序中,使用了本书中介绍的一些主要概念进行了不同类型的测试(单元测试、集成测试和端到端测试)。
软件开发过程
在软件工程中,软件开发过程(也称为软件开发生命周期)是指用于创建软件系统所需的活动、行为和任务的工作流程。正如在第六章中介绍的,从需求到测试用例,任何软件开发过程中通常的阶段包括:
-
what的定义:需求获取、分析和用例建模。
-
how的定义:结构和行为图的系统架构和建模。
-
实际的软件开发(编码)。
-
使软件可供使用的一系列活动(发布、安装、激活等)。
在整个软件开发过程中设计和实施测试的时间安排导致了不同的测试方法论,即(见列表后的图表):
- 行为驱动开发(BDD):在分析阶段开始时,软件消费者(最终用户或客户)与开发团队的一些成员(通常是项目负责人、经理或分析师)进行了对话。这些对话用于具体化场景(即,具体示例以建立对系统功能的共同理解)。这些示例构成了使用工具(如 Cucumber)开发验收测试的基础(有关更多详细信息,请参阅第五章,JUnit 5 与外部框架的集成)。在 BDD 中描述验收测试(例如,在 Cucumber 中使用 Gherkin)产生了准确描述应用程序功能的自动化测试和文档。BDD 方法自然地与迭代或敏捷方法论对齐,因为很难事先定义需求,并且随着团队对项目的了解而不断发展。
术语敏捷是在 2001 年敏捷宣言的诞生时被推广的(agilemanifesto.org/
)。它是由 17 位软件从业者(Kent Beck、James Grenning、Robert C. Martin、Mike Beedle、Jim Highsmith、Steve Mellor、Arie van Bennekum、Andrew Hunt、Ken Schwaber、Alistair Cockburn、Ron Jeffries、Jeff Sutherland、Ward Cunningham、Jon Kern、Dave Thomas、Martin Fowler 和 Brian Marick)撰写的,并包括一系列 12 条原则,以指导迭代和以人为中心的软件开发过程。基于这些原则,出现了几种软件开发框架,如 SCRUM、看板或极限编程(XP)。
-
测试驱动开发(TDD):TDD 是一种方法,其中测试在实际软件设计之前被设计和实施。其思想是将分析阶段获得的需求转化为具体的测试用例。然后,软件被设计和实施以通过这些测试。TDD 是 XP 方法的一部分。
-
测试优先开发(TFD):在这种方法中,测试是在设计阶段之后但实际实施 SUT 之前实施的。这样可以确保在实际实施之前正确理解了软件单元。这种方法在统一过程中得到遵循,这是一种流行的迭代和增量软件开发过程。统一过程(RUP)是统一过程的一个知名框架实现。除了 TFD,RUP 还支持其他方法,如 TDD 和 TLD。
-
测试后开发(TLD):在这种方法论中,测试的实施是在实际软件(SUT)的实施之后进行的。这种测试方法遵循经典的软件开发流程,如瀑布(顺序)、增量(多瀑布)或螺旋(风险导向的多瀑布)。
软件开发过程中的测试方法
到目前为止,这些术语没有普遍接受的定义。这些概念不断发展和辩论,就像软件工程本身一样。请将其视为一个提议,适用于大量的软件项目。
关于谁负责编写测试,有一个普遍接受的共识。广泛建议 SUT 开发人员应编写单元测试。在某些情况下,特别是在小团队中,这些开发人员还负责其他类型的测试。
此外,独立测试组的角色(通常称为测试人员或 QA 团队)也是一种常见的做法,特别是在大型团队中。这种角色分离的目标之一是消除可能存在的利益冲突。我们不能忘记,从生理学角度来看,测试被理解为一种破坏性的活动(目标是发现缺陷)。这个独立的测试组通常负责集成、系统和非功能测试。在这种情况下,两组工程师应该密切合作;在进行测试时,开发人员应该随时准备纠正错误并尽量减少未来的错误。
最后,通常会在异构组中进行高级别的验收测试,包括非程序员(客户、业务分析、管理人员等)与软件工程师或测试人员(例如,在 Cucumber 中实现步骤定义)。
持续集成
CI 的概念最早是由 Grady Booch(美国软件工程师,与 Ivar Jacobson 和 James Rumbaugh 一起开发 UML 而闻名)于 1991 年首次提出的。极限编程(XP)方法采用了这个术语,使其非常流行。根据 Martin Fowler 的说法,CI 的定义如下:
持续集成是一个软件开发实践,团队成员经常集成他们的工作,通常每个人至少每天集成一次 - 导致每天多次集成。每次集成都由自动构建(包括测试)进行验证,以尽快检测到集成错误。
在 CI 系统中,我们可以识别出不同的部分。首先,我们需要一个源代码存储库,这是一个文件存档,用于托管软件项目的源代码,通常使用版本控制系统。如今,首选的版本控制系统是 Git(最初由 Linus Torvalds 开发),而不是较早的解决方案,如 CVS 或 SVN。在撰写本文时,领先的版本控制存储库是 GitHub(github.com/
),正如其名称所示,它是基于 Git 的。此外,还有其他选择,如 GitLab(gitlab.com
)、BitBucket(bitbucket.org/
)或 SourceForge(sourceforge.net/
)。后者曾经是领先的开发平台,但现在使用较少。
源代码存储库的副本与开发人员的本地环境同步。编码工作是针对这个本地副本进行的。开发人员应该每天提交新的更改(称为补丁)到远程存储库。频繁的提交可以避免由于对同一文件的相互修改而导致的冲突错误。
CI 的基本理念是每次提交都应该执行构建并测试带有新更改的软件。因此,我们需要一个自动化这个过程的服务器端基础设施。这个基础设施被称为构建服务器(或直接 CI 服务器)。目前最重要的两个构建服务器是 Jenkins 和 Travis CI。它们的详细信息将在下一小节中提供。作为构建过程的结果,构建服务器应该通知原始开发人员的处理结果。如果测试成功,补丁将合并到代码库中:
持续集成过程
靠近 CI,术语 DevOps 已经蓬勃发展。DevOps 来自开发和运维,它是一个强调项目软件中不同团队之间沟通和协作的软件开发过程的名称:开发(软件工程)、QA(质量保证)和运维(基础设施)。DevOps 这个术语也指的是一个工作职位,通常负责构建服务器的设置、监控和运行:
DevOps 处于开发、运维和 QA 之间
如下图所示,CI 的概念可以扩展到:
-
持续交付:当 CI 管道正确完成时,至少一个软件发布将部署到测试环境(例如,将 SNAPSHOT 工件部署到 Maven 存档器)。在此阶段,还可以执行验收测试。
-
持续部署:作为自动化工具链的最后一步,软件的发布可以发布到生产环境(例如,将 Web 应用程序部署到每个提交的生产服务器,以通过完整的管道)。
持续集成、持续交付和持续部署链
Jenkins
Jenkins (jenkins.io/
)是一个开源的构建服务器,支持构建、部署和自动化任何项目。Jenkins 是用 Java 开发的,可以通过其 Web 界面轻松管理。Jenkins 实例的全局配置包括关于 JDK、Git、Maven、Gradle、Ant 和 Docker 的信息。
Jenkins 最初是由 Sun Microsystems 于 2004 年开发的 Hudson 项目。在 Sun 被 Oracle 收购后,Hudson 项目被分叉为一个开源项目,并更名为 Jenkins。Hudson 和 Jenkins 这两个名字都是为了听起来像典型的英国男仆名字。其想法是它们帮助开发人员执行乏味的任务,就像一个乐于助人的男仆一样。
在 Jenkins 中,构建通常由版本控制系统中的新提交触发。此外,构建可以由其他机制启动,例如定期的 cron 任务,甚至可以通过 Jenkins 界面手动启动。
Jenkins 由于其插件架构而具有很高的可扩展性。由于这些插件,Jenkins 已经扩展到由大量第三方框架、库、系统等组成的丰富插件生态系统。这是由开源社区维护的。Jenkins 插件组合可在plugins.jenkins.io/
上找到。
在 Jenkins 的核心,我们找到了作业的概念。作业是由 Jenkins 监控的可运行实体。如此屏幕截图所示,Jenkins 作业由四个组成:
-
源代码管理:这是源代码存储库(Git、SVN 等)的 URL
-
构建触发器:这是启动构建过程的机制,例如源代码存储库中的新更改、外部脚本、定期等。
-
构建环境:可选设置,例如在构建开始前删除工作空间,卡住时中止构建等。
-
作业步骤的集合:这些步骤可以使用 Maven、Gradle、Ant 或 shell 命令完成。之后,可以配置后构建操作,例如存档工件、发布 JUnit 测试报告(我们将在本章后面描述此功能)、电子邮件通知等。
Jenkins 作业配置
配置作业的另一种有趣方式是使用 Jenkins pipeline,它是使用 Pipeline DSL(基于 Groovy 的特定领域语言)描述构建工作流程。Jenkins 管道描述通常存储在一个名为 Jenkinsfile 的文件中,该文件可以受源代码存储库的控制。简而言之,Jenkins 管道是由步骤组成的阶段的声明性链。例如:
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'make'
}
}
stage('Test') {
steps {
sh 'make check'
junit 'reports/**/*.xml'
}
}
stage('Deploy') {
steps {
sh 'make publish'
}
}
}
}
Travis CI
Travis CI (travis-ci.org/
)是一个分布式构建服务器,用于构建和测试托管在 GitHub 上的软件项目。Travis 支持无需收费的开源项目。
Travis CI 的配置是使用名为.travis.yaml的文件完成的。该文件的内容使用不同的关键字进行结构化,包括:
-
language
:项目语言,即 java、node_js、ruby、python 或 php 等(完整列表可在docs.travis-ci.com/user/languages/
上找到)。 -
sudo
:如果需要超级用户权限(例如安装 Ubuntu 软件包)的标志值。 -
dist
:可以在 Linux 环境(Ubuntu Precise 12.04 或 Ubuntu Trusty 14.04)上执行构建。 -
addons
:apt-get 命令的基本操作的声明性快捷方式。 -
install
:Travis 构建生命周期的第一部分,其中完成所需依赖项的安装。可以选择使用before_install
来启动此部分。 -
script
:构建的实际执行。此阶段可以选择由before_script
和after_script
包围。 -
deploy
:最后,可以选择在此阶段进行构建的部署。此阶段有其自己的生命周期,由before_deploy
和after_deploy
控制。
YAML 是一种轻量级标记语言,由于其简约的语法,广泛用于配置文件。最初它被定义为 Yet Another Markup Language,但后来被重新定义为 YAML Ain't Markup Language,以区分其作为数据导向的目的。
.travis.yaml:
language: java
sudo: false
dist: trusty
addons:
firefox: latest
apt:
packages:
- google-chrome-stable
sonarcloud:
organization: "bonigarcia-github"
token:
secure: "encripted-token"
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start &
- sleep 3
script:
- mvn test sonar:sonar
- bash <(curl -s https://codecov.io/bash)
Travis CI 提供了一个 Web 仪表板,我们可以在其中检查使用 Travis CI 生成的当前和过去构建的状态,这些构建是在我们的 GitHub 帐户中使用 Travis CI 的项目中生成的:
Travis CI 仪表板
测试报告
从其最初版本开始,JUnit 测试框架引入了一种 XML 文件格式来报告测试套件的执行情况。多年来,这种 XML 格式已成为报告测试结果的事实标准,在 xUnit 家族中广泛采用。
这些 XML 可以由不同的程序处理,以以人类友好的格式显示结果。这就是构建服务器所做的事情。例如,Jenkins 实现了一个名为JUnitResultArchiver
的工具,它解析作业测试执行产生的 XML 文件为 HTML。
尽管这种 XML 格式已经变得普遍,但并没有普遍的正式定义。JUnit 测试执行器(例如 Maven,Gradle 等)通常使用自己的 XSD(XML 模式定义)。例如,在 Maven 中,这种 XML 报告的结构如下图所示。请注意,测试套件由一组属性和一组测试用例组成。每个测试用例可以声明为失败(具有某些断言失败的测试),跳过(忽略的测试)和错误(具有意外异常的测试)。如果测试套件的主体中没有出现这些状态中的任何一个,那么测试将被解释为成功。最后,对于每个测试用例,XML 还存储标准输出(system-out)和标准错误输出(system-err):
Maven Surefire XML 报告的模式表示
rerunFailure
是 Maven Surefire 为重试不稳定(间歇性)测试而实现的自定义状态(maven.apache.org/surefire/maven-surefire-plugin/examples/rerun-failing-tests.html
)。
关于 JUnit 5,用于运行 Jupiter 测试的 Maven 和 Gradle 插件(分别为maven-surefire-plugin
和junit-platform-gradle-plugin
)遵循此 XML 格式编写测试执行结果。在接下来的部分中,我们将看到如何将此 XML 输出转换为人类可读的 HTML 报告。
Maven Surefire 报告
默认情况下,maven-surefire-plugin
生成来自测试套件执行的 XML 结果为${basedir}/target/surefire-reports/TEST-*.xml
。可以使用插件maven-surefire-report-plugin
轻松将此 XML 输出解析为 HTML。为此,我们只需要在pom.xml
的报告子句中声明此插件,如下所示:
<reporting>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>${maven-surefire-report-plugin.version}</version>
</plugin>
</plugins>
</reporting>
这样,当我们调用 Maven 生命周期以进行文档(mvn site
)时,测试结果的 HTML 页面将包含在总体报告中。
查看报告的示例,使用 GitHub 存储库示例中的项目junit5-reporting
(github.com/bonigarcia/mastering-junit5
):
由 maven-surefire-report-plugin 生成的 HTML 报告
Allure
Allure(allure.qatools.ru/
)是一个轻量级的开源框架,用于为不同的编程语言生成测试报告,包括 Java,Python,JavaScript,Ruby,Groovy,PHP,.NET 和 Scala。总的来说,Allure 使用 XML 测试输出并将其转换为 HTML5 丰富报告。
Allure 支持 JUnit 5 项目。这可以使用 Maven 和 Gradle 来完成。关于 Maven,我们需要在maven-surefire-plugin
中注册一个监听器。这个监听器将是类 AllureJunit5(位于库io.qameta.allure:allure-junit5
中),它基本上是 JUnit 5 的TestExecutionListener
的实现。正如在第二章中所描述的,JUnit 5 的新功能,TestExecutionListener
是 Launcher API 的一部分,用于接收有关测试执行的事件。总的来说,这个监听器允许 Allure 在生成 JUnit 平台时编译测试信息。这些信息由 Allure 存储为 JSON 文件。之后,我们可以使用插件io.qameta.allure:allure-maven
从这些 JSON 文件生成 HTML5。命令是:
mvn test
mvn allure:serve
我们的pom.xml
的内容应包含以下内容:
<dependencies>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>${allure-junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<properties>
<property>
<name>listener</name>
<value>io.qameta.allure.junit5.AllureJunit5</value>
</property>
</properties>
<systemProperties>
<property>
<name>allure.results.directory</name>
<value>${project.build.directory}/allure-results</value>
</property>
</systemProperties>
</configuration>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>${junit.platform.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>${allure-maven.version}</version>
</plugin>
</plugins>
</build>
使用 Gradle 也可以完成相同的过程,这次使用等效的插件io.qameta.allure:allure-gradle
。总的来说,我们的build.gradle
文件的内容应包含:
buildscript {
repositories {
jcenter()
mavenCentral()
}
dependencies {
classpath("org.junit.platform:junit-platform-gradle-plugin:${junitPlatformVersion}")
classpath("io.qameta.allure:allure-gradle:${allureGradleVersion}")
}
}
apply plugin: 'io.qameta.allure'
dependencies {
testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
testCompile("io.qameta.allure:allure-junit5:${allureJUnit5Version}")
testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
}
以下图片显示了使用上述步骤生成的 Allure 报告的几个屏幕截图(使用 Maven 或 Gradle 生成的最终结果相同)。该示例项目称为junit5-allure
,通常托管在 GitHub 上。
在 JUnit 5 项目中生成的 Allure 报告
缺陷跟踪系统
缺陷跟踪系统(也称为 bug 跟踪系统,bug 跟踪器或问题跟踪器)是一个软件系统,用于跟踪软件项目中报告的软件缺陷。这种系统的主要好处是提供开发管理,bug 报告甚至功能请求的集中概览。通常还会维护一个待处理项目列表,通常称为积压。
有许多可用的缺陷跟踪系统,既有专有的也有开源的。在本节中,我们简要介绍了几个最知名的系统:
-
JIRA(
www.atlassian.com/software/jira
):这是由 Atlasian 创建的专有缺陷跟踪系统。除了错误和问题跟踪外,它还提供了管理功能,如 SCRUM 和 Kanban 板,用于查询问题的语言(JIRA 查询语言),与外部系统的集成(例如 GitHub,Bitbucket),以及通过 Atlasian Marketplace(marketplace.atlassian.com/
)的插件机制来扩展 JIRA 的插件。 -
Bugzilla(
www.bugzilla.org/
):这是由 Mozilla 基金会开发的开源基于 Web 的缺陷跟踪系统。在其功能中,我们可以找到用于改善性能和可伸缩性的数据库,用于搜索缺陷的查询机制,集成电子邮件功能以及用户角色管理。 -
Redmine(
www.redmine.org/
):这是一个开源的基于 Web 的缺陷跟踪系统。它提供了维基,论坛,时间跟踪,基于角色的访问控制,或者用于项目管理的甘特图。 -
MantisBT(
www.mantisbt.org/
):它是另一个开源的、基于 Web 的缺陷跟踪系统,旨在简单而有效。其中的特点包括事件驱动的插件系统,允许官方和第三方扩展,多通道通知系统(电子邮件、RSS 订阅、Twitter 插件等),或基于角色的访问控制。 -
GitHub issues(
guides.github.com/features/issues/
):它是集成在每个 GitHub 存储库中的跟踪系统。GitHub issues 的方法是提供一个通用的缺陷跟踪系统,用于任务调度、讨论,甚至使用 GitHub issues 进行功能请求。每个问题都可以使用可自定义的标签系统进行分类,参与者管理和通知。
静态分析
这本即将完成的书主要关注软件测试。毫不奇怪,JUnit 就是关于测试的。但正如我们在第一章中所看到的,关于软件质量和 Java 测试的回顾,尽管软件测试是验证和验证(V&V)中最常见的活动,但并不是唯一的类型。另一个重要的活动组是静态分析,在这种活动中没有执行软件测试。
可以将不同的活动归类为静态分析。其中,自动化软件分析是一种相当廉价的替代方案,可以帮助显著提高内部代码质量。在本章中,我们将回顾几种自动化软件分析工具,即linters:
-
Checkstyle(
checkstyle.sourceforge.net/
):它分析 Java 代码遵循不同的规则,如缺少 Javadoc 注释,使用魔术数字,变量和方法的命名约定,方法的参数长度和行长度,导入的使用,一些字符之间的空格,类构造的良好实践,或重复的代码。它可以作为 Eclipse 或 IntelliJ 插件等使用。 -
FindBugs(
findbugs.sourceforge.net/
):它在 Java 代码中查找三种类型的错误: -
正确性错误:明显的编码错误(例如,类定义了
equal(Object)
而不是equals(Object)
)。 -
不良实践:违反推荐最佳实践(丢弃异常、滥用 finalize 等)。
-
可疑错误:混乱的代码或以导致错误的方式编写(例如,类
literal
从未被使用,switch 穿透,未经确认的类型转换和多余的空指针检查)。 -
PMD(
pmd.github.io/
):它是一个跨语言的静态代码分析器,包括 Java、JavaScript、C++、C#、Go、Groovy、Perl、PHP 等。它有许多插件,包括 Maven、Gradle、Eclipse、IntelliJ 和 Jenkins。 -
SonarQube(
www.sonarqube.org/
):它(以前只是 Sonar)是一个基于 Web 的、开源的持续质量评估仪表板。它支持多种语言,包括 Java、C/C++、Objective-C、C#等。提供重复代码、代码异味、代码覆盖率、复杂性和安全漏洞的报告。SonarQube 有一个名为SonarCloud(sonarcloud.io/
)的分布式版本。它可以在开源项目中免费使用,通过在.travis.yml
中进行几行配置,包括 SonarCloud 组织标识符和安全令牌,与 Travis CI 实现无缝集成。这些参数可以在将 SonarCloud 帐户与 GitHub 关联后,在 SonarCloud Web 管理面板中获取。
addons:
sonarcloud:
organization: "bonigarcia-github"
token:
secure: "encrypted-token"
之后,我们只需要使用 Maven 或 Gradle 调用 SonarCloud:
script:
- mvn test sonar:sonar
script:
- gradle test sonarQube
下图显示了 SonarCloud 仪表板,用于上一章节中描述的示例应用程序“Rate my cat!”:
SonarCloud 报告应用程序“Rate my cat!”
在许多软件项目中广泛采用的另一种分析静态技术是同行审查。这种方法在时间和精力方面相当昂贵,但正确应用时,可以保持非常高水平的内部代码质量。如今有许多旨在简化软件代码库的同行审查过程的工具。其中,我们找到了以下工具:
-
Collaborator(
smartbear.com/product/collaborator/
):SmartBear 公司创建的同行代码(和文档)审查专有工具。 -
Crucible(
www.atlassian.com/software/crucible
):Atlassian 创建的企业产品的本地代码审查专有工具。 -
Gerrit(
www.gerritcodereview.com/
):基于 Web 的开源代码协作工具。可以通过 GerritHub(gerrithub.io/
)与 GitHub 存储库一起使用。 -
GitHub 拉取请求审查(
help.github.com/articles/about-pull-request-reviews/
):在 GitHub 中,拉取请求是向第三方存储库提交贡献的一种方法。作为 GitHub 提供的协作工具的一部分,拉取请求允许以简单和集成的方式进行审查和评论。
将所有部分整合在一起
在本书的最后一节中,我们将通过一个实际示例回顾本书涵盖的一些主要方面。为此,我们将开发一个完整的应用程序,并使用 JUnit 5 实现不同类型的测试。
功能和需求
我们应用程序的历史始于一个热爱猫的假设人物。这个人拥有一群猫,他/她希望从外部世界得到关于它们的反馈。因此,这个人(我们从现在开始称之为客户)与我们联系,要求我们实现一个满足他/她需求的 Web 应用程序。该应用程序的名称将是“Rate my cat!”。在与客户的对话中,我们得出了应用程序开发的以下功能列表:
-
F1:每个用户应通过观看其名称和图片对猫的列表进行评分。
-
F2:每个用户应使用星级机制(从
0.5
到5
星)对每只猫进行一次评分,还可以选择包括每只猫的评论。
作为我们开发过程中分析阶段的一部分,这些功能被细化为以下功能需求(FR)列表:
-
FR1:应用程序向最终用户呈现猫的列表(由名称和图片组成)。
-
FR2:每只猫都可以单独评分。
-
FR3:对猫进行评分的范围是从
0.5
到5
(星)的区间。 -
FR4:除了每只猫的数字评分外,用户还应包括一些评论。
-
FR5:每个最终用户只能对每只猫(评论和/或星级)评分一次。
设计
由于我们的应用程序相当简单,我们决定在这里停止分析阶段,而不将我们的需求建模为用例。相反,我们继续使用经典的三层模型对 Web 应用程序进行高层架构设计:表示层、应用(或业务)逻辑和数据层。关于应用逻辑,如下图所示,需要两个组件。第一个称为CatService
负责所有在需求列表中描述的评分操作。第二个称为CookiesServices
用于处理 HTTP Cookies,需要实现 FR5*:
“Rate my cat!”应用程序的高层架构设计
在这个阶段,我们能够决定实现我们的应用程序所涉及的主要技术:
-
Spring 5:这将是我们应用程序的基础框架。具体来说,我们使用 Spring Boot 通过 Spring MVC 简化我们的 Web 应用程序的创建。此外,我们使用 Spring Data JPA 使用简单的 H2 数据库来持久化应用程序数据,并使用 Thymeleaf (
www.thymeleaf.org/
)作为模板引擎(用于 MVC 中的视图)。最后,我们还使用 Spring Test 模块以简单的方式进行容器内集成测试。 -
JUnit 5:当然,我们不能使用与 JUnit 5 不同的测试框架来进行我们的测试用例。此外,为了提高我们断言的可读性,我们使用 Hamcrest。
-
Mockito:为了实现单元测试用例,我们将使用 Mockito 框架,在几个容器外的单元测试中将 SUT 与其 DOCs 隔离开来。
-
Selenium WebDriver:我们还将使用 Selenium WebDriver 实现不同的端到端测试,以便从 JUnit 5 测试中执行我们的 Web 应用程序。
-
GitHub:我们的源代码存储库将托管在公共 GitHub 存储库中。
-
Travis CI:我们的测试套件将在每次提交新补丁到我们的 GitHub 存储库时执行。
-
Codecov:为了跟踪我们测试套件的代码覆盖率,我们将使用 Codecov。
-
SonarCloud:为了提供对我们源代码内部质量的完整评估,我们通过 SonarCloud 补充我们的测试过程进行一些自动静态分析。
这里的屏幕截图显示了应用程序 GUI 的操作。本节的主要目标不是深入挖掘应用程序的实现细节。有关详细信息,请访问github.com/bonigarcia/rate-my-cat
上的应用程序的 GitHub 存储库。
应用程序 Rate my cat 的屏幕截图!
用于实现此示例的图片已从pixabay.com/
上的免费图库中下载。
测试
现在让我们专注于这个应用程序的 JUnit 5 测试。我们实现了三种类型的测试:单元测试、集成测试和端到端测试。如前所述,对于单元测试,我们使用 Mockito 来隔离 SUT。我们决定使用包含不同 JUnit 5 测试的 Java 类来对我们应用程序的两个主要组件(CatService
和CookiesServices
)进行单元测试。
考虑第一个测试(称为RateCatsTest
)。如代码所示,在这个类中,我们将类CatService
定义为 SUT(使用注解@InjectMocks
),将类CatRepository
(由CatService
使用依赖注入)定义为 DOC(使用注解@Mock
)。这个类的第一个测试(testCorrectRangeOfStars
)是一个参数化的 JUnit 5 测试的示例。这个测试的目标是评估CatService
内的 rate 方法(方法rateCate
)。为了选择这个测试的测试数据(输入),我们遵循黑盒策略,因此我们使用需求定义的信息。具体来说,FR3规定了用于评价猫的评分机制的星级范围。遵循边界分析方法,我们选择输入范围的边缘,即 0.5 和 5。第二个测试用例(testCorrectRangeOfStars
)也测试相同的方法(rateCat
),但这次测试评估了 SUT 在超出范围的输入时的响应(负面测试场景)。然后,在这个类中实现了另外两个测试,这次旨在评估FR4(即,还使用评论来评价猫)。请注意,我们使用 JUnit 5 的@Tag
注解来标识每个测试及其相应的需求:
package io.github.bonigarcia.test.unit;
import static org.hamcrest.CoreMatchers.*equalTo*;
import static org.hamcrest.MatcherAssert.*assertThat*;
import static org.hamcrest.text.IsEmptyString.*isEmptyString*;
import static org.junit.jupiter.api.Assertions.*assertThrows*;
import static org.mockito.ArgumentMatchers.*any*;
import static org.mockito.Mockito.*when*;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.Cat;
import io.github.bonigarcia.CatException;
import io.github.bonigarcia.CatRepository;
import io.github.bonigarcia.CatService;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
@DisplayName("Unit tests (black-box): rating cats")
@Tag("unit")
class RateCatsTest {
@InjectMocks
CatService catService;
@Mock
CatRepository catRepository;
// Test data
Cat dummy = new Cat("dummy", "dummy.png");
int stars = 5;
String comment = "foo";
@ParameterizedTest(name = "Rating cat with {0} stars")
@ValueSource(doubles = { 0.5, 5 })
@DisplayName("Correct range of stars test")
@Tag("functional-requirement-3")
void testCorrectRangeOfStars(double stars) {
*when*(catRepository.save(dummy)).thenReturn(dummy);
Cat dummyCat = catService.rateCat(stars, dummy);
*assertThat*(dummyCat.getAverageRate(), *equalTo*(stars));
}
@ParameterizedTest(name = "Rating cat with {0} stars")
@ValueSource(ints = { 0, 6 })
@DisplayName("Incorrect range of stars test")
@Tag("functional-requirement-3")
void testIncorrectRangeOfStars(int stars) {
*assertThrows*(CatException.class, () -> {
catService.rateCat(stars, dummy);
});
}
@Test
@DisplayName("Rating cats with a comment")
@Tag("functional-requirement-4")
void testRatingWithComments() {
*when*(catRepository.findById(*any*(Long.class)))
.thenReturn(Optional.*of*(dummy));
Cat dummyCat = catService.rateCat(stars, comment, 0);
*assertThat*(catService.getOpinions(dummyCat).iterator().next()
.getComment(), *equalTo*(comment));
}
@Test
@DisplayName("Rating cats with empty comment")
@Tag("functional-requirement-4")
void testRatingWithEmptyComments() {
*when*(catRepository.findById(*any*(Long.class)))
.thenReturn(Optional.*of*(dummy));
Cat dummyCat = catService.rateCat(stars, dummy);
*assertThat*(catService.getOpinions(dummyCat).iterator().next()
.getComment(), *isEmptyString*());
}
}
接下来,单元测试评估了 cookies 服务(FR5)。为此,以下测试使用CookiesService
类作为 SUT,这次我们将模拟标准的 Java 对象,即操作 HTTP Cookies 的javax.servlet.http.HttpServletResponse
。检查此测试类的源代码,我们可以看到第一个测试方法(称为testUpdateCookies
)练习了服务方法updateCookies
,验证了 cookies 的格式是否符合预期。接下来的两个测试(testCheckCatInCookies
和testCheckCatInEmptyCookies
)评估了服务的isCatInCookies
方法,使用了积极的策略(即输入猫与 cookie 的格式相对应)和消极的策略(相反的情况)。最后,最后两个测试(testUpdateOpinionsWithCookies
和testUpdateOpinionsWithEmptyCookies
)练习了 SUT 的updateOpinionsWithCookiesValue
方法,遵循相同的方法,即使用有效和空 cookie 检查 SUT 的响应。所有这些测试都是按照白盒策略实施的,因为它的测试数据和逻辑完全依赖于 SUT 的特定内部逻辑(在这种情况下,cookie 的格式和管理方式)。
这个测试并不是按照纯白盒方法进行的,因为它的目标是在 SUT 内部练习所有可能的路径。它可以被视为白盒,因为它直接与实现相关联,而不是与需求相关联。
package io.github.bonigarcia.test.unit;
import static org.hamcrest.CoreMatchers.*containsString*;
import static org.hamcrest.CoreMatchers.*equalTo*;
import static org.hamcrest.CoreMatchers.*not*;
import static org.hamcrest.MatcherAssert.*assertThat*;
import static org.hamcrest.collection.IsEmptyCollection.*empty*;
import static org.mockito.ArgumentMatchers.*any*;
import static org.mockito.Mockito.*doNothing*;
import java.util.List;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import io.github.bonigarcia.Cat;
import io.github.bonigarcia.CookiesService;
import io.github.bonigarcia.Opinion;
import io.github.bonigarcia.mockito.MockitoExtension;
@ExtendWith(MockitoExtension.class)
@DisplayName("Unit tests (white-box): handling cookies")
@Tag("unit")
@Tag("functional-requirement-5")
class CookiesTest {
@InjectMocks
CookiesService cookiesService;
@Mock
HttpServletResponse response;
// Test data
Cat dummy = new Cat("dummy", "dummy.png");
String dummyCookie = "0#0.0#_";
@Test
@DisplayName("Update cookies test")
void testUpdateCookies() {
*doNothing*().when(response).addCookie(*any*(Cookie.class));
String cookies = cookiesService.updateCookies("", 0L, 0D, "",
response);
*assertThat*(cookies,
*containsString*(CookiesService.*VALUE_SEPARATOR*));
*assertThat*(cookies,
*containsString*(Cookies.*CA**T_SEPARATOR*));
}
@Test
@DisplayName("Check cat in cookies")
void testCheckCatInCookies() {
boolean catInCookies = cookiesService.isCatInCookies(dummy,
dummyCookie);
*assertThat*(catInCookies, *equalTo*(true));
}
@DisplayName("Check cat in empty cookies")
@Test
void testCheckCatInEmptyCookies() {
boolean catInCookies = cookiesService.isCatInCookies(dummy, "");
*assertThat*(catInCookies, *equalTo*(false));
}
@DisplayName("Update opinions with cookies")
@Test
void testUpdateOpinionsWithCookies() {
List<Opinion> opinions = cookiesService
.updateOpinionsWithCookiesValue(dummy, dummyCookie);
*assertThat*(opinions, *not*(*empty*()));
}
@DisplayName("Update opinions with empty cookies")
@Test
void testUpdateOpinionsWithEmptyCookies() {
List<Opinion> opinions = cookiesService
.updateOpinionsWithCookiesValue(dummy, "");
*assertThat*(opinions, *empty*());
}
}
让我们继续下一个类型的测试:集成测试。对于这种类型的测试,我们将使用 Spring 提供的容器内测试功能。具体来说,我们使用 Spring 测试对象MockMvc
来评估我们的应用程序的 HTTP 响应是否符合客户端的预期。在每个测试中,不同的请求被练习,以验证响应(状态码和内容类型)是否符合预期:
package io.github.bonigarcia.test.integration;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*get*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*post*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*content*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*status*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
@ExtendWith(SpringExtension.class)
@SpringBootTest
@DisplayName("Integration tests: HTTP reponses")
@Tag("integration")
@Tag("functional-requirement-1")
@Tag("functional-requirement-2")
class WebContextTest {
@Autowired
MockMvc mockMvc;
@Test
@DisplayName("Check home page (GET /)")
void testHomePage() throws Exception {
mockMvc.perform(*get*("/")).andExpect(*status*().isOk())
.andExpect(*content*().contentType("text/html;charset=UTF-8"));
}
@Test
@DisplayName("Check rate cat (POST /)")
void testRatePage() throws Exception {
mockMvc.perform(*post*("/").param("catId", "1").param("stars", "1")
.param("comment", "")).andExpect(*status*().isOk())
.andExpect(*content*().contentType("text/html;charset=UTF-8"));
}
@Test
@DisplayName("Check rate cat (POST /) of an non-existing cat")
void testRatePageCatNotAvailable() throws Exception {
mockMvc.perform(*post*("/").param("catId", "0").param("stars", "1")
.param("comment", "")).andExpect(*status*().isOk())
.andExpect(*content*().contentType("text/html;charset=UTF-8"));
}
@Test
@DisplayName("Check rate cat (POST /) with bad parameters")
void testRatePageNoParameters() throws Exception {
mockMvc.perform(*post*("/")).andExpect(*status*().isBadRequest());
}
}
最后,我们还使用 Selenium WebDriver 实施了几个端到端测试。检查此测试的实现,我们可以看到这个测试同时使用了两个 JUnit 5 扩展:SpringExtension
(在 JUnit 5 测试生命周期内启动/停止 Spring 上下文)和SeleniumExtension
(在测试方法中注入 WebDriver 对象,用于控制 Web 浏览器)。特别是,在一个测试中我们使用了三种不同的浏览器:
-
PhantomJS(无头浏览器),以评估猫的列表是否在 Web GUI 中正确呈现(FR1)。
-
Chrome,通过应用程序 GUI 对猫进行评分(FR2)。
-
Firefox,使用 GUI 对猫进行评分,但结果出现错误(FR2)。
package io.github.bonigarcia.test.e2e;
import static org.hamcrest.CoreMatchers.*containsString*;
import static org.hamcrest.CoreMatchers.*equalTo*;
import static org.hamcrest.MatcherAssert.*assertThat*;
import static org.openqa.selenium.support.ui.ExpectedConditions.*elementToBeClickable*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.*RANDOM_PORT*;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import io.github.bonigarcia.SeleniumExtension;
@ExtendWith({ SpringExtension.class, SeleniumExtension.class })
@SpringBootTest(webEnvironment = *RANDOM_PORT*)
@DisplayName("E2E tests: user interface")
@Tag("e2e")
public class UserInferfaceTest {
@LocalServerPort
int serverPort;
@Test
@DisplayName("List cats in the GUI")
@Tag("functional-requirement-1")
public void testListCats(PhantomJSDriver driver) {
driver.get("http://localhost:" + serverPort);
List<WebElement> catLinks = driver
.findElements(By.*className*("lightbox"));
*assertThat*(catLinks.size(), *equalTo*(9));
}
@Test
@DisplayName("Rate a cat using the GUI")
@Tag("functional-requirement-2")
public void testRateCat(ChromeDriver driver) {
driver.get("http://localhost:" + serverPort);
driver.findElement(By.*id*("Baby")).click();
String fourStarsSelector = "#form1 span:nth-child(4)";
new WebDriverWait(driver, 10)
.until(*elementToBeClickable
* (By.*cssSelector*(fourStarsSelector)));
driver.findElement(By.*cssSelector*(fourStarsSelector)).click();
driver.findElement(By.*xpath*("//*[@id=\"comment\"]"))
.sendKeys("Very nice cat");
driver.findElement(By.*cssSelector*("#form1 > button")).click();
WebElement sucessDiv = driver
.findElement(By.*cssSelector*("#success > div"));
*assertThat*(sucessDiv.getText(), *containsString*("Your vote for
Baby"));
}
@Test
@DisplayName("Rate a cat using the GUI with error")
@Tag("functional-requirement-2")
public void testRateCatWithError(FirefoxDriver driver) {
driver.get("http://localhost:" + serverPort);
driver.findElement(By.*id*("Baby")).click();
String sendButtonSelector = "#form1 > button";
new WebDriverWait(driver, 10).until(
*elementToBeClickable*(By.*cssSelector*(sendButtonSelector)));
driver.findElement(By.*cssSelector*(sendButtonSelector)).click();
WebElement sucessDiv = driver
.findElement(By.*cssSelector*("#error > div"));
*assertThat*(sucessDiv.getText(), *containsString*(
"You need to select some stars for rating each cat"));
}
}
为了更容易追踪测试执行,在所有实施的测试中,我们使用@DisplayName
选择了有意义的测试名称。此外,对于参数化测试,我们使用元素名称来细化每次测试执行的测试名称,具体取决于测试输入。以下是在 Eclipse 4.7(Oxygen)中执行测试套件的屏幕截图:
在 Eclipse 4.7 中执行应用程序“评价我的猫!”的测试套件
如前所述,我们使用 Travis CI 作为构建服务器,在开发过程中执行我们的测试。在 Travis CI 的配置(文件.travis.yml
)中,我们设置了两个额外的工具,以增强我们应用程序的开发和测试过程。一方面,Codecov 提供了全面的测试覆盖报告。另一方面,SonarCloud 提供了完整的静态分析。这两个工具都由 Travis CI 触发,作为持续集成构建过程的一部分。因此,我们可以评估应用程序的测试覆盖率和内部代码质量(如代码异味、重复块或技术债务),以及我们的开发过程。
以下图片显示了 Codecov 提供的在线报告的屏幕截图(SonarCloud 提供的报告在本章的前一部分中呈现):
\
Codecov 报告应用程序 Rate my cat!
最后但并非最不重要的是,我们在 GitHub 存储库的README
中使用了几个徽章。具体来说,我们为 Travis CI(最后构建过程的状态)、SonarCloud(最后分析的状态)和 Codecov(最后代码覆盖分析的百分比)添加了徽章:
GitHub 应用程序 Rate my cat!的徽章
总结
在本章中,我们回顾了测试活动管理方面的几个问题。首先,我们了解到测试可以在软件开发过程(软件生命周期)的不同部分进行,这取决于测试方法论:BDD(在需求分析之前定义验收测试),TDD(在系统设计之前定义测试),TFD(在系统设计之后实现测试)和 TLD(在系统实现之后实现测试)。
CI 是在软件开发中越来越多地使用的一个过程。它包括对代码库的自动构建和测试。这个过程通常是由源代码存储库中的新提交触发的,比如 GitHub、GitLab 或 Bitbucket。CI 扩展到持续交付(当发布到开发环境)和持续部署(当不断地部署到生产环境)。我们回顾了当今最常用的两个构建服务器:Jenkins(CI 作为服务)和 Travis(内部)。
有一些其他工具可以用来改进测试的管理,例如报告工具(如 Maven Surefire Report 或 Allure)或缺陷跟踪系统(如 JIRA、Bugzilla、Redmine、MantisBT 和 GitHub 问题)。自动静态分析是测试的一个很好的补充,例如使用诸如 Checkstyle、FindBugs、PMD 或 SonarQube 之类的代码检查工具,以及同行审查工具,如 Collaborator、Crucible、Gerrit 和 GitHub 拉取请求审查。
为了结束这本书,本章的最后一部分介绍了一个完整的 Web 应用程序(名为Rate my cat!)及其相应的 JUnit 5 测试(单元测试、集成测试和端到端测试)。它包括使用本书中介绍的不同技术开发和评估的 Web 应用程序,即 Spring、Mockito、Selenium、Hamcrest、Travis CI、Codecov 和 SonarCloud。