精通-Mockito-和-JUnit-单元测试-全-
精通 Mockito 和 JUnit 单元测试(全)
原文:
zh.annas-archive.org/md5/bbe68d9c21e7005869c4d4e539008d28译者:飞龙
前言
如果你是一名资深的软件开发者,你肯定参加过软件会议或开发者论坛,并经历过许多有趣的对话。它们通常从一个开发者描述他/她遵循的酷炫开发过程开始,然后另一个开发者提出一个前沿的技术或工具,或者他/她正在处理的令人困惑的企业集成模式。每位演讲者都试图超越前一位演讲者。老手们会谈论那些必须用穿孔卡片或开关编程的古老机器,或者他们开始描述 COBOL 作为一种遵循模型-视图-控制器模式的动态语言。问他们三个问题:“你是如何对程序进行单元测试的?”“你能通过更频繁地监测血压来缓解高血压吗?”“你曾经维护过自己的代码吗?”
我问了这个问题的第一个问题给超过 200 名开发者。相信我,80%的开发者回答说:“我们付钱给测试人员,或者我们有熟练的测试人员。”5%的人说:“我们的客户测试软件。”现在剩下的 15%进行单元测试,使用打印语句或编写 JUnit 测试。
一味地按老方法做事并期望它们改进是愚蠢的。任何程序只有在其有用时才是好的;因此,在应用复杂的工具、模式或 API 之前,我们应该验证我们的软件是否真的能工作。我们应该配置我们的开发环境,以便我们能够快速获得关于正在开发的反馈。自动化的 JUnit 测试帮助我们持续验证我们的假设。副作用被迅速检测到,这节省了时间。
正如马丁·福勒所说,任何傻瓜都能编写计算机能理解的代码。优秀的程序员编写的是人类能理解的代码。
我们可以编写晦涩难懂的代码来给同事留下深刻印象,但编写可读的代码是一门艺术。可读性是代码质量的一部分。
你能通过更频繁地监测血压来治疗高血压吗?不,你需要药物治疗。同样,我们应该分析我们的代码来提高代码质量。静态代码分析工具建议纠正措施,这意味着我们应该持续监控我们的代码质量。
总是编写代码,就好像最终维护你代码的人将是一个知道你住处的暴力精神病患者。我们在全新的绿色项目中工作,也在现有的棕色项目中工作。绿色项目总是遵循测试驱动开发来交付可维护和可测试的代码。
测试驱动开发是一种进化式开发方法。它提供测试优先的开发方式,其中生产代码仅编写以满足测试。编写测试的第一种简单想法减少了编码后编写单元测试的额外工作量。在测试驱动开发中,测试替身和模拟对象被广泛用于模拟外部依赖。Mockito 是一个开源的 Java 模拟单元测试框架。它允许创建、验证和存根模拟对象。
正如温斯顿·丘吉尔所说 我们通过我们所获得的东西谋生,但我们通过我们所给予的东西创造生活。
我们继承了别人的遗留代码——它可能来自一个非常古老的项目,来自无法维护代码的其他团队,或者可能是从另一家公司收购的。然而,我们的责任是提高其质量。
本书是一本高级指南,将帮助软件开发者使用 Mockito 作为模拟框架,在 JUnit 框架中掌握单元测试的完整专业知识。本书的重点是向读者提供关于如何有效地编写 JUnit 测试的全面细节。构建脚本可以定制来自动化单元测试,可以使用静态代码分析工具和代码质量仪表板来监控代码质量,并且可以为 Web 和数据库组件编写测试。遗留代码可以被重构。可以使用测试驱动开发和 Mockito 进行软件开发;可以遵循 JUnit 最佳实践。
拥有高级 JUnit 概念、测试自动化、构建脚本工具、模拟框架、代码覆盖率工具、静态代码分析工具、Web 层单元测试、数据库层单元测试、测试驱动开发和重构遗留代码的知识,你将会惊喜地发现,你可以多么快速和容易地编写出高质量的、干净的、可读的、可测试的、可维护的和可扩展的代码。在下一场软件会议上,拥有这些技能,你将能够给参与者留下深刻印象。
本书涵盖的内容
第一章,JUnit 4 – 全部回忆,涵盖了单元测试概念、JUnit 4 框架、Eclipse 设置以及 JUnit 4 的高级特性。它简要介绍了 JUnit 4 框架,以便你能够快速上手。我们将讨论围绕 JUnit 基本概念、注解、断言、@RunWith 注解和异常处理的概念,以便你能够充分了解 JUnit 4 的工作原理。高级读者可以跳到下一节。JUnit 4++探讨了 JUnit 4 的高级主题,并深入以下主题:参数化测试、匹配器和 assertThat、假设、理论、超时、类别、规则、测试套件和测试顺序。
第二章, 自动化 JUnit 测试,专注于让读者快速开始使用极限编程(XP)概念、持续集成(CI)、CI 的好处以及使用 Gradle、Maven、Ant 和 Jenkins 等工具的 JUnit 测试自动化。到本章结束时,读者将能够使用 Gradle、Maven 和 Ant 编写构建脚本并配置 Jenkins 来执行构建脚本。
第三章, 测试替身,阐述了测试替身的概念并解释了各种测试替身类型,如模拟、伪造、占位符、存根和间谍。
第四章, 渐进式 Mockito,提炼了 Mockito 框架到其核心,并提供了技术示例。不需要对模拟有任何先前的知识。到本章结束时,读者将能够使用 Mockito 框架的高级功能;使用 Mockito 开始行为驱动开发;并使用 Mockito 编写可读的、可维护的、干净的 JUnit 测试。
第五章, 探索代码覆盖率,展开介绍了代码覆盖率的概念、代码覆盖率工具,并提供了使用各种构建脚本的逐步指南来生成覆盖率报告。以下主题被涵盖:代码覆盖率;分支和行覆盖率;覆盖率工具——Clover、Cobertura、EclEmma 和 JaCoCo;使用 Eclipse 插件测量覆盖率;以及使用 Ant、Maven 和 Gradle 生成报告。到本章结束时,读者将能够配置 Eclipse 插件和构建脚本来测量代码覆盖率。
第六章, 揭示代码质量,探讨了静态代码分析和代码质量改进。到本章结束时,读者将能够配置 SONAR 仪表板,设置 Eclipse 插件,配置 Sonar 运行器和构建脚本来使用 PMD、FindBugs 和 Checkstyle 分析代码质量。
第七章, 单元测试 Web 层,处理单元测试 Web 层或表示层。它涵盖了单元测试 servlet、玩转 Spring MVC 以及与模型视图控制器(MVC)模式一起工作。到本章结束时,读者将能够单元测试 Web 层组件并将视图组件从表示逻辑中隔离出来。
第八章,玩转数据,涵盖了数据库层的单元测试。包括分离关注点、单元测试持久化逻辑、使用 Spring 简化持久化、验证系统完整性和使用 Spring 编写集成测试等主题。到本章结束时,读者将能够独立于数据库对数据访问层组件进行单元测试,使用 Spring 编写整洁的 JDBC 代码,并使用 Spring API 编写集成测试。
第九章,解决测试难题,解释了在绿色和棕色项目中单元测试的重要性。包括处理测试障碍、识别构造函数问题、实现初始化问题、处理私有方法、处理最终方法、探索静态方法问题、处理最终类、学习新关注点、探索静态变量和块以及测试驱动开发等主题。到本章结束时,读者将能够为遗留代码编写单元测试;重构遗留代码以改进现有代码的设计;按照测试优先和测试驱动开发的原则开始编写简单、整洁和可维护的代码;以及重构代码以提高代码质量。
第十章,最佳实践,专注于 JUnit 指南和编写整洁、可读和可维护 JUnit 测试用例的最佳实践。包括处理断言、处理异常和处理测试异味。到本章结束时,读者将能够编写整洁和可维护的测试用例。
您需要为这本书准备以下内容
在您运行示例之前,需要安装以下软件:
-
Java 6 或更高版本:可以从以下 Oracle 网站下载 JDK 1.6 或更高版本:
-
Eclipse 编辑器:Eclipse 的最新版本是 Kepler(4.3)。可以从以下网站下载 Kepler:
-
Mockito 用于创建、验证模拟对象和存根。可以从以下网站下载:
这本书面向的对象
这本书面向的是使用 Mockito 在 JUnit 框架中的高级到初级水平的软件测试人员或开发者。需要具备对单元测试元素和应用的合理知识和理解。
本书非常适合那些在 Java 应用程序开发方面有一定经验,并且对 JUnit 测试有一些基本知识的开发者。但它涵盖了 JUnit 测试的基本原理、测试自动化、静态代码分析、遗留代码重构和测试驱动开发,以便在使用这些概念之前让您熟悉它们。
惯例
在这本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称按以下方式显示:“afterClass和beforeClass方法只执行一次。”
代码块按以下方式设置:
@Test
public void currencyRoundsOff() throws Exception {
assertNotNull(CurrencyFormatter.format(100.999));
assertTrue(CurrencyFormatter.format(100.999).
contains("$"));
assertEquals("$101.00", CurrencyFormatter.format(100.999));
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public class LocaleTest {
private Locale defaultLocale;
@Before
public void setUp() {
defaultLocale = Locale.getDefault();
Locale.setDefault(Locale.GERMANY);
}
@After
public void restore() {
Locale.setDefault(defaultLocale);
}
@Test
public void currencyRoundsOff() throws Exception {
assertEquals("$101.00",
CurrencyFormatter.format(100.999));
}
}
任何命令行输入或输出都按以下方式编写:
green(com.packtpub.junit.recap.rule.TestWatcherTest) success!
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在左侧点击Java 构建路径并打开库选项卡。”
注意
警告或重要提示以如下方式显示。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表格链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过选择您的标题从 www.packtpub.com/support 查看任何现有勘误。
盗版
在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章。JUnit 4 – 全部回忆
本章涵盖了单元测试的概念、JUnit 4 框架、Eclipse 设置以及 JUnit 4 的高级特性。在 JUnit 4 中,我们将简要介绍 JUnit 框架,以便您能够快速上手。我们将讨论围绕 JUnit 基本概念、注解、断言、@RunWith注解和异常处理的概念,以便您对 JUnit 4 的工作原理有足够的了解。高级读者可以跳到下一节。
在 JUnit 4++中,我们将探讨 JUnit 4 的高级主题,深入探讨参数化测试、Hamcrest 匹配器和assertThat、假设、理论、超时、类别、规则、测试套件和测试顺序。
定义单元测试
测试是对我们知识的评估,是对概念验证,或是对数据的检验。类测试是对我们知识的检验,以确定我们是否可以进入下一个层次。对于软件来说,它是在将软件交付给客户之前对功能和非功能需求的验证。
单元测试代码意味着验证或执行代码的合理性检查。合理性检查是一种基本的测试,用于快速评估计算结果是否可能为真。它是一种简单的检查,用于查看产生的材料是否连贯。
使用主方法中的打印语句或执行应用程序来进行单元测试是一种常见的做法。但这两种方法都不是正确的方法。将生产代码与测试代码混合不是好的做法。在生产代码中测试逻辑是一种代码恶臭,尽管它不会破坏测试中的代码。然而,这增加了代码的复杂性,并可能导致严重的维护问题或系统故障,如果配置出现错误。在生产系统中执行打印语句或日志语句会打印出不必要的信 息。它们增加了执行时间并降低了代码的可读性。此外,垃圾日志信息可能会隐藏一个真正的问题,例如,由于过度记录垃圾信息,你可能会忽略一个关键的死锁或挂起线程警告。
单元测试是测试驱动开发(TDD)中的常见做法。TDD 是一种进化式开发方法。它提供了一种先测试后开发的模式,其中生产代码仅编写以满足测试,代码被重构以提高其质量。在 TDD 中,单元测试驱动设计。你编写代码以满足失败的测试,因此它限制了你需要编写的代码,只编写所需的代码。测试提供了快速、自动化的重构和新增强的回归。
Kent Beck 是极限编程和 TDD 的创始人。他著有许多书籍和论文。
通常,所有测试都包含在同一个项目中,但位于不同的目录/文件夹下。因此,一个 org.packt.Bar.java 类将有一个 org.packt.BarTest.java 测试类。这些将在同一个包 (org.packt) 中,但分别组织在:src/org/foo/Bar.java 和 test/org/foo/BarTest.java 目录中。
我们的客户不执行单元测试,所以我们不会向他们提供测试源文件夹。将代码和测试放在同一个包中允许测试访问受保护的和方法/属性。这在处理遗留代码时尤其有用。
可以使用代码驱动的单元测试框架对 Java 代码进行单元测试。以下是 Java 可用的几个代码驱动的单元测试框架:
-
SpryTest
-
Jtest
-
JUnit
-
TestNG
JUnit 是最流行且广泛使用的 Java 单元测试框架。我们将在下一节中探讨 JUnit 4。
使用 JUnit 4
JUnit 是一个 Java 单元测试框架。它允许开发者优雅地进行单元测试。显然,TestNG 比 JUnit 更简洁,但 JUnit 比 TestNG 更受欢迎。JUnit 有更好的模拟框架支持,如 Mockito,它提供了一个自定义的 JUnit 4 运行器。
JUnit 的最新版本(4.11)可以从 github.com/junit-team/junit/wiki/Download-and-Install 下载。
JUnit 4 是一个基于注解的、灵活的框架。其前身存在许多缺点。以下是 JUnit 4 相比其前身的一些优势:
-
不需要从
junit.framework.Testcase继承,任何类都可以成为测试类 -
setUp和tearDown方法被@before和@after注解所取代 -
任何被标注为
@test的公共方法都可以作为测试方法
在本章中,我们将使用 Eclipse 来执行 JUnit 测试;在接下来的章节中,我们将使用 Ant、Maven 和 Gradle 来执行工具。Eclipse 是一个集成开发环境,可以用来开发 Java 应用程序。可以从 www.eclipse.org/downloads/ 下载。截至今天,最新的 IDE 版本是 KEPLER(4.3)。
注意
自 2006 年以来,Eclipse 每年发布一个项目。它以 Callisto(以 C 开头)命名。按字典顺序,Eclipse 项目名称依次为 C、E、G、H、I、J、K 和 L。
2014 年,他们将发布 Luna(以 L 开头)版本。从 2006 年到如今,他们发布了 Europa(E)、Ganymede(G)、Galileo(G)、Helios(H)、Indigo(I)、Juno(J)和 Kepler(K)。
在下一节中,我们将设置 Eclipse 并执行我们的第一个 JUnit 测试。
设置 Eclipse
如果你已经知道如何安装 Eclipse 并将 JUnit JAR 添加到 classpath 项目中,可以跳过这一节。以下是设置 Eclipse 的步骤:
-
访问
www.eclipse.org/downloads/。从下拉菜单中选择操作系统—Windows、Mac或Linux—然后点击硬件架构超链接,即32 位或64 位,下载二进制文件,如图所示:![设置 Eclipse]()
-
提取二进制文件并启动 Eclipse,例如,在 Windows 上点击
Eclipse.exe来启动 Eclipse。 -
创建一个新的工作空间(例如,在 Windows 上,输入
C:\dev\junit或在 Linux 或 Mac 上输入/user/local/junit;Eclipse 将创建目录)。一旦工作空间打开,按Ctrl + N或导航到文件 | 新建;它将打开一个向导。选择Java 项目并点击下一步。输入JUnitTests作为项目名称并点击完成。这将创建一个名为JUnitTests的 Java 项目。 -
从
github.com/junit-team/junit/wiki/Download-and-Install下载junit.jar和hamcrest-core.jar包,并将 jar 文件复制到JUnitTests项目文件夹中。 -
您可以通过两种方式将 JAR 添加到
classpath项目;要么在两个 JAR 上右键单击,选择构建路径,然后点击添加到构建路径。或者,在项目上右键单击并选择属性菜单项。在左侧点击Java 构建路径并打开库标签。然后,点击添加 JARs...按钮,它将打开一个弹出窗口。从弹出窗口中展开JUnitTests项目,选择两个 JAR(junit.jar和hamcrest-core.jar),并将它们添加到库中。我们现在已经准备好了 Eclipse 的设置。
运行第一个单元测试
JUnit 4 是一个基于注解的框架。它不会强制你扩展TestCase类。任何 Java 类都可以作为测试。在本节中,我们将揭示 JUnit 4 的注解、断言和异常。
在编写第一个测试之前,我们将检查注解。
探索注解
@Test注解表示一个测试。任何public方法都可以通过添加@Test注解来使其成为测试方法,不需要以test开头作为方法名。
我们需要数据来验证一段代码。例如,如果一个方法接受一个学生列表并根据获得的分数进行排序,那么我们必须构建一个学生列表来测试该方法。这被称为数据设置。为了执行数据设置,JUnit 3 在TestCase类中定义了一个setUp()方法。测试类可以重写setUp()方法。方法签名如下:
protected void setUp() throws Exception
JUnit 4 提供了一个@Before注解。如果我们用@Before注解任何名为public void的方法,那么该方法将在每个测试执行之前执行。
同样,任何带有@After注解的方法都会在每个测试方法执行后执行。JUnit 3 为此目的提供了一个tearDown()方法。
JUnit 4 提供了两个额外的注解:@BeforeClass 和 @AfterClass。它们在每个测试类中只执行一次。@BeforeClass 和 @AfterClass 注解可以与任何公共静态 void 方法一起使用。@BeforeClass 注解在第一个测试之前执行,@AfterClass 注解在最后一个测试之后执行。以下示例解释了注解的使用和注解方法的执行顺序。
让我们按照以下步骤编写第一个测试:
-
我们将在测试源包下创建一个测试类。创建一个名为
test的源文件夹,并在包com.packtpub.junit.recap下创建一个SanityTest.javaJava 类。![探索注解]()
创建以
Test后缀结尾的测试类是一种良好的实践。因此,一个MyClass类将有一个MyClassTest测试类。一些代码覆盖率工具会忽略不以Test后缀结尾的测试。 -
将以下代码添加到
SanityTest类中:import org.junit.After; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; public class SanityTest { @BeforeClass public static void beforeClass() { System.out.println("***Before Class is invoked"); } @Before public void before() { System.out.println("____________________"); System.out.println("\t Before is invoked"); } @After public void after() { System.out.println("\t After is invoked"); System.out.println("================="); } @Test public void someTest() { System.out.println("\t\t someTest is invoked"); } @Test public void someTest2() { System.out.println("\t\t someTest2 is invoked"); } @AfterClass public static void afterClass() { System.out.println("***After Class is invoked"); } }提示
下载示例代码
您可以从您在
www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。在前面的类中,我们创建了六个方法。两个测试方法用
@Test注解。请注意,两个方法(beforeClass和afterClass)是静态的,其他四个是非静态的。用@BeforeClass注解的静态方法仅在测试类实例化之前调用一次,即@AfterClass在类完成所有执行后调用。 -
运行测试。按 Alt + Shift + X 和 T 或导航到 运行 | 运行方式 | JUnit 测试。你将看到以下控制台(
System.out.println)输出:![探索注解]()
检查
before和after方法是否在每个测试运行前后执行。然而,测试方法执行的顺序可能会变化。在某些运行中,someTest可能会在someTest2之前执行,反之亦然。afterClass和beforeClass方法只执行一次。
恭喜!我们成功运行了第一个 JUnit 4 测试。
注意
@Before 和 @After 可以应用于任何 public void 方法。@AfterClass 和 @BeforeClass 只能应用于 public static void 方法。
使用断言验证测试条件
断言是一种工具(一个谓词),用于验证程序实现的实际结果与编程假设(期望)的一致性;例如,程序员可以期望两个正数的相加将得到一个正数。因此,他或她可以编写一个程序来相加两个数,并用实际结果断言期望的结果。
org.junit.Assert 包提供了静态重载方法,用于断言所有原始类型、对象和数组的预期和实际值。
以下是有用的断言方法:
-
assertTrue(condition)或assertTrue(failure message, condition):如果条件变为假,断言失败并抛出AssertionError。当传递失败消息时,将抛出失败消息。 -
assertFalse(condition)或assertFalse(failure message, condition):如果条件变为真,断言失败并抛出AssertionError。 -
assertNull:这个方法检查对象是否为空,如果参数不为空,则抛出AssertionError。 -
assertNotNull:这个方法检查参数是否不为空;否则,如果参数不为空,则抛出AssertionError。 -
assertEquals(string message, object expected, object actual),或assertEquals(object expected, object actual),或assertEquals(primitive expected, primitive actual):如果传递了原始值并比较这些值,则此方法表现出有趣的行为。如果传递了对象,则调用equals()方法。此外,如果实际值与预期值不匹配,则抛出AssertionError。 -
assertSame(object expected, object actual): 这个方法只支持对象,并使用==操作符检查对象引用。如果传递了两个不同的对象,则抛出AssertionError。 -
assertNotSame:这是assertSame的反义词。当两个参数引用相同时,它将失败。注意
有时
double由于 Java 存储双精度浮点数的方式,可能会导致令人惊讶的结果。任何对双精度浮点数值的操作都可能导致意外结果。断言不依赖于双精度比较;因此,assertEquals(double expected, double actual)已被弃用。声明一个
double变量sum = .999 + .98。sum变量应该将值相加并存储 1.98,但当你打印机器上的值时,你会得到1.9889999999999999作为输出。所以,如果你用double值 1.98 断言sum,测试将失败。assert方法提供了一个重载方法用于double值断言,即assertEquals(double expected, double actual, double delta)。在比较过程中,如果预期值和实际值之间的差异小于 delta 值,则结果被认为是通过的。对于货币计算,建议使用
BigDecimal而不是双精度浮点数。
我们将在测试中使用 assert 方法如下:
-
在
com.packtpub.junit.recap下创建一个AssertTest测试类。将以下行添加到该类中:package com.packtpub.junit.recap; import org.junit.Assert; import org.junit.Test; public class AssertTest { @Test public void assertTrueAndFalseTest() throws Exception { Assert.assertTrue(true); Assert.assertFalse(false); } @Test public void assertNullAndNotNullTest() throws Exception { Object myObject = null; Assert.assertNull(myObject); myObject = new String("Some value"); Assert.assertNotNull(myObject); } }在前面的代码中,
assertTrueAndFalseTest向assertTrue发送true,向assertFalse发送false。因此,测试不应该失败。在
assertNullAndNotNullTest中,我们向assertNull传递null,向assertNotNull传递非空String;因此,这个测试不应该失败。运行测试。它们应该是绿色的。
-
我们将检查
assertEquals并添加以下测试和静态导入assertEquals方法:import static org.junit.Assert.assertEquals; @Test public void assertEqualsTest() throws Exception { Integer i = new Integer("5"); Integer j = new Integer("5");; assertEquals(i,j); }在前面的代码中,我们定义了两个
Integer对象,i和j,并将它们初始化为 5。现在,当我们将它们传递给assertEquals时,测试通过,因为assertEquals方法调用i.equals(j)而不是i == j。因此,只比较值,而不是引用。assertEquals方法适用于所有原始类型和对象。要验证双精度值,可以使用重载的assertEquals(actual, expected, delta)方法,或者直接使用BigDecimal而不是使用Double。 -
添加一个测试以验证
assertNotSame的行为并静态导入assertNotSame方法:import static org.junit.Assert.assertNotSame; @Test public void assertNotSameTest() throws Exception { Integer i = new Integer("5"); Integer j = new Integer("5");; assertNotSame(i , j); }assertNotSame方法仅在预期对象和实际对象引用相同的内存位置时失败。在这里,i和j持有相同的值,但内存引用不同。 -
添加一个测试以验证
assertSame的行为并静态导入assertSame方法:import static org.junit.Assert.assertSame; @Test public void assertSameTest() throws Exception { Integer i = new Integer("5"); Integer j = i; assertSame(i,j); }assertSame方法仅在预期对象和实际对象引用相同的内存位置时通过。在这里,i和j持有相同的值并指向相同的地址。
与异常处理一起工作
要测试错误条件,异常处理功能很重要。例如,一个 API 需要三个对象;如果任何参数为 null,则 API 应该抛出异常。这可以很容易地测试。如果 API 没有抛出异常,测试将失败。
@Test注解接受expected=<<Exception class name>>.class参数。
如果预期的异常类与代码抛出的异常不匹配,测试将失败。考虑以下代码:
@Test(expected=RuntimeException.class)
public void exception() {
throw new RuntimeException();
}
这只是一个解决方案。还有几种其他方法通常被认为是更好的解决方案。在 JUnit 4.8+中使用@Rule并分配ExpectedException是一个更强的解决方案,因为你可以检查消息以及类型。我们在本章的与 JUnit 4++一起工作部分中介绍了@Rule。
探索@RunWith注解
测试运行器执行 JUnit 测试。Eclipse 有一个内置的本地图形运行器。JUnit 4 提供定义要运行的套件和显示其结果的工具。
当一个类被@RunWith注解或类扩展了一个被@RunWith注解的类时,JUnit 将调用它引用的类来运行该类的测试,而不是使用内置的运行器。@RunWith注解用于改变测试类的性质。它可以用来运行参数化测试,甚至是 Spring 测试,或者它可以是 Mockito 运行器,用于初始化带有@Mock注解的模拟对象。
@RunWith注解接受一个参数。该参数必须是一个从org.junit.runner.Runner扩展的类。
JUnit4.class是一个运行器的例子。这个类将当前默认的 JUnit 4 类运行器别名为。
Suite是一个标准运行器,允许我们构建包含来自多个包的测试的套件。以下是一个@RunWith的示例:
@RunWith(Suite.class)
public class Assumption {
}
使用 JUnit 4++
本节探讨了 JUnit 4 框架的高级特性,包括以下主题:参数化测试、Hamcrest 匹配器和 assertThat、假设、理论、超时、类别、规则、测试套件和测试顺序。
忽略一个测试
假设一个失败的测试阻止你检查一个关键任务代码,而你又得知代码的所有者正在度假。你该怎么办?你尝试修复测试,或者只是注释掉或删除测试以继续你的检查(将文件提交到源控制如 SVN),或者你等待直到测试被修复。
有时我们注释掉测试是因为功能尚未开发。JUnit 为此提供了解决方案。我们不必注释测试,只需通过使用@Ignore注解测试方法来忽略它。注释掉测试或代码是糟糕的,因为它除了增加代码大小并降低其可读性外,什么都不做。此外,当你注释掉测试时,测试报告不会告诉你关于注释掉的测试的任何信息;然而,如果你忽略一个测试,那么测试报告会告诉你需要修复某些被忽略的测试。因此,你可以跟踪被忽略的测试。
使用@Ignore("Reason: why do you want to ignore?")。给出适当的描述可以解释忽略测试的意图。以下是一个示例,其中测试方法被忽略是因为假日计算不工作:
@Test
@Ignore("John's holiday stuff failing")
public void when_today_is_holiday_then_stop_alarm() {
}
以下是从 Eclipse 中截取的屏幕截图:

你可以在测试类上放置@Ignore注解,从而有效地忽略所有包含的测试。
按顺序执行测试
JUnit 被设计为允许随机执行,但通常它们是按线性方式执行的,顺序没有保证。JUnit 运行器依赖于反射来执行测试。通常,测试执行顺序不会随运行而变化;实际上,随机性是环境特定的,并且从 JVM 到 JVM 会有所不同。因此,最好不要假设它们将以相同的顺序执行并依赖于其他测试,但有时我们需要依赖顺序。
例如,当你想编写慢速测试以向数据库中插入一行时,首先更新该行,最后删除该行。在这里,除非插入函数被执行,否则删除或更新函数无法运行。
JUnit 4.11 为我们提供了一个@FixMethodOrder注解来指定执行顺序。它接受enum MethodSorters。
要更改执行顺序,使用@FixMethodOrder注解你的测试类并指定以下可用的enum MethodSorters常量之一:
-
MethodSorters.JVM:这会保留由 JVM 返回的测试方法顺序。这个顺序可能每次运行都会变化。 -
MethodSorters.NAME_ASCENDING: 这将按字典顺序对测试方法进行排序。 -
MethodSorters.DEFAULT: 这是默认值,但不保证执行顺序。
我们将编写一些测试来验证这种行为。
添加一个 TestExecutionOrder 测试并创建测试,如下面的代码片段所示:
public class TestExecutionOrder {
@Test public void edit() throws Exception {
System.out.println("edit executed");
}
@Test public void create() throws Exception {
System.out.println("create executed");
}
@Test public void remove() throws Exception {
System.out.println("remove executed");
}
}
运行测试。执行顺序可能会变化,但如果我们用 @FixMethodOrder(MethodSorters.NAME_ASCENDING) 注解类,测试将按以下顺序执行:
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestExecutionOrder { … }
以下 Eclipse 截图显示了按顺序执行的测试:

学习假设
在多站点项目中,偶尔,日期或时区测试在本地 CI 服务器上失败,但在不同时区的其他服务器上运行良好。我们可以选择不在本地服务器上运行那些自动测试。
有时我们的测试会因为第三方代码或外部软件中的错误而失败,但我们知道在某个特定的构建或版本之后,错误将被修复。我们应该注释掉代码并等待构建可用吗?
在许多项目中,Jenkins(用于测试自动化)和SONAR(用于代码质量指标)在服务器上运行。观察到由于资源不足,当 SONAR 处理时,自动测试会无限期地运行,并且测试会同时进行。
JUnit 对所有这些问题都有答案。它建议使用 org.junit.Assume 类。
与 Assert 类似,Assume 提供了许多静态方法,例如 assumeTrue(condition)、assumeFalse(condition)、assumeNotNull(condition) 和 assumeThat(condition)。在执行测试之前,我们可以使用 assumeXXX 方法检查我们的假设。如果我们的假设失败,那么 assumeXXX 方法将抛出 AssumptionViolatedException,JUnit 运行器将忽略有失败假设的测试。
所以,基本上,如果我们的假设不正确,测试将被忽略。我们可以假设测试是在 EST 时区运行的;如果测试在其他地方运行,它们将被自动忽略。同样,我们可以假设第三方代码版本高于构建/版本 123;如果构建版本较低,测试将被忽略。
让我们编写代码来验证我们对 Assume 的假设。
在这里,我们将尝试解决 SONAR 服务器问题。我们将假设 SONAR 没有运行。如果测试执行期间 SONAR 正在运行,假设将失败,测试将被忽略。
创建一个 Assumption 测试类。以下是该类的主体:
public class Assumption {
boolean isSonarRunning = false;
@Test
public void very_critical_test() throws Exception {
isSonarRunning = true;
Assume.assumeFalse(isSonarRunning);
assertTrue(true);
}
}
这里,为了简单起见,我们添加了一个 isSonarRunning 变量来复制一个 SONAR 服务器外观。在实际代码中,我们可以调用一个 API 来获取值。我们将变量设置为 false。然后,在测试中,我们将重置该值为 true。这意味着 SONAR 正在运行。因此,我们的假设 SONAR 没有运行是错误的;因此,测试将被忽略。
以下屏幕截图显示测试被忽略。我们没有使用 @Ignore 注解测试:

当我们将 isSonarRunning 变量的值更改为 false 时,如以下代码片段所示,测试将被执行:
public void very_critical_test() throws Exception {
isSonarRunning = false;
Assume.assumeFalse(isSonarRunning);
assertTrue(true);
}
持续集成工具,如 Jenkins,可以运行多个工具,如 Sonar,以获取代码质量指标。始终是一个好习惯,在测试通过之后才检查代码质量,这样可以防止在同时进行 CPU 密集型任务。
假设(Assumption)也用于 @Before 方法中,但请注意不要过度使用它。假设在 TDD(测试驱动开发)中很有用,因为它允许提前编写预测试。
探索测试套件
要运行多个测试用例,JUnit 4 提供了 Suite.class 和 @Suite.SuiteClasses 注解。此注解接受一个数组(以逗号分隔)的测试类。
创建一个 TestSuite 类,并使用 @RunWith(Suite.class) 注解该类。此注解将强制 Eclipse 使用套件运行器。
接下来,使用 @Suite.SuiteClasses({ AssertTest.class, TestExecutionOrder.class, Assumption.class }) 注解类,并传递以逗号分隔的测试类名称。
以下是一个代码片段:
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@RunWith(Suite.class)
@Suite.SuiteClasses({ AssertTest.class, TestExecutionOrder.class,Assumption.class })
public class TestSuite {
}
在执行过程中,套件将执行所有测试。以下是一个套件运行的屏幕截图。检查它是否从三个测试固定装置 AssertTest、TestExecutionOrder 和 Assumption 中运行了七个测试。

为相关测试组创建测试套件,例如数据访问、API 使用测试组或输入验证逻辑测试组。
使用 assertThat 进行断言
Joe Walnes 创建了 assertThat(Object actual, Matcher matcher) 方法。普遍认为 assertThat 比 assertEquals 更易读且更有用。assertThat 方法的语法如下:
public static void assertThat(Object actual, Matcher matcher
在这里,Object 是实际接收到的值,而 Matcher 是 org.hamcrest.Matcher 接口的一个实现。此接口来自一个名为 hamcrest.jar 的独立库。
匹配器允许对期望进行部分或精确匹配,而 assertEquals 使用精确匹配。Matcher 提供了如 is、either、or、not 和 hasItem 等实用方法。Matcher 方法使用 建造者模式,这样我们就可以组合一个或多个匹配器来构建复合匹配器链。就像 StringBuilder 一样,它通过多个步骤构建字符串。
以下是一些匹配器和 assertThat 的示例:
-
assertThat(calculatedTax, is(not(thirtyPercent)) ); -
assertThat(phdStudentList, hasItem(DrJohn) ); -
assertThat(manchesterUnitedClub, both( is(EPL_Champion)).and(is(UEFA_Champions_League_Champion)) );
前面的例子比 JUnit 测试代码更像是英语。因此,任何人都可以理解代码和测试的意图,而匹配器提高了可读性。
Hamcrest 提供了一个名为org.hamcrest.CoreMatchers的实用匹配器类。
CoreMatchers的一些实用方法包括allOf、anyOf、both、either、describedAs、everyItem、is、isA、anything、hasItem、hasItems、equalTo、any、instanceOf、not、nullValue、notNullValue、sameInstance、theInstance、startsWith、endsWith和containsString。所有这些方法都返回一个匹配器。
我们使用了assertEquals;因此,让我们从equalTo开始。equalTo方法等同于assertEquals。
比较匹配器 - equalTo、is 和 not
创建一个AssertThatTest.java JUnit 测试并静态导入org.hamcrest.CoreMatchers.*;如下:
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import org.junit.Test;
public class AssertThatTest {
@Test
public void verify_Matcher() throws Exception {
int age = 30;
assertThat(age, equalTo(30));
assertThat(age, is(30));
assertThat(age, not(equalTo(33)));
assertThat(age, is(not(33)));
}
}
将age变量设置为30,然后同样为assertEquals调用equalTo,这里它是Matcher。equalTo方法接受一个值。如果Matcher值与实际值不匹配,则assertThat抛出AssertionError异常。
将age变量的值设置为29并重新运行测试。以下错误将会发生:

is(a)属性接受一个值并返回一个布尔值,其行为类似于equalTo(a)。is(a)属性等同于is(equalTo(a))。
not属性接受一个值或一个匹配器。在上面的代码中,我们使用了assertThat(age, is(not(33)))。这个表达式不过是age is not 33,并且比assert方法更易读。
处理复合值匹配器 - either、both、anyOf、allOf 和 not
在本节中,我们将使用either、both、anyOf、allOf和not。将以下测试添加到AssertThatTest.java文件中:
@Test
public void verify_multiple_values() throws Exception {
double marks = 100.00;
assertThat(marks, either(is(100.00)).or(is(90.9)));
assertThat(marks, both(not(99.99)).and(not(60.00)));
assertThat(marks, anyOf(is(100.00),is(1.00),is(55.00),is(88.00),is(67.8)));
assertThat(marks, not(anyOf(is(0.00),is(200.00))));
assertThat(marks, not(allOf(is(1.00),is(100.00), is(30.00))));
}
在前面的例子中,一个名为marks的双变量被初始化为100.00的值。这个变量的值通过一个either匹配器进行断言。
基本上,使用either,我们可以将两个值与实际或计算出的值进行比较。如果其中任何一个匹配,则断言通过。如果没有一个匹配,则抛出AssertionError异常。
either(Matcher)方法接受一个匹配器并返回一个CombinableEitherMatcher类。CombinableEitherMatcher类有一个or(Matcher other)方法,这样either和or就可以组合使用。
or(Matcher other)方法被翻译为return (new CombinableMatcher(first)).or(other);,最后变为new CombinableMatcher(new AnyOf(templatedListWith(other)))。
使用both,我们可以将两个值与实际或计算出的值进行比较。如果其中任何一个不匹配,则抛出AssertionError异常。如果两个都匹配,则断言通过。
一个如数学分数这样的数值不能同时等于 60 和 80。然而,我们可以否定这个表达式。如果数学分数是 80,那么使用both匹配器,我们可以将表达式写为assertThat(mathScore, both(not(60)).and(not(90)))。
anyOf 匹配器更像是具有多个值的 either。使用 anyOf,我们可以将多个值与实际或计算值进行比较。如果其中任何一个匹配,则断言通过。如果没有一个匹配,则抛出 AssertionError 异常。
allOf 匹配器更像是具有多个值的 both。使用 allOf,我们可以将多个值与实际或计算值进行比较。如果其中任何一个不匹配,则抛出 AssertionError 异常。类似于 both,我们可以使用 allOf 与 not 一起检查一个值是否属于集合。
在前面的示例中,使用 allOf 和 not,我们检查了 marks 属性是否不是 1、100 或 30。
使用集合匹配器 – hasItem 和 hasItems
在上一节中,我们针对多个值进行了断言。在本节中,我们将对一组值与一个值或多个值进行断言。
考虑以下示例。一个工资列表被填充了三个值:50.00、200.00 和 500.00。使用 hasItem 检查一个值是否存在于集合中,并使用 hasItems 检查多个值是否存在于集合中,如下面的代码所示:
@Test
public void verify_collection_values() throws Exception {
List<Double> salary =Arrays.asList(50.0, 200.0, 500.0);
assertThat(salary, hasItem(50.00));
assertThat(salary, hasItems(50.00, 200.00));
assertThat(salary, not(hasItem(1.00)));
}
hasItem 匹配器有两个版本:一个接受一个值,另一个接受匹配器。因此,我们可以使用 hasItem 检查集合中的一个值,或者使用 not 和 hasItem 检查一个值是否不存在于集合中。hasItems 匹配器对一组值进行操作。
探索字符串匹配器 – startsWith、endsWith 和 containsString
在本节中,我们将探索字符串匹配器。CoreMatchers 有三个内置的字符串匹配器方法。在以下示例中,一个 String 变量 name 被赋予一个值,然后我们断言该名称以特定值开头、包含一个值和以一个值结尾:
@Test
public void verify_Strings() throws Exception {
String name = "John Jr Dale";
assertThat(name, startsWith("John"));
assertThat(name, endsWith("Dale"));
assertThat(name, containsString("Jr"));
}
startsWith 匹配器仅对字符串进行操作。它检查字符串是否以给定的字符串开头。endsWith 匹配器检查字符串是否以给定的字符串结尾。containsString 匹配器检查字符串是否包含另一个字符串。
有时,一个方法调用返回一个 JSON 响应。使用 containsString,可以断言一个特定的值。
注意
注意,startsWith、endsWith 和 containsStrings 并不是唯一的字符串匹配器。其他内置匹配器,如 both、either、anyOf 等等,也可以应用于 String 对象。
探索内置匹配器
JUnitMatchers 有内置的匹配器方法,但所有这些方法都已弃用。请使用 Hamcrest 匹配器代替 JUnitMatchers。
构建自定义匹配器
assertThat works:
if(!matcher.matches(actual)){
Description description = new StringDescription();
description.appendText(reason).appendText("\nExpected: ).appendDescriptionOf(matcher).appendText("\n but: ");
matcher.describeMismatch(actual, description);
throw new AssertionError(description.toString());
}
注意,当 matcher.matches() 返回 false 时,描述是由实际值和匹配器构建的。appendDescriptionOf() 方法调用匹配器的 describeTo() 方法来构建错误消息。
最后,matcher.describeMismatch(actual, description) 将字符串 but: was <<actual>> 追加到描述中。
-
lessThanOrEqual类需要比较两个对象,因此Matcher类应该在Comparable对象上操作。创建一个通用的类,它可以操作实现Comparable接口的任何类型,如下所示:public class LessThanOrEqual<T extends Comparable<T>> extends BaseMatcher<Comparable<T>> { }- 现在我们需要实现
describeTo和matches方法。assertThat方法将实际值传递给匹配器的matches(Object o)方法,lessThanOrEqual将接受一个与实际值比较的值。因此,在matches方法中,我们需要两个可比较的对象:一个作为参数传递,另一个传递给匹配器对象。期望值在matcher对象实例化期间传递,如下所示:
assertThat (actual, matcher(expectedValue)).我们将在创建
Matcher对象期间存储expectedValue并在matches()方法中使用它来比较expectedValue与actual,如下所示:public class LessThanOrEqual<T extends Comparable<T>> extends BaseMatcher<Comparable<T>> { private final Comparable<T> expectedValue; public LessThanOrEqual(T expectedValue) { this.expectedValue = expectedValue; } @Override public void describeTo(Description description) { description.appendText(" less than or equal(<=) "+expectedValue); } @Override public boolean matches(Object t) { int compareTo = expectedValue.compareTo((T)t); return compareTo > -1; } }前面的
LessThanOrEqual类应该仅在expectedValue.compareTo(actual) >= 0时返回true,然后describeTo()方法将字符串"less than or equals (<=) "+ expectedValue添加到description中,这样如果断言失败,则将显示消息 "less than or equals (<=) "+ expectedValue"。assertThat方法接受一个匹配器,但new LessThanOrEqual(expectedValue)的样子看起来并不好。我们将在LessThanOrEqual类中创建一个static方法来创建一个新的LessThanOrEqual对象。如下从assertThat方法调用此方法:
@Factory public static<T extends Comparable<T>> Matcher<T> lessThanOrEqual(T t) { return new LessThanOrEqual(t); }@Factory注解不是必需的,但对于 Hamcrest 工具是必需的。当我们创建许多自定义匹配器时,逐个导入它们会变得很烦人。Hamcrest 随带一个org.hamcrest.generator.config.XmlConfigurator命令行工具,该工具拾取带有@Factory注解的谓词,并将它们收集到一个Matcher类中以方便导入。- 静态导入
LessThanOrEqual类,并将测试添加到AssertThatTest.java中以验证自定义匹配器,如下所示:
@Test public void lessthanOrEquals_custom_matcher() throws Exception { int actualGoalScored = 2; assertThat(actualGoalScored, lessThanOrEqual(4)); assertThat(actualGoalScored, lessThanOrEqual(2)); double originalPI = 3.14; assertThat(originalPI, lessThanOrEqual(9.00)); String authorName = "Sujoy"; assertThat(authorName, lessThanOrEqual("Zachary")); }这个测试应该通过。
- 用更大的值测试代码怎么样?在 Java 中,
Integer.MAX_VALUE存储最大整数值,Integer.MIN_VALUE存储最小整数值。如果我们期望最大值将大于或等于最小值,那么断言应该失败。考虑以下代码片段:
int maxInt = Integer.MAX_VALUE; assertThat(maxInt, lessThanOrEqual(Integer.MIN_VALUE));这将抛出以下错误:
![构建自定义匹配器]()
- 现在我们需要实现
创建参数化测试
参数化测试用于对单个输入进行多次迭代以在测试中压力测试对象。主要原因是减少测试代码的数量。
在 TDD 中,代码是为了满足失败的测试而编写的。生产代码逻辑是从一系列测试用例和不同的输入值构建的。例如,如果我们需要构建一个将返回数字阶乘的类,那么我们将传递不同的数据集并验证我们的实现是否通过验证。
我们知道 0 的阶乘是 1,1 的阶乘是 1,2 的阶乘是 2,3 的阶乘是 6,4 的阶乘是 24,依此类推。
因此,如果我们编写像 factorial_of_1_is_1 和 factorial_of_4_is_24 这样的测试,那么测试类将很容易被污染。我们将编写多少个方法?
我们可以创建两个数组:一个包含期望值,另一个包含原始数字。然后,我们可以遍历数组并断言结果。我们不必这样做,因为 JUnit 4 框架为我们提供了一个类似的解决方案。它为我们提供了一个 Parameterized 运行器。
我们在前面的小节中了解了 @RunWith 注解。Parameterized 是一种特殊的运行器,可以与 @RunWith 注解一起使用。
参数化有两种类型:构造函数和方法。
使用参数化构造函数
按照以下步骤使用构造函数构建参数化测试:
-
创建一个源文件夹
src,并在src/ com.packtpub.junit.recap下添加一个Factorial.java类。 -
实现阶乘算法。将以下代码添加到
Factorial.java类中:package com.packtpub.junit.recap; public class Factorial { public long factorial(long number) { if(number == 0) { return 1; } return number*factorial(number-1); } } -
在
test/ com.packtpub.junit.recap下添加一个ParameterizedFactorialTest.java测试,并用@RunWith(Parameterized.class)注解该类,如下所示:import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @RunWith(Parameterized.class) public class ParameterizedFactorialTest { } -
添加一个创建阶乘算法数据集的方法。该方法应返回
Collection的Object[]方法。我们需要一个二维数组集合来保存数字和阶乘值。为了定义数据参数,用@Parameters注解该方法。@parameters method factorialData():@Parameters public static Collection<Object[]> factorialData() { return Arrays.asList(new Object[][] { { 0, 1 }, { 1, 1 }, { 2, 2 }, { 3, 6 }, { 4, 24 }, { 5, 120 },{ 6, 720 } }); }检查数组是否包含数字和期望的阶乘结果(0 的阶乘是 1,5 的阶乘是 120,依此类推)。
-
Parameterized运行器需要一个构造函数来传递数据集合。对于集合中的每一行,0^(th) 数组元素将作为 1^(st) 构造函数参数传递,下一个索引将作为 2^(nd) 参数传递,依此类推,如下所示:private int number; private int expectedResult; public ParameterizedFactorialTest(int input, int expected) { number= input; expectedResult= expected; }在测试类中,我们添加了两个成员来保存数字和期望的阶乘值。在构造函数中设置这些值。
Parameterized运行器将遍历数据集合(用@Parameters注解),并将值传递给构造函数。例如,它将以 0 作为输入并期望得到 1,然后以 1 作为输入并期望得到 1,依此类推。
-
现在,我们需要添加一个测试方法来断言数字和阶乘,如下所示:
@Test public void factorial() throws Exception { Factorial fact = new Factorial(); assertEquals(fact.factorial(number),expectedResult); }我们创建了一个
Factorial对象,并将数字传递给它以获取实际结果,然后使用expectedResult断言实际值。在这里,运行者将创建测试类的七个实例并执行测试方法。以下截图显示了从 Eclipse 中获取的测试运行结果:
![使用参数化构造函数]()
注意,运行了七个测试,测试名称为 [0] factorial[0],[1] factorial[1],等等,直到 [6]。
注意
如果数据集返回一个空集合,测试不会失败;实际上,什么也不会发生。
如果对象数组和构造函数参数的数量不匹配,则抛出
java.lang.IllegalArgumentException: wrong number of arguments异常。例如,{ 0, 1, 3 }将抛出异常,因为传递了 3 个参数,但构造函数只能接受 2 个。如果构造函数未定义但数据集包含值,则抛出
java.lang.IllegalArgumentException: wrong number of arguments异常。
使用参数化方法
我们学习了参数化构造函数;现在我们将运行参数化测试,但不包括构造函数。按照以下步骤使用@Parameter注解运行测试:
-
添加一个
ParameterizeParamFactorialTest.java测试类。 -
从构造函数测试复制内容并删除构造函数。将类成员更改为
public,如下所示:@RunWith(Parameterized.class) public class ParameterizeParamFactorialTest { @Parameters public static Collection<Object[]> factorialData() { return Arrays.asList(new Object[][] { { 0, 1 }, { 1, 1 }, { 2, 2 }, { 3, 6 }, { 4, 24 }, { 5, 120 },{ 6, 720 } }); } public int number; public int expectedResult; @Test public void factorial() throws Exception { Factorial fact = new Factorial(); assertEquals(fact.factorial(number),expectedResult); } } -
如果我们运行测试,它将失败,因为反射过程找不到匹配的构造函数。JUnit 提供了一个注解来遍历数据集并将值设置到类成员中。
@Parameter(value=index)接受一个值。该值是数据收集对象数组的数组索引。确保number和expectedResult变量是public的;否则,将抛出安全异常。用以下参数注解它们:@Parameter(value=0) public int number; @Parameter(value=1) public int expectedResult;
Eclipse 有一个错误,会截断名称。
处理超时
JUnit 测试在代码更改后自动执行以获得快速反馈。如果测试运行时间过长,则违反了快速反馈原则。JUnit 在@Test注解中提供了一个超时值(以毫秒为单位),以确保如果测试运行时间超过指定值,则测试失败。
以下是一个超时的示例:
@Test(timeout=10)
public void forEver() throws Exception {
Thread.sleep(100000);
}
在这里,测试将在 10 毫秒后自动失败。以下是一个 Eclipse 截图,显示了错误:

探索 JUnit 理论
理论是一种 JUnit 测试,但与典型的基于示例的 JUnit 测试不同,在典型的测试中,我们断言特定的数据集并期望特定的结果。JUnit 理论是 JUnit 参数化测试的替代方案。JUnit 理论封装了测试者对对象通用行为的理解。这意味着理论断言的任何内容都应适用于所有数据集。理论对于在边界值情况下查找错误很有用。
参数化测试允许我们编写灵活的数据驱动测试,并将数据与测试方法分离。理论类似于参数化测试——两者都允许我们在测试用例之外指定测试数据。
参数化测试很好,但它们有以下缺点:
-
参数被声明为成员变量。它们污染了测试类,并使系统变得不必要地复杂。
-
参数需要传递给单个构造函数或变量需要注解,这使类变得难以理解。
-
测试数据不能外部化。
理论提供了许多注解和一个运行器类。让我们检查理论中的重要注解和类,如下所示:
-
@Theory:与@Test类似,这个注解标识了一个要运行的理论测试。@Test注解与理论运行器不兼容。 -
@DataPoint:这个注解标识一组单个测试数据(类似于@Parameters),即静态变量或方法。 -
@DataPoints:这个注解标识多组测试数据,通常是一个数组。 -
@ParametersSuppliedBy:这个注解为测试用例提供参数。 -
Theories:这个注解是针对基于理论的测试用例的 JUnit 运行器,并扩展了org.junit.runners.BlockJUnit4ClassRunner。 -
ParameterSupplier:这是一个抽象类,它为我们提供了可以提供给测试用例的参数的句柄。
我们将从简单的理论开始,然后探索更多。执行以下步骤:
-
创建一个
MyTheoryTest.java类,并用@RunWith(Theories.class)注解该类。要运行一个理论,需要这个特殊的运行器。考虑以下代码:@RunWith(Theories.class) public class MyTheoryTest { } -
现在运行测试。它将因为
java.lang.Exception: No runnable methods错误而失败,因为没有定义任何理论。像@Test注解一样,我们将定义一个方法并用@Theory注解它,如下所示:@RunWith(Theories.class) public class MyTheoryTest { @Theory public void sanity() { System.out.println("Sanity check"); } }运行理论,它将无错误地执行。因此,我们的理论设置已准备就绪。
-
定义一个
public staticString类型的name变量,并用@DataPoint注解这个变量。现在执行测试,没有发生任何特别的事情。如果一个理论方法(用@Theory注解)接受一个参数,并且用@DataPoint注解的变量与该类型匹配,那么该变量将在执行期间传递给理论。因此,修改sanity方法并添加一个String参数以将@DataPoint传递给sanity()方法,如下所示:@RunWith(Theories.class) public class MyTheoryTest { @DataPoint public static String name ="Jack"; @Theory public void sanity(String aName) { System.out.println("Sanity check "+aName); } }现在运行理论。在执行过程中,它将
@DataPoint名称传递给sanity(String aName)方法,并且名称将被打印到控制台。 -
现在,添加另一个静态的
@DataPoint,命名为mike,并将name变量重命名为jack,如下所示:@RunWith(Theories.class) public class MyTheoryTest { @DataPoint public static String jack ="Jack"; @DataPoint public static String mike ="Mike"; @Theory public void sanity(String aName) { System.out.println("Sanity check "+aName); } }在理论执行期间,两个
@DataPoint变量都将传递给sanity(String aName)方法。输出将如下所示:![探索 JUnit 理论]()
-
现在,稍微修改一下
sanity()方法——将aName参数重命名为firstName并添加第二个String参数,lastName。因此,现在sanity方法接受String参数firstName和lastName。使用以下代码打印这些变量:@RunWith(Theories.class) public class MyTheoryTest { @DataPoint public static String jack ="Jack"; @DataPoint public static String mike ="Mike"; @Theory public void sanity(String firstName, String lastName) { System.out.println("Sanity check "+firstName+", "+lastName); } }当执行时,输出将如下所示:
![探索 JUnit 理论]()
因此,使用了 2 x 2 = 4 种组合。当在测试中定义多个
@DataPoint注解时,理论适用于测试参数的所有可能的良好类型的数据点组合。 -
到目前为止,我们只检查了单维变量。
@DataPoints注解用于提供一组数据。添加一个静态的char数组来存储字符变量,并添加一个Theory方法来接受两个字符。它将以 9(3²)种可能的组合执行理论如下:@DataPoints public static char[] chars = new char[] {'A', 'B', 'C'}; @Theory public void build(char c, char d) { System.out.println(c+" "+d); }以下输出:
![探索 JUnit 理论]()
使用 @ParametersSuppliedBy 和 ParameterSupplier 外部化数据
到目前为止,我们已经介绍了如何使用 @DataPoint 和 @DataPoints 来设置测试数据。现在,我们将使用外部类通过 @ParametersSuppliedBy 和 ParameterSupplier 在测试中提供数据。为此,执行以下步骤:
-
创建一个
Adder.java类。这个类将有两个重载的add()方法,用于添加数字和字符串。我们将使用理论进行单元测试。以下是
Adder类:public class Adder { public Object add(Number a, Number b) { return a.doubleValue()+b.doubleValue(); } public Object add(String a, String b) { return a+b; } } -
创建一个
ExternalTheoryTest.java理论如下:@RunWith(Theories.class) public class ExternalTheoryTest { } -
我们将不会使用
@DataPoints来创建数据。相反,我们将创建一个单独的类来提供数字以验证add操作。JUnit 提供了一个ParameterSupplier类用于此目的。ParameterSupplier是一个抽象类,它强制你定义一个如下所示的方法:public abstract List<PotentialAssignment> getValueSources(ParameterSignature parametersignature);PotentialAssignment是一个抽象类,JUnit 理论使用它以一致的方式为测试方法提供测试数据。它有一个静态的forValue方法,你可以使用它来获取PotentialAssignment的实例。创建一个
NumberSupplier类来提供不同类型的数字:float、int、double、long等。扩展ParameterSupplier类如下:import org.junit.experimental.theories.ParameterSignature; import org.junit.experimental.theories.ParameterSupplier; import org.junit.experimental.theories.PotentialAssignment; public class NumberSupplier extends ParameterSupplier { @Override public List<PotentialAssignment> getValueSources(ParameterSignature sig) { List<PotentialAssignment> list = new ArrayList<PotentialAssignment>(); list.add(PotentialAssignment.forValue("long", 2L)); list.add(PotentialAssignment.forValue("float", 5.00f)); list.add(PotentialAssignment.forValue("double", 89d)); return list; } };检查重写的方法是否创建了一个包含不同数字的
PotentialAssignment值的列表。 -
现在,修改理论以添加两个数字。添加一个理论方法如下:
import org.junit.experimental.theories.ParametersSuppliedBy; import org.junit.experimental.theories.Theories; import org.junit.experimental.theories.Theory; import org.junit.runner.RunWith; @RunWith(Theories.class) public class ExternalTheoryTest { @Theory public void adds_numbers( @ParametersSuppliedBy(NumberSupplier.class) Number num1, @ParametersSuppliedBy(NumberSupplier.class) Number num2) { System.out.println(num1 + " and " + num2); } }检查
adds_numbers方法;两个Number参数num1和num2被注解为@ParametersSuppliedBy(NumberSupplier.class)。当此理论执行时,
NumberSupplier类将传递一个列表。 -
执行理论;它将打印以下结果:
![使用 @ParametersSuppliedBy 和 ParameterSupplier 外部化数据]()
-
现在,我们可以检查我们的
Adder功能。修改理论以断言结果。创建一个
Adder类的实例,并通过传递num1和num2调用add方法。将两个数字相加,并使用assert断言与Adder的结果。assertEquals(double, double)方法已被弃用,因为双精度值计算结果不可预测。因此,assert类为doubles添加了另一个版本的assertEquals;它接受三个参数:实际值、预期值和 delta。如果实际值和预期值之间的差异大于或等于 delta,则断言通过如下:@RunWith(Theories.class) public class ExternalTheoryTest { @Theory public void adds_numbers( @ParametersSuppliedBy(NumberSupplier.class) Number num1, @ParametersSuppliedBy(NumberSupplier.class) Number num2) { Adder anAdder = new Adder(); double expectedSum = num1.doubleValue()+num2.doubleValue(); double actualResult = (Double)anAdder.add(num1, num2); assertEquals(actualResult, expectedSum, 0.01); } }Adder类有一个用于String的add方法。创建一个StringSupplier类来为我们的理论提供String值,并修改理论类以验证add (String, String)方法的行为。你可以如下断言Strings:-
String expected = str1+str2; -
assertEquals(expected, actual);
在这里,
str1和str2是理论的两个方法参数。 -
处理 JUnit 规则
规则允许非常灵活地添加或重新定义测试类中每个测试方法的行为。规则类似于 面向切面编程(AOP);我们可以在实际测试执行之前和/或之后做有用的事情。你可以在 en.wikipedia.org/wiki/Aspect-oriented_programming 找到更多关于 AOP 的信息。
我们可以使用内置规则或定义我们的自定义规则。
在本节中,我们将查看内置规则并创建我们的自定义 Verifier 和 WatchMan 规则。
玩转超时规则
超时规则将相同的超时应用于类中的所有测试方法。之前,我们在 @Test 注解中使用了超时,如下所示:
@Test(timeout=10)
以下是对 timeout 规则的语法:
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.Timeout;
public class TimeoutTest {
@Rule
public Timeout globalTimeout = new Timeout(20);
@Test
public void testInfiniteLoop1() throws InterruptedException{
Thread.sleep(30);
}
@Test
public void testInfiniteLoop2() throws InterruptedException{
Thread.sleep(30);
}
}
当我们运行这个测试时,它在 20 毫秒后超时。请注意,超时是全局应用于所有方法的。
使用 ExpectedException 规则
ExpectedException 规则是处理异常的重要规则。它允许你断言期望的异常类型和异常消息,例如,你的代码可能对所有失败条件抛出通用异常(如 IllegalStateException),但你可以断言通用异常消息以验证确切原因。
之前,我们使用了 @Test(expected=Exception class) 来测试错误条件。
ExpectedException 规则允许在测试中指定期望的异常类型和消息。
以下代码片段解释了如何使用异常规则来验证异常类和异常消息:
public class ExpectedExceptionRuleTest {
@Rule
public ExpectedException thrown= ExpectedException.none();
@Test
public void throwsNothing() {
}
@Test
public void throwsNullPointerException() {
thrown.expect(NullPointerException.class);
throw new NullPointerException();
}
@Test
public void throwsIllegalStateExceptionWithMessage() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("Is this a legal state?");
throw new IllegalStateException("Is this a legal state?");
}
}
expect 对象设置期望的异常类,而 expectMessage 设置异常中的期望消息。如果消息或异常类与规则的期望不匹配,则测试失败。在每次测试中都会重置抛出的 ExpectedException 对象。
展开 TemporaryFolder 规则
TemporaryFolder 规则允许创建在测试方法结束时(无论通过与否)保证被删除的文件和文件夹。考虑以下代码:
@Rule
public TemporaryFolder folder = new TemporaryFolder();
@Test
public void testUsingTempFolder() throws IOException {
File createdFile = folder.newFile("myfile.txt");
File createdFolder = folder.newFolder("mysubfolder");
}
探索 ErrorCollector 规则
ErrorCollector 规则允许在发现第一个问题后继续执行测试(例如,收集表中的所有错误行并一次性报告它们),如下所示:
import org.junit.rules.ErrorCollector;
import static org.hamcrest.CoreMatchers.equalTo;
public class ErrorCollectorTest {
@Rule
public ErrorCollector collector = new ErrorCollector();
@Test
public void fails_after_execution() {
collector.checkThat("a", equalTo("b"));
collector.checkThat(1, equalTo(2));
collector.checkThat("ae", equalTo("g"));
}
}
在这个例子中,没有验证通过,但测试仍然完成了执行,并在最后通知所有错误。
以下是对应的日志——箭头指示错误——并且请注意,只有一个测试方法正在执行,但 Eclipse 指示有三次失败:

与 Verifier 规则一起工作
验证器是 ErrorCollector 的基类,否则如果验证检查失败,它可以将通过测试转换为失败测试。以下示例演示了 Verifier 规则:
public class VerifierRuleTest {
private String errorMsg = null;
@Rule
public TestRule rule = new Verifier() {
protected void verify() {
assertNull("ErrorMsg should be null after each test execution",errorMsg);
}
};
@Test
public void testName() throws Exception {
errorMsg = "Giving a value";
}
}
验证器的 verify 方法在每个测试执行后执行。如果 verify 方法定义了任何断言,并且该断言失败,则测试被标记为失败。
在前面的示例中,测试不应该失败,因为测试方法没有执行任何比较;然而,它仍然失败了。它失败是因为 Verifier 规则检查在每次测试执行后,errorMsg 字符串应该设置为 null,但测试方法将值设置为 Giving a value;因此,验证失败。
学习 TestWatcher 规则
TestWatcher(以及已弃用的 TestWatchman)是记录测试动作的规则的基类,而不修改它。考虑以下代码:
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestWatcherTest {
private static String dog = "";
@Rule
public TestWatcher watchman = new TestWatcher() {
@Override
public Statement apply(Statement base, Description description) {
return super.apply(base, description);
}
@Override
protected void succeeded(Description description) {
dog += description.getDisplayName() + " " + "success!\n";
}
@Override
protected void failed(Throwable e, Description description) {
dog += description.getDisplayName() + " " + e.getClass().getSimpleName() + "\n";
}
@Override
protected void starting(Description description) {
super.starting(description);
}
@Override
protected void finished(Description description) {
super.finished(description);
}
};
@Test
public void red_test() {
fail();
}
@Test
public void green() {
}
@AfterClass
public static void afterClass() {
System.out.println(dog);
}
}
我们创建了一个 TestWatcher 类来监听每次测试执行,收集失败和成功实例,并在最后,在 afterClass() 方法中打印结果。
以下是在控制台上显示的错误:
green(com.packtpub.junit.recap.rule.TestWatcherTest) success!
red_test(com.packtpub.junit.recap.rule.TestWatcherTest) AssertionError
与 TestName 规则一起工作
TestName 规则使当前测试名称在测试方法内部可用。TestName 规则可以与 TestWatcher 规则结合使用,使单元测试框架编译单元测试报告。
以下测试代码片段显示测试名称在测试内部被断言:
public class TestNameRuleTest {
@Rule
public TestName name = new TestName();
@Test
public void testA() {
assertEquals("testA", name.getMethodName());
}
@Test
public void testB() {
assertEquals("testB", name.getMethodName());
}
}
以下部分使用 TestName 规则在测试执行前获取方法名称。
处理外部资源
有时 JUnit 测试需要与外部资源(如文件、数据库或服务器套接字)进行通信。处理外部资源总是很混乱,因为你需要设置状态并在以后将其拆除。ExternalResource 规则提供了一种机制,使资源处理变得更加方便。
在以前,当你需要在测试用例中创建文件或与服务器套接字一起工作时,你必须设置临时目录,或在 @Before 方法中打开套接字,然后在 @After 方法中删除文件或关闭服务器。但现在,JUnit 提供了一种简单的 AOP 类似的机制,称为 ExternalResource 规则,使这种设置和清理工作成为资源的责任。
以下示例演示了 ExternalResource 的功能。Resource 类代表外部资源,并在控制台打印输出:
class Resource{
public void open() {
System.out.println("Opened");
}
public void close() {
System.out.println("Closed");
}
public double get() {
return Math.random();
}
}
以下测试类创建了 ExternalResource 并处理资源生命周期:
public class ExternalResourceTest {
Resource resource;
public @Rule TestName name = new TestName();
public @Rule ExternalResource rule = new ExternalResource() {
@Override protected void before() throws Throwable {
resource = new Resource();
resource.open();
System.out.println(name.getMethodName());
}
@Override protected void after() {
resource.close();
System.out.println("\n");
}
};
@Test
public void someTest() throws Exception {
System.out.println(resource.get());
}
@Test
public void someTest2() throws Exception {
System.out.println(resource.get());
}
}
ExternalResource 类匿名地覆盖了 ExternalResource 类的 before 和 after 方法。在 before 方法中,它启动资源并使用 TestName 规则打印测试方法名称。在 after 方法中,它仅关闭资源。
以下为测试运行输出:
Opened
someTest2
0.5872875884671511
Closed
Opened
someTest
0.395586457988541
Closed
注意,资源在测试执行之前打开,在测试之后关闭。测试名称使用TestName规则打印。
探索 JUnit 类别
Categories运行器仅运行带有@IncludeCategory注解提供的类别或该类别的子类型的类和方法。类或接口都可以用作类别。子类型是有效的,所以如果您使用@IncludeCategory(SuperClass.class),标记为@Category({SubClass.class})的测试将被运行。
我们可以使用@ExcludeCategory注解排除类别。
我们可以使用以下代码定义两个接口:
public interface SmartTests { /* category marker */ }
public interface CrazyTests { /* category marker */ }
public class SomeTest {
@Test
public void a() {
fail();
}
@Category(CrazyTests.class)
@Test
public void b() {
}
}
@Category({CrazyTests.class, SmartTests.class})
public class OtherTest {
@Test
public void c() {
}
}
@RunWith(Categories.class)
@IncludeCategory(CrazyTests.class)
@SuiteClasses( { SomeTest.class, OtherTest.class }) // Note that Categories is a kind of Suite
public class CrazyTestSuite {
// Will run SomeTest.b and OtherTest.c, but not SomeTest.a
}
@RunWith(Categories.class)
@IncludeCategory(CrazyTests.class)
@ExcludeCategory(SmartTests.class)
@SuiteClasses( { SomeTest.class, OtherTest.class })
public class CrazyTestSuite {
// Will run SomeTest.b, but not SomeTest.a or OtherTest.c
}
摘要
本 JUnit 复习章节涵盖了 JUnit 的基本和高级用法。
基础部分涵盖了基于 JUnit 4 测试的注解、断言、@RunWith注解、异常处理,以及 Eclipse 的 JUnit 测试运行设置。
高级部分涵盖了参数化测试、匹配器和assertThat,一个自定义的lessThanOrEqual()匹配器,假设、理论,一个自定义的NumberSupplier类,超时,类别,TestName,ExpectedException,TemporaryFolder,ErrorCollector,Verifier和TestWatcher规则,测试套件,以及按顺序执行测试。
到目前为止,您将能够编写和执行 JUnit 4 测试,并熟悉 JUnit 4 的高级概念。
第二章,自动化 JUnit 测试,专注于让您快速入门项目构建工具和测试自动化。它提供了持续集成的概述,探讨了 Gradle 构建和 Maven 构建生命周期的增量构建,Ant 脚本,以及使用 Gradle、Maven 和 Ant 脚本的 Jenkins 自动化。
第二章:自动化 JUnit 测试
在本章中,你将了解 极限编程(XP)、持续集成(CI)、CI 的好处以及使用各种工具进行 JUnit 测试自动化的概念。
本章将涵盖以下主题:
-
CI
-
Gradle 自动化
-
Maven 项目管理
-
Ant
-
Jenkins
持续集成
在大学期间,我正在做一个关键的水印技术(图像水印)项目,并同时在我的家用计算机上开发一个模块,我在那里将我的更改与其他在大学服务器上的更改集成在一起。我大部分时间都浪费在集成上。在手动集成之后,我会发现一切都不正常;所以,集成是可怕的。
当 CI 不可用时,开发团队或开发者会对代码进行更改,然后将所有代码更改合并在一起。有时,这种合并并不简单;它涉及到大量冲突更改的集成。通常,在集成之后,奇怪的错误会出现,一个工作模块可能开始失败,因为它涉及到众多模块的完全重做。一切都不按计划进行,交付被延迟。结果,可预测性、成本和客户服务受到影响。
CI 是一个 XP 概念。它被引入以防止集成问题。在 CI 中,开发者定期提交代码,每次提交都会构建。自动测试验证系统完整性。它有助于增量开发和定期交付可工作的软件。
CI 的好处
CI 的目的是确保我们在匆忙中不会无意识地破坏某些东西。我们希望持续运行测试,如果测试失败,我们需要得到警告。
在一个优秀的软件开发团队中,我们会发现 测试驱动开发(TDD)以及 CI。
CI 需要一个监听工具来关注版本控制系统中的更改。每当有更改提交时,此工具会自动编译和测试应用程序(有时它还会创建 WAR 文件,部署 WAR/EAR 文件等)。
如果编译失败,或者测试失败,或者部署失败,或者出现其他问题,CI 工具会立即通知相关团队,以便他们可以解决问题。
CI 是一个概念;为了遵守 CI,可以将 Sonar 和 FindBugs 等工具添加到构建过程中,以跟踪代码质量,并自动监控代码质量和代码覆盖率指标。高质量的代码让我们有信心认为团队正在走正确的道路。技术债务可以非常快速地识别出来,团队可以开始减少债务。通常,CI 工具具有展示与质量指标相关的仪表板的能力。
简而言之,CI 工具强制执行代码质量、可预测性和快速反馈,从而降低潜在风险。CI 有助于提高构建的信心。一个团队仍然可以编写非常低质量的代码,甚至测试低质量的代码,CI 也不会关心。
市场上有许多 CI 工具,如 Go、Bamboo、TeamCity、CruiseControl 和 Jenkins。然而,CruiseControl 和 Jenkins 是广泛使用的工具。
Jenkins 支持各种构建脚本工具。它几乎可以集成所有类型的项目,并且易于配置。在本章中,我们将使用 Jenkins。
CI 只是一个通用的命令执行通道;通常,构建工具用于执行命令,然后 CI 工具收集由命令或构建工具产生的指标。Jenkins 需要构建脚本来执行测试、编译源代码,甚至部署成果。Jenkins 支持不同的构建工具来执行命令——Gradle、Maven 和 Ant 是广泛使用的工具。我们将探讨构建工具,然后与 Jenkins 一起工作。
注意
您可以下载本章的代码。解压缩 ZIP 文件。它包含一个名为Packt的文件夹。此文件夹有两个子文件夹:gradle和chapter02。gradle文件夹包含基本的 Gradle 示例,而chapter02文件夹包含 Java 项目和 Ant、Gradle 和 Maven 构建脚本。
Gradle 自动化
Gradle是一种构建自动化工具。Gradle 具有许多优点,如松散的结构、编写构建脚本的能力、简单的两遍项目解析、依赖管理、远程插件等。
Gradle 的最佳特性是能够为构建创建领域特定语言(DSL)。一个例子是 generate-web-service-stubs 或 run-all-tests-in-parallel。
注意
DSL 是一种针对特定领域的编程语言,专注于系统的特定方面。HTML 是 DSL 的一个例子。我们无法使用 DSL 构建整个系统,但 DSL 用于解决特定领域的问题。以下是一些 DSL 的例子:
-
用于构建 Java 项目的 DSL
-
用于绘制图形的 DSL
它的一个独特卖点(USP)是增量构建。它可以配置为仅在项目中的任何资源发生变化时构建项目。因此,整体构建执行时间减少。
Gradle 为不同类型的项目提供了许多预加载的插件。我们可以使用它们或覆盖它们。
与 Maven 或 Ant 不同,Gradle 不是基于 XML 的;它基于一种名为Groovy的动态语言。Groovy 是一种面向开发者的Java 虚拟机(JVM)语言。它的语法使得表达代码意图更加容易,并提供有效使用表达式、集合、闭包等方法。Groovy 程序在 JVM 上运行;因此,如果我们在一个 Groovy 文件中编写 Java 代码,它将会运行。Groovy 支持 DSL,以使代码更易于阅读和维护。
Groovy 的官方网站是groovy.codehaus.org/。
小贴士
我们可以在 Gradle 脚本中使用 Ant 或 Maven。Gradle 支持 Groovy 语法。Gradle 为 Java、Web、Hibernate、GWT、Groovy、Scala、OSGi 以及许多其他项目提供支持。
大型公司如 LinkedIn 和西门子使用 Gradle。许多开源项目,如 Spring、Hibernate 和 Grails,也使用 Gradle。
入门
在执行 Gradle 脚本之前,需要安装 Java (jdk 1.5+)。以下是操作步骤:
-
打开命令提示符并运行
java –version;如果 Java 未安装或版本低于 1.5,请从 Oracle 网站安装最新版本。 -
Gradle 可在
www.gradle.org/downloads获取。下载完成后,提取媒体文件。你会发现它包含一个bin目录。打开命令提示符并进入bin目录。你可以将媒体文件提取到任何你想要的目录。例如,如果你将 Gradle 媒体文件提取到D:\Software\gradle-1.10下,那么打开命令提示符并进入D:\Software\gradle-1.10\bin。 -
现在,使用
gradle –v命令检查 Gradle 版本。它将显示版本和其他配置。要在计算机的任何位置运行 Gradle,请创建一个GRADLE_HOME环境变量,并将其值设置为提取 Gradle 媒体文件的位置。 -
将
%GRADLE_HOME%\bin(在 Windows 上)添加到PATH变量中(在 Linux 的bash_login和 Mac 的bashrc中导出GRADLE_HOME和PATH)。 -
打开一个新的命令提示符,进入任何文件夹,并再次运行相同的命令
gradle –v以检查PATH变量是否设置正确。
另一种选择是使用 Gradle 包装器 (gradlew),并允许批处理文件(或 shell 脚本)下载针对每个项目特定的 Gradle 版本。这是使用 Gradle 的行业标准,确保 Gradle 版本之间的一致性。Gradle 包装器也随构建工件一起提交到源代码控制。
Gradling
在编程世界中,“Hello World” 是起点。在本节中,我们将编写第一个“Hello World” Gradle 脚本。Gradle 脚本可以构建一个或多个项目。每个项目可以有一个或多个任务。任务可以是编译 Java 文件或构建 WAR 文件等任何操作。
小贴士
要执行一个任务,我们将创建一个 build.gradle 文件并执行 gradle 命令以运行构建。Gradle 将在当前目录中查找名为 build.gradle 的文件。要执行除 build.gradle 之外的构建文件,请使用 –b <文件名> 选项。
我们将创建一个任务,在控制台上打印“Hello World”。执行以下步骤:
-
打开一个文本编辑器并输入以下内容:
task firstTask << { println 'Hello world.' }将文件保存为
build.gradle。 -
打开命令提示符并浏览到保存
build.gradle文件的文件夹。运行gradle firstTask命令,或者如果你将文件保存在D:\Packt\gradle下,只需打开命令提示符并运行gradle –b D:\Packt\gradle\build.gradle firstTask。命令提示符将打印以下信息:
:firstTask Hello world. BUILD SUCCESSFUL
method style task definition and subtask ordering:
task aTask(){
doLast{
println 'Executing last.'
}
doFirst {
println 'Running 1st'
}
}
在这里,我们使用 Java 方法风格定义了一个名为 aTask 的任务。任务 aTask 包含两个闭包关键字:doLast 和 doFirst。
当任务被调用后,doFirst 闭包会被执行,而 doLast 闭包会在任务结束时执行。
当我们运行 gradle aTask 时,它会打印以下信息:
:aTask
Running 1st
Executing last.
BUILD SUCCESSFUL
默认任务
在 Ant 中,我们可以定义一个默认目标;同样,Gradle 提供了使用关键字 defaultTasks 'taskName1', …'taskNameN' 的默认任务选项。
defaultTasks 关键字 'aTask' 定义 aTask 为默认任务。因此,如果我们只执行 gradle 而不带任务名称,那么它将调用默认任务。
任务依赖
在 Ant 中,一个目标依赖于另一个目标,例如,Java 代码编译任务可能依赖于输出文件夹的清理;同样,在 Gradle 中,一个任务可能依赖于另一个任务。依赖是通过 dependsOn 关键字定义的。以下语法用于定义任务依赖:
secondTask.dependsOn 'firstTask'
在这里,secondTask 依赖于 firstTask。
定义任务依赖的另一种方式是以类似方法的方式传递依赖。以下代码片段显示了方法参数样式:
task secondTask (dependsOn: 'firstTask') {
doLast {
println 'Running last'
}
doFirst {
println 'Running first'
}
}
执行 gradle secondTask;它将首先执行依赖任务 firstTask,然后执行任务 secondTask,如下所示:
:firstTask
Hello world.
:secondTask
Running first
Running last
定义任务间依赖的另一种方式是使用 secondTask.dependsOn = ['firstTask'] 或 secondTask.dependsOn 'firstTask'。
注意
我们可以将任务名称中的每个单词缩写为驼峰式,以执行任务。例如,任务名称 secondTask 可以缩写为 sT。
守护进程
每次调用 gradle 命令时,都会启动一个新的进程,加载 Gradle 类和库,并执行构建。加载类和库需要时间。如果每次都不加载 JVM、Gradle 类和库,则可以减少执行时间。--daemon 命令行选项启动一个新的 Java 进程并预加载 Gradle 类和库;因此,第一次执行需要时间。带有 --daemon 选项的后续执行几乎不需要时间,因为只有构建被执行——JVM 以及所需的 Gradle 类和库已经加载。守护进程的配置通常放入 GRADLE_OPTS 环境变量中;因此,不是所有调用都需要这个标志。以下截图显示了守护进程的执行:

注意,第一次构建花费了 31 秒,而第二次构建工具只花费了 2 秒。
要停止守护进程,请使用命令行选项 gradle –stop。
Gradle 插件
构建脚本通常是单调的,例如,在一个 Java 构建脚本中,我们定义源文件位置、第三方 JAR 文件位置、清理输出文件夹、编译 Java 文件、运行测试、创建 JAR 文件等。几乎所有的 Java 项目构建脚本看起来都很相似。
这类似于重复代码。我们通过重构和将重复的代码移动到公共位置并共享公共代码来解决重复的代码问题。Gradle 插件通过将重复的任务移动到公共位置来解决重复的构建任务问题,这样所有项目都可以共享和继承公共任务,而不是重新定义它们。
插件是一个 Gradle 配置扩展。它包含一些预配置的任务,这些任务组合起来可以完成一些有用的功能。Gradle 随带了许多插件,帮助我们编写整洁的脚本。
在本章中,我们将探讨 Java 和 Eclipse 插件。
Eclipse 插件
Eclipse 插件生成导入 Eclipse 项目所需的文件。
任何 Eclipse 项目都有两个重要的文件:一个 .project 文件和一个 .classpath 文件。.project 文件包含项目信息,如项目名称和项目类型。.classpath 文件包含项目的类路径条目。
让我们按照以下步骤使用 Eclipse 插件创建一个简单的 Gradle 构建脚本:
-
创建一个名为
eclipse的文件夹,然后创建一个名为build.gradle的文件,并添加以下脚本:apply plugin: 'eclipse'要继承插件类型,Gradle 使用
apply plugin: '<plug-in name>'语法。 -
打开命令提示符,使用
gradle tasks –-all命令检查所有可用的任务。这将为您列出可用的 Eclipse 插件任务。 -
现在运行
gradle eclipse命令。它将只生成.project文件,因为该命令不知道需要构建哪种类型的项目。您将在命令提示符上看到以下输出::eclipseProject :eclipse BUILD SUCCESSFUL -
要创建一个 Java 项目,将
apply plugin: 'java'添加到build.gradle文件中,并重新运行命令。这次它将执行以下四个任务::eclipseClasspath :eclipseJdt :eclipseProject :eclipse -
打开
Eclipse文件夹(放置build.gradle文件的位置)。您将找到.project和.classpath文件以及一个.settings文件夹。对于 Java 项目,需要一个 Java 开发工具(JDT)配置文件。.settings文件夹包含org.eclipse.jdt.core.prefs文件。
现在,我们可以启动 Eclipse 并导入项目。我们可以编辑 .project 文件并更改项目名称。
通常,Java 项目依赖于第三方 JAR 文件,例如 JUnit JAR 和 Apache 工具 JAR。在下一节中,我们将学习如何使用 JAR 依赖项生成类路径。
Java 插件
Java 插件为您的项目提供了一些默认任务,这些任务将编译和单元测试您的 Java 源代码,并将其打包成一个 JAR 文件。
Java 插件定义了项目许多方面的默认值,例如源文件的位置和 Maven 仓库。我们可以遵循约定或根据需要自定义它们;通常,如果我们遵循传统的默认值,那么我们就不需要在构建脚本中做很多事情。
让我们创建一个简单的 Gradle 构建脚本,使用 Java 插件并观察插件提供了什么。执行以下步骤:
-
创建一个
java.gradle构建文件并添加apply plugin: 'java'行。 -
打开命令提示符并输入
gradle -b java.gradle tasks –-all。这将为您列出 Java 插件任务。 -
要构建一个项目,我们可以使用构建任务;构建依赖于许多任务。执行
gradle -b java.gradle build命令。以下截图显示了输出:![Java 插件]()
由于没有提供源代码,构建脚本没有构建任何内容。然而,我们可以看到可用的任务列表——构建任务依赖于编译、JAR 创建、测试执行等。
Java 插件遵循一个约定,即构建源文件将位于项目目录下的src/main/java。非 Java 资源文件,如 XML 和属性文件,将位于src/main/resources。测试将位于src/test/java,测试资源位于src/test/resources。
要更改默认的 Gradle 项目源文件目录设置,使用sourceSets关键字。sourceSets关键字允许我们更改默认源文件的位置。
Gradle 脚本必须知道lib目录的位置才能编译文件。Gradle 对库位置的约定是仓库。Gradle 支持本地lib文件夹、外部依赖项和远程仓库。
Gradle 还支持以下仓库:
-
Maven 仓库:Maven 可以配置在我们的本地机器上、网络机器上,甚至是预配置的中心仓库。
-
Maven 中心仓库:Maven 的中心仓库位于
repo1.maven.org/maven2。可以使用mavenCentral()groovy 方法从集中的 Maven 仓库加载依赖项。以下是一个访问中心仓库的示例:repositories { mavenCentral() } -
Maven 本地仓库:如果我们有一个本地 Maven 仓库,我们可以使用
mavenLocal()方法如下解决依赖项:repositories { mavenLocal() }提示
可以使用
maven()方法来访问内网配置的仓库。以下是一个访问内网 URL 的示例:repositories { maven { name = 'Our Maven repository name' url = '<intranet URL>' } }可以使用以下代码使用
mavenRepo()方法:repositories { mavenRepo(name: '<name of the repository>', url: '<URL>') }受保护的 Maven 仓库需要用户凭据。Gradle 提供了
credentials关键字来传递用户凭据。以下是一个访问受保护 Maven 仓库的示例:repositories { maven(name: repository name') { credentials { username = 'username' password = 'password' } url = '<URL>' } }
-
-
Ivy 仓库:这是一个远程或本地 Ivy 仓库。Gradle 支持与 Maven 相同的 Ivy 方法。以下是一个访问 Ivy 仓库和受保护 Ivy 仓库的示例:
repositories { ivy(url: '<URL>', name: '<Name>') ivy { credentials { username = 'user name' password = 'password' } url = '<URL>' } } -
平面目录仓库:这是一个本地或网络目录。以下是一个访问本地目录的示例:
repositories { flatDir(dir: '../thirdPartyFolder', name: '3rd party library') flatDir { dirs '../springLib', '../lib/apacheLib', '../lib/junit' name = ' Configured libraries for spring, apache and JUnit' } }Gradle 使用
flatDir()来定位本地或网络共享的库文件夹。在这里,dir用于定位单个目录,而使用以逗号分隔的目录位置dirs用于定位分布式文件夹。
在本节中,我们将创建一个 Java 项目,编写一个测试,执行测试,编译源文件或测试文件,并最终构建一个 JAR 文件。执行以下步骤:
-
在
packt\chapter02\java下创建一个build.gradle构建脚本文件。 -
使用以下代码行添加 Eclipse 和 Java 插件支持:
apply plugin: 'eclipse' apply plugin: 'java' -
我们将编写一个 JUnit 测试,因此我们的项目将依赖于 JUnit JARs。在
packt\chapter02下创建一个lib目录,并复制hamcrest-core-1.3.jar和junit-4.11.jarJAR 文件(我们在第一章中下载了这些 JAR 文件,JUnit 4 – 全部回忆)。 -
在此示例中,我们将使用平面目录仓库。我们为 JUnit JARs 创建了一个
lib目录。将以下行添加到build.gradle文件中,以配置我们的仓库:repositories { flatDir(dir: '../lib', name: 'JUnit Library') }我们有一个单独的
lib文件夹;因此,我们将使用flatDir和dir约定。一个仓库可以包含大量的库文件,但我们可能只需要其中的一部分。例如,源文件编译不需要 JUnit JARs,但测试文件和测试执行需要它们。
Gradle 自带依赖管理。
dependencies关键字用于定义依赖项。闭包依赖支持以下默认类型:
-
编译:这些是编译项目源代码所需的依赖项。
-
运行时:这些依赖项是生产类在运行时所需的。默认情况下,这些也包括编译时依赖项。
-
testCompile:这些依赖项是编译项目测试源代码所必需的。默认情况下,它们还包括编译后的生产类和编译时依赖项。
-
testRuntime:这些依赖项是运行测试所必需的。默认情况下,它们还包括编译、运行时和 testCompile 依赖项。
每种依赖类型都需要一个坐标:依赖 JAR 的组、名称和版本。
一些网站,如mvnrepository.com,可以帮助我们生成一个可复制粘贴的依赖字符串,例如
mvnrepository.com/artifact/org.springframework/spring-aop/3.1.1.RELEASE。注意
假设我们需要将
org.springframework.aop-3.1.1.RELEASE.jar文件包含在我们的类路径中(这里org.springframework是组,aop是名称,3.1.1.RELEASE是版本)。我们可以简单地写org.springframework:aop:3.1.1.RELEASE来标识aop.jar。 -
-
测试需要 JUnit JAR 支持。将以下行添加到我们的
build.gradle文件中,以添加 JUnit 依赖项:dependencies { testCompile group: 'junit', name: 'junit', version: '4.11' testCompile group: '', name: 'hamcrest-core', version: '1.3' }或者简单地添加以下行到文件中:
dependencies { testCompile 'junit:junit:4.11', ':hamcrest-core:1.3' } -
使用 Eclipse 插件生成 Eclipse 项目,并执行
gradle eclipse命令。eclipse命令将执行三个任务:eclipseClasspath、eclipseJdt和eclipseProject。进入
\chapter02\java文件夹,你将找到.classpath和.project文件。打开.classpath文件,检查junit-4.11和hamcrest-core-1.3.jar是否已添加为classpathentry。以下截图显示了
gradle eclipse命令的输出:![Java 插件]()
以下截图显示了生成的
.classpath文件的内容:![Java 插件]()
-
启动 Eclipse,通过导航到 文件 | 导入 | 将现有项目导入工作空间 来导入项目。现在浏览到
D:\Packt\chapter02\java文件夹并导入项目。Eclipse 将打开java项目——Java 社区的最佳实践是将测试和源代码文件放在同一个包下,但不同的源文件夹中。Java 代码文件存储在src/main/java下,测试文件存储在src/test/java下。源资源存储在src/main/resources下。我们需要在 Java 项目的直接下创建
src/main/java、src/main/resources和src/test/java文件夹。以下截图显示了文件夹结构:
![Java 插件]()
-
右键单击叶文件夹(分别在
src/main和src/test下的java和resources文件夹);将打开一个弹出菜单。现在,转到 构建路径 | 使用为源文件夹。以下截图显示了该操作:
![Java 插件]()
-
我们将创建一个 Java 类并测试其行为;Java 类将读取属性文件并根据属性文件中提供的值返回一个
enum类型。从测试中读取文件是不推荐的,因为 I/O 操作是不可预测且缓慢的;你的测试可能无法读取文件,并花费时间减慢测试执行。我们可以使用模拟对象来模拟文件读取,但为了简单起见,我们将在服务类中添加两个方法——一个将接受一个String参数并返回一个enum类型,另一个将读取属性文件并调用第一个方法。从测试中,我们将使用字符串调用第一个方法。以下是为配置项目所需的步骤:-
在
/java/src/main/resources下添加一个environment.properties属性文件,并在该文件中添加env = DEV。 -
在
/java/src/main/java源包下的com.packt.gradle包中创建一个enum文件:public enum EnvironmentType { DEV, PROD, TEST } -
创建一个 Java 类来读取属性文件,如下所示:
package com.packt.gradle; import java.util.ResourceBundle; public class Environment { public String getName() { ResourceBundle resourceBundle = ResourceBundle.getBundle("environment"); return resourceBundle.getString("env"); } } -
创建一个
EnvironmentService类,根据环境设置返回一个enum类型,如下所示:package com.packt.gradle; public class EnvironmentService { public EnvironmentType getEnvironmentType() { return getEnvironmentType(new Environment().getName()); } public EnvironmentType getEnvironmentType(String name) { if("dev".equals(name)) { return EnvironmentType.DEV; }else if("prod".equals(name)) { return EnvironmentType.PROD; } return null; } }getEnvironmentType()方法调用Environment类来读取属性文件值,然后使用读取的值调用getEnvironmentType(String name)方法以返回一个enum类型。 -
在
com.packt.gradle包下的/src/test/java中添加一个测试类。以下是其代码:package com.packt.gradle; import static org.junit.Assert.*; import static org.hamcrest.CoreMatchers.*; import org.junit.Test; public class EnvironmentServiceTest { EnvironmentService service = new EnvironmentService(); @Test public void returns_NULL_when_environment_not_configured(){ assertNull(service.getEnvironmentType("xyz")); } @Test public void production_environment_configured(){ EnvironmentType environmentType = service.getEnvironmentType("prod"); assertThat(environmentType, is(EnvironmentType.PROD)); } }在这里,
returns_NULL_when_environment_not_configured()测试将xyz传递给getEnvironmentType方法,并期望服务返回null,假设没有xyz环境。在另一个测试中,它将prod值传递给getEnvironmentType方法,并期望返回一个类型。
-
-
现在,打开命令提示符并运行
gradle build;它将编译源文件和测试文件,执行测试,并最终创建一个 JAR 文件。要仅执行测试,请运行
gradle test。打开
\chapter02\java\build文件夹,您将找到三个重要的文件夹:-
libs:此文件夹包含构建输出 JAR 文件—Java.jar -
reports:此文件夹包含 HTML 测试结果 -
test-results:此文件夹包含 XML 格式的测试执行结果和每个测试的执行时间
以下截图显示了 HTML 格式的测试执行结果:
![Java 插件]()
-
Gradle 是一个智能构建工具,它支持增量构建。重新运行 gradle build 命令。它将跳过任务并显示 UP-TO-DATE。以下是一个增量构建的截图:

如果我们对测试类进行更改,只有测试任务将被执行。以下是一些测试任务:compileTestJava、testClasses、test、check 和 build。
在下一章中,我们将探讨更多关于 Gradle 的内容。你现在想深入了解吗?如果是这样,你可以访问 www.gradle.org/docs/current/userguide/userguide.html。
Maven 项目管理
Maven 是一个项目构建工具。使用 Maven,我们可以构建一个可见的、可重用的和可维护的项目基础设施。
Maven 提供了用于可见性的插件:代码质量/最佳实践通过 PMD/checkstyle 插件可见,XDOC 插件生成项目内容信息,JUnit 报告插件使团队可见失败/成功故事,项目活动跟踪插件使每日活动可见,变更日志插件生成变更列表,等等。
因此,开发者知道可以使用哪些 API 或模块;因此,他或她不会重新发明轮子(而是重用现有的 API 或模块)。这减少了重复,并允许创建一个可维护的系统。
在本节中,我们将探讨 Maven 架构,并使用 Maven 重新构建我们的 Gradle 项目。
安装
Maven 的先决条件是 Java 开发工具包(JDK)。请确保您的计算机上已安装 JDK。
以下设置 Maven 的步骤:
-
下载 Maven 媒体。转到
maven.apache.org/download.html获取 Maven 的最新版本。 -
下载 Maven 后,将其存档提取到一个文件夹中;例如,我将其提取到
D:\Software\apache-maven-3.1.1。 -
对于 Windows 操作系统,创建一个名为
M2_HOME的环境变量,并将其指向 Maven 安装文件夹。修改PATH变量,并追加%M2_HOME%\bin。 -
对于 Linux,我们需要将
PATH和M2_HOME环境变量导出到.bashrc文件。打开.bashrc文件,并使用以下文本进行编辑:export M2_HOME=/home/<location of Maven installation> export PATH=${PATH}:${M2_HOME}/bin -
对于 Mac,需要修改
.bash_login文件,添加以下文本:export M2_HOME=/usr/local/<maven folder> export PATH=${PATH}:${M2_HOME}/bin -
检查安装并执行
mvn –version命令。这应该会打印出 Maven 版本。以下是一个输出截图:![安装]()
Maven 已经安装,因此我们可以开始探索 Maven。已经安装了 m2eclipse 插件的 Eclipse 用户已经拥有 Maven,他们可以直接在 Eclipse 中使用 Maven,无需安装 Maven。
架构插件
在 Maven 中,架构是一个项目模板生成插件。
Maven 允许我们从预定义的项目类型列表中从头开始创建项目基础设施。Maven 命令 mvn archetype:generate 生成一个新的项目骨架。
archetype:generate 命令加载可用的项目类型目录。它尝试连接到 Maven 中央仓库 repo1.maven.org/maven2,并下载架构目录。
提示
要获取最新目录,您应该连接到互联网。
按照以下步骤生成 Java 项目骨架:
-
创建文件夹层次结构
/Packt/chapter02/maven,打开命令提示符,并浏览到/Packt/chapter02/maven文件夹。 -
发出
mvn archetype:generate命令;您将看到一个大型架构列表正在下载,每个架构都有一个编号、一个名称和简短描述。它将提示您输入架构编号。输入默认的
maven-archetype-quickstart架构。在我的情况下,编号是 343。以下截图显示编号
343是默认值:![架构插件]()
提示
要在 Windows 操作系统中获取整个目录,请输入
mvn archetype:generate > archetype.txt命令。这将把项目类型列表填充到文本文件中。 -
输入
343或直接按 Enter 键选择默认值。接下来,它将提示您选择版本。按 Enter 键选择默认值。 -
现在,它将要求您提供一个
groupId。groupId是多个项目的根包,org.springframework是所有 Spring 项目的groupId。输入org.packt作为groupId。 -
接下来,它将询问
artifactId。这是项目名称,aop是org.springframework.aop-3.1.1.RELEASE的artifactId。输入Demo作为artifactId。 -
Maven 将询问版本,默认为
1.0-SNAPSHOT。版本是项目的版本,在这里3.1.1.RELEASE是org.springframework.aop-3.1.1.RELEASE项目的版本。我们将接受默认值。按 Enter 键接受默认值。 -
现在将提示您输入包名。输入
com.packt.edu作为包名。 -
最后,它将显示您输入的内容。请查看并接受,如下面的截图所示:
![架构插件]()
打开
/Packt/chapter02/maven文件夹;您将看到创建的Demo项目文件夹具有以下文件结构:![架构插件]()
Maven 对于源 Java 文件的约定是 src/main/java,而测试源文件是 src/test/java。
Maven 将自动在 src/main/java/com/packt/edu 下创建一个 Java 文件 App.java,并在 src/test/java/com/packt/edu 下创建一个测试文件 AppTest。
此外,它将在 Demo 下的直接位置创建一个 XML 文件 pom.xml。此文件将用于构建项目。在下一节中,我们将了解 POM 文件。
项目对象模型(POM)文件。
每个 Maven 项目都包含一个 pom.xml 文件,这是一个项目元数据文件。
POM 文件可以包含以下部分:
-
项目坐标,如
<groupId/>、<artifactId/>、<version/>、<dependency>以及通过<modules/>和<parent/>的继承。在
Demo文件夹中打开pom.xml文件;它包含以下坐标细节:<groupId>org.packt</groupId> <artifactId>Demo</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging> -
<build>和<reporting>中的构建细节。 -
项目可见性细节,如
<name>、<organization>、<developers>、<url>和<contributors>。我们生成的
pom.xml包含以下细节:<name>Demo</name> <url>http://maven.apache.org</url> -
项目环境细节,如
<scm>、<repository>和<mailingList>。
项目依赖。
在多模块项目中,一个项目可以依赖于许多其他项目。例如,假设我们依赖于 JUnit。Maven 会自动发现所需的工件依赖。这对于我们依赖于许多开源项目来说非常有用。无论是开源还是闭源项目,这总是有用的。
你还记得 Gradle 依赖闭包吗?它有四个默认类型:编译、运行时、测试编译和测试运行时。
类似地,Maven 有以下依赖范围:
-
编译:代码编译时间类路径依赖;这是默认范围。如果没有明确定义,则设置编译时间范围。
-
运行时:这是运行时所需的。
-
测试:此依赖对于测试代码编译和测试执行是必需的。
-
提供:运行时所需的 JDK 或环境依赖。
父项目使用以下代码片段定义依赖:
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
</dependencies
所有子项目只需添加 <dependency> 标签即可继承依赖,如下所示:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</dependency>
构建生命周期。
构建生命周期明确定义了构建和分发特定项目工件的过程。
Maven 有以下三个内置的构建生命周期:
-
默认:此生命周期处理编译、测试、打包、部署以及更多功能。
-
清理:此生命周期通常清理由之前的构建(s)生成的构建工件。
-
站点:此生命周期负责生成和部署项目的站点文档。
现在,我们将编译和测试我们的 Demo 项目。
在本节中,我们将处理默认生命周期的编译、测试和打包目标。
编译项目
执行以下步骤以编译项目:
-
打开命令提示符并浏览到
\Packt\chapter02\maven\Demo。Maven 需要一个pom.xml文件来编译项目。 -
输入
mvn compile;它将编译项目并在\Demo\target\classes下创建类文件。以下截图显示了输出:![编译项目]()
测试项目
要在 Demo 中执行测试,打开命令提示符并输入 mvn test;它将下载 JUnit JARs 和 surefire JARs 分别用于测试编译和测试报告生成,然后执行测试。以下截图显示了输出:

打包项目
mvn package 命令编译源代码,编译测试,执行测试,并最终构建一个 JAR 文件。它将在 \Packt\chapter02\maven\Demo\target 下生成 Demo-1.0-SNAPSHOT.jar。
清理生命周期
mvn clean 命令会删除 target 文件夹并删除所有内容。运行该命令并检查 \Packt\chapter02\maven\Demo\ 中的 target 文件夹是否已被删除。
网站生命周期
mvn site 命令在目标或站点下生成详细的 HTML 格式项目报告。它包括关于、插件管理、分发管理、依赖信息、源代码库、邮件列表、问题跟踪、持续集成、项目插件、项目许可、项目团队、项目摘要和依赖项。
参考以下链接以了解更多关于 Maven 的信息:maven.apache.org/guides/index.html
下一节将介绍 Apache Ant。
另一个整洁的工具(Ant)
Ant 是 Apache 软件基金会的一个基于 Java 的构建工具。Ant 的构建文件是用 XML 编写的。你需要 Java 来执行 Ant 任务。
从 ant.apache.org/ 下载 Apache Ant,提取媒体文件,并创建一个 ANT_HOME 变量,将其值设置为提取的位置。在 Windows 中编辑 PATH 并追加 %ANT_HOME%\bin。对于 Mac 或 Linux 操作系统,你需要像本章前面提到的 Maven 项目管理 部分的 安装 部分中描述的那样导出 ANT_HOME 和 PATH。
Ant 需要一个 build.xml 文件来执行任务。Ant 支持使用 –f 选项来指定构建脚本;因此,ant –f myBuildFile.xml 命令将有效。
我们将创建一个构建脚本并使用 Ant 执行 Maven 项目 (\Packt\chapter02\maven\Demo)。按照以下步骤操作:
-
在
\Packt\chapter02\maven\Demo中创建一个 XML 文件build.xml。 -
在
build.xml文件中添加以下行:<?xml version="1.0"?> <project name="Demo" basedir="."> <property name="src.dir" location="src/main/java" /> <property name="build.dir" location="bin" /> <property name="dist.dir" location="ant_output" /> </project><project>标签是 Ant 中定义的标签。你可以给你的项目命名,Demo是这个项目的名称。接下来,我们将设置属性;一个属性可以有一个名称和值或位置。在这里,src.dir是一个属性名称,这个属性可以通过${src.dir}语法在任何任务中使用。location属性指的是从build.xml文件开始的相对位置。由于src/main/java包含源文件,我们将位置值设置为src/main/java。其他两个属性,build.dir和dist.dir,将由 Java 编译任务用来编译类文件并生成 JAR 文件。 -
你还记得 Maven 中的 clean 任务吗?Ant 不提供默认目标。我们必须定义一个
clean目标来删除旧的构建输出,然后我们将使用 Ant 的<delete>命令来删除目录。然后,使用<mkdir>命令,我们将重新创建目录:<target name="clean"> <delete dir="${build.dir}" /> <delete dir="${dist.dir}" /> </target> <target name="makedir"> <mkdir dir="${build.dir}" /> <mkdir dir="${dist.dir}" /> </target>注意,我们使用
<target>标签添加了两个目标。每个目标都通过一个名称来标识。我们将调用clean目标来删除build.dir(生成的.class文件)和dist.dir(构建输出 JAR 文件)。 -
编译任务内置于 Gradle/Maven 中,但 Ant 没有内置的编译目标;因此,我们将创建一个目标来编译 Java 文件,如下所示:
<target name="compile" depends="clean, makedir"> <javac srcdir="${src.dir}" destdir="${build.dir}"> </javac> </target>使用
<javac>命令编译 Java 文件。<javac>命令接受srcdir和destdir。编译器从srcdir读取 Java 文件,并将类文件生成到destdir。一个目标可能依赖于另一个,
depends允许我们传递以逗号分隔的目标名称。在这里,编译目标依赖于clean和makedir。 -
编译已完成。现在,我们将使用
<jar>命令从类文件创建jar,如下所示:<target name="jar" depends="compile"> <jar destfile="${dist.dir}\${ant.project.name}.jar" basedir="${build.dir}"> </jar> </target>jar目标需要知道类文件的位置和目标位置。destfile属性指的是目标 JAR 文件名和位置,basedir指的是类文件位置。检查我们是否使用了${dist.dir}\${ant.project.name}.jar来表示目标 JAR 文件名和文件夹。在这里,${dist.dir}指的是目标文件夹,而${ant.project.name}.jar代表 JAR 名称。${ant.project.name}是我们之前在<project>标签中提到的名称(Demo)。 -
Ant 脚本已经准备好编译并创建 JAR 文件。打开命令提示符,转到
\Packt\chapter02\maven\Demo,然后执行ant jar命令。在这里,jar依赖于compile,而compile依赖于clean和makedir。因此,jar命令将创建两个目录,bin和ant_output,编译 Java 文件并在 bin 文件夹中生成.class文件,最后在ant_output文件夹中创建Demo.jarJAR 文件。 -
编译已完成;现在,是时候执行测试了。测试需要 JUnit JAR 文件和生成的源代码类文件来编译和执行。我们已经在
Packt\chapter02\lib中为 Gradle 创建了lib目录,并将 JUnit 4 JAR 文件保存在其中。我们将使用这个lib。添加以下三个属性,用于测试源文件目录、库目录和测试报告:<property name="test.dir" location="src/test/java" /> <property name="lib.dir" location="../../lib" /> <property name="report.dir" location="${dist.dir}/report" />检查
lib.dir位置是否相对于build.xml位置。test.dir属性指向src/test/main,测试报告将在ant_output/report内生成。 -
路径允许我们引用一个目录或文件路径。我们将定义一个
jclass.path路径来引用lib目录下的所有 JAR 文件和生成的.class文件,如下所示:<path id="jclass.path"> <fileset dir="${lib.dir}/"> <include name="**/*" /> </fileset> <pathelement location="${build.dir}" /> </path><fileset>标签接受目录位置,而<include>接受文件名或正则表达式。**/*值表示${lib.dir}下的所有目录和文件。pathelement属性引用放置编译后的类文件的bin目录。 -
现在,我们需要编译测试文件。添加一个
testcompile目标并使用javac命令。将test.dir作为srcdir用于编译。添加<classpath>来引用jclass.path值。这将编译测试文件。考虑以下代码片段:<target name="testcompile" depends="compile"> <javac srcdir="${test.dir}" destdir="${build.dir}"> <classpath refid="jclass.path" /> </javac> </target> -
添加另一个目标来执行 JUnit 测试。Ant 有一个
junit命令来运行测试。传递jclass.path来指向lib目录和生成的文件,如下所示:<target name="test" depends="testcompile"> <junit printsummary="on" fork="true" haltonfailure="yes"> <classpath refid="jclass.path" /> <formatter type="xml" /> <batchtest todir="${report.dir}"> <fileset dir="${test.dir}"> <include name="**/*Test*.java" /> </fileset> </batchtest> </junit> </target>执行
ant test命令。此命令编译并执行测试。我们可以在
<project>标签中的build.xml文件中设置一个默认任务。语法是<project name="Demo" default="task name" basedir=".">。现在,我们不需要指定目标名称。
我们的 Ant 脚本已准备好编译 Java 文件、执行测试和生成报告。在下一节中,我们将设置 Jenkins 并使用构建脚本。
要了解更多关于如何编译 Web 归档和高级主题的信息,请访问 ant.apache.org/。
Jenkins
Jenkins 是一个用 Java 编写的开源 CI 工具。它可以在任何符合 Servlet 规范 2.4 的 Web 容器上运行。新的 Apache Tomcat 服务器是 Jenkins 可以集成为 Windows 服务的一个 Web 容器示例。
Jenkins 通过使用插件支持各种源代码控制平台,如 CVS、SVN、Git、Mercurial 和 ClearCase。
它可以在 Ant 和 Maven 项目上执行自动化构建。Jenkins 是免费的(MIT 许可证),并在许多操作系统上运行。Jenkins 不允许你创建 Gradle 项目,但你可以创建一个自由风格的项目并构建 Gradle 项目。
要在你的本地机器上安装 Jenkins,请遵循 wiki.jenkins-ci.org/display/JENKINS/Installing+Jenkins 中的说明。
一旦 Jenkins 安装完成,我们将执行以下步骤来配置 Jenkins:
-
启动 Jenkins URL;从主页转到 管理 Jenkins | 配置系统。
-
现在,您需要设置 JDK。转到 JDK 部分,点击 JDK 安装,然后点击 添加 JDK。取消选择 自动安装 复选框,并输入一个 名称 和 JAVA_HOME 路径。您可以添加任意数量的 JDK。名称 和 JAVA_HOME 位置唯一标识 JDK 的版本。在您的项目中,您可以引用您想要使用的 JDK。以下截图显示了 JDK 的安装:
![Jenkins]()
-
现在,设置 Maven。转到 Maven 部分,点击 Maven 安装。现在,点击 添加 Maven,取消选择 自动安装 复选框,输入一个 名称,并将其设置为 MAVEN_HOME。
通常,如果复选框 自动安装 被勾选,那么 Jenkins 将要求您选择工具的版本并下载该版本。您可以安装或添加多个软件版本,只需给出一个独特的名称。例如,您可以添加一个名称,
Maven3,来引用 Maven 版本 3.1.1,并添加Maven2来引用版本 2.2.1。在您的构建作业中,Jenkins 将显示列表并选择您需要的适当版本。以下截图显示了 Maven 的安装:![Jenkins]()
-
转到 Ant 部分,点击 Ant 安装。然后,点击 添加 Ant,取消选择 自动安装 复选框,输入一个 名称,并将其设置为 ANT_HOME。
![Jenkins]()
我们的基本配置已完成。接下来,我们将开始使用 Gradle 构建一个 Java 项目。
Gradle 项目
Jenkins 不自带 Gradle。您需要按照以下步骤安装插件:
-
启动 Jenkins URL;从主页转到 管理 Jenkins | 管理插件。转到 可用 选项卡;在页面右上角的 过滤器 文本框中,输入
gradle。这将带您到 Gradle 插件。勾选与 Gradle 插件 相关的复选框,然后点击 不重启安装。这将安装 Gradle 插件。Jenkins 将显示安装进度。安装完成后,您需要配置 Gradle,就像我们对 Ant 和 Maven 所做的那样。参考以下截图来安装 Gradle 插件:
![Gradle 项目]()
-
从主页转到 管理 Jenkins | 配置系统。滚动到 Gradle 部分,点击 Gradle 安装。然后,点击 添加 Gradle,取消选择 自动安装 复选框,输入一个 名称,并将 GRADLE_HOME 设置好。
![Gradle 项目]()
-
返回主页。Jenkins 的项目构建约定是作业。一个作业会持续运行,调用脚本,并给出反馈。为了设置自动构建过程,用户必须配置一个作业。点击创建新作业超链接以添加新的项目类型。Jenkins 支持多种类型的构建作业。最常用的两种作业是自由式构建和 Maven 2/3 构建。自由式项目允许您配置任何类型的构建作业;这种作业类型非常灵活且可配置。然而,您可以安装插件以支持其他类型。
以下截图显示了如何创建一个
gradleProject自由式作业:![Gradle 项目]()
-
自由式项目有几个设置。在高级项目选项中,您可以设置静默期(构建后的等待时间)、重试次数(从仓库签出的尝试次数)等。在源代码管理中,您可以选择版本控制工具类型。版本控制是 CI 中最重要的东西之一。它跟踪软件版本,我们可以在任何时间点回滚我们的更改,查看文件历史记录等。默认情况下,Jenkins 附带源代码管理工具插件,CVS 和 SVN,但我们可以安装插件以支持其他类型,如 Git 和 Rational ClearCase。我们尚未配置任何版本控制工具;因此,选择无,如下面的截图所示:
![Gradle 项目]()
-
接下来是构建触发器事件,构建触发器知道何时启动作业。有几种类型:
-
在构建其他项目之后构建:这意味着作业将在另一个作业之后调用
-
定期构建:这表示 cron 表达式的周期性调度,即每 5 分钟或每 30 分钟等
-
轮询 SCM:这意味着在调度选项中设置特定时间后轮询版本控制位置
我们没有其他作业或版本控制工具,因此选择定期构建并将调度设置为H/5****以每 5 分钟执行一次构建,如下面的截图所示:
![Gradle 项目]()
-
-
下一个部分是构建。您可以为构建添加多个步骤。点击添加构建步骤。它将显示一个步骤;选择调用 Gradle 脚本以调用我们的 Gradle 项目,如下面的截图所示:
![Gradle 项目]()
-
现在点击调用 Gradle单选按钮并选择我们添加的 Gradle 版本。在任务字段中,输入
build以调用构建任务;您可以在此处添加多个任务。在构建文件字段中,输入您的 Gradle 构建文件的完整路径\Packt\chapter02\java\build.gradle,如下面的截图所示:![Gradle 项目]()
-
现在点击保存。Jenkins 将带您到项目的首页。点击立即构建超链接。它将开始构建我们的第一个项目。它将显示一个包含构建编号的构建历史表,例如#1 2014 年 2 月 4 日 09:18:45 PM。点击build#超链接,然后点击控制台输出。它将显示构建日志。以下截图显示了我们的 Gradle 构建日志:
![Gradle 项目]()
-
现在返回首页;它显示了所有构建及其状态的列表。有一个天气列——当所有构建失败时,天气显示多云的图片,当所有构建通过时,天气变为晴朗。您可以通过点击每个构建行右侧的轮形符号来调用构建。参见图表:
![Gradle 项目]()
我们的 Gradle 构建配置已完成。自动地,每过五分钟,构建将被启动。我们可以配置构建后的操作,在每次构建后发送电子邮件。这样,如果构建失败,则立即发送邮件,相关人员可以处理问题。因此,反馈周期更快。
在下一节中,我们将配置 Maven 作业。
Maven 项目
在本节中,我们将配置 Jenkins 执行 Maven 构建作业。请执行以下步骤:
-
点击新建工作超链接以添加新的项目类型。选择构建 Maven2/3 项目并输入一个工作名称,如图所示:
![Maven 项目]()
-
在详情页面上,选择源代码管理为无,构建触发器为定期构建,并将H/5****设置为每 5 分钟执行一次构建。
-
接下来,转到构建部分,并设置根 POM值;在
Demo项目中设置pom.xml文件的完整文件路径位置。您可以将目标和选项部分留空。Gradle 将发出默认的mvn install命令。参见图表:![Maven 项目]()
-
现在点击保存。Jenkins 将带您到项目的首页。点击立即构建超链接,它将开始构建我们的第一个项目。它将显示一个包含构建编号的构建历史表,例如#1 2014 年 2 月 4 日 09:18:45 PM。点击build#超链接,然后点击控制台输出。它将显示以下构建日志:
Executing Maven: -B -f D:\Packt\chapter02\maven\Demo\pom.xml install [INFO] Scanning for projects... [INFO] Building Demo 1.0-SNAPSHOT [INFO] Downloading: http://repo.maven.apache.org/maven2/org/apache/maven/plugin (226 KB at 33.4 KB/sec) [INFO] BUILD SUCCESS [INFO] Total time: 2:28.150s [ [JENKINS] Archiving D:\Packt\chapter02\maven\Demo\pom.xml to org.packt/Demo/1.0-SNAPSHOT/Demo-1.0-SNAPSHOT.pom [JENKINS] Archiving D:\Packt\chapter02\maven\Demo\target\Demo-1.0-SNAPSHOT.jar to org.packt/Demo/1.0-SNAPSHOT/Demo-1.0-SNAPSHOT.jar channel stopped Finished: SUCCESS -
检查 Jenkins 是否发出了
mvn install命令,创建了 JAR 文件,并在.m2仓库中安装了工件。
构建 Ant 项目
我们将设置一个使用 Ant 构建的自由风格软件项目。以下步骤如下:
-
打开 Jenkins URL,点击新建工作,然后选择构建自由风格软件项目。输入名称
ant,然后点击确定。 -
我们没有源代码管理,因此跳过此部分。转到构建触发器并设置**H/5 * * * ***值以自动每 5 分钟启动构建。
-
前往构建部分,添加一个调用 Ant构建步骤,如图所示:
![构建 Ant 项目]()
-
从下拉菜单中选择一个 Ant 版本,将目标设置为
jar;jar将调用测试和编译。在构建文件中,浏览到我们的build.xml文件位置并设置值,如图所示:![构建 Ant 项目]()
-
保存设置,新的作业将被保存。点击立即构建。它将开始构建本章中较早创建的
Demo项目。以下为控制台输出的截图:![构建 Ant 项目]()
你可以在 Jenkins wiki 上阅读有关保护 Jenkins、构建后操作、损坏的构建声明插件和 CI 游戏的内容,网址为jenkins-ci.org/。
摘要
本章涵盖了 CI 的概念,探索了构建自动化工具,并配置 Jenkins 以实现 CI。
Gradle 部分涵盖了环境设置、Gradle 任务、守护进程、依赖管理、仓库设置、Eclipse/Java 插件,并逐步探索了 Gradle 的功能。Maven 部分演示了如何设置 Maven,描述了 POM 文件、项目依赖,并探索了默认、清理和站点生命周期。Ant 部分描述了如何编写 Ant 脚本来编译和执行 JUnit 测试。Jenkins 部分涵盖了构建自动化设置以及使用 Gradle、Maven 和 Ant 的自动化构建。
到目前为止,读者将能够使用 Gradle、Maven 和 Ant 编写构建脚本,并配置 Jenkins 以执行构建脚本。
下一章提供了测试替身和不同测试替身类型的概述,并包含如哑元、存根、模拟、间谍和伪造等主题。
第三章:测试替身
本章涵盖了测试替身的概念,并解释了各种测试替身类型,如模拟、伪造、假设、存根和间谍。有时,由于协作对象不可用或实例化的成本,可能无法对一段代码进行单元测试。测试替身减轻了对协作对象的需求。
我们了解替身——一种用于电影中危险动作序列的受过训练的替代品,例如从帝国大厦跳下,在一辆燃烧的火车上打斗,从飞机上跳下或类似动作。替身用于保护真实演员或当演员不在场时进行补充。
在测试与 API 通信的类时,你不想在每次测试时都调用 API;例如,当一段代码依赖于数据库访问时,除非数据库可访问,否则无法对代码进行单元测试。同样,在测试与支付网关通信的类时,你不能向真实的支付网关提交支付以运行测试。
测试替身充当替身。它们是协作对象的熟练替代品。Gerard Meszaros 提出了测试替身这个术语,并在他的书籍《xUnit Test Patterns》中解释了测试替身。Pearson Education。
测试替身被分为五种类型。以下图表显示了这些类型:

假设
假设的一个例子是电影场景中,替身演员没有进行任何表演,只是在屏幕上出现。它们在实际演员不在场但需要出现在场景中时使用,例如观看美国公开赛网球决赛。
类似地,为了避免对强制参数对象传递NullPointerException,会传递假设对象,如下所示:
Book javaBook = new Book("Java 101", "123456");
Member dummyMember = new DummyMember());
javaBook.issueTo(dummyMember);
assertEquals(javaBook.numberOfTimesIssued(),1);
在前面的代码片段中,创建了一个假设成员并将其传递给一个图书对象,以测试图书能否报告其被借阅的次数。在这里,成员对象在别处没有使用,但它需要用于借阅图书。
存根
当存根的方法被调用时,存根会向调用者提供间接输入。存根仅针对测试范围进行编程。存根可能会记录其他信息,例如方法被调用的次数等。
如果 ATM 的货币分配器未能分配现金,则应回滚账户交易。当我们没有 ATM 机器或如何模拟分配器失败的场景时,我们如何测试这一点?我们可以使用以下代码来完成:
public interface Dispenser {
void dispense(BigDecimal amount) throws DispenserFailed;
}
public class AlwaysFailingDispenserStub implements Dispenser{
public void dispense(BigDecimal amount) throws DispenserFailed{
throw new DispenserFailed (ErrorType.HARDWARE,"not responding");
}
}
class ATMTest...
@Test
public void transaction_is_rolledback_when_hardware_fails() {
Account myAccount = new Account("John", 2000.00);
TransactionManager txMgr = TransactionManager.forAccount(myAccount);
txMgr.registerMoneyDispenser(new AlwaysFailingDispenserStub());
WithdrawalResponse response = txMgr.withdraw(500.00);
assertEquals(false, response.wasSuccess());
assertEquals(2000.00, myAccount.remainingAmount());
}
在前面的代码中,AlwaysFailingDispenserStub在调用dispense()方法时引发错误。这允许我们在硬件不存在的情况下测试事务行为。
Mockito 允许我们模拟接口和具体类。使用 Mockito,你可以模拟dispense()方法以抛出异常。
伪造
模拟对象是工作实现;大多数情况下,模拟类扩展了原始类,但它通常会对性能进行修改,这使得它不适合生产环境。以下是一个模拟对象的示例:
public class AddressDao extends SimpleJdbcDaoSupport{
public void batchInsertOrUpdate(List<AddressDTO> addressList, User user){
List<AddressDTO> insertList = buildListWhereLastChangeTimeMissing(addressList);
List<AddressDTO> updateList = buildListWhereLastChangeTimeValued(addressList);
int rowCount = 0;
if (!insertList.isEmpty()) {
rowCount = getSimpleJdbcTemplate().batchUpdate(INSERT_SQL,…);
}
if (!updateList.isEmpty()){
rowCount += getSimpleJdbcTemplate().batchUpdate(UPDATE_SQL,…);
}
if (addressList.size() != rowCount){
raiseErrorForDataInconsistency(…);
}
}
AddressDAO类继承自 Spring 框架类,并提供了一个用于批量更新的 API。同一个方法用于创建新地址和更新现有地址;如果计数不匹配,则会引发错误。这个类不能直接进行测试,需要getSimpleJdbcTemplate()。因此,为了测试这个类,我们需要绕过 JDBC 协作者;我们可以通过扩展原始 DAO 类但重写协作者方法来实现。以下FakeAddressDao类是AddressDao的模拟实现:
public class FakeAddressDao extends AddressDao{
@Override
public SimpleJdbcTemplate getSimpleJdbcTemplate() {
return jdbcTemplate;
}
}
FakeAddressDao扩展了AddressDao,但只重写了getSimpleJdbcTemplate()并返回一个 JDBC 模板存根。我们可以使用 Mockito 创建JdbcTemplate的模拟版本,并从模拟实现中返回它。这个类不能用于生产,因为它使用了一个模拟的JdbcTemplate;然而,模拟类继承了 DAO 的所有功能,因此可以用于测试。模拟类对于遗留代码非常有用。
模拟
模拟对象有期望值;测试会从模拟对象中期望得到一个值,在执行过程中,模拟对象返回期望的结果。此外,模拟对象可以跟踪调用次数,即模拟对象上的方法被调用的次数。
以下示例是 ATM 示例的延续,使用模拟版本。在前一个示例中,我们模拟了Dispenser接口的 dispense 方法以抛出异常;这里,我们将使用模拟对象来复制相同的行为。我们将解释语法在第四章,渐进式 Mockito。
public class ATMTest {
@Mock Dispenser failingDispenser;
@Before public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
}
@Test public void transaction_is_rolledback_when_hardware_fails() throws DispenserFailed {
Account myAccount = new Account(2000.00, "John");
TransactionManager txMgr = TransactionManager.forAccount(myAccount);
txMgr.registerMoneyDispenser(failingDispenser);
doThrow(new DispenserFailed()).when(failingDispenser).dispense(isA(BigDecimal.class));
txMgr.withdraw(500);
assertEquals(2000.00, myAccount.getRemainingBalance());
verify(failingDispenser, new Times(1)).dispense(isA(BigDecimal.class));
}
}
上述代码是 ATM 测试的模拟(Mockito)版本。同一个对象可以在不同的测试中使用;只需设置期望值。在这里,doThrow()会在模拟对象被任何BigDecimal值调用时引发错误。
间谍
间谍(Spy)是模拟/存根(Mock/Stub)的一种变体,但它不仅设置期望值,还会记录对协作者的调用。以下示例解释了这一概念:
class ResourceAdapter{
void print(String userId, String document, Object settings) {
if(securityService.canAccess("lanPrinter1", userId)) {
printer.print(document, settings);
}
}
}
要测试ResourceAdapter类的print行为,我们需要知道当用户有权限时,printer.print()方法是否被调用。在这里,printer协作者不做任何事情;它只是用来验证ResourceAdapter的行为。
现在,考虑以下代码:
class SpyPrinter implements Printer{
private int noOfTimescalled = 0;
@Override
public void print(Object document, Object settings) {
noOfTimescalled++;
}
public int getInvocationCount() {
return noOfTimescalled;
}
}
SpyPrinter实现了Printer.print()调用,增加一个noOfTimescalled计数器,getInvocationCount返回计数。创建一个SecurityService类的模拟实现,使其从canAccess(String printerName, String userId)方法返回true。以下是SecurityService类的模拟实现:
class FakeSecurityService implements SecurityService{
public boolean canAccess(String printerName, String userId){
return true;
}
}
print behavior of the ResourceAdapter class:
@Test public void verify() throws Exception {
SpyPrinter spyPrinter = new SpyPrinter();
adapter = new ResourceAdapter(new FakeSecurityService(), spyPrinter);
adapter.print("john", "helloWorld.txt", "all pages");
assertEquals(1, spyPrinter.getInvocationCount());
}
创建了一个假的 SecurityService 对象和一个 SpyPrinter 对象,并将它们传递给 ResourceAdapter 类,然后调用 adapter.print。反过来,预期 securityService 对象将返回 true,并且将访问打印机,spyPrinter.print(…) 将增加 noOfTimescalled 计数器。最后,在前面代码中,我们验证了计数为 1。
摘要
本章通过示例概述了测试替身,包括哑元、存根、模拟、伪造和间谍。本章是 Mockito 的先决条件。
下一章将介绍 Mockito 框架及其高级用法。Mockito 是一个用于 Java 的模拟框架。它提供了创建模拟、间谍和存根的 API。
第四章。渐进式 Mockito
本章提炼了 Mockito 框架的主要核心,并提供了技术示例。不需要对模拟有任何先前的知识。
本章涵盖了以下主题:
-
Mockito 概述
-
探索 Mockito API
-
高级 Mockito 示例
-
使用 Mockito 进行行为驱动开发(BDD)
与 Mockito 一起工作
Mockito 是一个开源的 Java 模拟单元测试框架。在上一章中,我们阅读了有关测试替身和模拟对象的内容。Mockito 允许创建模拟对象、验证和存根。
要了解更多关于 Mockito 的信息,请访问以下链接:
你为什么应该使用 Mockito?
自动化测试是安全网。它们会运行并通知用户系统是否出现故障,以便可以非常快速地修复有问题的代码。
如果测试套件运行了一个小时,快速反馈的目的就受到了损害。单元测试应作为安全网并提供快速反馈;这是 TDD 的主要原则。
我在一个环境中工作,当一段代码被提交时,自动化测试会运行,需要花费数小时才能完成。因此,开发者必须等待一个小时才能提交新代码,除非之前的构建/测试运行完成。开发者可以在构建过程中提交代码,但最佳实践是在签出之前监控状态;否则,新代码可能会破坏下一个构建并给其他开发者带来问题。因此,开发者必须额外等待一个小时来监控下一个构建。这种缓慢的构建环境阻碍了开发进度。
由于以下原因,测试可能需要花费时间来执行:
-
有时测试会从数据库获取连接以获取/更新数据
-
它连接到互联网并下载文件
-
它与 SMTP 服务器交互以发送电子邮件
-
它执行 I/O 操作
现在的问题是,我们是否真的需要在单元测试代码时获取数据库连接或下载文件?
答案是肯定的。如果它没有连接到数据库或下载最新的股票价格,系统中的许多部分将未经过测试。因此,数据库交互或网络连接对于系统的某些部分是强制性的,这些是集成测试。为了对这些部分进行单元测试,需要模拟外部依赖。
Mockito 在模拟外部依赖方面发挥着关键作用。它模拟数据库连接或任何外部 I/O 行为,以便实际逻辑可以进行单元测试。
单元测试应遵循多个原则以实现灵活性和可维护性。下一节将阐明我们将遵循的原则。
单元测试的品质
单元测试应遵循以下原则:
-
顺序无关和隔离:
ATest.java测试类不应依赖于BTest.java测试类的输出,或者when_an_user_is_deleted_the_associated_id_gets_deleted()测试不应依赖于另一个when_a_new_user_is_created_an_id_is_returned()测试的执行。如果BTest.java在ATest.java之后执行,或者when_a_new_user_is_created_an_id_is_returned()测试在when_an_user_is_deleted_the_associated_id_gets_deleted()之后执行,测试不应失败。 -
无障碍设置和运行:单元测试不应需要数据库连接或互联网连接或清理临时目录。
-
轻松执行:单元测试应在所有计算机上运行良好,而不仅仅是特定计算机上。
-
一级方程式执行:测试的执行时间不应超过一秒。
Mockito 提供了模拟外部依赖项并实现此处提到的特性的 API。
喝 Mockito
从以下链接下载最新的 Mockito 二进制文件并将其添加到项目依赖项中:
code.google.com/p/mockito/downloads/list
截至 2014 年 2 月,最新的 Mockito 版本是 1.9.5。
配置 Mockito
http://mvnrepository.com/artifact/org.mockito/mockito-core):
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>1.9.5</version>
<scope>test</scope>
</dependency>
以下 Gradle 脚本片段将 Mockito 依赖项添加到 Gradle 项目中:
testCompile 'org.mockito:mockito-core:1.9.5'
动态模拟
本节通过股票报价示例演示了模拟对象。在现实世界中,人们会在股票市场上投资金钱——购买和出售股票。股票符号是用于在特定市场上唯一标识特定股票的缩写,例如 Facebook 的股票在纳斯达克注册为 FB,而苹果的股票为 AAPL。
我们将构建一个股票经纪人模拟程序。程序将监视市场统计数据,并根据当前市场数据执行以下任何操作:
-
买入股票
-
卖出股票
-
持有股票
程序中将使用的域类有 Stock、MarketWatcher、Portfolio 和 StockBroker。
Stock 代表现实世界的股票。它有一个符号、公司名称和价格。
MarketWatcher 查找股票市场并返回股票报价。一个市场观察者的真实实现可以从 www.wikijava.org/wiki/Downloading_stock_market_quotes_from_Yahoo!_finance 实现。请注意,真实实现将连接到互联网并从提供商下载股票报价。
Portfolio 代表用户的股票数据,例如股票数量和价格详情。Portfolio 提供了获取平均股票价格和买卖股票的 API。假设第一天某人以 $10.00 的价格购买了一股股票,第二天,客户以 $8.00 的价格购买了同一股股票。因此,第二天这个人有两股股票,平均股价为 $9.00。
represents the StockBroker class. StockBroker collaborates with the MarketWatcher and Portfolio classes. The perform() method of StockBroker accepts a portfolio and a Stock object:
public class StockBroker {
private final static BigDecimal LIMIT = new BigDecimal("0.10");
private final MarketWatcher market;
public StockBroker(MarketWatcher market) {
this.market = market;
}
public void perform(Portfolio portfolio,Stock stock) {
Stock liveStock = market.getQuote(stock.getSymbol());
BigDecimal avgPrice = portfolio.getAvgPrice(stock);
BigDecimal priceGained = liveStock.getPrice().subtract(avgPrice);
BigDecimal percentGain = priceGained.divide(avgPrice);
if(percentGain.compareTo(LIMIT) > 0) {
portfolio.sell(stock, 10);
}else if(percentGain.compareTo(LIMIT) < 0){
portfolio.buy(stock);
}
}
}
看一下 perform 方法。它接受一个 portfolio 对象和一个 stock 对象,调用 MarketWatcher 的 getQuote 方法并传递一个 stock 符号。然后,它从 portfolio 获取平均股价并与当前市场价格进行比较。如果当前股价比平均价格高 10%,则 StockBroker 程序从 Portfolio 中卖出 10 股股票;然而,如果当前股价下跌 10%,则程序从市场上购买股份以平均损失。
为什么我们卖出 10 股股票?这只是一个例子,10 只是一个数字;这可以是任何你想要的东西。
StockBroker 依赖于 Portfolio 和 MarketWatcher;Portfolio 的真实实现应该与数据库交互,而 MarketWatcher 需要连接到互联网。因此,如果我们为经纪人编写单元测试,我们需要在数据库和互联网连接的情况下执行测试。数据库连接将花费时间,互联网连接取决于互联网提供商。因此,测试执行将依赖于外部实体,并且需要一段时间才能完成。这将违反快速测试执行原则。此外,数据库状态可能不会在所有测试运行中相同。这也适用于互联网连接服务。每次数据库可能返回不同的值,因此在单元测试中断言特定值是非常困难的。
我们将使用 Mockito 模拟外部依赖并独立执行测试。因此,测试将不再依赖于真实的外部服务,因此它将快速执行。
模拟对象
可以使用静态 mock() 方法创建模拟,如下所示:
import org.mockito.Mockito;
public class StockBrokerTest {
MarketWatcher marketWatcher = Mockito.mock(MarketWatcher.class);
Portfolio portfolio = Mockito.mock(Portfolio.class);
}
否则,你可以使用 Java 的静态导入功能,并如下静态导入 org.mockito.Mockito 类的 mock 方法:
import static org.mockito.Mockito.mock;
public class StockBrokerTest {
MarketWatcher marketWatcher = mock(MarketWatcher.class);
Portfolio portfolio = mock(Portfolio.class);
}
另外还有一个选择;你可以如下使用 @Mock 注解:
import org.mockito.Mock;
public class StockBrokerTest {
@Mock
MarketWatcher marketWatcher;
@Mock
Portfolio portfolio;
}
MockitoAnnotations to create mocks:
import static org.junit.Assert.assertEquals;
import org.mockito.MockitoAnnotations;
public class StockBrokerTest {
@Mock
MarketWatcher marketWatcher;
@Mock
Portfolio portfolio;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
@Test
public void sanity() throws Exception {
assertNotNull(marketWatcher);
assertNotNull(portfolio);
}
}
MockitoJUnitRunner JUnit runner:
import org.mockito.runners.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class StockBrokerTest {
@Mock
MarketWatcher marketWatcher;
@Mock
Portfolio portfolio;
@Test
public void sanity() throws Exception {
assertNotNull(marketWatcher);
assertNotNull(portfolio);
}
}
注意
getQuote(String symbol) method of MarcketWatcher and returns a specific Stock object:
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class StockBrokerTest {
@Mock MarketWatcher marketWatcher;
@Mock Portfolio portfolio;
@Test
public void marketWatcher_Returns_current_stock_status() {
Stock uvsityCorp = new Stock("UV", "Uvsity Corporation", new BigDecimal("100.00"));
when(marketWatcher.getQuote(anyString())).
thenReturn(uvsityCorp);
assertNotNull(marketWatcher.getQuote("UV"));
}
}
StockBroker class:
import com.packt.trading.dto.Stock;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class StockBrokerTest {
@Mock MarketWatcher marketWatcher;
@Mock Portfolio portfolio;
StockBroker broker;
@Before public void setUp() {
broker = new StockBroker(marketWatcher);
}
@Test
public void when_ten_percent_gain_then_the_stock_is_sold() {
//Portfolio's getAvgPrice is stubbed to return $10.00
when(portfolio.getAvgPrice(isA(Stock.class))).
thenReturn(new BigDecimal("10.00"));
//A stock object is created with current price $11.20
Stock aCorp = new Stock("A", "A Corp", new BigDecimal("11.20"));
//getQuote method is stubbed to return the stock
when(marketWatcher.getQuote(anyString())).thenReturn(aCorp);
//perform method is called, as the stock price increases
// by 12% the broker should sell the stocks
broker.perform(portfolio, aCorp);
//verifying that the broker sold the stocks
verify(portfolio).sell(aCorp,10);
}
}
注意
测试方法名为 when_ten_percent_gain_then_the_stock_is_sold;测试名称应该解释测试的意图。我们使用下划线使测试名称可读。我们将使用 when_<<something happens>>_then_<<the action is taken>> 测试约定。
在前面的测试示例中,portfolio 的 getAvgPrice() 方法被模拟以返回 $10.00,然后 getQuote() 方法被模拟以返回一个硬编码的 stock 对象,其当前股价为 $11.20。当股价上涨 12% 时,broker 逻辑应该卖出股票。
portfolio 对象是一个模拟对象。因此,除非我们模拟一个方法,否则默认情况下,portfolio 的所有方法都会自动模拟以返回默认值,对于 void 方法,则不执行任何操作。sell 方法是一个 void 方法;因此,而不是连接到数据库以更新股票数量,自动模拟将不执行任何操作。
然而,我们如何测试 sell 方法是否被调用呢?我们使用 Mockito.verify。
verify()方法是一个静态方法,用于验证方法调用。如果方法没有被调用,或者参数不匹配,则 verify 方法将引发错误,以指示代码逻辑存在问题。
验证方法调用
为了验证冗余方法调用,或者验证被模拟的方法没有被调用但根据测试角度来说很重要,我们应该手动验证调用;为此,我们需要使用静态的verify方法。
为什么我们使用 verify?
模拟对象用于模拟外部依赖。我们设置一个期望值,模拟对象返回一个预期值。在某些条件下,模拟对象的行为或方法不应该被调用,或者有时我们可能需要调用方法 N(一个数字)次。verify方法用于验证模拟对象的调用。
Mockito 不会自动验证所有模拟调用。
如果一个被模拟的行为不应该被调用,但由于代码中的错误该方法被调用,verify会标记错误,尽管我们不得不手动验证。void方法不返回值,因此你不能断言返回值。因此,verify对于测试void方法非常有用。
深入验证
verify()方法有一个重载版本,它接受Times作为参数。Times是org.mockito.internal.verification包中的 Mockito 框架类,它接受wantedNumberOfInvocations作为整数参数。
如果将0传递给Times,它意味着在测试路径中方法不会被调用。我们可以将0传递给Times(0)以确保sell或buy方法不会被调用。如果将负数传递给Times构造函数,Mockito 将抛出MockitoException - org.mockito.exceptions.base.MockitoException,这表明不允许负值错误。
以下方法与verify一起使用:
-
times(int wantedNumberOfInvocations): 这个方法恰好被调用n次;如果方法没有被调用wantedNumberOfInvocations次,则测试失败。 -
never(): 这个方法表示被模拟的方法从未被调用,或者你可以使用times(0)来表示相同的情况。如果被模拟的方法至少被调用一次,那么测试将失败。 -
atLeastOnce(): 这个方法至少被调用一次,如果多次调用则操作正常。然而,如果没有被调用,则操作失败。 -
atLeast(int minNumberOfInvocations): 这个方法至少被调用n次,如果方法被调用次数超过minNumberOfInvocations,则操作正常。然而,如果方法没有被调用minNumberOfInvocations次,则操作失败。 -
atMost(int maxNumberOfInvocations): 这个方法最多被调用n次。然而,如果方法被调用次数超过minNumberOfInvocations次,则操作失败。 -
only(): 在模拟对象上调用only方法时,如果模拟对象上还调用了其他方法,则测试失败。在我们的例子中,如果我们使用verify(portfolio, only()).sell(aCorp,10);,测试将失败,输出如下:![深入验证]()
在第 15 行测试失败,因为调用了
portfolio.getAvgPrice(stock)。 -
timeout(int millis): 此方法在指定的时间范围内进行交互。
验证零和更多交互
verifyZeroInteractions(Object... mocks) 方法验证给定的模拟对象上是否没有发生任何交互。
以下测试代码直接调用 verifyZeroInteractions 并传递两个模拟对象。由于没有在模拟对象上调用任何方法,测试通过:
@Test public void verify_zero_interaction() {
verifyZeroInteractions(marketWatcher,portfolio);
}
verifyNoMoreInteractions(Object... mocks) 方法检查给定的模拟对象中是否有任何未验证的交互。我们可以在验证模拟方法之后使用此方法,以确保没有在模拟对象上调用其他方法。
以下测试代码演示了 verifyNoMoreInteractions:
@Test public void verify_no_more_interaction() {
Stock noStock = null;
portfolio.getAvgPrice(noStock);
portfolio.sell(null, 0);
verify(portfolio).getAvgPrice(eq(noStock));
//this will fail as the sell method was invoked
verifyNoMoreInteractions(portfolio);
}
以下是 JUnit 输出:

以下是对参数匹配器的理由和示例。
使用参数匹配器
ArgumentMatcher 是一个具有预定义的 describeTo() 方法的 Hamcrest 匹配器。ArgumentMatcher 扩展了 org.hamcrest.BaseMatcher 包。它验证对模拟依赖项的间接输入。
Matchers.argThat(Matcher) 方法与 verify 方法一起使用,以验证方法是否以特定的参数值被调用。
ArgumentMatcher 在模拟中扮演着关键角色。以下部分描述了 ArgumentMatcher 的上下文。
模拟对象返回预期值,但当它们需要为不同的参数返回不同的值时,参数匹配器就派上用场。假设我们有一个方法,它接受一个玩家名称作为输入,并返回作为输出的总得分(得分是板球比赛中得分的点数)。我们想要模拟它,并为 Sachin 返回 100,为 xyz 返回 10。我们必须使用参数匹配器来模拟这个。
Mockito 在方法被模拟时返回预期值。如果方法接受参数,则参数必须在执行期间匹配;例如,以下方式模拟了 getValue(int someValue) 方法:
when(mockObject.getValue(1)).thenReturn(expected value);
在这里,getValue 方法被调用为 mockObject.getValue(100)。然后,参数不匹配(预期该方法将以 1 被调用,但在运行时遇到 100),因此模拟对象未能返回预期值。它将返回返回类型的默认值——如果返回类型是布尔型,则返回 false;如果返回类型是对象,则返回 null,依此类推。
Mockito 通过使用 equals() 方法以自然 Java 风格验证参数值。有时,当需要额外灵活性时,我们会使用参数匹配器。
Mockito 提供了内置匹配器,如 anyInt()、anyDouble()、anyString()、anyList() 和 anyCollection()。
更多内置匹配器和自定义参数匹配器或 Hamcrest 匹配器的示例可以在以下链接找到:
docs.mockito.googlecode.com/hg/latest/org/mockito/Matchers.html
注意
request object is created and passed to service. Now, from a test, if we call the someMethod method and service is a mocked object, then from test, we cannot stub callMethod with a specific request as the request object is local to the someMethod:
public void someMethod(Object obj){
Request req = new Request();
req.setValue(obj);
Response resp = service.callMethod(req);
}
注意
如果我们使用参数匹配器,所有参数都必须由匹配器提供。
我们传递了三个参数,并且所有参数都是通过匹配器传递的:
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
以下示例将失败,因为第一个和第三个参数没有使用匹配器传递:
verify(mock).someMethod(1, anyString(), "third argument");
ArgumentMatcher类
ArgumentMatcher类允许创建自定义参数匹配器。ArgumentMatcher是一个具有预定义的describeTo()方法的 Hamcrest 匹配器。
使用Matchers.argThat(org.hamcrest.Matcher)方法并传递 Hamcrest 匹配器的实例。
考虑MarketWatcher类;它接受一个股票代码,然后从市场获取报价。
我们将为MarketWatcher.getQuote方法创建一个模拟,该方法接受一个String对象。我们希望使此方法具有条件性。如果传递给方法的股票代码是蓝筹股,则该方法将返回$1000.00;否则,它将返回$5.00。
我们将如何识别蓝筹股?蓝筹股是知名公司的普通股票,其价值和股息可靠且通常对投资安全。例如,如果股票代码是 FB 或 AAPL,我们将该股票视为蓝筹股。
让我们创建一个自定义匹配器来识别蓝筹股。以下代码展示了自定义参数匹配器:
class BlueChipStockMatcher extends ArgumentMatcher<String>{
@Override
public boolean matches(Object symbol) {
return "FB".equals(symbol) ||
"AAPL".equals(symbol);
}
}
以下类扩展了BlueChipStockMatcher,然后否定结果以指示该股票不是蓝筹股:
class OtherStockMatcher extends BlueChipStockMatcher{
@Override
public boolean matches(Object symbol) {
return !super.matches(symbol);
}
}
以下测试使用自定义匹配器来卖出股票:
@Test
public void argument_matcher() {
when(portfolio.getAvgPrice(isA(Stock.class))).
thenReturn(new BigDecimal("10.00"));
Stock blueChipStock = new Stock("FB", "FB Corp", new BigDecimal(1000.00));
Stock otherStock = new Stock("XY", "XY Corp", new BigDecimal(5.00));
when(marketWatcher.getQuote(argThat(new BlueChipStockMatcher()))).thenReturn(blueChipStock);
when(marketWatcher.getQuote(argThat(new OtherStockMatcher()))).thenReturn(otherStock);
broker.perform(portfolio, blueChipStock);
verify(portfolio).sell(blueChipStock,10);
broker.perform(portfolio, otherStock);
verify(portfolio, never()).sell(otherStock,10);
}
在前面的代码中,marketWatcher被模拟,当股票代码是FB或AAPL时返回蓝筹股;否则,它返回普通股票。
抛出异常
单元测试不仅仅是为了测试成功的路径。我们还应该测试我们的代码的失败条件。Mockito 提供了一个在测试期间引发错误的 API。假设我们正在测试一个流程,其中我们计算一个值然后打印到打印机。如果打印机未配置,或发生网络错误,或页面未加载,系统将抛出异常。我们可以使用 Mockito 的异常 API 来测试这种情况。
我们如何测试异常条件,例如数据库访问失败?
Mockito 提供了一个名为thenThrow(Throwable)的方法;当模拟的方法被调用时,此方法将抛出异常。
我们将模拟getAvgPrice方法,在调用该方法时抛出异常,如下所示:
@Test(expected = IllegalStateException.class)
public void throwsException() throws Exception {
when(portfolio.getAvgPrice(isA(Stock.class))).thenThrow(new IllegalStateException("Database down"));
portfolio.getAvgPrice(new Stock(null, null, null));
}
我们正在模拟portfolio,在调用getAvgPrice()时抛出异常。以下是从返回void的方法中抛出异常的语法:
doThrow(exception).when(mock).voidmethod(arguments);
Portfolio 中的 buy 方法是一个 void 方法;我们将模拟 buy 方法以抛出异常。以下测试代码在调用 portfolio 对象上的 buy 方法时抛出 IllegalStateException。请注意,将使用 doThrow().when() 来从 buy 方法引发错误:
@Test(expected = IllegalStateException.class)
public void throwsException_void_methods() throws Exception {
doThrow(new IllegalStateException()).
when(portfolio).buy(isA(Stock.class));
portfolio.buy(new Stock(null, null, null));
}
模拟连续调用
在以下情况下需要为连续调用模拟方法:
-
当在循环中调用模拟方法,需要不同调用返回不同结果时
-
当需要一次调用抛出异常,而其他调用返回值时
我们需要测试一个条件,即第一次调用将返回一个值,下一次调用不应找到任何值,然后再次返回一个值。
thenReturn(objects...) 的可变参数版本接受逗号分隔的返回值,并按顺序返回参数,因此如果我们向 thenReturn 方法传递两个参数,则模拟方法的第一次调用将返回第一个参数。之后,所有其他调用将返回第二个参数,如下面的代码所示:
@Test
public void consecutive_calls() throws Exception {
Stock stock = new Stock(null, null, null);
when(portfolio.getAvgPrice(stock)).thenReturn(BigDecimal.TEN, BigDecimal.ZERO);
assertEquals(BigDecimal.TEN, portfolio.getAvgPrice(stock));
assertEquals(BigDecimal.ZERO, portfolio.getAvgPrice(stock));
assertEquals(BigDecimal.ZERO, portfolio.getAvgPrice(stock));
}
注意,thenReturn 接受两个值:BigDecimal.TEN 和 BigDecimal.ZERO。getAvgPrice 的第一次调用将返回 BigDecimal.TEN,然后每次调用将返回 BigDecimal.ZERO。
这也可以用另一种方式完成——Mockito 方法返回模拟对象,并遵循构建器模式以允许一系列调用。
在以下示例中,thenReturn 和 thenThrow 被组合起来构建响应链。在第二次调用之后,每次 getAvgPrice 调用都将抛出异常:
when(portfolio.getAvgPrice(stock)).thenReturn(BigDecimal.TEN).thenReturn(BigDecimal.TEN).thenThrow(new IllegalStateException())
使用 Answer 进行模拟
模拟的方法返回硬编码的值,但不能返回即时结果。Mockito 框架提供了回调来计算即时结果。
Mockito 允许使用泛型 Answer 接口进行模拟。这是一个回调;当在模拟对象上调用模拟的方法时,将调用 Answer 对象的 answer(InvocationOnMock invocation) 方法。此 Answer 对象的 answer() 方法返回实际对象。
Answer 的语法是 when(mock.someMethod()).thenAnswer(new Answer() {…}); 或 when(mock.someMethod()).then(answer);,这与 thenReturn() 和 thenThrow() 类似。
Answer 接口定义如下:
public interface Answer<T> {
T answer(InvocationOnMock invocation) throws Throwable;
}
InvocationOnMock 参数是回调的一个重要部分。它可以返回传递给方法的参数,也可以返回模拟对象,如下所示:
Object[] args = invocation.getArguments();
Object mock = invocation.getMock();
Answer classes. Add HashMap to the test class:
Map<String, List<Stock>> stockMap = new HashMap<String, List<Stock>>();
可以购买 10 股 Facebook 或 10 种不同的股票。stockMap 对象存储键值对。键是 Stock 符号,值是股票列表。10 股 Facebook 股票将添加一个单独的键 FB 和一个包含 10 股 Facebook 股票的列表。一股苹果股票将添加另一个条目到映射中,具有 AAPL 键和值以及一个包含单一苹果股票的列表。
当调用buy方法时,以下Answer实现被调用。invocationOnMock对象返回参数,而buy方法只接受一个参数,即一个Stock对象。因此,将 0^(th)参数转换为Stock类型。然后,将Stock插入到stockMap对象中:
class BuyAnswer implements Answer<Object>{
@Override
public Object answer(InvocationOnMock invocation) throws Throwable
{
Stock newStock = (Stock)invocation.getArguments()[0];
List<Stock> stocks = stockMap.get(newStock.getSymbol());
if(stocks != null) {
stocks.add(newStock);
}else {
stocks = new ArrayList<Stock>();
stocks.add(newStock);
stockMap.put(newStock.getSymbol(), stocks);
}
return null;
}
}
以下answer对象实现了总价计算逻辑:
class TotalPriceAnswer implements Answer<BigDecimal>{
@Override
public BigDecimal answer(InvocationOnMock invocation) throws Throwable {
BigDecimal totalPrice = BigDecimal.ZERO;
for(String stockId: stockMap.keySet()) {
for(Stock stock:stockMap.get(stockId)) {
totalPrice = totalPrice.add(stock.getPrice());
}
}
return totalPrice;
}
}
getCurrentValue()方法将被模拟以返回前面的答案实现。
以下 JUnit 测试代码使用了TotalPriceAnswer方法:
@Test
public void answering() throws Exception {
stockMap.clear();
doAnswer(new BuyAnswer()).when(portfolio).
buy(isA(Stock.class));
when(portfolio.getCurrentValue()).
then(new TotalPriceAnswer());
portfolio.buy(new Stock("A", "A", BigDecimal.TEN));
portfolio.buy(new Stock("B", "B", BigDecimal.ONE));
assertEquals(new BigDecimal("11"), portfolio.getCurrentValue());
}
检查stockMap对象是否已清除以删除现有数据。然后,使用doAnswer方法模拟void buy方法以向stockMap添加股票,然后模拟getCurrentValue方法为TotalPriceAnswer答案。
间谍对象
Mockito 的spy对象允许我们通过用模拟的方法替换一些方法来使用真实对象,而不是使用模拟对象。这种行为允许我们测试遗留代码;不能模拟需要被测试的类。遗留代码带有无法测试的方法,但其他方法使用它们;因此,这些方法需要被模拟以与其他方法一起工作。spy对象可以模拟不可测试的方法,以便其他方法可以轻松地进行测试。
一旦为spy对象上的方法设置了一个期望值,那么spy就不再返回原始值。它开始返回模拟值,但仍然表现出未模拟的其他方法的原始行为。
Mockito 可以创建一个真实对象的spy。与模拟不同,当我们使用spy时,会调用真实方法(除非方法已被模拟)。
Spy也被称为部分模拟;spy在现实世界中的一个例子是处理遗留代码。
使用以下代码声明spy:
SomeClass realObject = new RealImplemenation();
SomeClass spyObject = spy(realObject);
以下是一个自解释的spy示例:
@Test public void spying() throws Exception {
Stock realStock = new Stock("A", "Company A", BigDecimal.ONE);
Stock spyStock = spy(realStock);
//call real method from spy
assertEquals("A", spyStock.getSymbol());
//Changing value using spy
spyStock.updatePrice(BigDecimal.ZERO);
//verify spy has the changed value
assertEquals(BigDecimal.ZERO, spyStock.getPrice());
//Stubbing method
when(spyStock.getPrice()).thenReturn(BigDecimal.TEN);
//Changing value using spy
spyStock.updatePrice(new BigDecimal("7"));
//Stubbed method value 10.00 is returned NOT 7
assertNotEquals(new BigDecimal("7"), spyStock.getPrice());
//Stubbed method value 10.00
assertEquals(BigDecimal.TEN, spyStock.getPrice());
}
模拟 void 方法
在本章的抛出异常部分,我们了解到doThrow用于为void方法抛出异常。本章的使用 Answer 进行模拟部分展示了如何使用doAnswer对void方法进行模拟。
在本节中,我们将探讨其他void方法:doNothing、doReturn、doThrow和doCallRealMethod。
doNothing() API 什么都不做。默认情况下,所有void方法都不做任何事情。但是,如果你需要在void方法上连续调用,第一次调用是抛出错误,下一次调用是不做任何事情,然后下一次调用使用doAnswer()执行一些逻辑,然后遵循以下语法:
doThrow(new RuntimeException()).
doNothing().
doAnswer(someAnswer).
when(mock).someVoidMethod();
//this call throws exception
mock.someVoidMethod();
// this call does nothing
mock.someVoidMethod();
当你想在模拟或spy对象上调用方法的实际实现时,使用doCallRealMethod() API,如下所示:
doCallRealMethod().when(mock).someVoidMethod();
doReturn()方法与模拟方法并返回预期值类似。然而,这仅在when(mock).thenReturn(return)无法使用时使用。
when-thenReturn 方法比 doReturn() 更易读;此外,doReturn() 不是一个安全类型。thenReturn 方法检查方法返回类型,如果传递了不安全的类型,则会引发编译错误。
doReturn:
@Test public void doReturn_is_not_type_safe() throws Exception {
//then return is type safe- It has to return a BigDecimal
when(portfolio.getCurrentValue()).thenReturn(BigDecimal.ONE);
//method call works fine
portfolio.getCurrentValue();
//returning a String instead of BigDecimal
doReturn("See returning a String").
when(portfolio.getCurrentValue());
//this call will fail with an error
portfolio.getCurrentValue();
}
以下截图显示了测试失败的情况:

在间谍对象上调用真实方法会产生副作用;为了抵消这种副作用,请使用 doReturn() 而不是 thenReturn()。
以下代码描述了间谍和调用 thenReturn() 的副作用:
@Test
public void doReturn_usage() throws Exception {
List<String> list = new ArrayList<String>();
List<String> spy = spy(list);
//impossible the real list.get(0) is called and fails
//with IndexOutofBoundsException, as the list is empty
when(spy.get(0)).thenReturn("not reachable");
}
在前面的代码中,间谍对象在尝试模拟 get(index) 时调用了一个真实方法,并且与模拟对象不同,真实方法被调用并因 ArrayIndexOutOfBounds 错误而失败。
以下截图显示了失败信息:

这可以通过以下代码中的 doReturn() 进行保护,但请注意,我们通常不会模拟列表或域对象;这只是一个例子:
@Test public void doReturn_usage() throws Exception {
List<String> list = new ArrayList<String>();
List<String> spy = spy(list);
//doReturn fixed the issue
doReturn("now reachable").when(spy).get(0);
assertEquals("now reachable", spy.get(0));
}
使用 ArgumentCaptor 捕获参数
ArgumentCaptor 用于验证传递给模拟方法的参数。有时,我们计算一个值,然后使用这个计算值创建另一个对象,接着使用这个新对象调用一个模拟对象。这个计算值不会从原始方法返回,但它被用于其他一些计算。
ArgumentCaptor 提供了一个 API,用于访问在测试方法中实例化的对象。
以下代码片段解释了方法参数不可访问的问题:
public void buildPerson(String firstName, String lastName, String middleName, int age){
Person person = new Person();
person.setFirstName(firstName);
person.setMiddleName(middleName);
person.setLastName(lastName);
person.setAge(age);
this,personService.save(person);
}
我们向 buildPerson 方法传递了名字、中间名、姓氏和年龄。此方法创建一个 Person 对象,并将名称和年龄设置到其中。最后,它调用 personService 类并将 person 对象保存到数据库中。
在这里,由于 Person 对象是在方法内部创建的,我们无法从 JUnit 测试中用特定值模拟 personService 的 save 行为。我们可以使用一个通用匹配器对象,如 isA(Person.class) 来模拟 save,然后使用参数捕获器验证 Person 对象是否包含正确的姓名和年龄。
Mockito 通过使用 equals() 方法以自然 Java 风格验证参数值。这也是推荐匹配参数的方式,因为它使测试变得简洁简单。然而,在某些情况下,在验证之后对某些参数进行断言是必要的。
以下代码使用两个 ArgumentCaptors 并验证在调用方法时是否使用了特定的股票符号 A,而不是任何其他值:
@Test
public void argument_captor() throws Exception {
when(portfolio.getAvgPrice(isA(Stock.class))).thenReturn(new BigDecimal("10.00"));
Stock aCorp = new Stock("A", "A Corp", new BigDecimal(11.20));
when(marketWatcher.getQuote(anyString())).thenReturn(aCorp);
broker.perform(portfolio, aCorp);
ArgumentCaptor<String> stockIdCaptor = ArgumentCaptor.forClass(String.class);
verify(marketWatcher).getQuote(stockIdCaptor.capture());
assertEquals("A", stockIdCaptor.getValue());
//Two arguments captured
ArgumentCaptor<Stock> stockCaptor = ArgumentCaptor.forClass(Stock.class);
ArgumentCaptor<Integer> stockSellCountCaptor = ArgumentCaptor.forClass(Integer.class);
verify(portfolio).sell(stockCaptor.capture(), stockSellCountCaptor.capture());
assertEquals("A", stockCaptor.getValue().getSymbol());
assertEquals(10, stockSellCountCaptor.getValue().intValue());
}
检查ArgumentCaptor在forClass方法中接受一个Class类型,然后捕获器被传递给verify方法以收集参数细节。sell方法接受两个参数,Stock和Integer。因此,创建了两个ArgumentCaptors。stockCaptor对象捕获Stock参数,而stockSellCountCaptor捕获股票数量。最后,比较这些值以验证是否将正确的值传递给了sell方法。
验证调用顺序
Mockito 通过使用InOrder API 简化了验证模拟交互是否按给定顺序执行。它允许我们创建模拟的InOrder并验证所有模拟的所有调用顺序。
以下测试按顺序调用了getAvgPrice、getCurrentValue、getQuote和buy方法,但验证了buy()方法是否在getAvgPrice()方法之前被调用。因此,验证顺序是错误的,所以测试失败了:
@Test public void inorder() throws Exception {
Stock aCorp = new Stock("A", "A Corp", new BigDecimal(11.20));
portfolio.getAvgPrice(aCorp);
portfolio.getCurrentValue();
marketWatcher.getQuote("X");
portfolio.buy(aCorp);
InOrder inOrder=inOrder(portfolio,marketWatcher);
inOrder.verify(portfolio).buy(isA(Stock.class));
inOrder.verify(portfolio).getAvgPrice(isA(Stock.class));
}
以下截图显示了错误信息输出:

重新排序验证顺序,我们按以下方式修复了测试:
@Test public void inorder() throws Exception {
Stock aCorp = new Stock("A", "A Corp", new BigDecimal(11.20));
portfolio.getAvgPrice(aCorp);
portfolio.getCurrentValue();
marketWatcher.getQuote("X");
portfolio.buy(aCorp);
InOrder inOrder=inOrder(portfolio,marketWatcher);
inOrder.verify(portfolio).getAvgPrice(isA(Stock.class));
inOrder.verify(portfolio).getCurrentValue();
inOrder.verify(marketWatcher).getQuote(anyString());
inOrder.verify(portfolio).buy(isA(Stock.class));
}
更改默认设置
我们了解到模拟对象的非存根方法返回默认值,例如对象为 null,布尔值为 false。然而,Mockito 允许我们更改默认设置。
以下是可以设置的允许值:
-
RETURNS_DEFAULTS: 这是默认设置。对于对象返回 null,对于布尔值返回 false,等等。
-
RETURNS_SMART_NULLS: 这返回给定类型的
spy。 -
RETURNS_MOCKS: 这返回对象模拟和原语类型的默认值。
-
RETURNS_DEEP_STUBS: 这返回一个深度存根。
-
CALLS_REAL_METHODS: 这将调用一个实际方法。
以下示例覆盖了默认的 Mockito 设置并使用了不同的返回类型:
@Test
public void changing_default() throws Exception {
Stock aCorp = new Stock("A", "A Corp", new BigDecimal(11.20));
Portfolio pf = Mockito.mock(Portfolio.class);
//default null is returned
assertNull(pf.getAvgPrice(aCorp));
Portfolio pf1 = Mockito.mock(Portfolio.class, Mockito.RETURNS_SMART_NULLS);
//a smart null is returned
System.out.println("#1 "+pf1.getAvgPrice(aCorp));
assertNotNull(pf1.getAvgPrice(aCorp));
Portfolio pf2 = Mockito.mock(Portfolio.class, Mockito.RETURNS_MOCKS);
//a mock is returned
System.out.println("#2 "+pf2.getAvgPrice(aCorp));
assertNotNull(pf2.getAvgPrice(aCorp));
Portfolio pf3 = Mockito.mock(Portfolio.class, Mockito.RETURNS_DEEP_STUBS);
//a deep stubbed mock is returned
System.out.println("#3 "+pf3.getAvgPrice(aCorp));
assertNotNull(pf3.getAvgPrice(aCorp));
}
以下截图显示了控制台输出:

重置模拟对象
一个静态方法reset(T…)可以重置模拟对象。reset方法应该特别小心处理;如果你需要重置一个模拟,你很可能还需要另一个测试。
getAvgPrice method to return a value, but reset clears the stub; after reset, the getAvgPrice method returns NULL:
@Test
public void resetMock() throws Exception {
Stock aCorp = new Stock("A", "A Corp", new BigDecimal(11.20));
Portfolio portfolio = Mockito.mock(Portfolio.class);
when(portfolio.getAvgPrice(eq(aCorp))).
thenReturn(BigDecimal.ONE);
assertNotNull(portfolio.getAvgPrice(aCorp));
Mockito.reset(portfolio);
//Resets the stub, so getAvgPrice returns NULL
assertNull(portfolio.getAvgPrice(aCorp));
}
探索 Mockito 注解
我们了解到 Mockito 支持@Mock注解进行模拟。就像@Mock一样,Mockito 还支持以下三个有用的注解:
-
@Captor: 这简化了ArgumentCaptor的创建,这在需要捕获的参数是一个超级通用类时很有用,例如List<Map<String,Set<String>>>。 -
@Spy: 这创建了一个给定对象的spy。用它代替spy (object)。 -
@InjectMocks: 这自动使用构造函数注入、setter 注入或字段注入将mock或spy字段注入到测试对象中。
使用内联存根工作
Mockito 允许我们在存根的同时创建模拟。基本上,它允许在一行代码中创建存根。这有助于保持测试代码的整洁。
例如,可以在测试中创建并初始化字段时模拟一些存根。我们在几乎所有的测试中都使用 Stock 对象。我们可以创建一个全局模拟 Stock 并在定义时对其进行存根,如下面的代码片段所示:
Stock globalStock = when(Mockito.mock(Stock.class).getPrice()).thenReturn(BigDecimal.ONE).getMock();
@Test
public void access_global_mock() throws Exception {
assertEquals(BigDecimal.ONE, globalStock.getPrice());
}
确定模拟细节
Mockito.mockingDetails 识别特定对象是否是模拟或间谍,如下所示:
@Test
public void mocking_details() throws Exception {
Portfolio pf1 = Mockito.mock(Portfolio.class, Mockito.RETURNS_MOCKS);
BigDecimal result = pf1.getAvgPrice(globalStock);
assertNotNull(result);
assertTrue(Mockito.mockingDetails(pf1).isMock());
Stock myStock = new Stock(null, null, null);
Stock spy = spy(myStock);
assertTrue(Mockito.mockingDetails(spy).isSpy());
}
使用 Mockito 进行行为驱动开发
BDD 是基于 TDD 的软件工程流程。BDD 结合了 TDD、领域驱动开发(DDD)和面向对象编程(OOP)的最佳实践。
在敏捷团队中,确定功能范围是一项艰巨的任务。业务利益相关者讨论业务利益,而开发团队讨论技术挑战。BDD 提供了一种通用语言,允许利益相关者之间进行有用的沟通和反馈。
Dan North 开发了 BDD,创建了 BDD 的 JBehave 框架,并提出了以下最佳实践:
-
单元测试名称应该以单词 should 开头,并且 should 按照业务价值的顺序编写
-
验收测试(AT)应该以用户故事的方式编写,例如“作为(角色)我想(功能)以便(好处)”
-
验收标准应该以场景的形式编写,并实现为“Given(初始上下文),when(事件发生),then(确保某些结果)”
让我们为我们的股票经纪人模拟编写一个用户故事:
故事:股票被卖出
-
为了 最大化利润
-
作为 股票经纪人
-
我想 在价格上升 10% 时卖出股票
以下是一个场景示例:
场景:股票价格上升 10% 应该在市场上卖出股票
-
Given 客户之前以每股 $10.00 的价格购买了 FB 股票
-
并且 他目前在投资组合中还有 10 股剩余
-
当 Facebook 股票价格变为 $11.00
-
那么 我应该卖出所有的 Facebook 股票,并且投资组合应该没有 Facebook 股票
Mockito 支持使用 given-when-then 语法编写 BDD 风格的测试。
以 BDD 风格编写测试
在 BDD 中,given 代表初始上下文,而 when 代表事件或条件。然而,Mockito 已经有一个(初始上下文定义)方法存根的 when 风格;因此,when 与 BDD 不太搭配。因此,BDDMockito 类引入了一个别名,这样我们就可以使用 given(object) 方法存根方法调用。
以下 JUnit 测试是以 BDD 风格实现的:
@RunWith(MockitoJUnitRunner.class)
public class StockBrokerBDDTest {
@Mock MarketWatcher marketWatcher;
@Mock Portfolio portfolio;
StockBroker broker;
@Before public void setUp() {
broker = new StockBroker(marketWatcher);
}
@Test
public void should_sell_a_stock_when_price_increases_by_ten_percent(){
Stock aCorp = new Stock("FB", "FaceBook", new BigDecimal(11.20));
//Given a customer previously bought 10 'FB' stocks at
//$10.00/per share
given(portfolio.getAvgPrice(isA(Stock.class))).
willReturn(new BigDecimal("10.00"));
given(marketWatcher.getQuote(eq("FB"))).
willReturn(aCorp);
//when the 'FB' stock price becomes $11.00
broker.perform(portfolio, aCorp);
//then the 'FB' stocks are sold
verify(portfolio).sell(aCorp,10);
}
}
注意,测试名称以 should 语句开头。Mockito 的 given 语法用于设置初始上下文,即投资组合已经以每股 $10.00 的价格购买了 FB 股票,并且当前的 FB 股票价格为每股 $11.00。
以下截图显示了测试执行输出:

BDD 语法
以下方法与 given 一起使用:
-
willReturn(要返回的值):这返回一个给定的值 -
willThrow(要抛出的异常): 这将抛出一个给定的异常 -
will(Answer answer) 和willAnswer(Answer answer): 这与then(answer) 和thenAnswer(answer) 类似 -
willCallRealMethod(): 这将在模拟对象或间谍对象上调用实际方法注意
jMock和EasyMock框架是另外两个支持模拟自动化单元测试的基于 Java 的单元测试框架。jMock和EasyMock框架提供了模拟功能,但其语法不如 Mockito 简单。您可以访问以下网址来探索这些框架:要了解更多关于 BDD 和 JBehave 的信息,请访问
jbehave.org/.
摘要
在本章中,Mockito 将被详细描述,并提供技术示例以展示 Mockito 的功能。
到本章结束时,您将能够使用 Mockito 框架的高级功能,并开始使用 Mockito 进行行为驱动开发 (BDD)。
下一章将解释代码覆盖率的重要性,包括行和分支覆盖率,如何衡量代码覆盖率,Eclipse 插件,设置 Cobertura,以及使用 Ant、Gradle 和 Maven 生成覆盖率报告。
第五章:探索代码覆盖率
本章解释了代码覆盖率、覆盖率工具,并提供了生成覆盖率报告的逐步指导。
本章涵盖了以下主题:
-
代码、分支和行覆盖率
-
覆盖率工具,如 Clover、Cobertura、EclEmma 和 JaCoCo
-
使用 Eclipse 插件测量覆盖率
-
使用 Ant、Maven 和 Gradle 生成报告
理解代码覆盖率
代码覆盖率是在自动化测试运行时,代码指令被执行的百分比度量。
高代码覆盖率意味着代码已经彻底进行了单元测试,并且比低代码覆盖率代码含有更少的错误机会。你应该专注于编写有意义的(业务逻辑)单元测试,而不是追求 100%的覆盖率,因为很容易通过完全无用的测试来欺骗并达到 100%的覆盖率。
可以使用许多指标来衡量代码覆盖率。以下是一些广泛使用的指标:
-
语句或行覆盖率:这衡量了被覆盖的语句或行
-
分支覆盖率:这衡量了每个控制结构(如 if-else 和 switch-case 语句)的每个分支的百分比
-
函数或方法覆盖率:这衡量了函数的执行
以下 Java 代码将阐明这些指标。
absSum方法接受两个整数参数,然后返回这两个参数的绝对和。Integer类型可以持有NULL值,因此该方法会检查NULL。如果两个参数都是NULL,则该方法返回0,如下面的代码所示:
public class Metrics {
public int absSum(Integer op1, Integer op2) {
if (op1 == null && op2 == null) {
return 0;
}
if (op1 == null && op2 != null) {
return Math.abs(op2);
}
if (op2 == null) {
return Math.abs(op1);
}
return Math.abs(op1)+Math.abs(op2);
}
}
以下示例有 10 个分支:第一个if(op1 == null && op2 == null)语句有四个分支:op1 == null、op1!= null、op2 == null和op2 != null。同样,第二个if语句有四个分支,最后一个if (op2 == null)语句有两个分支,op2== null和op2 != null。
如果测试将两个非空整数传递给absSum方法,则它覆盖了四行,即三个if语句和最后的return语句,但前三个return语句仍然未被覆盖。它覆盖了十个分支中的三个;第一个if语句覆盖了四个分支中的一个,即op1 == null。同样,第二个if语句覆盖了四个分支中的一个,最后一个if语句覆盖了两个分支中的一个op2 != null。因此,分支覆盖率变为 30%。
要覆盖所有指令和所有分支,需要将以下四个输入对传递给方法:[null, null]、[null, value]、[value, null]和[value, value]。
学习代码插装的内部细节
覆盖率是通过基本代码分支或指令被某些测试执行的比例来衡量的,与在测试的系统中的指令或分支总数相比。
比率是通过一系列步骤测量的。首先,在源代码的一个副本中,每个语句块都被一个累加标志检测。然后,在检测代码上运行测试并更新标志。最后,一个程序收集累加标志并测量开启的标志与总标志数的比例。字节码可以在运行时或编译时更改。这正是测试覆盖率框架在幕后所做的事情。
有两种代码检测选项:源代码检测和对象代码检测。对象代码检测修改生成的字节码,因此很难实现。
之前的代码覆盖率示例有七行,但如果我们将分支展开成行,那么就会变成 14 行。如果一个覆盖率工具需要为代码添加检测点,那么它将修改源代码,并初始化一个长度为 14 的数组,初始值为 0,当测试运行时执行一行,则将该值设置为 1。以下示例演示了源代码检测:
int[] visitedLines = new int[14];
public int absSumModified(Integer op1 , Integer op2) {
visitedLines[0] = 1;
if(op1 == null) {
visitedLines[1] = 1;
if(op2 == null) {
visitedLines[2] = 1;
return 0;
}else {
visitedLines[3] = 1;
}
}else {
visitedLines[4] = 1;
}
visitedLines[5] = 1;
if(op1 == null) {
visitedLines[6] = 1;
if(op2 != null) {
visitedLines[7] = 1;
return Math.abs(op2);
}else {
visitedLines[8] = 1;
}
}else {
visitedLines[9] = 1;
}
visitedLines[10] = 1;
if(op2 == null) {
visitedLines[11] = 1;
return Math.abs(op1);
}else {
visitedLines[12] = 1;
}
visitedLines[13] = 1;
return Math.abs(op1)+Math.abs(op2);
}}
测试执行后,覆盖率工具检查 visitedLines 数组,并计算所有 visitedLines[index] 等于 1 的行的比例与总行数的比例。如果我们用输入集 [null, null] 和 [value, value] 测试该方法,那么五行(第 4、7、8、9 和 12 行)仍然未被覆盖。要达到 100% 的覆盖率,我们需要用四个可能的 null 和非 null 整数的组合来测试该方法。
配置 Eclipse 插件
我们了解到覆盖率工具可以检测对象代码或源代码。Java 代码覆盖率工具可以分为两类:检测源代码的工具和检测字节码的工具。
源代码检测更容易,但需要重新编译源代码。字节码检测更复杂,但不需要重新编译源代码。
以下是可以用的 Java 代码覆盖率工具:
-
Cobertura:这个工具在离线检测字节码,是一个广泛使用的覆盖率工具。Cobertura 是一个开源项目(GNU GPL),并且与 Eclipse 和构建工具配置非常简单。2010 年 3 月发布的 1.9 版本是最新的稳定版本。
-
EMMA:这个工具可以在离线或运行时检测字节码,并在 Common Public License(CPL)下分发。2005 年 6 月发布的 2.1 版本是最新的版本。Google CodePro AnalytiX 基于 EMMA。
-
Clover:这个工具检测源代码,并附带 Atlassian 的专有许可证,最新的稳定版本 3.2 于 2014 年 2 月发布。
-
JaCoCo:这个工具在 Eclipse Public License(EPL)下分发。JaCoCo 在运行代码时动态检测字节码。最新的稳定版本,0.6.4,于 2013 年 12 月发布。JaCoCo 是 EMMA 的替代品。EclEmma 是基于 JaCoCo 的 Eclipse 插件。
以下部分将探讨基于前面 Java 覆盖率工具的 Eclipse 插件。
揭示 Clover 插件
可以安装 Clover 插件的试用版一个月。您可以在confluence.atlassian.com/display/CLOVER/查看安装说明。Clover Eclipse 插件支持站点更新和手动下载安装。
以下安装和执行 Clover 插件的步骤:
-
在安装过程中,Clover 显示可安装元素的列表。展开Clover选项卡,选择Clover 3和Clover 3 Ant Support。以下截图显示了详细信息:
![揭示 Clover 插件]()
-
打开显示视图菜单,选择所有Clover视图。以下截图显示了Clover视图:
![揭示 Clover 插件]()
-
创建一个名为
Chapter05的新 Java 项目,并将Metrics.java和MetricsTest.javaJava 文件添加到项目中,如前所述。打开 Clover 的覆盖率探索器,点击启用或禁用 Clover 在项目上按钮。以下截图显示了按钮的详细信息:![揭示 Clover 插件]()
-
选择
Chapter05项目。Clover 将在此项目上启用源代码仪器化。右键单击MetricsTest文件,转到使用 Clover 运行 | JUnit 测试。以下截图显示了弹出菜单:![揭示 Clover 插件]()
-
打开覆盖率探索器,它将显示以下覆盖率输出:
![揭示 Clover 插件]()
-
打开Clover 仪表板。仪表板将显示覆盖率细节、测试结果、复杂性和最少测试的方法。以下截图显示了仪表板的详细信息:
![揭示 Clover 插件]()
-
打开源代码。Clover 插件装饰了源代码;未覆盖的行变成红色,覆盖的行变成绿色。它还显示每行的执行次数。以下是对仪器化源代码输出的显示:
![揭示 Clover 插件]()
使用 EclEmma 插件
EclEmma 版本 2.0 基于 JaCoCo 代码覆盖率库。按照www.eclemma.org/index.html上的说明安装 EclEmma Eclipse 插件。与 Clover 一样,EclEmma 支持站点更新和手动下载。
一旦安装了 EclEmma,请按照以下步骤配置和执行测试:
-
右键单击测试类,转到覆盖率 | 1 JUnit 测试。这将即时对字节码进行仪器化,并显示覆盖率报告。
-
在 EclEmma 安装后,主菜单面板下会出现一个新的菜单按钮。当你展开这个菜单时,它会显示最近执行过的 JUnit 测试。点击菜单按钮以生成覆盖率报告。以下截图显示了 EclEmma 代码覆盖率菜单按钮:
![使用 EclEmma 插件]()
-
当你打开覆盖率标签时,它会显示覆盖率详情。以下截图显示了输出:
![使用 EclEmma 插件]()
-
在 EclEmma 中,分支覆盖率报告更为突出。以下截图显示了覆盖率详情:
![使用 EclEmma 插件]()
绿色菱形表示分支被 100%覆盖,红色菱形表示分支没有被覆盖,黄色菱形表示分支部分被覆盖。
检查 eCobertura 插件
eCobertura是一个基于 Cobertura 的 Eclipse 插件。eCobertura 以表格格式显示分支覆盖率。要安装 eCobertura 插件,请访问marketplace.eclipse.org/content/ecobertura#.UxYBmoVh85w,并将安装按钮拖到正在运行的 Eclipse 工作空间中。Eclipse 将自动为你安装插件。以下截图显示了 Marketplace 的安装按钮:

安装后,Cobertura 菜单面板下会出现一个新的菜单按钮,如下截图所示:

使用 eCobertura 测量代码覆盖率有以下步骤:
-
前往显示视图 | 其他,然后在eCobertura下选择覆盖率会话视图选项。
-
执行测试然后点击 Cobertura 菜单按钮,或者从下拉菜单中选择你想要测量的测试。
-
打开覆盖率会话视图标签。这将显示以下输出:
![检查 eCobertura 插件]()
注意分支覆盖率是 60%。在上一个章节中,我们测量了 10 个分支。使用我们的自定义覆盖率程序,我们测量出 10 个分支中有 4 个被覆盖。这证明了我们的自定义代码覆盖率程序运行良好。
使用 Gradle 测量覆盖率
Gradle 可以被配置为使用 JaCoCo 生成覆盖率报告。本节将解释如何在项目中配置 Gradle JaCoCo 插件。
以下是如何配置 Gradle 插件的步骤:
-
在任何目录下创建一个名为
Chapter05的基础文件夹,例如D:/Packt;然后在Chapter05下添加一个lib文件夹,并将JUnit4和hamcrestJAR 文件复制到lib文件夹中。在基础文件夹Chapter05下添加另一个名为Chapter05的文件夹用于 Java 项目。根据 Gradle 约定,源文件保存在src/main/java下,测试文件保存在src/test/java下。在Chapter05\Chapter05下创建目录。小贴士
这个
Chapter05命名策略是为了让您更容易跟踪项目并从 Packt Publishing 网站下载代码,但您的代码应该表达代码的意图。名称Chapter05没有任何意义,也许您可以将其命名为类似SimpleGradleProject或GradleCoverageProject。 -
将 Eclipse 项目的内容以及我们在“揭示 Clover 插件”部分创建的
Metrics和MetricsTestJava 文件复制到新目录。按照 Gradle 约定,将src文件夹的内容复制到src/main/java,将test文件夹的内容复制到src/test/java。 -
在
Chapter05\Chapter05下直接创建一个build.gradle文件,并将以下代码片段添加到文件中以启用 JaCoCo 覆盖率:apply plugin: 'java' apply plugin: "jacoco" repositories { flatDir(dir: '../lib', name: 'JUnit Library') mavenCentral() } dependencies { testCompile'junit:junit:4.11', ':hamcrest-core:1.3'} jacocoTestReport { reports { xml.enabled false csv.enabled false html.destination "${buildDir}/jacocoHtml" } } -
jaCoCo插件添加了一个新的任务jacocoTestReport。要执行jacocoTestReport任务,需要在repositories闭包中添加一个mavenCentral()仓库依赖项。Gradle 从mavenCentral仓库下载所需的jaCoCoJAR 文件。 -
打开命令提示符,转到
Chapter05\Chapter05目录,并运行gradle jacocoTestReport命令。这将下载 JAR 文件并生成覆盖率报告。以下截图显示了控制台输出:![使用 Gradle 测量覆盖率]()
-
打开
Chapter05\Chapter05\build\jacocoHtml并启动index.html文件。以下是 JaCoCo 覆盖率报告输出:![使用 Gradle 测量覆盖率]()
使用 Maven Cobertura 插件
Maven 有一个 Cobertura 插件来测量代码覆盖率;本节将解释如何配置项目中的 Cobertura Maven 插件。
小贴士
Cobertura 使用asm来检测字节码。asm框架是一个 Java 字节码操作和分析框架。访问asm.ow2.org/获取asm的详细信息。Cobertura 修改.class文件,导入net.sourceforge.cobertura.coveragedata.*,实现HasBeenInstrumented接口,并添加捕获覆盖率的代码,例如ProjectData.getGlobalProjectData().getOrCreateClassData("com.packt.coverage.Metrics").touch(21);。
在检测字节码后,Cobertura 创建一个.ser文件并在测试执行期间更新该文件。这个.ser文件包含测试覆盖率详情。没有它,检测过的字节码可能会比正常字节码稍微慢一些。
按照以下步骤配置 Maven 以生成 Cobertura 报告:
-
创建一个
pom.xml文件并将其放置在/Chapter05/Chapter05下。 -
修改
pom.xml文件以添加项目详细信息如下:<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>org.packt</groupId> <artifactId>Chapter05</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging> <name>Chapter05</name> <url>http://maven.apache.org</url> -
按照以下方式添加 Cobertura 插件的详细信息:
<build> <plugins> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>cobertura-maven-plugin</artifactId> <version>2.2</version> <configuration> <formats> <format>html</format> <format>xml</format> </formats> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>cobertura</goal> </goals> </execution> </executions> </plugin> </plugins> </build> -
打开命令提示符,将目录更改为
/Chapter05/Chapter05,并执行mvn cobertura:cobertura命令。这将开始下载 Cobertura 插件文件并开始对.class文件进行检测。以下截图展示了 Maven 控制台输出:![使用 Maven Cobertura 插件]()
-
打开
/Chapter05/Chapter05/target。target文件夹包含以下重要子文件夹:-
cobertura: 这包含cobertura.ser文件 -
generated-classes: 这包含被检测的字节码或.class文件 -
site: 这包含 XML 和 HTML 格式的覆盖率报告 -
surefire-reports: 这包含测试执行报告
-
以下截图显示了在site文件夹中生成的 HTML 格式的覆盖率报告:

运行 Cobertura Ant 任务
本节将解释如何在项目中配置 Cobertura Ant 任务。
以下是为配置的步骤:
-
Gradle 和 Maven 可以在构建过程中下载覆盖率工具 JAR 文件,但 Ant 需要将 Cobertura JAR 文件添加到类路径。从
cobertura.github.io/cobertura/下载 Cobertura ZIP 文件。 -
提取 ZIP 文件,并将下载的 ZIP 文件中的所有 JAR 文件复制到
Chapter05\lib。包括lib文件夹中的所有 JAR 文件以及root文件夹中的cobertura.jar。 -
在
Chapter05\Chapter05下创建一个build.properties文件,并输入以下信息:src.dir=src/main/java test.dir=src/test/java # The path to cobertura.jar cobertura.dir=../lib classes.dir=classes instrumented.dir=instrumented reports.dir=reports # Unit test reports from JUnit are deposited into this directory reports.xml.dir=${reports.dir}/junit-xml reports.html.dir=${reports.dir}/junit-html coverage.xml.dir=${reports.dir}/cobertura-xml coverage.summaryxml.dir=${reports.dir}/cobertura-summary-xml coverage.html.dir=${reports.dir}/cobertura-htmlsrc.dir属性表示源文件夹位置,test.dir表示测试文件位置。cobertura.dir属性指的是 Cobertura 库或 JAR 文件。覆盖率工具需要访问 Cobertura 库文件。其他条目是用于报告生成和字节码检测所必需的。 -
在
Chapter05\Chapter05下创建一个build.xml文件,并添加 Cobertura 检测和 JUnit 测试的目标以更新.ser文件并生成报告。从 Packt Publishing 网站(Chapter05代码)下载build.xml文件。重要目标包括init、compile、testcompile、instrument、test、coverage-report、summary-coverage-report、alternate-coverage-report和clean。 -
打开命令提示符,将目录更改为
Chapter05\Chapter05,并执行ant命令。这将生成报告。以下是该命令的控制台输出:![运行 Cobertura Ant 任务]()
Cobertura 在
Chapter05\Chapter05\reports中生成报告。reports文件夹包含各种 XML 和 HTML 格式的报告。小贴士
代码覆盖率并不是一个能够提供零缺陷软件的银弹!最重要的是编写有效的测试和单元测试逻辑。为 getter 和 setter 或构造函数编写测试并不增加价值。
摘要
在本章中,我们将深入介绍代码覆盖率,并提供使用 Eclipse 插件和各种覆盖率工具(如 Clover、JaCoCo、EclEmma 和 Cobertura)来测量代码覆盖率的示例。我们还已配置 Ant、Maven 和 Gradle,使用覆盖率工具生成代码覆盖率报告。
到本章结束时,你应该能够配置 Eclipse 插件和构建脚本来测量代码覆盖率。
下一章涵盖了静态代码分析、代码指标以及各种开源工具。它配置并使用 PMD、Checkstyle 和 FindBugs 来分析代码质量,并探索 Sonar 代码质量仪表板。
第六章。揭示代码质量
"仅仅进行测试本身并不能提高软件质量。测试结果只是质量的一个指标,但本身并不能提高质量。试图通过增加测试量来提高软件质量,就像试图通过更频繁地称体重来减肥一样。你在上秤之前吃的食物决定了你的体重,而你使用的软件开发技术决定了测试能发现多少错误。如果你想减肥,不要买新的秤;改变你的饮食。如果你想提高你的软件质量,不要进行更多的测试;而是开发更好的软件。"
——史蒂夫·麦克康奈尔
一个开发不良的系统比一个设计良好的系统产生更多的错误。手动测试可以识别软件错误,但不能提高系统的质量;然而,TDD 和 JUnit 测试被认为是自动单元测试框架,并且它们确实有助于提高系统的质量。静态代码质量分析揭示了代码中的质量问题,并提供了改进建议,而持续的健康监控使系统保持健康。
本章将涵盖以下主题:
-
代码质量指标
-
使用 PMD、Checkstyle 和 FindBugs 进行静态代码分析
-
SonarQube 仪表板
-
SonarQube 运行器
-
使用 Ant、Maven 和 Gradle 进行代码质量分析
理解静态代码分析
静态代码分析是在不执行代码的情况下分析代码的过程。代码审查也是一种静态代码分析,但由人类或团队成员执行。通常,静态代码分析是由自动化工具执行的。
通常,静态分析包括以下指标:
-
违反编码最佳实践,如方法体过长、参数列表过长、类过大以及变量命名不当。
-
内聚性代表单个模块(类)的责任。如果一个模块或类承担太多的责任,例如税务计算、发送电子邮件和格式化用户输入,那么这个类或模块的内聚性就会降低。执行多个不同的任务会引入复杂性和可维护性问题。高内聚性意味着只执行特定类型的工作。
假设一个人被分配处理客户工单、编写新功能、设计架构、组织年度办公室派对等工作;这个人将会非常忙碌,并且难免会出错。他或她将很难管理所有的责任。
在重构的术语中,如果一个类执行太多的任务,那么这个类被称为 GOD 对象或类。
-
耦合度衡量对其他模块或代码的依赖性。低依赖性强制高内聚性。如果模块 C 依赖于两个其他模块,A 和 B,那么 A 或 B 的 API 的任何变化都将迫使 C 发生变化。
事件驱动架构是松耦合的一个例子。在一个事件驱动系统中,当某个东西发生变化时,会向一个目的地发布一个事件,而无需知道谁将处理该事件;事件消费者消费事件并采取行动。这解耦了事件发布者与事件消费者。因此,消费者中的任何变化都不会强迫发布者发生变化。
-
循环复杂度衡量程序的复杂度。1976 年,托马斯·J·麦卡贝(Thomas J. McCabe, Sr.)开发了循环复杂度。它衡量程序中线性独立路径的数量。这不仅仅限于程序级别的复杂度,也可以应用于程序中的单个函数、模块、方法或类。
程序的循环复杂度是通过程序的控制流图定义的。复杂度表示为 M = E-N+2P,其中 M 是复杂度,E 是图的边数,N 是图的节点数,P 是连通分量的数量。任何复杂度大于 10 的方法都存在严重问题。
没有条件语句的方法具有 1 的循环复杂度。以下图表表示了有向图和复杂度:
![理解静态代码分析]()
具有一个条件(IF 语句)或一个循环(FOR 循环)的方法具有 2 的复杂度。以下图表解释了计算方法:
![理解静态代码分析]()
以下是对应的代码:
public void trim(String input){ if(input != null){ return input.trim(); } return null; }
静态代码分析有多种自动化工具可用。此外,内置的 Eclipse 编译器已经可以执行大量的静态代码分析。以下是一些广泛使用的工具:
-
Checkstyle:这个工具执行静态代码分析,也可以用来显示配置的编码标准的违规情况。它遵循 GNU 通用公共许可证。您可以在以下链接查看它:
checkstyle.sourceforge.net。 -
FindBugs:这是一个开源的 Java 潜在错误的静态字节码分析器。它为 Eclipse、NetBeans 和 IntelliJ IDEA 提供了插件。它遵循 GNU 通用公共许可证。FindBugs 可以通过 Jenkins 进行配置。以下为 FindBugs 网站链接:
findbugs.sourceforge.net。 -
PMD:这是一个基于 Java 源代码分析器的静态规则集,用于识别潜在问题。PMD 有一个 Eclipse 插件,在编辑器中显示错误图标,但 PMD 错误不是真正的错误;而是不高效代码的结果。
在下一节中,我们将检查静态分析工具。
使用 Checkstyle 插件
Calculator.java:
package com.packt.code.quality;
public class Calculator<T extends Number> {
public String add(T... numbers) {
T result = null;
int x =0;
for(T t:numbers) { x++;
if(result == null) {
if(t instanceof Integer) {
result = (T) new Integer("0");
}else if(t instanceof Short) {
result = (T) new Short("0");
}else if(t instanceof Long) {
result = (T) new Long("0");
}else if(t instanceof Float) {
result = (T) new Float("0.0");
}else if(t instanceof Double) {
result = (T) new Double("0.0");
}
}
if(t instanceof Integer) {
Integer val = ((Integer)result + (Integer)t);
result =(T)val;
}else if(t instanceof Short) {
Short val = (short) ((Short)result + (Short)t);
result =(T)val;
}else if(t instanceof Long) {
Long val = ((Long)result + (Long)t);
result =(T)val;
}else if(t instanceof Float) {
Float val = ((Float)result + (Float)t);
result =(T)val;
}else if(t instanceof Double) {
Double val = ((Double)result + (Double)t);
result =(T)val;
}
if(x == 1045) {
System.out.println("warning !!!");
}
}
return result.toString();
} }
这个类,Calculator.java,计算一系列数字的总和。它是一个泛型类;我们可以计算整数或双精度浮点数或任何数字的总和。
右键单击CodeQualityChapter06并启用Checkstyle。以下屏幕截图显示了 Checkstyle 弹出菜单:

此操作将触发 Checkstyle 验证。它将打开检查选项卡(如果检查选项卡没有自动打开,则从显示视图菜单中打开视图)并显示违规的图形视图。以下屏幕截图显示了违规的图形饼图:

另一个视图以表格格式显示违规。以下屏幕截图显示了以表格格式显示的违规:

探索 FindBugs 插件
本节描述了 FindBugs 插件的配置和使用。
FindBugs 与三种类型的错误一起工作。您可以访问findbugs.sourceforge.net/bugDescriptions.html以获取 FindBugs 错误详情。以下是一些 FindBugs 支持的错误类别和错误:
-
正确性错误: 这是一种明显的编码错误,导致代码可能是开发者不希望的结果;例如,一个方法忽略了自赋值字段的返回值。以下是一些正确性错误的例子:
-
该类定义了
tostring()但应该是toString() -
在这里检查一个值是否为 null,但这个值不能为 null,因为它之前已经被解引用,如果它是 null,那么在早期解引用时就会发生空指针异常
-
子类中的方法没有覆盖超类中类似的方法,因为参数的类型与超类中相应参数的类型不完全匹配
-
类定义了
equal(Object)但应该是equals(Object)
-
-
不良实践: 这包括违反推荐的最佳实践和基本编码实践。以下是不良实践的例子:
-
哈希码和 equals 问题:
-
类定义了
hashCode()但应该是equals()和hashCode() -
类定义了
equals()但应该是hashCode() -
类定义了
hashCode()并使用Object.equals() -
类定义了
equals()并使用Object.hashCode()
-
-
可克隆习语:
- 类定义了
clone()但没有实现Cloneable
- 类定义了
-
可序列化问题:
-
类是
Serializable,但没有定义serialVersionUID -
比较器没有实现
Serializable -
非序列化类有一个
serializable内部类
-
-
丢弃的异常: 在这里,创建了一个异常但没有抛出,例如以下示例中,异常被创建但没有抛出:
if (x < 0) new IllegalArgumentException("x must be nonnegative"); -
finalize 方法滥用:
-
明确调用
finalize -
清理器没有调用超类的清理器
-
-
-
可疑错误: 这类代码令人困惑、异常,或以导致错误的方式编写。以下是一些例子:
-
类字面量的无效存储:一条指令将类字面量赋给一个变量,然后从未使用它。
-
switch 语句穿透:由于 switch 语句穿透,这里覆盖了之前 switch 情况中存储的值。很可能你忘记在之前的 case 末尾放置 break 或 return。
-
未确认的类型转换 和 冗余的空检查:当值是
null时,会发生此错误,例如,考虑以下代码:Object x = null; Car myCar = (Car)x; if(myCar != null){ //... }
-
以下是为 FindBugs Eclipse 插件提供的更新站点 URL:findbugs.cs.umd.edu/eclipse。
您也可以通过 Eclipse Marketplace 安装它。
安装 FindBugs,然后向 CodeQualityChapter06 项目添加以下代码以进行验证:
public class Buggy implements Cloneable {
private Integer magicNumber;
public Buggy(Integer magicNumber) {
this.magicNumber = magicNumber;
}
public boolean isBuggy(String x) {
return "Buggy" == x;
}
public boolean equals(Object o) {
if (o instanceof Buggy) {
return ((Buggy) o).magicNumber == magicNumber;
}
if (o instanceof Integer) {
return magicNumber == ((Integer) o);
}
return false;
}
Buggy() { }
static class MoreBuggy extends Buggy {
static MoreBuggy singleton = new MoreBuggy();
}
static MoreBuggy foo = MoreBuggy.singleton;
}
右键单击项目并单击 查找错误 菜单。以下显示的是弹出菜单:

打开源文件;它显示错误图标。以下截图显示了错误:

以下截图以表格形式显示了错误类别中的错误:

使用 PMD 插件
PMD 可以找到重复代码、死代码、空的 if/while 语句、空的 try/catch 块、复杂的表达式、循环复杂度等。
以下是为 Eclipse 提供的更新站点 URL:sourceforge.net/projects/pmd/files/pmd-eclipse/update-site/。您也可以通过 Eclipse Marketplace 安装它。
安装后,右键单击 CodeQualityChapter06 项目并选择 切换 PMD 性质 菜单项。它将为 PMD 分析启用项目。以下截图演示了 PMD 弹出菜单选项:

PMD 在 问题 选项卡中显示错误。以下截图显示了 问题 选项卡中的 PMD 违规:

下一节将描述 SonarQube 仪表板,并使用 SonarQube 运行器、Ant、Gradle 和 Maven 分析项目。
使用 SonarQube 监控代码质量
SonarQube 是一个基于 Web 的开源持续质量评估仪表板。它附带 GNU 通用公共许可证,支持跨平台,因此可以安装在许多流行的操作系统上。SonarQube 使用 Java 开发。截至 2014 年 3 月,最新版本是 4.1.2。
SonarQube 展示以下功能:
-
它是一个基于 Web 的代码质量仪表板,可以从任何地方访问。
-
它支持多种语言。在 4.1.2 版本中支持的语言和编码平台包括 ABAP、Android、C/C++、C#、COBOL、Erlang、Flex/ActionScript、Groovy、Java、JavaScript、Natural、PHP、PL/I、PL/SQL、Python、VB.NET、Visual Basic 6、Web(包括 HTML、JSP、JSF、Ruby、PHP 等页面上的 HTML 分析)和 XML。
-
它提供了以下指标:
-
缺陷和潜在缺陷
-
编码标准违规
-
重复
-
缺少单元测试
-
复杂性分布不均
-
意大利面式设计
-
注释不足或过多
-
-
它在数据库中记录历史,并提供质量指标的按时间顺序的图表。
-
它可以通过众多插件进行扩展。
-
它支持使用 Ant/Maven/Gradle 和 CI 工具(如 Jenkins、CruiseControl 和 Bamboo)进行持续自动化检查。
-
它与 Eclipse 集成。
以下部分涵盖了 SonarQube 的安装和使用。
运行 SonarQube
以下是在 SonarQube 中配置的步骤:
-
从
www.sonarqube.org/downloads/下载 SonarQube。 -
将下载的文件解压缩到您选择的目录中。在接下来的步骤中,我们将将其称为
<sonar_install_directory>或SONAR_HOME。 -
打开
<sonar_install_directory>/bin目录。bin目录列出了 SonarQube 支持的操作系统。转到特定的 OS 目录,例如为 Windows 64 位机器打开windows-x86-64。 -
运行一个 shell 脚本或批处理文件来启动 Sonar。以下截图显示了 Windows 64 位机器的命令提示符输出。注意,当 Web 服务器启动时,服务器会记录Web 服务器已启动的信息:
![运行 SonarQube]()
-
打开 Internet Explorer 并输入
http://localhost:9000。这将启动 SonarQube 仪表板。最初,仪表板显示一个空的项目列表。首先,我们需要分析项目以在仪表板中显示它们。以下是在仪表板中显示的 SonarQube 仪表板:![运行 SonarQube]()
安装完成。接下来,我们需要使用 SonarQube 分析一个项目。
使用 SonarQube 运行器分析代码
sonar-runner.properties file. Check whether sonar.host.url and sonar.jdbc.url are enabled:
* 创建一个新的SONAR_RUNNER_HOME环境变量,将其设置为<runner_install_directory>。* 将<runner_install_directory>/bin目录添加到您的Path变量中。* 打开命令提示符并检查运行器是否已安装。输入sonar-runner –h命令,您将得到以下输出:
* 进入CodeQualityChapter06项目文件夹,创建一个名为sonar-project.properties的属性文件,并将以下行添加到文件中:
```java
# Required metadata
sonar.projectKey=packt:CodeQualityChapter06
sonar.projectName=CodeQualityChapter06
sonar.projectVersion=1.0
#source file location
sonar.sources=src/main/java
# The value of the property must be the key of the language.
sonar.language=java
# Encoding of the source code
sonar.sourceEncoding=UTF-8
```
+ 打开命令提示符,将目录更改为`CodeQualityChapter06`,并输入`sonar-runner`命令;这将启动项目分析。Sonar 将下载 JAR 文件并将分析数据存储到 H2 数据库中。一旦分析完成,打开`http://localhost:9000`;这将启动 SonarQube 仪表板。
仪表板中显示的指标包括技术债务、代码详细情况、文档、代码重复、复杂性和覆盖率。
以下截图显示了**技术债务**指标:

以下截图显示了代码详细指标:

以下截图显示了**文档**指标:

以下截图显示了循环**复杂度**指标:
* 点击**技术债务**指标中的**问题 12**超链接;这将打开一个带有严重性图例的问题详情。以下为**严重性**图例:
以下截图显示了问题详情:
* 点击任何三个复杂度超链接。Sonar 将打开文件并显示复杂度详情。
以下为`Buggy.java`的复杂度示例:

**热点**视图显示了项目的痛点区域,例如重复行、主要违规、最常违反的规则和最常违反的资源。
**时间机器**视图显示了项目的按时间顺序视图,例如代码复杂度或代码覆盖率按日或按月进行图形比较。
使用 Sonar Eclipse 插件提高质量
Sonar 为 Eclipse 编辑器提供了一个插件,用于访问和修复 Sonar 报告的代码问题。插件可以从www.sonarsource.com/products/plugins/developer-tools/eclipse/下载。
一旦插件安装完毕,右键单击项目,打开配置菜单,然后点击与 Sonar 关联...菜单项。以下截图显示了配置菜单的详细信息:

在sonar-project.properties文件中,我们存储了sonar.projectKey=packt:CodeQualityChapter06项目密钥。
在 Sonar 向导中,输入GroupId=packt和ArtifactId=CodeQualityChapter06。点击在服务器上查找然后点击完成。这将连接到本地 Sonar 服务器并将问题详情带入问题选项卡。
以下为 Sonar 向导的截图:

以下是从 Sonar 仓库中获取的违规情况:

点击任何问题,它将带您到源代码的行并显示问题的工具提示。以下截图显示了hashCode()方法未实现的阻塞违规:

添加一个hashCode方法,重新运行 Sonar 运行器,并启动 Sonar Eclipse 向导;它将移除阻塞问题。
使用 Gradle 和 SonarQube 监控质量
本节介绍了 Gradle 与 Sonar 的集成。Gradle 有一个内置的 Sonar 插件。在 /Packt/Chapter06/CodeQualityChapter06 下创建一个 build.gradle 文件。将以下行添加到 build.gradle 文件中,并突出显示 Sonar 行:
apply plugin: 'java'
apply plugin: 'sonar-runner'
apply plugin: "jacoco"
repositories {
flatDir(dir: '../lib', name: 'JUnit Library')
mavenCentral()
}
dependencies {
testCompile'junit:junit:4.11', ':hamcrest-core:1.3'
}
jacocoTestReport {
reports {
xml.enabled false
csv.enabled false
html.destination "${buildDir}/jacocoHtml"
}
}
sonarRunner {
sonarProperties {
property "sonar.projectName", "CodeQualityChapter06"
property "sonar.projectKey", "packt:CodeQualityChapter06"
property "sonar.jacoco.reportPath", "${project.buildDir}/jacoco/test.exec"
}
}
注意,sonar.projectKey 指的是 packt:CodeQualityChapter06。打开命令提示符并执行 gradle sonarRunner 命令。这将开始构建项目。以下是在控制台上的输出:

打开 Sonar URL,它将显示 JaCoCo 插件计算出的覆盖率。以下是代码覆盖率和技术债务输出。请注意,项目中新增加了 +8 个问题。技术债务从 0.4 天增加到 1.2 天:

使用 Maven 和 SonarQube 监控质量
本节描述了如何将 Maven 与 SonarQube 集成。我们将使用 CodeQualityChapter06 Eclipse 项目进行分析。Maven 有一个用于 Sonar 的插件。在 /Packt/Chapter06/CodeQualityChapter06 下创建一个 pom.xml 文件。将以下行添加到 pom.xml 文件中:
<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>packt</groupId>
<artifactId>Chapter06</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Chapter06</name>
<url>http://maven.apache.org</url>
<properties>
<project.build.sourceEncoding>UTF-8
</project.build.sourceEncoding>
<sonar.language>java</sonar.language>
</properties>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
</dependencies>
打开命令提示符,转到项目基本文件夹,并执行 mvn sonar:sonar 命令。此命令将从存储库下载 SonarQube 版本的 JAR 文件并开始分析项目。注意前面脚本中突出显示的 <sonar.language>java<…> 部分。此 <sonar.language> 标签表示 Maven 将分析一个 java 项目。
在 Gradle 脚本或 Sonar 运行器中,我们没有提到项目版本;在这里,根据 Maven 项目约定,我们必须在 POM.xml 文件中指定 <version>1.0-SNAPSHOT</version> 版本。
SonarQube 使用一个密钥(GroupId 或 ArtifactId)和一个版本来唯一标识一个项目。因此,Maven 分析将在 Sonar 服务器中创建一个新的项目统计信息,因为 Maven 提供了版本号,但 Gradle 和 Sonar 运行器没有。
以下截图显示了 Sonar 仪表板上的 项目 部分。请注意,Maven 分析创建了 版本 1.0-SNAPSHOT,而 Gradle 和 Sonar 运行器都更新了项目的 未指定 版本:

使用 Ant 和 SonarQube 监控质量
本节描述了如何配置 Ant 以与 Sonar 集成。Ant 目标需要一个任务来执行构建步骤。SonarQube 提供了一个用于项目分析的 Ant 任务。需要从 repository.codehaus.org/org/codehaus/sonar-plugins/sonar-ant-task/2.1/sonar-ant-task-2.1.jar 下载 Ant 任务 JAR。
我们将使用 Ant 分析 CodeQualityChapter06 项目。将下载的 JAR 文件复制到 \Packt\chapter06\lib 目录下,并在 CodeQualityChapter06 目录下直接创建一个 build.xml 文件。您可以复制我们在 第五章,代码覆盖率 中使用的现有 build.xml 文件,或者下载本章的代码。
提示
XML 命名空间类似于 Java 包,为 XML 元素或属性提供一个限定名,以避免名称冲突。命名空间由元素的起始标签中的 xmlns 属性定义。命名空间声明具有 syntax. 语法。
<project name="chapter06" default="coverage" basedir="." **>**
**<property name="sonar.projectKey" value="packt:chapter06_ant" />**
<property name="sonar.projectName" value="Chapter06" />
**<property name="sonar.projectVersion" value="2.0" />**
<property name="sonar.language" value="java" />
<property name="sonar.sources" value="src/main/java" />
<property name="sonar.binaries" value="target" />
<property name="sonar.sourceEncoding" value="UTF-8" />
**<target name="sonar" depends="compile">**
**<taskdef uri="antlib:org.sonar.ant" ** **resource="org/sonar/ant/antlib.xml">**
**<classpath path="${lib.dir}/sonar-ant-task-2.1.jar" />**
**</taskdef>**
**<sonar:sonar />**
**</target>**
以下是 SonarQube 仪表板输出。第二行带有 版本 2.0 和键 packt:chapter06_ant 的行是 Ant 分析结果:
 # 熟悉误报 这一节讨论了误报问题。一般来说,静态代码分析工具会根据一组规则分析源代码,并在源代码中找到违规模式时报告违规。然而,当我们审查模式并发现违规在上下文中不正确时,那么报告的违规就是一个误报。 静态分析工具报告违规,但我们必须过滤掉正确的规则集并移除误报规则。SonarQube 的手动代码审查功能允许您审查代码,添加注释,并将违规标记为误报。以下 Sonar URL 描述了如何审查违规并将违规标记为误报:[www.sonarqube.org/sonar-2-8-in-screenshots/](http://www.sonarqube.org/sonar-2-8-in-screenshots/)。 # 摘要 本章深入讲解了静态代码分析和代码质量属性。它涵盖了 SonarQube 代码质量仪表板、使用 Eclipse 插件进行的静态代码分析、Sonar 运行器和构建脚本(如 Ant、Maven 和 Gradle),以及代码质量工具(如 PMD、Checkstyle 和 FindBugs)。 到目前为止,读者将能够配置 Sonar 仪表板,设置 Eclipse 插件,并配置 Sonar 运行器和构建脚本,以使用 PMD、FindBugs 和 Checkstyle 分析代码质量。 下一章将介绍使用模拟对象进行单元测试 Web 层代码。
第七章:单元测试 Web 层
“如果你不喜欢对你的产品进行单元测试,那么你的客户很可能也不喜欢对其进行测试。”
——匿名
企业应用程序遵循N 层架构模型来处理众多非功能性关注点,例如可升级性、可扩展性和可维护性。最佳的设计方法是解耦各个层;这允许在不影响其他层的情况下扩展一个层,或者在不影响其他层的情况下重构一个层的代码。通常,任何 Web 应用程序都包含三个层:表示层、业务逻辑层和数据库层。本章将处理对 Web 层或表示层的单元测试。下一章将涵盖应用程序和数据库层。
本章将涵盖以下主题:
-
单元测试 MVC 中的 servlet 控制器
-
理解在表示层中要测试的内容
单元测试 servlet
模型-视图-控制器(MVC)是一种广泛使用的 Web 开发模式。MVC 模式定义了三个相互关联的组件:模型、视图和控制器。
模型表示应用程序数据、逻辑或业务规则。
视图是信息或模型的表示。一个模型可以有多个视图;例如,学生的分数可以用表格格式或图形图表表示。
控制器接受客户端请求并启动命令以更新模型或更改视图。
控制器控制应用程序的流程。在 JEE 应用程序中,控制器通常实现为一个 servlet。控制器 servlet 拦截请求并将每个请求映射到适当的处理资源。在本节中,我们将构建一个经典的 MVC 前端控制器 servlet 以将请求重定向到视图。
只有上下文路径的请求,例如http://localhost:8080/context/,将被路由到login.jsp页面,所有主页请求(带有 URL /home.do)将被路由到home.jsp页面,所有其他请求将被路由到error.jsp页面。
构建和单元测试一个 J2EE Web 应用程序
按照以下步骤构建 Web 应用程序并测试控制器逻辑:
-
在 Eclipse 中创建一个名为
DemoServletTest的动态 Web 项目。 -
创建一个名为
com.packt.servlet.DemoController的控制器 servlet,并将以下行添加到doGet方法中:protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { String urlContext = req.getServletPath(); if(urlContext.equals("/")) { req.getRequestDispatcher("login.jsp").forward(req, res); }else if(urlContext.equals("/home.do")) { req.getRequestDispatcher("home.jsp").forward(req, res); }else { req.setAttribute("error", "Invalid request path '"+urlContext+"'"); req.getRequestDispatcher("error.jsp").forward(req, res); } }此方法从请求中获取 servlet 路径并与
/标记进行匹配。如果没有找到匹配项,则doGet方法将错误属性设置到请求中。 -
创建三个 JSP 文件:
login.jsp、home.jsp和error.jsp。修改error.jsp文件,并添加以下脚本以显示错误消息:<body> <font color="RED"><%=request.getAttribute("error") %></font> </body> -
修改
web.xml文件以将所有请求映射到DemoController。在web.xml文件中添加以下代码行:<web-app xsi:schemaLocation="http://java.sun.com/xml/ ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0"> <display-name>DemoServletTest</display-name> <servlet> <servlet-name>demo</servlet-name> <servlet-class>com.packt.servlet.DemoController </servlet-class> </servlet> <servlet-mapping> <servlet-name>demo</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>demoservlet 映射url-pattern标签。
应用程序已准备就绪,但我们如何对控制器逻辑进行单元测试?
我们不能实例化 HttpServletRequest 或 HttpServletResponse 对象。我们可以使用 Mockito 模拟 HttpServletRequest 或 HttpServletResponse 对象。
创建一个名为 DemoControllerTest 的测试类,并添加以下代码片段:
@RunWith(MockitoJUnitRunner.class)
public class DemoControllerTest {
@Mock HttpServletRequest req;
@Mock HttpServletResponse res;
@Mock RequestDispatcher dispatcher;
DemoController controllerServlet;
@Before
public void setup() {
controllerServlet = new DemoController();
when(req.getRequestDispatcher(anyString())). thenReturn(dispatcher);
}
@Test
public void when_servlet_path_is_empty_then_opens_login_page(){
when(req.getServletPath()).thenReturn("/");
controllerServlet.doGet(req, res);
ArgumentCaptor<String> dispatcherArgument = ArgumentCaptor.forClass(String.class);
verify(req).getRequestDispatcher( dispatcherArgument.capture());
assertEquals("login.jsp", dispatcherArgument.getValue());
}
@Test
public void when_home_page_request_then_opens_home_page(){
when(req.getServletPath()).thenReturn("/home.do");
controllerServlet.doGet(req, res);
ArgumentCaptor<String> dispatcherArgument = ArgumentCaptor.forClass(String.class);
verify(req).getRequestDispatcher( dispatcherArgument.capture());
assertEquals("home.jsp", dispatcherArgument.getValue());
}
@Test
public void when_invalid_request_then_opens_error_page(){
when(req.getServletPath()).thenReturn("/xyz.do");
controllerServlet.doGet(req, res);
ArgumentCaptor<String> dispatcherArgument = ArgumentCaptor.forClass(String.class);
verify(req).getRequestDispatcher( dispatcherArgument.capture());
assertEquals("error.jsp", dispatcherArgument.getValue());
}
}
注意,使用 mockito 模拟了 request 和 response 对象,并设置了期望以获取 ServletPath,然后使用 verify 检查控制器返回的视图名称。我们添加了三个测试来验证控制器逻辑:一个用于检查默认上下文路径,一个用于检查 home.do URL,另一个用于验证错误条件。
从服务器视图(在服务器视图中右键单击并创建一个新的服务器;从服务器向导中选择 Tomcat 并设置运行时配置)创建一个 Tomcat 服务器实例并运行应用程序。打开浏览器并访问 http://localhost:8080/DemoServletTest/,检查应用程序是否打开 登录页面。以下为浏览器输出:

访问 http://localhost:8080/DemoServletTest/home.do;它将打开 主页。以下为浏览器输出:

访问任何其他 URL,例如 http://localhost:8080/DemoServletTest/abc。它将打开一个错误页面并显示错误信息。以下为错误输出:

上述浏览器验证结果表明我们的 JUnit 测试运行良好。
DemoServletTest 充当 前端控制器。前端控制器是一种设计模式,其中单个 servlet 处理所有网络请求并将它们路由到其他控制器或处理器进行实际处理。所有使用 Java 或 Servlet API 编写的动态网络应用程序都需要一个前端控制器 servlet 来处理 HTTP 请求,因此所有项目都编写逻辑上重复的代码来通过前端控制器 servlet 处理请求。
Spring MVC 是为了为网络应用程序开发者提供一个灵活的框架而构建的。Spring 的 DispatcherServlet 充当前端控制器;类似于 DemoServletTest 测试,它接收所有传入的请求并将请求的处理委托给处理器。它允许开发者专注于业务逻辑,而不是在自定义前端控制器的样板代码上工作。下一节将描述 Spring MVC 架构以及如何使用 Spring MVC 对网络应用程序进行单元测试。
撒娇 Spring MVC
在 Spring MVC 中,以下是一个简化的请求处理机制的示例:
-
DispatcherServlet接收一个请求并与处理器映射协商以确定哪个控制器可以处理该请求,然后将请求传递给该控制器。 -
控制器执行业务逻辑(可以委托请求到服务或业务逻辑处理器)并将一些信息返回给
DispatcherServlet以供用户显示或响应。而不是直接将信息(模型)发送给用户,控制器返回一个可以渲染模型的视图名称。 -
DispatcherServlet然后根据视图名称解析物理视图,并将模型对象传递给视图。这样,DispatcherServlet就与视图实现解耦了。 -
视图渲染模型。一个视图可以是 JSP 页面、servlet、PDF 文件、Excel 报告或任何可展示的组件。
以下序列图表示了 Spring MVC 组件的流程和交互:

我们将构建一个 Spring 网络应用程序,并使用 JUnit 对代码进行单元测试。以下是需要执行的步骤:
-
启动 Eclipse 并创建一个名为
SpringMvcTest的动态网络项目。 -
打开
web.xml并输入以下行:<display-name>SpringMVCTest</display-name> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class> org.springframework.web.servlet.DispatcherServlet </servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <context-param> <param-name>contextConfigLocation</param-name> <param-value> /WEB-INF/dispatcher-servlet.xml </param-value> </context-param> </web-app>分发器被命名为
DispatcherServlet,它映射所有请求。注意contextConfigLocation参数。这表明 Spring 容器在/WEB-INF/dispatcher-servlet.xml中定义了 Spring 容器。 -
在
WEB-INF中创建一个名为dispatcher-servlet.xml的 XML 文件,并添加以下行:<?xml version="1.0" encoding="UTF-8"?> <beans xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd"> <context:component-scan base-package="com.packt" /> <bean class= "org.springframework.web.servlet.view. InternalResourceViewResolver"> <property name="prefix"> <value>/WEB-INF/pages/</value> </property> <property name="suffix"> <value>.jsp</value> </property> </bean> </beans>此 XML 定义了一个 Spring 视图解析器。任何视图都将位于
/WEB-INF/pages位置,并具有.jsp后缀,所有容器都配置在com.packt包下,并使用 Spring 注解。 -
在
com.packt.model包中创建一个名为LoginInfo的类。这个类表示登录信息。添加两个私有的String字段,userId和password,并生成获取器和设置器。 -
在
/WEB-INF/view下创建一个名为login.jsp的 JSP 页面,并添加以下行以使用 Spring 标签库创建表单。修改表单并添加用于用户名和密码的正常 HTML 输入:<%@ taglib prefix="sf" uri="http://www.springframework.org/tags/form"%> <sf:form method="POST" modelAttribute="loginInfo" action="/onLogin"> </sf:form> -
创建一个名为
com.packt.controller.LoginController的控制器类来处理登录请求。添加以下行:@Controller @Scope("session") public class LoginController implements Serializable { @RequestMapping({ "/", "/login" }) public String onStartUp(ModelMap model) { model.addAttribute("loginInfo", new LoginInfo()); return "login"; } }@Controller注解表示该类是一个 Spring MVC 控制器类。在smapl-servlet.xml中,我们定义了<context:component-scan base-package="com.packt" />,因此 Spring 将扫描此@Controller注解并创建一个 Bean。@RequestMapping注解将任何请求与默认路径/SpringMvcTest/或/SpringMvcTest/login映射到onStartUp方法。此方法返回一个逻辑视图名称login。XML 文件中定义的视图解析器将登录请求映射到/WEB-INF/pages下的物理视图login.jsp页面。 -
在
Login类中创建另一个方法来处理登录和提交请求,如下所示:@RequestMapping({ "/onLogin" }) public String onLogin(@ModelAttribute("loginInfo") LoginInfo loginInfo, ModelMap model) { if(!"junit".equals(loginInfo.getUserId())) { model.addAttribute("error", "invalid login name"); return "login"; } if(!"password".equals(loginInfo.getPassword())) { model.addAttribute("error", "invalid password"); return "login"; } model.addAttribute("name", "junit reader!"); return "greetings"; }onLogin方法与/onLogin映射。@ModelAttribute("loginInfo")方法是来自login.jsp表单提交的模型。此方法检查用户名是否为junit且密码为password。如果用户 ID 或密码不匹配,则登录页面将显示错误信息,否则将打开greetings视图。 -
将
login.jsp文件的内容更改为将表单提交到/SpringMvcTest/onLogin,并将modelattribute名称更改为loginInfo,如下所示:<sf:form method="POST" modelAttribute="loginInfo" action="/SpringMvcTest/onLogin">此外,添加
<h1>${error}</h1>JSTL 表达式以显示错误信息。 -
在
com.packt.controller目录下创建一个名为greetings.jsp的 JSP 文件,并添加以下行:<h1>Hello :${name}</h1> -
在浏览器中输入
http://localhost:8080/SpringMvcTest/;这将打开登录页面。在登录页面,不要输入任何值,只需点击提交。它将显示无效登录名错误信息。现在,在用户 ID字段中输入junit,在密码字段中输入password并按Enter。应用程序将使用以下截图所示的消息问候你:![与 Spring MVC 玩耍]()
我们可以对controller类进行单元测试。以下是一些步骤:
-
在
com.packt.controller目录下创建一个LoginControllerTest.java类。 -
使用以下代码,添加一个测试来检查当用户 ID 为 null 时,是否会抛出错误信息:
public class LoginControllerTest { LoginController controller = new LoginController(); @Test public void when_no_name_entered_shows_error_message(){ ModelMap model = new ModelMap(); String viewName = controller.onLogin(new LoginInfo(), model); assertEquals("login", viewName); assertEquals("invalid login name", model.get("error")); } } -
添加另一个测试来检查无效密码,如下所示:
@Test public void when_invalid_password_entered_shows_error_message() { ModelMap model = new ModelMap(); LoginInfo loginInfo = new LoginInfo(); loginInfo.setUserId("junit"); String viewName =controller.onLogin(loginInfo, model); assertEquals("login", viewName); assertEquals("invalid password", model.get("error")); } -
添加一个
happyPath测试,如下所示:@Test public void happyPath(){ loginInfo.setUserId("junit"); loginInfo.setPassword("password"); String viewName =controller.onLogin(loginInfo, model); assertEquals("greetings", viewName); }
这只是一个 Spring MVC 的示例,所以我们使用硬编码的常量来检查用户名和密码。在现实世界中,服务会查询数据库以获取用户信息,并返回错误信息;服务可以被自动注入到控制器中。这样,我们可以对控制器和服务层进行单元测试。
摘要
本章解释了表示层的单元测试策略,并提供了前端控制器 servlet 和 Spring MVC 的示例。
到目前为止,你应该能够对 Web 层组件进行单元测试,并将视图组件从表示逻辑中隔离出来。
下一章将介绍数据库层的单元测试。
第八章。玩转数据
"任何程序的价值仅在于它的实用性。"
——林纳斯·托瓦兹
企业应用程序存储、检索、传输、操作和分析数据。存储、处理和分析数据对任何业务都至关重要。商业智能(BI)过程将数据转换为对业务有意义的情报。BI 分析统计数据,帮助业务进行决策和预测,例如风险评估、计划和预测以及分析购买趋势。信息可以存储在文件或数据库中。从关系型数据库查询和访问数据比文件系统更容易。本章涵盖了数据库层的单元测试。以下主题将进行深入探讨:
-
分离关注点
-
单元测试持久化层
-
使用 Spring JDBC 编写干净的数据库访问代码
-
JDBC 代码的集成测试
-
Spring JDBC 的集成测试
分离关注点
本节详细阐述了分离关注点。企业应用程序的信息可以使用以下构建块表示:
-
什么:这代表要存储的信息。我们无法存储一切;因此,对要存储的数据进行分类非常重要。
-
谁:这代表参与者。信息是敏感的,控制用户之间的访问权限很重要;例如,员工不应能够访问其他员工的薪酬信息,但经理或人力资源部门的员工可以访问员工的薪酬数据。
-
数据存储:这代表信息和其可访问性。
-
过程:这代表数据处理。除非对信息执行某些操作,否则任何信息都没有意义。
以下图描述了企业应用程序的关键信息块:

本节涵盖了存储块和单元测试数据访问层。
以下图表示了一个松散耦合应用程序的组件:

视图组件代表 JSP、标签库、小部件等。为视图组件编写自动化的 JUnit 测试并不容易,需要手动工作。我们将在本章中跳过视图组件。
我们在第七章中单元测试了控制器逻辑组件,单元测试 Web 层。
控制器逻辑组件访问业务逻辑组件。业务逻辑组件执行业务逻辑并将数据访问委托给持久化逻辑组件。我们将在接下来的章节中介绍业务逻辑的单元测试。使用模拟对象来模拟持久化或数据访问层。
持久逻辑层或数据库客户端层负责管理数据库连接,从数据库检索数据,并将数据存储回数据库。对数据访问层进行单元测试非常重要;如果在这个层中出现问题,应用程序将失败。我们可以从数据库中独立地对数据访问逻辑进行单元测试,并执行集成测试以验证应用程序和数据库的完整性。
您可以对数据库访问代码实现 100% 的测试覆盖率。然而,如果控制器和/或视图层误用了此代码,整个应用程序将变得无用。您需要集成测试来验证连接,这将在稍后介绍。
数据库代表一个数据存储或关系数据库。
将数据访问层从业务逻辑层分离出来,有助于我们在不影响业务逻辑层的情况下修改数据库,并允许我们独立于数据库对业务逻辑层进行单元测试。假设您正在使用 MySQL 数据库,并希望迁移到 SQL Server。在这种情况下,您不需要修改业务逻辑层。
单元测试持久逻辑
在本节中,我们将构建一个电话簿应用程序并存储电话号码。我们将使用 Apache Derby 数据库进行持久化。Derby 可以从 db.apache.org/derby/ 下载。
您可以使用更好的内置数据库,例如 H2。它具有更多功能,比 Derby 更少限制;然而,我们为了简单起见使用 Derby。
以下是运行 Derby 的步骤:
-
下载二进制媒体文件并将其提取到首选位置。在接下来的步骤中,我们将将其称为
DERBY_HOME。 -
在 Windows 机器上,转到
DERBY_HOME\bin并执行startNetworkServer.bat文件。 -
它将启动一个命令提示符,并在控制台打印一条消息,表明数据库服务器已启动,例如 已启动并准备好在端口 1527 接受连接。
我们将创建一个 Java 项目来测试电话簿应用程序。按照以下步骤构建应用程序:
-
启动 Eclipse 并创建一个名为 DatabaseAccess 的 Java 项目。
-
添加一个
PhoneEntry类来存储电话详情。以下是类的详情:package com.packt.database.model; public class PhoneEntry implements Serializable { private static final long serialVersionUID = 1L; private String phoneNumber; private String firstName; private String lastName; // getters and setters } -
为电话簿创建一个数据访问接口。以下是 API 详情:
package com.packt.database.dao; import java.util.List; import com.packt.database.model.PhoneEntry; public interface PhoneBookDao { boolean create(PhoneEntry entry); boolean update(PhoneEntry entryToUpdate); List<PhoneEntry> searchByNumber(String number); List<PhoneEntry> searchByFirstName(String firstName); List<PhoneEntry> searchByLastName(String lastName); boolean delete(String number); } -
创建一个数据库访问接口实现,用于与数据库通信。以下是数据访问对象详情:
public class PhoneBookDerbyDao implements PhoneBookDao { private String driver = "org.apache.derby.jdbc.EmbeddedDriver"; private String protocol = "jdbc:derby:"; private String userId = "dbo"; private String dbName = "phoneBook"; public PhoneBookDerbyDao() { loadDriver(); } protected void loadDriver() { try { Class.forName(driver).newInstance(); } catch (ClassNotFoundException cnfe) { cnfe.printStackTrace(System.err); } catch (InstantiationException ie) { ie.printStackTrace(System.err); } catch (IllegalAccessException iae) { iae.printStackTrace(System.err); } } protected Connection getConnection() throws SQLException { Connection conn = null; Properties props = new Properties(); props.put("user", userId); conn = DriverManager.getConnection(protocol + dbName + ";create=true",props); conn.setAutoCommit(false); return conn; } }注意,
PhoneBookDerbyDao类是 dao 的 Derby 实现。它具有配置属性,如driver、protocol和dbName,以及获取器或设置器。loadDriver()方法加载数据库驱动程序,并从PhoneBookDerbyDao构造函数中调用。getConnection()方法连接到 Derby 数据库并建立连接。 -
实现以下
create行为:@Override public boolean create(PhoneEntry entry) { PreparedStatement preparedStmt = null; Connection conn = null; try { conn = getConnection(); preparedStmt = conn.prepareStatement("insert into PhoneBook values (?,?,?)"); preparedStmt.setString(1, entry.getPhoneNumber()); preparedStmt.setString(2, entry.getFirstName()); preparedStmt.setString(3, entry.getLastName()); preparedStmt.executeUpdate(); // Note that it can cause problems on some dbs if //autocommit mode is on conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); } finally { if (preparedStmt != null) { try { preparedStmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } return false; }create方法首先获取数据库连接,并从connection创建一个预处理语句。然后,它使用PhoneEntry值填充预处理语句,执行预处理语句,然后提交连接。在finally块中关闭资源。然后,关闭预处理语句和连接。 -
我们需要对 JDBC API 调用进行单元测试,因为我们还没有配置数据库。我们将独立于数据库测试
create()行为。在test\com.packt.database.dao包下创建一个PhoneBookDerbyDaoTestJUnit 测试。为了独立于数据库运行测试,我们需要绕过loadDriver和getConnection方法。因此,我们需要一个假对象来测试该类,并需要模拟对象来模拟 JDBC 配置类,例如Connection、ResultSet和PreparedStatement。TestablePhoneBookDerbyDao是 dao 的假对象实现。我们创建了一个模拟的Connection对象,并从假对象的getConnection方法返回。以下是对 dao 类的 JUnit 测试:@RunWith(MockitoJUnitRunner.class ) public class PhoneBookDerbyDaoTest { @Mock Connection connection; class TestablePhoneBookDerbyDao extends PhoneBookDerbyDao{ protected void loadDriver() { } protected Connection getConnection() throws SQLException { return connection; } } } -
PhoneBookDerbyDao需要PreparedStatement来将PhoneEntry细节传递到数据库。创建模拟的PreparedStatement和connection方法。更新测试类并添加以下行:@Mock Connection connection; @Mock PreparedStatement statement; PhoneBookDerbyDao dao; @Before public void setUp(){ dao = new TestablePhoneBookDerbyDao(); }使用
PhoneEntry调用create方法,并验证PhoneEntry细节是否传递给了statement对象。最后,验证connection是否已提交,以及statement和connection是否已关闭,如下所示:@Test public void creates_phone_entry() throws Exception { //Setting up sample object PhoneEntry johnDoe= new PhoneEntry(); johnDoe.setFirstName("John"); johnDoe.setLastName("Doe"); johnDoe.setPhoneNumber("123"); //Stubbing the connection obj to return the mocked statement when(connection.prepareStatement(anyString())).thenReturn(statement; //Calling the actual method boolean succeed = dao.create(johnDoe); assertTrue(succeed); //Creating argument captors ArgumentCaptor<String> stringArgCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor<Integer> intArgCaptor = ArgumentCaptor.forClass(Integer.class); //verifying that the mocked statement's setString is //invoked 3 times for firstName, lastName and //phoneNumber verify(statement, new Times(3)).setString(intArgCaptor. capture(), stringArgCaptor.capture()); //Verify the arguments passed to the statement object assertEquals("123", stringArgCaptor.getAllValues().get(0)); assertEquals("John", stringArgCaptor.getAllValues().get(1)); assertEquals("Doe", stringArgCaptor.getAllValues().get(2)); verify(connection).prepareStatement(stringArgCaptor.capture()); assertEquals(PhoneBookDerbyDao.INSERT_INTO_PHONE_BOOK_VALUES stringArgCaptor.getValue()); //verify that the mock resources were used and closed verify(statement).executeUpdate(); verify(connection).commit(); verify(statement).close(); verify(connection).close(); }注意
过度使用参数捕获器可能导致测试脆弱,因为你的测试系统不再是黑盒。
-
我们将验证数据检索逻辑,并增强
searchByNumber()方法以按数字检索PhoneEntry。以下是对逻辑的描述:@Override public List<PhoneEntry> searchByNumber(String number) { PreparedStatement preparedStmt = null; Connection conn = null; ResultSet resultSet = null; List<PhoneEntry> entries = new ArrayList<PhoneEntry>(); try { conn = getConnection(); preparedStmt = conn.prepareStatement("SELECT * FROM PhoneBook where num=?"); preparedStmt.setString(1, number); resultSet = preparedStmt.executeQuery(); while (resultSet.next()) { PhoneEntry entry = new PhoneEntry(); entry.setFirstName(resultSet.getString("fname")); entry.setLastName(resultSet.getString("lname")); entry.setPhoneNumber(resultSet.getString("num")); entries.add(entry); } return entries; } catch (SQLException e) { e.printStackTrace(); } finally { try { if (resultSet != null) { resultSet.close(); resultSet = null; } } catch (SQLException e) { e.printStackTrace(); } if (preparedStmt != null) { try { preparedStmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } return null; }在前面的代码中,以下语句按顺序执行:
-
获取数据库
Connection。然后,从Connection对象创建PreparedStatement。 -
之后,填充
PreparedStatement。 -
现在,执行
PreparedStatement并返回ResultSet。 -
ResultSet被迭代,并从ResultSet中填充PhoneEntry对象。 -
最后,关闭 JDBC 资源。
-
-
为了单元测试这个逻辑,我们需要模拟
ResultSet、PreparedStatement和Connection对象。ResultSet对象将被模拟以返回一个PhoneEntry对象,PreparedStatement对象将被模拟以返回模拟的ResultSet对象,而Connection对象将被模拟以返回模拟的PreparedStatement对象。提示
在持久化逻辑单元测试中,以下事项被验证:
-
JDBC API 调用序列,如连接,已被提交
-
资源已被关闭或清理
-
将
ResultSet映射到模型对象(POJO)
以下是对逻辑进行验证的测试代码:
@Test public void retrieves_phone_entry() throws Exception { //Stub JDBC resources to return mock objects when(mockConn.prepareStatement(anyString())). thenReturn(mockPrepStmt); when(mockPrepStmt.executeQuery()). thenReturn(mockResultSet); when(mockResultSet.next()).thenReturn(true). thenReturn(false); //Stub the resultSet to return value when(mockResultSet.getString("fname")).thenReturn("John"); when(mockResultSet.getString("lname")).thenReturn("Doe"); when(mockResultSet.getString("num")).thenReturn("123"); //Execute List<PhoneEntry> phoneEntries = dao.searchByNumber("123"); assertEquals(1, phoneEntries.size()); PhoneEntry johnDoe = phoneEntries.get(0); //verify mapping assertEquals("John", johnDoe.getFirstName()); assertEquals("Doe", johnDoe.getLastName()); assertEquals("123", johnDoe.getPhoneNumber()); //Verify Resource Clean up verify(mockResultSet).close(); verify(mockPrepStmt).close(); verify(mockConn).close(); }我们应该为
update、delete和searchByXXX行为编写单元测试。 -
使用 Spring 简化持久化
查看 PhoneBookDerbyDao 类。它有 398 行代码来支持创建、读取、更新和删除(CRUD)操作。每个方法执行几乎相似的任务。以下任务是从 CRUD 方法中调用的:
-
传递连接参数
-
打开连接
-
创建语句
-
准备语句
-
执行语句
-
遍历结果(仅在读取方法中)
-
填充模型对象(仅在读取方法中)
-
处理任何异常
-
处理事务
-
关闭 ResultSet(仅在读取方法中)
-
关闭语句
-
关闭连接
Spring 框架提供了 API 来减少 JDBC 代码的重复。Spring JDBC 隐藏了底层细节,使我们能够专注于业务逻辑。我们将使用 Spring JDBC 实现 PhoneBookDao。
从 maven.springframework.org/release/org/springframework/spring/ 下载最新的 JDBC JAR 及其依赖项。
按照以下步骤实现 Spring JDBC 并简化代码:
-
启动 Eclipse,打开
DatabaseAccess项目,并编辑.classpath以添加屏幕截图所示的以下 Spring 依赖项:![使用 Spring 简化持久化]()
-
创建一个实现
PhoneBookDao接口的PhoneBookDerbySpringDao类。以下是对 Springcreate方法的实现:public class PhoneBookDerbySpringDao implements PhoneBookDao { private final JdbcTemplate jdbcTemplate; public PhoneBookDerbySpringDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public boolean create(PhoneEntry entry) { int rowCount = jdbcTemplate.update("insert into PhoneBook values (?,?,?)", new Object[]{entry.getPhoneNumber(), entry.getFirstName(), entry.getLastName() }); return rowCount == 1; } }JdbcTemplate简化了 JDBC 的使用;它处理资源并帮助避免常见的错误,例如未关闭连接。它创建并填充statement对象,遍历ResultSet对象,这使应用程序代码提供 SQL 并提取结果。PhoneBookDerbySpringDao包含一个JdbcTemplate实例并将数据库任务委托给jdbcTemplate。JdbcTemplate有一个用于插入和更新操作的更新方法。它接受一个 SQL 查询和参数。新版本的 Springcreate()方法在jdbcTemplate上调用update()方法并传递PhoneEntry详细信息。现在create方法看起来很简单,只有两行代码。Spring 框架处理资源生命周期。 -
创建一个名为
PhoneBookDerbySpringDaoTest的 JUnit 类进行单元测试。我们将创建一个jdbcTemplate模拟对象并将其传递给 dao。以下是对 JUnit 的实现:@RunWith(MockitoJUnitRunner.class) public class PhoneBookDerbySpringDaoTest { @Mock JdbcTemplate mockJdbcTemplate; PhoneBookDerbySpringDao springDao; @Before public void init() { springDao = new PhoneBookDerbySpringDao(mockJdbcTemplate); } @Test public void creates_PhoneEntry() throws Exception { //create PhoneEntry String charlsPhoneNumber = "1234567"; String charlsFirstName = "Charles"; String charlsLastName = "Doe"; PhoneEntry charles = new PhoneEntry(); charles.setFirstName(charlsFirstName); charles.setLastName(charlsLastName); charles.setPhoneNumber(charlsPhoneNumber); //Stub jdbcTemplate's update to return 1 when(mockJdbcTemplate.update(anyString(), anyObject(), anyObject(), anyObject())).thenReturn(1); //Execute assertTrue(springDao.create(charles)); //Create argument capture ArgumentCaptor<Object> varArgs = ArgumentCaptor.forClass(Object.class); ArgumentCaptor<String> strArg = ArgumentCaptor.forClass(String.class); //Verify update method was called and capture args verify(mockJdbcTemplate).update(strArg.capture(),varArgs.capture(),varArgs.capture(), varArgs.capture()); //Verify 1st dynamic argument was the phone number assertEquals(charlsPhoneNumber, varArgs.getAllValues().get(0)); //Verify the name arguments assertEquals(charlsFirstName, varArgs.getAllValues().get(1)); assertEquals(charlsLastName, varArgs.getAllValues().get(2)); } }查看新的 Spring dao;它只有 54 行代码。这个类看起来整洁、简单且易于阅读。它不处理资源,而是专注于数据访问。
验证系统完整性
集成测试使我们能够找到单元测试无法捕获的错误。我们已经对 JDBC API 的使用进行了单元测试,但我们需要测试数据与数据访问 API 的集成,例如 JDBC 驱动程序、连接和回滚。在本节中,我们将使用数据库测试数据访问层。
在开始编写测试之前,我们需要创建数据库表。从 Packt Publishing 网站下载代码,并将项目 DatabaseAccess 导入到您的 Eclipse 工作空间中,转到 com.packt.database.util 包并运行 DatabaseManager 类。它将创建表。以下为相对简单的表创建代码:
conn = DriverManager.getConnection(url, props);
conn.setAutoCommit(false);
statement = conn.createStatement();
statement.execute("create table PhoneBook (num varchar(50), fname varchar(40),lname varchar(40))");
conn.commit();
以下是在测试 JDBC 代码时的步骤:
-
为数据库相关的测试创建一个名为
integration的源文件夹,例如src或test。 -
创建一个名为
PhoneBookDerbyJdbcDaoIntegrationTest的新 JUnit 测试,并添加以下行以测试创建、搜索、更新和删除功能:public class PhoneBookDerbyJdbcDaoIntegrationTest { PhoneBookDerbyDao jdbcDao; @Before public void init() { jdbcDao = new PhoneBookDerbyDao(); } @Test public void integration() throws Exception { PhoneEntry entry = new PhoneEntry(); entry.setFirstName("john"); entry.setLastName("smith"); entry.setPhoneNumber("12345"); assertTrue(jdbcDao.create(entry)); List<PhoneEntry> phoneEntries = jdbcDao.searchByFirstName("john"); //verify create assertFalse(phoneEntries.isEmpty()); //modify last name entry.setLastName("doe"); //update assertTrue(jdbcDao.update(entry)); //retrieve phoneEntries = jdbcDao.searchByFirstName("john"); //verify update assertFalse(phoneEntries.isEmpty()); assertEquals("doe", phoneEntries.get(0).getLastName()); //delete jdbcDao.delete(entry.getPhoneNumber()); //retrieve phoneEntries = jdbcDao.searchByFirstName("john"); //verify delete assertTrue(phoneEntries.isEmpty()); } }
集成测试创建一个 PhoneBookDerbyJdbcDao 实例,并调用 PhoneBookDerbyJdbcDao 方法以断言结果。
使用 Spring 编写集成测试
Spring 提供了模块或实用库以进行集成测试。以下是用 Spring 事务管理 API 和 SpringJUnit4ClassRunner 编写 JUnit 测试的步骤:
-
Spring 支持基于 XML 的配置和 Bean 连接。在
integration源包中创建一个名为integration.xml的 XML 文件。修改 XML 文件并定义dataSource、transactionManager和JdbcTemplateSpring Bean。以下为 XML 内容:<beans xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="org.apache.derby.jdbc.EmbeddedDriver"/> <property name="url" value="jdbc:derby:derbyDB;create=true"/> <property name="username" value="dbo"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <constructor-arg ref="dataSource"/> </bean> <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate"> <property name="dataSource" ref="dataSource"/> </bean> </beans>提示
要了解更多关于 Spring Bean 的信息,请访问
docs.spring.io/spring/docs/1.2.9/reference/beans.html。定义了一个带有
driverClassName、url和username的dataSourceBean。将dataSource引用传递给jdbcTemplate和transactionManagerBean。 -
Spring 支持在测试执行后自动回滚事务。这有助于我们保护开发数据库免受损坏。测试运行器在测试执行之前需要引用一个事务管理器 Bean。
SpringJUnit4ClassRunner处理集成测试。添加一个PhoneBookDerbySpringDaoIntegrationTestJUnit 测试,并向其中添加以下行:@ContextConfiguration({ "classpath:integration.xml" }) @TransactionConfiguration(transactionManager = "transactionManager", defaultRollback = true) @Transactional @RunWith(SpringJUnit4ClassRunner.class) public class PhoneBookDerbySpringDaoIntegrationTest { @Autowired JdbcTemplate jdbcTemplate; PhoneBookDerbySpringDao springDao; @Before public void init() { springDao = new PhoneBookDerbySpringDao(jdbcTemplate); } @Test public void integration() throws Exception { PhoneEntry entry = newEntry("12345", "John", "Smith"); //create assertTrue(springDao.create(entry)); //retrieve List<PhoneEntry> phoneEntries = springDao.searchByFirstName("John"); //verify create assertFalse(phoneEntries.isEmpty()); //modify last name entry.setLastName("Kallis"); //update assertTrue(springDao.update(entry)); //retrieve phoneEntries = springDao.searchByFirstName("John"); //verify update assertFalse(phoneEntries.isEmpty()); assertEquals("Kallis", phoneEntries.get(0).getLastName()); //delete springDao.delete(entry.getPhoneNumber()); //retrieve phoneEntries = springDao.searchByFirstName("John"); //verify delete assertTrue(phoneEntries.isEmpty()); } }
@ContextConfiguration({ "classpath:integration.xml" }) 注解指示 JUnit 运行器从类路径位置加载 Spring Bean。它将从 integration.xml 文件中加载三个 Bean。
类级别的 @Transactional 注解使所有方法都具有事务性。
@TransactionConfiguration(transactionManager = "transactionManager", defaultRollback = true) 注解定义了事务管理器,defaultRollback 属性告诉事务管理器在给定测试结束后回滚所有事务。
当运行 JUnit 测试时,以下事情会发生:
-
Spring Bean 从
integration.xml文件中加载。 -
配置了一个事务管理器以回滚所有事务。
-
jdbcTemplateBean 会自动连接到测试类的成员jdbcTemplate。 -
init方法创建 dao 类的新实例,并将jdbcTemplateBean 传递给 dao。 -
测试首先执行,然后创建、更新和删除
PhoneEntry。 -
测试执行后,事务管理器回滚事务。没有数据被创建、修改或从
PhoneBook表中删除。
当 JUnit 测试运行时,以下 Spring 控制台日志显示:
INFO: Began transaction (1): transaction manager [org.springframework.jdbc.datasource.DataSourceTransactionManager@569c60]; rollback [true]
Apr 11, 2014 10:02:25 PM org.springframework.test.context.transaction.TransactionalTestExecutionListener endTransaction
INFO: Rolled back transaction after test execution for test context [[TestContext@134eb84 testClass = PhoneBookDerbySpringDaoIntegrationTest, testInstance = com.packt.database.dao.PhoneBookDerbySpringDaoIntegrationTest@1522de2, testMethod = integration@PhoneBookDerbySpringDaoIntegrationTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@425743 testClass = PhoneBookDerbySpringDaoIntegrationTest, locations = '{classpath:integration.xml}', classes = '{}', activeProfiles = '{}', contextLoader = 'org.springframework.test.context.support.DelegatingSmartContextLoader']]]
日志显示,一个事务已经开始,最终事务被回滚。然而,事务回滚并不是由于任何异常,而是由于事务设置 [defaultRollback = true]。日志显示 testException 等于 null,这意味着没有抛出异常。
摘要
本章解释了数据库层的单元测试策略;它提供了一个与数据库隔离的单元测试示例,使用 Spring 编写干净的 JDBC 代码,以及使用数据库编写集成测试。我们还了解了在 Spring JDBC 集成测试中配置的自动事务回滚。
现在,你应该能够独立于数据库对数据访问层组件进行单元测试,使用 Spring 编写整洁的 JDBC 代码,并使用 Spring API 编写集成测试。
下一章将涵盖服务层和测试遗留代码。
第九章. 解决测试难题
“我们通过我们所得到的东西谋生,但我们通过我们所给予的东西创造生活。”
——温斯顿·丘吉尔
你可能参与过使用测试驱动开发(TDD)编写的绿地开发项目,也参与过没有使用 TDD 编写的棕地开发或维护项目。你必须已经注意到,使用 TDD 编写的测试优先代码比没有单元测试或编码后编写的单元测试的代码更容易扩展。
小贴士
绿地项目是从零开始构建的,不考虑任何先前的工作。
棕地项目是先前工作的扩展或从现有项目重建项目。
本章涵盖了在绿地和棕地项目中单元测试的重要性。以下主题将进行深入探讨:
-
与遗留代码一起工作
-
设计可测试性
-
与绿地代码一起工作
“与遗留代码一起工作”部分涵盖了遗留代码,并解释了如何进行单元测试和重构遗留代码。“设计可测试性”部分解释了如何设计可测试性。“与绿地代码一起工作”部分详细阐述了 TDD、TDD 生命周期、重构,并以 TDD 的示例结束。
与遗留代码一起工作
术语遗产经常被用作俚语,用来描述复杂、难以理解、本质上是僵化和脆弱的代码,几乎不可能进行增强。
然而,事实是,任何没有自动单元测试的代码都是遗留代码。一段代码可以写得很好。它也可以遵循编码指南,可能易于理解,可以干净、松散耦合,并且非常容易扩展。然而,如果没有自动单元测试,那么它就是遗留代码。
统计上,修复遗留项目中的错误或添加新功能比在绿地项目中做同样的事情要困难得多。在遗留代码中,要么没有自动单元测试,要么测试很少;代码没有设计为可测试。
我们从其他来源继承了遗留代码,可能来自一个非常古老的项目,来自无法维护代码的其他团队,或者我们从另一家公司获得它,但我们的责任是提高其质量。
单元测试为我们提供了一定程度的保证,即我们的代码正在执行预期的操作,并且允许我们快速更改代码并更快地验证更改。
通常,遗留代码不可测试,需要修改代码结构(重构)才能使其可测试。然而,大多数情况下,遗留系统对业务至关重要,没有人敢触碰代码。除非出现严重问题,否则修改现有关键模块是没有意义的。僵局!除非你有自动测试套件,否则你不能重构代码,而且你不能在代码需要重构时编写测试。
有时感觉即使有单元测试,遗留代码也很难理解、维护和增强;因此,我们需要小心地编写可读性强的测试,并避免与实际实现细节紧密耦合。
与测试障碍一起工作
本节解释了使单元测试变得困难的代码的性质或质量。自动测试帮助我们快速开发软件,即使我们有一个庞大的代码库要处理。然而,自动测试应该执行得非常快,以便测试可以给我们提供快速反馈。当代码表现出以下任何特征时,我们无法对代码进行单元测试:
-
它执行长时间运行的操作
-
它连接到数据库并修改数据库记录
-
它执行远程计算
-
它查找 JNDI 资源或 Web/应用程序服务器对象
-
它访问文件系统
-
它与原生对象或图形小部件(UI 组件、警告、Java Swing 组件等)一起工作
-
它访问网络资源,例如局域网打印机,并从互联网下载数据
单元测试不应该等待长时间运行的过程完成;这将违背快速反馈的目的。
单元测试应该是可靠的,并且只有在生产代码出错时才应该失败。然而,如果你的单元测试验证的是一个慢速、易出错且不可预测的 I/O 操作,例如连接到局域网打印机,那么你的单元测试可能会因为某些网络问题而失败,但它将错误地表明代码已损坏。因此,对网络操作进行单元测试违反了测试可靠性原则。
单元测试会自动运行,所以在测试执行期间打开模态对话框或显示警告消息是没有意义的,因为测试将会等待,除非 UI 对话框或警告被关闭。
因此,在生产代码中的前述特性在单元测试期间构成了障碍。以下示例展示了如何避免测试障碍:
public class MovieTicketPro {
public void book(Movie movie, ShowTime time, int noOfTickets) {
MovieDao dao = new MovieDao();
MovieHall hall = dao.findMovie(movie, time);
if (hall != null) {
List<String> seats = dao.getAvilableSeats(movie, time);
if (seats.size() < noOfTickets) {
BookingErrorController.createAndShowTicketNotAvailableError();
return;
}
int booked = 0;
String bookedSeats = "";
for (String aSeat : seats) {
try {
dao.book(hall, time, aSeat);
bookedSeats += " " + aSeat;
booked++;
if (booked == noOfTickets) {
BookingErrorController.createAndShowBookedMsg(bookedSeats);
break;
}
} catch (BookingException e) {
if (e.getType().equals(ErrorType.SeatAlreadyBooked)) {
BookingErrorController.createAndShowTicketNotAvailableError();
if (BookingErrorController.createAndShowAdjacentSeatsNotAvaialble()){
continue;
}
break;
}
} catch (Exception e) {
BookingErrorController.createAndShowDatabaseSaveError();
break;
}
}
}else{
BookingErrorController.createAndShowMovieOrShowTimeNotAvailableError();
}
}
在前一个示例中,book()方法接受一个电影、一个放映时间和要预订的票数,并预订票或显示错误消息。如果传递给book方法的电影或放映时间无效,它将显示一个错误消息,指出电影或放映时间不可用。以下是为票务预订的逻辑:
-
首先,查找方法会找到电影和电影的放映时间,例如,电影The HOBBIT,放映时间为Evening,正在SCREEN 2放映。如果电影没有放映,则会显示错误消息。
-
然后它检索可用的座位,例如,晚上在SCREEN 2有 40 个座位可用。
-
如果请求的座位数超过可用座位数,则会显示错误消息,例如,请求 10 张票但只有两个座位可用。
-
如果请求的座位可用,那么它将遍历座位并预订它们。
-
如果在座位预订过程中发生任何错误,例如有人同时预订了座位或发生了一些运行时错误,将显示相关的错误信息。
BookingErrorController类负责显示错误信息。以下是对BookingErrorController类的描述:
public class BookingErrorController {
public static void createAndShowTicketNotAvailableError() {
JOptionPane.showMessageDialog(null, "Ticket is not available","Booking message", JOptionPane.WARNING_MESSAGE);
}
public static void createAndShowDatabaseSaveError() {
JOptionPane.showMessageDialog(null, "Could not book ticket", "Booking Error", JOptionPane.ERROR_MESSAGE);
}
public static void createAndShowBookedMsg(String seats) {
JOptionPane.showMessageDialog(null, "Following tickets" + seats+ " Booked", "Booking Info", JOptionPane.ERROR_MESSAGE);
}
//other methods are ignored for brevity
}
每个方法都调用JOptionPane来显示消息。JOptionPane显示模态对话框,用户必须点击关闭按钮或是/否按钮来关闭对话框。如果用户不关闭对话框,程序将保持等待用户操作。
因此,除非将错误信息显示与代码逻辑分离,否则你不能对电影票预订逻辑进行单元测试。
第二个需要注意的事项是MovieDao的创建构造函数:
MovieDao dao = new MovieDao();
book()方法实例化一个数据库访问对象并在其上调用方法。我们应该将直接创建数据库访问对象与代码分离,以便我们可以传递一个模拟数据访问对象并模拟数据库调用;否则,book()方法将实例化真实的MovieDao对象,测试将需要花费时间执行。目前,我们将使用真实的数据访问逻辑对代码进行单元测试,稍后对代码进行重构以分离MovieDao对象的实例化。
创建一个MovieTicketProTest测试类,并添加一个检查方法以调用带有 null 对象的book方法。以下是对代码片段的描述:
public class MovieTicketProTest {
MovieTicketPro movieTicketPro= new MovieTicketPro();
@Test
public void sanity() throws Exception {
movieTicketPro.book(null, null, 1);
}
}
当我们在 Eclipse 中执行测试时,会弹出一个错误信息对话框,测试将等待用户操作。以下是 Eclipse 的输出,你可以看到测试正在等待弹出窗口:

如果我们将测试包含在我们的自动化测试套件中,自动化测试套件将永远运行并等待用户干预。我们可以定位问题;提取每个BookingErrorController方法调用中的受保护方法。这个更改将允许我们创建一个MovieTicketPro模拟对象并将受保护的方法替换为空实现。然而,问题是怎样验证错误条件?我们可以提取一个错误信息接口,创建一个通用的错误信息方法,并重构BookingErrorController类以实现该接口。以下是对接口的详细说明:
package com.packt.legacy;
public interface ErrorMessageDisplayer {
void showMessage(String title, String message, int messageType);
boolean showConfirmMessage(String title, String message);
}
修改BookingErrorController类以实现接口。以下是对实现的描述:
public class BookingErrorController implements ErrorMessageDisplayer{
@Override
public void showMessage(String title, String message, int messageType) {
JOptionPane.showMessageDialog(null, message, title, messageType);
}
@Override
public boolean showConfirmMessage(String title, String message) {
int output = JOptionPane.showConfirmDialog(null,message, title, JOptionPane.YES_NO_OPTION);
return output == JOptionPane.YES_OPTION;
}
//other methods are ignored for brevity
}
修改MovieTicketPro类,并在行内修改所有对BookingErrorController的调用。以下是一个这样的修改示例:
} catch (Exception e) {
JOptionPane.showMessageDialog(null, "Could not book ticket", "Booking Error", JOptionPane.ERROR_MESSAGE);
break;
}
}
}else {
JOptionPane.showMessageDialog(null, "Movie or showtime not available","Booking message", JOptionPane.WARNING_MESSAGE);
}
注意,BookingErrorController.createAndShowDatabaseSaveError()和BookingErrorController.createAndShowMovieOrShowTimeNotAvailableError()方法被原始方法内容替换。
现在从BookingErrorController类中删除静态错误信息方法。你不应该得到任何编译错误。
在MovieTicketPro中创建一个 getter 方法以返回ErrorMessageDisplayer的实现。以下是对方法的描述:
protected ErrorMessageDisplayer getErrorMessageDisplayer() {
return new BookingErrorController();
}
将JOptionPane.showMessageDialog代码的所有内容替换为getErrorMessageDisplayer()。以下是修改后的代码:
public class MovieTicketPro {
public void book(Movie movie, ShowTime time, int noOfTickets) {
MovieDao dao = new MovieDao();
MovieHall hall = dao.findMovie(movie, time);
if (hall != null) {
List<String> seats = dao.getAvilableSeats(movie, time);
if (seats.size() < noOfTickets) {
getErrorMessageDisplayer().showMessage("Booking message", "Ticket is not available", JOptionPane.WARNING_MESSAGE);
return;
}
int booked = 0;
String bookedSeats = "";
for (String aSeat : seats) {
try {
dao.book(hall, time, aSeat);
bookedSeats += " " + aSeat;
booked++;
if (booked == noOfTickets) {
getErrorMessageDisplayer().showMessage("Booking Info", "Following tickets" + bookedSeats + " Booked", JOptionPane.ERROR_MESSAGE);
break;
}
} catch (BookingException e) {
if (e.getType().equals(ErrorType.SeatAlreadyBooked)) {
getErrorMessageDisplayer().showMessage( "Booking message", "Ticket is not available", JOptionPane.WARNING_MESSAGE);
boolean yes = getErrorMessageDisplayer().showConfirmMessage("Booking message","Adjacent seats not available.Can I book any other seat?");
if (yes) {
getErrorMessageDisplayer().showMessage("Booking information","Going to auto allocate seats.", JOptionPane.INFORMATION_MESSAGE);
break;
}
}
} catch (Exception e) {
getErrorMessageDisplayer().showMessage("Booking Error","Could not book ticket", JOptionPane.ERROR_MESSAGE);
break;
}
}
} else {
getErrorMessageDisplayer().showMessage("Booking message","Movie or showtime not available",JOptionPane.WARNING_MESSAGE);
}
}
protected ErrorMessageDisplayer getErrorMessageDisplayer() {
return new BookingErrorController();
}
}
getErrorMessageDisplayer() method to return a ErrorMessageDisplayer mock. We can verify the error messages indirectly from the mock object arguments:
@RunWith(MockitoJUnitRunner.class)
public class MovieTicketProTest {
@Mock ErrorMessageDisplayer messageDisplayer;
MovieTicketPro movieTicketPro = new MovieTicketPro() {
protected ErrorMessageDisplayer getErrorMessageDisplayer() {
return messageDisplayer;
}
};
@Test public void when_invalid_movie_shows_error_message(){
movieTicketPro.book(null, null, 1);
ArgumentCaptor<String> stringArgCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Integer> intArgCaptor = ArgumentCaptor.forClass(Integer.class);
verify(messageDisplayer).showMessage(stringArgCaptor.capture(), stringArgCaptor.capture(), intArgCaptor.capture());
assertEquals("Movie or showtime not available", stringArgCaptor.getAllValues().get(1));
}
}
我们需要分离数据库访问,创建一个返回MovieDao对象的 getter 方法,并从book方法中调用 getter 方法。从测试中,我们可以创建一个假对象并重写getMovieDao()方法以返回一个模拟数据访问对象。
以下是代码中的更改:
protected MovieDao getMovieDao() {
return new MovieDao();
}
public void book(Movie movie, ShowTime time, int noOfTickets) {
MovieDao dao = getMovieDao();
//code ignored for brevity
}
以下是修改后的测试:
@RunWith(MockitoJUnitRunner.class)
public class MovieTicketProTest {
@Mock ErrorMessageDisplayer messageDisplayer;
@Mock MovieDao movieDao;
MovieTicketPro movieTicketPro = new MovieTicketPro() {
protected ErrorMessageDisplayer getErrorMessageDisplayer() {
return messageDisplayer;
}
protected MovieDao getMovieDao() {
return movieDao;
}
};
}
此更改后,测试执行完成得非常快。以下为测试执行输出:

下一节将介绍为可测试性而设计。
为可测试性设计
我们学习了测试障碍及其重构方法。当存在测试障碍时,我们无法对代码进行单元测试;我们重构代码,将障碍移出(到另一个类或方法),并在测试期间用模拟对象替换障碍。
然而,有时由于测试不友好的设计,我们无法模拟外部依赖。本节涵盖了可测试性的设计,或者更确切地说,是代码中需要避免的问题。以下 Java 结构违反了模拟测试障碍:
-
构造函数初始化测试障碍
-
类级别变量声明和初始化
-
private方法 -
final方法 -
static方法 -
final类 -
new的使用 -
静态变量声明和初始化
-
静态初始化块
由于遗留代码要么耦合紧密,要么测试不利的语言结构隐藏了测试障碍,因此您无法对遗留代码进行单元测试。以下部分解释了测试不利的结构。
注意
为了展示测试障碍,我们将抛出一个特殊的运行时异常TestingImpedimentException。如果您的测试因TestingImpedimentException而失败,那么这意味着您无法自动化测试,因为您的代码具有对测试不利的特性。
识别构造函数问题
要构建测试,我们需要在测试框架中实例化类,但遗留代码的问题在于很难打破依赖关系并在测试框架中实例化类。一个这样的例子是在构造函数中,类实例化了多个对象,从属性文件中读取,甚至创建数据库连接。可能有多个类调用者,因此您不能更改构造函数以传递依赖关系;否则,将导致一系列编译错误。
我们将查看一个示例遗留代码,并尝试为该类编写测试。
假设我们有一个 TestingUnfavorableConstructor 类,它有两个外部依赖 DatabaseDependency 和 FileReadDependency。这两个依赖都是缓慢的,并且是测试障碍。TestingUnfavorableConstructor 在构造函数中创建依赖。理想情况下,这些依赖代表从 TestingUnfavorableConstructor 构造函数的数据库访问和文件读取。以下是 TestingUnfavorableConstructor 类:
public class TestingUnfavorableConstructor {
private DatabaseDependency dependency1;
private FileReadDependency dependency2;
public TestingUnfavorableConstructor() {
this.dependency1 = new DatabaseDependency();
this.dependency2 = new FileReadDependency();
}
public Object testMe(Object arg) {
return arg;
}
}
如果我们想要对类的 testMe() 行为进行单元测试,那么我们需要创建一个 TestingUnfavorableConstructor 类的实例。然而,当我们尝试在单元测试中创建实例时,类无法从自动化测试套件中实例化,并显示错误。以下是输出:

要克服这个问题,你应该通过构造函数注入依赖,而不是在构造函数中创建它们。
我们不能修改默认构造函数,因为类被许多其他客户端调用。我们不能破坏客户端。其他两种选项如下:
-
保持默认构造函数不变。创建另一个构造函数并通过这个新构造函数注入依赖;从测试中,我们可以调用这个新构造函数。
-
创建一个受保护的方法,将依赖实例化移动到那个方法,创建两个设置器方法,并通过设置器注入初始化依赖。在测试中,创建主类的模拟对象并覆盖受保护的方法使其不执行任何操作,并通过设置器方法传递依赖。
第一种方法相对简单。我们将应用第二种方法。
以下是被修改的代码:
public class TestingUnfavorableConstructor {
private DatabaseDependency dependency1;
private FileReadDependency dependency2;
public TestingUnfavorableConstructor() {
createDependencies();
}
protected void createDependencies() {
this.dependency1 = new DatabaseDependency();
this.dependency2 = new FileReadDependency();
}
public void setDependency1(DatabaseDependency dependency1) {
this.dependency1 = dependency1;
}
public void setDependency2(FileReadDependency dependency2) {
this.dependency2 = dependency2;
}
public Object testMe(Object arg) {
return arg;
}
}
以下单元测试覆盖了 TestingUnfavorableConstructor 并提供了 createDependencies() 方法的空实现,创建了模拟依赖,并通过设置器方法设置模拟依赖:
@RunWith(MockitoJUnitRunner.class)
public class TestingUnfavorableConstructorTest {
@Mock DatabaseDependency dep1;
@Mock FileReadDependency dep2;
TestingUnfavorableConstructor unfavorableConstructor;
@Before public void setUp() {
unfavorableConstructor= new TestingUnfavorableConstructor() {
protected void createDependencies() {
}
};
unfavorableConstructor.setDependency1(dep1);
unfavorableConstructor.setDependency2(dep2);
}
@Test public void sanity() throws Exception {
}
}
提示
不要在构造函数中实例化依赖;依赖可能表现出测试障碍,使类不可测试。而不是在构造函数中实例化依赖,你可以将真实实现(真实依赖)传递给构造函数或被测试代码的设置器方法。
认识到初始化问题
在同一时间进行类级别变量声明和对象实例化会引发问题。你将没有机会模拟变量。以下示例解释了这个问题:
VariableInitialization 类有一个数据库依赖,并且依赖在其声明的地方实例化,如下所示:
Public class VariableInitialization {
DatabaseDependency dependency1 = new DatabaseDependency();
public void testMe(Object obj) {
}
}
当你在测试中实例化 VariableInitialization 类时,测试会失败。以下是输出:

以下是测试类:
public class VariableInitializationTest {
VariableInitialization initialization;
@Before public void setUp() throws Exception {
initialization = new VariableInitialization();
}
@Test public void sanity() throws Exception {
}
}
要克服类级别变量初始化,你可以尝试以下选项:
-
添加一个默认构造函数并将依赖实例化移动到默认构造函数中。创建另一个构造函数并通过这个新构造函数注入依赖;从测试的角度来看,我们可以称这个新构造函数为“新构造函数”。
-
添加一个默认构造函数,并将依赖实例化移动到受保护的方法中,并从默认构造函数中调用该方法。创建一个设置方法并通过设置注入初始化依赖。在测试中,创建主类的一个假对象并覆盖受保护的方法使其不执行任何操作,并通过设置方法传递依赖项。
小贴士
不要在类级别实例化变量。
与私有方法一起工作
private 方法对于隐藏内部状态和封装很有用,但它们也可能隐藏测试障碍。以下示例解释了细节:
PrivateMethod 类有一个名为 showError() 的 private 方法。此 private 方法隐藏了一个测试障碍。当我们使用 null 对象对 validate() 方法进行单元测试时,validate() 方法会调用 showError 消息,如下所示:
public class PrivateMethod {
public Object validate(Object arg) {
if(arg == null) {
showError("Null input");
}
return arg;
}
private void showError(String msg) {
GraphicalInterface.showMessage(msg);
}
}
以下是测试输出:

您可以将测试障碍提取到受保护的方法中,或者您可以分离关注点。创建一个新的类,将测试障碍移动到该类中,并将新类作为依赖项注入。
小贴士
不要在私有方法中隐藏测试障碍。
以下代码重构了测试障碍并使类可进行单元测试:
public class PrivateMethodRefactored {
public Object validate(Object arg) {
if(arg == null) {
showError("Null input");
}
return arg;
}
protected void showError(String msg) {
GraphicalInterface.showMessage(msg);
}
}
showError 方法的访问修饰符更改为 protected。
以下测试代码通过匿名实现扩展了类,并使用空实现覆盖了受保护的方法。测试代码在 PrivateMethodRefactored 类的新匿名实现上调用 validate() 方法。反过来,多态行为将调用空实现。因此,测试将通过调用测试障碍的覆盖空实现来绕过测试障碍,但实际的生产代码将始终调用受保护的方法:
public class PrivateMethodRefactoredTest {
PrivateMethodRefactored privateMethod;
@Before
public void setUp() {
privateMethod = new PrivateMethodRefactored() {
protected void showError(String msg) {
}
};
}
@Test
public void validate() throws Exception {
privateMethod.validate(null);
}
}
小贴士
这种通过覆盖测试障碍的版本来绕过测试障碍的方法被称为模拟或假对象。如果待测试的代码包含许多测试障碍,那么在匿名类中不可能覆盖所有这些障碍。相反,我们可以创建一个内部类,并扩展待测试的代码并覆盖所有不友好的方法。
与最终方法一起工作
当一个方法是最终方法时,您不能覆盖它。如果最终方法隐藏了任何测试障碍,您就不能对类进行单元测试。以下示例解释了这个问题:
FinalDependency 类有一个名为 doSomething 的最终方法。此方法隐藏了一个对测试不友好的特性。以下是该类的定义:
public class FinalDependency {
public final void doSomething() {
throw new TestingImpedimentException("Final methods cannot be overriden");
}
}
FinalMethodDependency 类依赖于 FinalDependency,在 testMe 方法中,它按照以下方式调用 doSomething 方法:
public class FinalMethodDependency {
private final FinalDependency dependency;
public FinalMethodDependency(FinalDependency dependency) {
this.dependency = dependency;
}
public void testMe() {
dependency.doSomething();
}
}
在测试中,我们将模拟依赖项并按照以下方式对代码进行单元测试:
@RunWith(MockitoJUnitRunner.class)
public class FinalMethodDependencyTest {
@Mock
FinalDependency finalDependency;
FinalMethodDependency methodDependency;
@Before
public void setUp() {
methodDependency = new FinalMethodDependency(finalDependency);
}
@Test
public void testSomething() throws Exception {
methodDependency.testMe();
}
}
当我们运行测试时,测试仍然访问测试障碍,因为模拟对象无法模拟最终方法。当我们尝试模拟方法时,我们会得到错误。以下测试模拟了最终方法调用:
@Test
public void testSomething() throws Exception {
doNothing().when(finalDependency).doSomething();
methodDependency.testMe();
}
当我们运行测试时,Mockito 框架抛出了以下错误信息:

提示
不要在最终方法中隐藏测试障碍。你不能重写或模拟最终方法。
解决这个问题的唯一方法是将最终方法的内 容提取到一个 protected 方法中;从最终方法中调用 protected 方法,并在测试中重写 protected 方法。否则,如果你根本无法接触该类,可以使用 PowerMock 或 PowerMockito 框架;例如,当你只有 JAR 文件时。
探索静态方法问题
static 方法对实用类很有用,但过度使用 static 可能会隐藏测试障碍,并在单元测试中造成问题。以下示例说明了这个问题:
SingletonDependency 类是 Gang of Four (GoF) 单例设计模式的实现。它有一个 private 构造函数和一个静态 getInstance() 方法来创建该类的一个唯一实例。静态 callMe() 方法隐藏了一个测试障碍。请注意,GoF 单例模式没有将方法定义为 static,但在这个例子中,我们将 callMe() 方法定义为 static 以显示 static 方法的缺点。以下是对单例的实现:
public class SingletonDependency {
private static SingletonDependency singletonDependency;
private SingletonDependency() {
}
public synchronized static SingletonDependency getInstance() {
if (singletonDependency == null) {
singletonDependency = new SingletonDependency();
}
return singletonDependency;
}
Public static void callMe() {
throw new TestingImpedimentException("we dont need singleton");
}
}
VictimOfAPatternLover 类依赖于 SingletonDependency。以下是该类的详细信息:
public class VictimOfAPatternLover {
private final SingletonDependency dependency;
public VictimOfAPatternLover(SingletonDependency dependency) {
this.dependency = dependency;
}
public void testMe() {
dependency.callMe();
}
}
Mockito 无法模拟静态方法。当我们尝试模拟静态 callMe() 方法时,它仍然调用原始方法,并因测试障碍而失败。你不能模拟 static 方法。
提示
不要在静态方法中隐藏测试障碍。你不能模拟静态方法。
解决这个问题的唯一方法是在类中创建一个 protected 方法并包装 static 调用。从代码中调用包装方法,从测试中重写 protected 方法。
在依赖类中添加一个 static 包装方法,并从其中调用 static 方法,如下面的代码所示:
public static void callMe() {
throw new TestingImpedimentException("Common we dont need singleton");
}
protected void wrapper() {
callMe();
}
在代码中,按照以下方式调用 wrapper 方法:
public void testMe() {
dependency.wrapper();
}
按照以下方式在测试中模拟 wrapper 方法:
@Test
public void testMe() throws Exception {
Mockito.doNothing().when(dependency).wrapper();
aPatternLover.testMe();
}
与最终类一起工作
你不能重写 final 类,因此你可以在 final 类中隐藏不利的测试特性。以下示例解释了这个问题:
最终类隐藏了一个测试障碍如下:
public final class FinalDepencyClass {
public void poison() {
throw new TestingImpedimentException("Finals cannot be mocked");
}
}
测试中的代码依赖于最终类如下:
public class FinalClassDependency {
private final FinalDepencyClass finalDepencyClass;
public FinalClassDependency(FinalDepencyClass finalDepencyClass) {
this.finalDepencyClass = finalDepencyClass;
}
public void testMe() {
finalDepencyClass.poison();
}
}
在测试中,我们将尝试按照以下方式模拟 poison 方法:
@RunWith(MockitoJUnitRunner.class)
public class FinalClassDependencyTest {
@Mock
FinalDepencyClass finalDependency;
FinalClassDependency test;
@Before
public void setUp() {
test = new FinalClassDependency(finalDependency);
}
@Test
public void testMe() throws Exception {
Mockito.doNothing().when(finalDependency).poison();
test.testMe();
}
}
测试失败是因为出现了 MockitoException,因为 Mockito 无法模拟一个最终的类。以下是 JUnit 的输出:

小贴士
不要在最终的类中隐藏测试障碍。你不能模拟一个最终的类。
最终类对于框架或架构设计很重要,这样就没有人可以黑客行为,但它可能会为单元测试造成严重问题。在你选择将类设置为最终之前,请考虑这一点。
学习新属性
Java 使用 new 操作符实例化类,但 new 操作符可能会为单元测试带来问题。
以下是一个解释这个问题的例子。PoisonIvy 的构造函数有一个测试障碍,比如从数据库表获取数据或从文件系统中读取;我们用 TestingImpedimentException 来表示测试障碍:
public class PoisonIvy {
public PoisonIvy() {
throw new TestingImpedimentException(
"Do not instantiate concrete class, use interfaces");
}
public void poison() {
}
}
以下调用 PoisonIvy 构造函数的代码:
public class NewExpressionDependency {
public void testMe() {
PoisonIvy ivy = new PoisonIvy();
ivy.poison();
}
}
当我们单元测试 testMe() 代码时,它失败了。testMe() 方法直接创建了一个依赖项的实例并调用了 poison() 方法。你不能覆盖这个 new 表达式。如果我们想对 testMe() 方法进行单元测试,首先我们需要将 new 操作符移出 testMe(),因为我们不能实例化 PoisonIvy 类。PoisonIvy 的构造函数会抛出异常。因此,除非我们将对象创建移出 testMe,否则我们无法对 testMe 的行为进行单元测试。而不是在 testMe() 内部创建 PoisonIvy 的新实例,我们可以将 PoisonIvy 的实例作为方法参数传递,或者创建一个类级别的依赖并将 PoisonIvy 作为构造函数或设置器依赖参数传递。
小贴士
面向接口编程,而不是面向实现。而不是将子类型的实例化硬编码到代码中,在运行时分配具体的实现对象。
“面向接口编程,而不是面向实现”是什么意思?
这意味着编写面向超类型而不是子类型的程序。你可以在运行时交换实现。在集合框架中,我们有 List 接口及其许多实现。在你的类中,始终定义一个 List 类型的变量,而不是 ArrayList;在运行时,你可以分配任何你想要的实现。
在这个例子中,你可以将 PoisonIvy 作为构造函数或设置器依赖项传递,在运行时(测试期间),你可以传递一个模拟或伪造的实现来抑制测试障碍。
探索静态变量和块
静态初始化和 static 块在类加载期间执行。你不能覆盖它们。如果你在一个 static 块中初始化一个测试障碍,那么你将无法对这个类进行单元测试。以下是一个解释这个问题的例子:
StaticBlockOwner 类有一个名为 StaticBlockDependency 的静态变量,并在一个 static 块中初始化这个变量。以下是这个类的代码:
public class StaticBlockOwner {
private static StaticBlockDependency blockDependency;
static {
blockDependency = new StaticBlockDependency();
blockDependency.loadTime = new Date();
}
public void testMe() {
}
}
当我们对类进行单元测试时,它失败了。以下是一个单元测试:
public class StaticBlockOwnerTest {
StaticBlockOwner owner;
@Before public void setUp() {
owner = new StaticBlockOwner();
}
@Test public void clean() throws Exception {
owner.testMe();
}
}
测试失败,出现java.lang.ExceptionInInitializationError异常,因为它尝试在static块中实例化依赖项,而依赖项抛出了异常。
小贴士
不要在静态块中实例化依赖项。你不能覆盖测试障碍。
Michael Feathers 所著的《与遗留代码有效工作》,由 Pearson Education 出版,解释了遗留代码以及你如何有效地重构遗留代码。你可以在www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052上阅读电子书。
与绿色代码一起工作
本节说明了编写一个失败的测试、编写足够的代码使其工作,然后重构它的三步节奏。这是隐含的绿色代码开发,而不是与现有遗留代码一起工作。
TDD 是一种进化式开发方法。它提供了测试优先的开发,生产代码仅编写以满足测试,代码被重构以提高代码质量。在 TDD 中,单元测试驱动设计。你编写代码以满足失败的测试,因此它限制了你要编写的代码仅限于所需的内容。测试提供了快速自动化的回归,用于重构和新增强。
Kent Beck 是极限编程和 TDD 的创始人。他撰写了许多书籍和论文。访问en.wikipedia.org/wiki/Kent_Beck获取详细信息。
以下图表表示了 TDD 的生命周期:

首先,我们编写一个失败的测试,然后添加代码以满足失败的测试,然后重构代码并再次从另一个测试开始。
以下部分提供了一个 TDD 的示例。我们将构建一个进行选举调查并预测结果的程序。该程序将编译调查结果并显示意见调查。
结果应展示区域(地理上)的民意调查意见和总体意见,例如,如果有两个区域,东部和西部,则结果将以以下格式展示:

让我们看看以下步骤:
-
创建一个名为
SurveyResultCompilerTest的测试类,并添加一个when_one_opinion_then_result_forecasts_the_opinion()测试来编译总体调查结果。注意
我们将遵循这种约定来命名测试方法,例如,
when_some_condition_then_this_happens。我们将使用下划线符号作为分隔符。 -
在这个新的测试方法中,输入
SurveyResultCompiler()。编译器会抱怨SurveyResultCompiler类不存在。将鼠标悬停在SurveyResultCompiler上;Eclipse 会为你提供一个快速修复建议。选择创建类 'SurveyResultCompiler',并在src源文件夹下的com.packt.tdd.survey包中创建该类,如图所示:![与绿色代码一起工作]()
-
SurveyResultCompiler已经准备好了。我们需要传递一个意见给SurveyResultCompiler以便它可以编译一个结果。修改测试以调用willVoteFor并传递一个意见。编译器会抱怨该方法不存在。通过快速修复选项将方法添加到SurveyResultCompiler中。以下是测试方法:@Test public void when_one_opinion_then_result_forecasts_the_opinion() { new SurveyResultCompiler().willVoteFor("Party A"); } -
调查后我们需要一个编译好的结果。结果应该给出政党名称和获胜百分比。我们可以考虑使用
Map数据类型。再次修改测试以获取结果。以下是修改后的测试:@Test public void when_one_opinion_then_result_forecasts_the_opinion() { SurveyResultCompiler surveyResultCompiler = new SurveyResultCompiler(); surveyResultCompiler.willVoteFor("Party A"); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); } -
将
forecastResult方法添加到SurveyResultCompiler类中。以下是SurveyResultCompiler类:public class SurveyResultCompiler { public void willVoteFor(String opinion) { } public Map<String, BigDecimal> forecastResult() { return null; } } -
验证当只有一个人参与调查时,调查结果应该返回该人投票的政党 100%的获胜机会。以下断言验证了我们的假设:
@Test public void when_one_opinion_then_result_forecasts_the_opinion() { SurveyResultCompiler surveyResultCompiler = new SurveyResultCompiler(); String opinion = "Party A"; surveyResultCompiler.willVoteFor(opinion); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("100"), result.get(opinion)); } -
当我们运行测试时,它因为
NullPointerException而失败。我们需要修改代码如下以返回一个结果:public Map<String, BigDecimal> forecastResult() { Map<String, BigDecimal> result = new HashMap<String, BigDecimal>(); return result; } -
重新运行测试。它因为
AssertionError而失败。以下是输出:![与绿色代码一起工作]()
-
我们需要修改代码以返回
Party A100%。以下是修改后的代码:public Map<String, BigDecimal> forecastResult() { Map<String, BigDecimal> result = new HashMap<String, BigDecimal>(); result.put("Party A", new BigDecimal("100")); return result; } -
重新运行测试。它将显示一个绿色条形图。以下是输出:
![与绿色代码一起工作]()
-
现在我们需要添加另一个测试来验证当两个人参与投票,并且他们为两个不同的政党投票时,结果应该显示每个政党有 50%的机会。添加一个
when_different_opinions_then_forecasts_50_percent_chance_for_each_party测试,并添加以下行来验证假设:@Test public void when_different_opinions_then_forecasts_50_percent_chance_for_each_party() { SurveyResultCompiler surveyResultCompiler = new SurveyResultCompiler(); String opinionA = "Party A"; surveyResultCompiler.willVoteFor(opinionA); String opinionB = "Party B"; surveyResultCompiler.willVoteFor(opinionB); Map<String, BigDecimal> result = surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("50"), result.get(opinionA)); assertEquals(new BigDecimal("50"), result.get(opinionB)); } -
当我们运行测试时,它失败了。它期望得到 50%,但得到了 100%,如下截图所示:
![与绿色代码一起工作]()
-
我们需要修改代码以返回
Party A50% 和Party B50%。以下是修改后的代码:public Map<String, BigDecimal> forecastResult() { Map<String, BigDecimal> result = new HashMap<String, BigDecimal>(); result.put("Party A", new BigDecimal("50")); result.put("Party B", new BigDecimal("50")); return result; } -
重新运行测试。第二个测试通过,但第一个测试失败,如下截图所示:
![与绿色代码一起工作]()
-
我们破坏了第一个测试。现在我们需要撤销更改,但第二个测试将失败。我们需要一个算法来计算百分比。首先,我们需要存储意见。向
SurveyResultCompiler类中添加一个List并存储每个意见。以下是代码:public class SurveyResultCompiler { List<String> opinions = new ArrayList<String>(); public void willVoteFor(String opinion) { opinions.add(opinion); } //the result method is ignored for brevity } -
现在我们需要修改
forecastResult方法来计算百分比。首先,遍历意见以获取每个政党的投票计数,例如 10 个选民为Party A投票,20 个选民为Party B投票。然后,我们可以计算百分比作为 投票计数 * 100 / 总投票数。以下是代码:public Map<String, BigDecimal> forecastResult() { Map<String, BigDecimal> result = new HashMap<String, BigDecimal>(); Map<String, Integer> countMap = new HashMap<String, Integer>(); for(String party:opinions) { Integer count = countMap.get(party); if(count == null) { count = 1; }else { count++; } countMap.put(party, count); } for(String party:countMap.keySet()) { Integer voteCount = countMap.get(party); int totalVotes = opinions.size(); BigDecimal percentage = new BigDecimal((voteCount*100)/totalVotes); result.put(party, percentage); } return result; } -
重新运行测试。你将得到一个绿色条形图,如下截图所示:
![与绿色代码一起工作]()
-
现在添加一个针对三个参与者的测试。以下是这个测试:
@Test public void when_three_different_opinions_then_forecasts_33_percent_chance_for_each_party() { SurveyResultCompiler surveyResultCompiler = new SurveyResultCompiler(); String opinionA = "Party A"; surveyResultCompiler.willVoteFor(opinionA); String opinionB = "Party B"; surveyResultCompiler.willVoteFor(opinionB); String opinionC = "Party C"; surveyResultCompiler.willVoteFor(opinionC); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("33"), result.get(opinionA)); assertEquals(new BigDecimal("33"), result.get(opinionB)); assertEquals(new BigDecimal("33"), result.get(opinionC)); } -
看一下测试类,你会在每个测试方法中找到重复的代码;清理它们。将
SurveyResultCompiler对象的实例化移到setUp方法中,而不是在每个测试方法中实例化类。内联的是opinion变量,例如opinionA。以下是被重构的测试类:public class SurveyResultCompilerTest { SurveyResultCompiler surveyResultCompiler; @Before public void setUp() { surveyResultCompiler = new SurveyResultCompiler(); } @Test public void when_one_opinion_then_result_forecasts_the_opinion() { surveyResultCompiler.willVoteFor("Party A"); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("100"), result.get("Party A")); } @Test public void when_two_different_opinions_then_forecasts_50_percent_chance_for_each_party() { surveyResultCompiler.willVoteFor("Party A"); surveyResultCompiler.willVoteFor("Party B"); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("50"), result.get("Party A")); assertEquals(new BigDecimal("50"), result.get("Party B")); } @Test public void when_three_different_opinions_then_forecasts_33_percent_chance_for_each_party() { surveyResultCompiler.willVoteFor("Party A"); surveyResultCompiler.willVoteFor("Party B"); surveyResultCompiler.willVoteFor("Party C"); Map<String, BigDecimal> result =surveyResultCompiler.forecastResult(); assertEquals(new BigDecimal("33"), result.get("Party A")); assertEquals(new BigDecimal("33"), result.get("Party B")); assertEquals(new BigDecimal("33"), result.get("Party C")); } } -
测试类现在看起来很干净。重新运行测试以确保没有出错。以下是测试输出:
![处理绿色代码]()
-
回顾
SurveyResultCompiler类。它与List和两个Map属性一起工作。我们真的需要保留List属性吗?我们可以在Map中直接存储意见而不是从List中计算投票,并保持意见计数最新。以下是被重构的代码:public class SurveyResultCompiler { private Map<String, Integer> opinions = new HashMap<String, Integer>(); private long participationCount = 0; public void willVoteFor(String opinion) { Integer sameOpinionCount = opinions.get(opinion); if (sameOpinionCount == null) { sameOpinionCount = 1; } else { sameOpinionCount++; } opinions.put(opinion, sameOpinionCount); participationCount++; } public Map<String, BigDecimal> forecastResult() { Map<String, BigDecimal> result = new HashMap<String, BigDecimal>(); for (String opinion : opinions.keySet()) { Integer sameOpinionCount = opinions.get(opinion); BigDecimal opinionPercentage = new BigDecimal((sameOpinionCount * 100) / participationCount); result.put(opinion, opinionPercentage); } return result; } } -
重新运行测试以确保没有出错。如果有任何错误,则立即撤销更改。测试应该运行良好,所以我们一切顺利。
-
一个特性已经完成。现在我们需要开发一个新特性——区域计算。现有的测试用例将保护我们的代码。如果你破坏了任何现有测试,立即回顾你的更改。
我们刚刚完成的是 TDD。它有以下好处:
-
TDD 为我们提供了干净、可测试和可维护的代码。
-
我们记录并更新代码,但忘记更新文档;这造成了混淆。你可以记录代码并保持其更新,或者以任何人都可理解的方式编写代码和单元测试。在 TDD 中,测试是为了提供足够的代码文档。因此,测试是我们的文档,但我们需要清理测试以保持其可读性和可维护性。
-
我们可以编写许多带有边界值条件的测试,如 null、零、负数等,并验证我们的代码。通过传递这些边界值,你试图破坏你自己的代码。无需打包整个应用程序并交付给质量保证(QA)或客户以发现问题。
-
你还避免了过度设计你编写的类。只需编写使所有测试变绿所需的内容。
-
逐步构建代码的另一个好处是 API 更容易使用,因为代码是在编写和使用的同时编写的。
摘要
本章解释了遗留代码和新开发的单元测试策略。它涵盖了遗留代码问题,重构了遗留代码,说明了可测试性设计,描述了 TDD 概念和 TDD 生命周期,演示了 TDD 示例和重构。
现在读者应该能够为遗留代码编写单元测试,重构遗留代码以改进现有代码的设计,并开始编写简单、干净、可维护的代码,遵循 TDD,并重构代码以提高其质量。
下一章将介绍单元测试的最佳实践。
第十章。最佳实践
“继续以同样的方式做事并期望事情变得更好是一种疯狂。”
——匿名
就像编写干净的代码一样,编写干净、可读和可维护的 JUnit 测试用例是一门艺术。一个编写良好的单元测试可以防止维护噩梦,并作为系统文档;然而,如果不小心使用,它可能会产生无意义的样板测试用例。只要你不反复犯错误,错误就是学习过程的一部分。JUnit 不是火箭科学,所以我们可以练习、遵循指南并从他人那里学习,使其完美。
本章涵盖了 JUnit 指南和最佳实践。以下类别将深入探讨:
-
编写有意义的测试
-
测试自动化
-
测试配置
-
断言约定
-
异常处理
-
测试异味和重构测试异味
编写有意义的测试
单元测试的普遍理解是测试软件中最小可能的组成部分,具体来说是一个方法。实际上,我们并不测试方法;而是测试一个逻辑单元或系统的行为。
逻辑单元可以扩展到单个方法、整个类或多个类的协作。例如,一个标准的计算器程序可以有一个加法方法来添加两个数字。我们可以通过调用add方法来验证加法行为,或者我们可以设计计算器程序以拥有一个简单的计算 API,该 API 可以接受两个数字和一个操作(加、减、除等),并根据操作数类型(整数、双精度浮点数等),计算器可能会将计算委托给协作类,例如双精度计算器或长计算器。我们仍然可以单元测试加法行为,但现在涉及到多个类。我们可以称这个新的测试为集成测试。
单元测试验证关于系统行为的假设。除此之外,如果一个测试测试整个系统,它就不能是单元测试——我们称这些测试为联盟测试,因为它们设置了整个生态系统,包括设置必要的组件。
以下部分详细阐述了编写有意义的测试。
提高可读性
马丁·福勒说:“任何傻瓜都能写出计算机能理解的代码。优秀的程序员编写的是人类能理解的代码。”编写晦涩的代码可能对老手来说很时髦,但这不是标准的 Java 实践。我们应该编写可读性和可维护的代码,以便任何人都能理解代码的目的,并在将来增强或维护代码。
JUnit 测试是编写来测试逻辑单元的。一个测试方法的名字应该表达测试的意图,以便读者可以理解正在测试的内容,例如条件、期望或动作。
假设你正在编写一个基于角色的系统的测试,并且系统拒绝未授权的访问。你可以使用以下模式,但如果你选择遵循一种模式,最好是坚持使用它:
-
testDenialOfUnauthorizedAccess() -
when_unauthorized_user_then_denies_the_access() -
should_deny_access_for_unauthorized_users()
我更喜欢下划线 (_) 模式,因为它更易读。
对于边界值条件,你可以遵循以下模式:
-
testRegisteringNullUser() -
should_not_register_a_null_user() -
should_throw_exception_when_a_null_user_is_registered() -
when_null_user_then_registrar_throws_exception()
同样,测试类应该描绘测试的意图。通常,我们遵循两种约定,Test<class name> 或 <class name>Test。假设你正在测试 UserRegistration 行为。你可以使用 UserRegistrationTest 或 TestUserRegistration。一些测试覆盖率工具无法识别没有 Test 后缀的类。因此,UserRegistrationTest 是一个安全的选择。
破坏所有可能出错的东西
极限编程的一个概念是测试所有可能出错的东西。这意味着尝试所有不同的输入组合,以确保我们没有错过任何可能导致类生成错误的组合。然而,在实践中这是不可能做到的。我们可以测试边界值条件。我们甚至可以覆盖所有分支和行,但我们不能测试所有可能的输入组合。假设一个方法添加两个整数。我们可以传递 NULL、0、Integer.MAX_VALUE、负数等,但我们实际上不能测试所有可能的整数值。
忽略简单的测试用例
编写简单的 JUnit 测试(例如对于 getter 和 setter)通常是时间和金钱的浪费。我们没有写无限测试的奢侈,因为这会消耗我们的开发时间、应用程序构建时间和降低测试的可维护性。如果我们开始为 getter/setter 编写测试,我们可能会错过更多有用的测试用例。通常,单元测试是自动化的,并在构建过程中运行。构建需要尽早完成以提供反馈,但如果我们继续添加简单的测试,这个过程将会延迟。单元测试是系统文档,因此它们描绘了系统行为;然而,如果我们继续为简单的事情添加测试,那么就违背了其目的。编写能够提供信息的测试。
验证无效参数
测试每个方法的无效参数。你的代码需要识别和处理无效数据。使用错误数据和边界值条件通过的测试提供了全面的 API 文档。
假设你正在编写一个针对 add 方法的测试。它接受两个整数并返回一个整数。以下是一个 Adder 类:
public class Adder {
public Integer add(Integer first, Integer second) {
if (first == null || second == null) {
throw new IllegalArgumentException("Invalid inputs first=[" + first+ "], second=[" + second + "]");
}
return first + second;
}
}
可以测试的边界值包括 null、零、负数和溢出条件,如下所示:
public class AdderTest {
Adder adder = new Adder();
@Test(expected=IllegalArgumentException.class)
public void should_throw_exception_when_encounters_a_NULL_input(){
adder.add(null, 1);
}
@Test(expected=IllegalArgumentException.class)
public void should_throw_exception_when_second_input_is_NULL(){
adder.add(2, null);
}
@Test
public void should_return_zero_when_both_inputs_are_zero(){
int actual =adder.add(0, 0);
assertEquals(0, actual);
}
@Test
public void should_return_first_input_when_second_input_is_zero() {
int actual =adder.add(1, 0);
assertEquals(1, actual);
}
@Test
public void should_return_second_input_when_first_input_is_zero() {
int actual =adder.add(0, 2);
assertEquals(2, actual);
}
@Test
public void should_return_zero_when_summation_is_zero(){
int actual =adder.add(5, -5);
assertEquals(0, actual);
}
@Test public void should_return_a_negative_when_both_inputs_are_negative() {
int actual =adder.add(-8, -5);
assertTrue(actual < 0);
}
@Test
public void should_overflow_when_summation_exceeds_integer_limit() {
int actual =adder.add(Integer.MAX_VALUE, 1);
assertTrue(actual< 0);
}
}
你的类可能有一个接受用户输入并将输入格式化委托给依赖类或方法的公共 API。你应该只在公共 API 中验证用户输入,而不是在所有方法或依赖类中。
假设类A有一个doSomething(String input)方法。A调用B来格式化输入。如果客户端只能调用类A,那么你不需要担心在类B中验证空输入。然而,如果A和B都公开,那么B肯定应该检查NULL值。在所有地方检查NULL是防御性编程。
依赖于直接测试
假设你有一个依赖于实用工具类的门面类。测试门面类可以覆盖实用工具类。这是一个间接测试的例子。以下Facade类依赖于StringService类进行格式化;当我们用String值测试Facade类时,StringService类也被测试:
public class Facade {
private final StringService stringService;
public Facade(StringService utility) {
this.stringService= utility;
}
public Object doSomething(Object o) {
if (o instanceof String) {
return stringService.format((String) o);
}
if (o instanceof Integer) {
return Integer.MIN_VALUE;
}
return null;
}
}
尽管其方法也被Facade类的测试调用,我们应该直接测试StringService。我们应该有两个测试类:FacadeTest和StringServiceTest。
依赖于间接测试不是一个好主意,因为如果我们更改Facade类的实现,那么依赖的类可能会被揭露。假设我们更改Facade类的实现,使其不再依赖于StringService。StringServiceTest中的测试将不再调用StringService的方法,因此我们将失去代码覆盖率。
避免调试
当我们发现一个错误时,通常的做法是开始调试应用程序——停止这样做。相反,添加更多测试来破坏代码;这将丰富你的测试套件并改进系统文档。同样,不要添加捕获块来打印堆栈跟踪。相反,使用ExpectedException规则(在处理异常部分中解释)断言异常消息。有时,完全避免调试是不可能的。所以无论如何,在开始调试之前,创建一个(集成)测试来重现问题,然后进行调试。这将缩小问题范围,为最低可能的单元创建单元测试,并保留测试供将来参考。
避免使用泛型匹配器
我们倾向于使用通配符匹配器来模拟模拟对象的方法;同样,使用泛型匹配器验证方法调用。这是一个坏习惯;当可能时,你应该追求精确的参数匹配。以下示例演示了通配符参数匹配。
StringDecorator类使用感叹号装饰输入:
public class StringDecorator {
public String decorate(String object) {
return object+"!";
}
}
PrinterService接口连接到局域网打印机,并按如下方式打印输入文本:
public interface PrinterService {
void print(String text);
}
Facade类接受一个输入,装饰该输入,并将其发送到PrinterService进行打印。为了测试这种行为,我们需要使用以下代码使用模拟对象模拟PrinterService:
public class Facade {
private final Decorator decorator;
private final PrinterService printerService;
public Facade(Decorator decorator, PrinterService printerService) {
this.decorator = decorator;
this.printerService = printerService;
}
public void process(String object) {
printerService.print(decorator.decorate(object));
}
}
通常,PrintService使用anyString()泛型匹配器进行模拟,并使用verify(mockService).print(anyString());进行PrintService调用的验证,如下所示:
@RunWith(MockitoJUnitRunner.class)
public class FacadeTest {
@Mock PrinterService mockService;
Facade facade;
@Before
public void setUp() throws Exception {
facade = new Facade(new StringDecorator(), mockService);
}
@Test
public void test() {
String input = "hello";
doNothing().when(mockService).print(anyString());
facade.process(input);
verify(mockService).print(anyString());
}
}
我们可以使用 eq("hello!") 而不是 anyString(),因为我们知道 StringDecorator 方法会在 String 输入后添加感叹号;如果 StringDecorator 方法没有添加感叹号,测试将失败。因此,StringDecorator 的副作用可以立即识别。
避免使用 @ignore
不要使用 @ignore 或 @exclude 注解跳过单元测试。正如我们所知,死代码删除是一种重构技术,死代码永远不会被使用。然而,它们会制造混乱。同样,当我们使用 @ignore 注解忽略测试时,测试会被跳过,但代码仍然保留在文件中作为死代码,造成混乱。跳过的单元测试没有任何好处。与其跳过单元测试,不如从源代码控制中删除它们。如果你需要测试,你可以从源代码控制的历史记录中获取它。有时人们创建测试以轻松理解某种 API,但他们不希望当测试套件运行时执行测试,或者可能无法在所有平台上运行某些测试。对于 Maven(和 Gradle),你可以有不同的配置文件和不同的测试套件。对于实用程序测试,创建一个特定的模块总是有帮助的。
避免调试信息
在早期,我们使用打印(System.out 或 System.err)消息到控制台进行调试或单元测试代码。单元测试是系统文档,打印语句不适合那里。如果你需要打印某些内容,只需编写一个测试并断言期望的值。此外,你可以添加一个日志工具,如 Log4J,并记录调试信息。如果在生产中出现问题时,只需打开这些日志并查看那里发生了什么,以便能够通过测试更好地重现问题。因此,测试和日志应该相互补充。
自动化 JUnit 测试
第二章,自动化 JUnit 测试,讨论了测试自动化、持续集成以及使用 Gradle、Maven 和 Ant 进行测试自动化的重要性。本节重申了测试自动化的好处。
以下是一些测试自动化的好处:
-
假设不断得到验证。我们重构代码(在不影响系统输出结构的情况下改变代码的内部结构)以提高代码质量,如可维护性、可读性或可扩展性。如果自动化的单元测试正在运行并提供反馈,我们可以有信心地重构代码。
-
副作用会被立即检测到。这对于脆弱、紧密耦合的系统很有用,当一个模块发生变化时,可能会破坏另一个模块。
-
自动化测试节省时间,不需要立即进行回归测试。假设您正在向现有的计算器程序添加科学计算行为并修改代码。每次更改后,您都执行回归测试以验证系统的完整性。回归测试很繁琐且耗时,但如果您有一个自动化的单元测试套件,那么您可以在功能完成之前推迟回归测试。这是因为自动化套件会在每个阶段通知您是否破坏了现有功能。
总是将您的 JUnit 与构建脚本集成并配置持续集成。
配置测试
本节处理测试配置。单元测试不是在测试系统。在 TDD 中,单元测试是为了获得以下好处而编写的:
-
它们驱动着您的设计。您编写一个测试,添加代码以修复测试,有信心地重构代码,并应用设计。这导致了一个简单、干净、易于维护、松散耦合且紧密的设计。您编写代码以满足失败的测试,因此它限制了您编写的代码仅限于所需的内容。
-
测试提供了快速、自动化的重构和代码增强。
您应该配置您的测试以遵循以下原则:
-
单元测试应该执行得非常快,以便它们可以提供快速反馈。您会从需要 10 分钟才能取款的 ATM 机中取款吗?
-
测试应该是可靠的。如果生产代码有误,测试应该失败。在您破坏了生产逻辑但测试通过,或者您没有修改生产代码但测试仍然失败的情况下,您的测试将被视为不可靠。
以下部分涵盖了测试配置。
运行内存测试
不要编写执行 HTTP 请求、查找 JNDI 资源、访问数据库、调用基于 SOAP 的 Web 服务或从文件系统中读取的单元测试。这些操作很慢且不可靠,因此不应被视为单元测试;相反,它们是集成测试。您可以使用 Mockito 模拟这些外部依赖。第四章,渐进式 Mockito,解释了如何模拟外部依赖。
避免使用Thread.sleep
Thread.sleep在生产代码中用于暂停当前执行一段时间,以便当前执行可以与系统同步,这样当前线程就可以等待另一个线程使用的资源。为什么在单元测试中需要Thread.sleep?单元测试的目的是要快速执行。
Thread.sleep可以用来等待长时间运行的过程(这通常用于测试并发),但如果在慢速机器上这个过程需要时间呢?尽管代码没有错误,测试仍然会失败,这违反了测试可靠性原则。避免在单元测试中使用Thread.sleep;相反,使用模拟对象模拟长时间运行的过程。
将单元测试与生产代码隔离开
不要将单元测试交付给客户;他们不会执行这些测试。测试代码应该与生产代码分离。将它们保存在各自的源目录树中,并使用相同的包命名结构。这将确保在构建过程中它们是分开的。
以下 Eclipse 截图显示了单独的源文件夹结构。源文件位于src文件夹下,测试文件位于test源文件夹下。请注意,Adder.java和AdderTest.java文件被放置在名为com.packt.bestpractices.invalidinput的同一包中:

避免使用静态变量
静态变量持有状态。当您在测试中使用静态变量时,这表示您想要保存某个状态。因此,您正在创建测试之间的依赖关系。如果执行顺序改变,即使代码没有出错,测试也会失败,这违反了测试可靠性原则。不要在单元测试中使用静态变量来存储全局状态。
不要将待测试的类初始化为静态,并使用setUp方法(带有@Before注解)来初始化对象。这将保护您免受意外修改问题的影响。以下示例演示了意外修改的副作用。
Employee类存储员工姓名:
public class Employee {
private String lastName;
private String name;
public Employee(String lastName , String name) {
this.lastName = lastName;
this.name = name;
}
public String getLastName() {
return lastName;
}
public String getName() {
return name;
}
}
HRService类有一个generateUniqueIdFor(Employee emp)方法。它根据姓氏返回一个唯一的员工 ID。具有姓氏 Smith 的两个员工将分别具有 IDsmith01和smith02。考虑以下代码:
public class HRService {
private Hashtable<String, Integer> employeeCountMap = new Hashtable<String, Integer>();
public String generateUniqueIdFor(Employee emp) {
Integer count = employeeCountMap.get(emp.getLastName());
if (count == null) {
count = 1;
} else {
count++;
}
employeeCountMap.put(emp.getLastName(), count);
return emp.getLastName()+(count < 9 ? "0"+count:""+count);
}
}
单元测试类将服务初始化为静态。服务存储了第一个测试的输入,并导致第二个测试失败,如下所示:
public class HRServiceTest {
String familyName = "Smith";
static HRService service = new HRService();
@Test
public void when_one_employee_RETURNS_familyName01() throws Exception {
Employee johnSmith = new Employee(familyName, "John");
String id = service.generateUniqueIdFor(johnSmith);
assertEquals(familyName + "01", id);
}
//This test will fail, to fix this problem remove the static modifier
@Test
public void when_many_employees_RETURNS_familyName_and_count() {
Employee johnSmith = new Employee(familyName, "John");
Employee bobSmith = new Employee(familyName, "Bob");
String id = service.generateUniqueIdFor(johnSmith);
id = service.generateUniqueIdFor(bobSmith);
assertEquals(familyName + "02", id);
}
}
以下 JUnit 输出显示了错误详情:

假设测试执行顺序
JUnit 被设计为以随机顺序执行测试。它依赖于 Java 反射 API 来执行测试。因此,一个测试的执行不应依赖于另一个测试。假设您正在测试EmployeeService的数据库集成,其中createEmployee()测试创建了一个新的Employee,updateEmployee()方法更新了在createEmployee()中创建的新员工,而deleteEmployee()删除了员工。因此,我们依赖于测试执行顺序;如果deleteEmployee()或updateEmployee()在createEmployee()之前执行,测试将失败,因为员工尚未创建。
为了解决这个问题,只需将测试合并为一个名为verifyEmployeePersistence()的单个测试即可。
因此,不要相信测试执行顺序;如果您必须更改一个测试用例,那么您需要在多个不必要的测试用例中进行更改。
从文件加载数据
JUnit 理论框架提供了一个用于为测试用例提供测试数据的抽象类ParameterSupplier。ParameterSupplier的实现可以从文件系统读取,例如 CSV 或 Excel 文件。然而,不建议您从文件系统读取。这是因为读取文件是一个 I/O(输入/输出)过程,它是不可预测且缓慢的。我们不希望我们的测试创建延迟。此外,从硬编码的文件路径读取可能会在不同机器上失败。而不是从文件读取,创建一个测试数据提供者类并返回硬编码的数据。
调用super.setUp()和super.tearDown()
有时单元测试的数据设置单调且丑陋。通常,我们创建一个基本测试类,设置数据,并创建子类来使用这些数据。从子类中,始终调用超类的设置和拆卸方法。以下示例显示了未调用超类的方法的错误。
我们有EmployeeService和EmployeeServiceImpl来执行一些业务逻辑:
public interface EmployeeService {
public void doSomething(Employee emp);
}
BaseEmployeeTest类是一个抽象类,并为子类设置数据,如下所示:
public abstract class BaseEmployeeTest {
protected HashMap<String, Employee> employee ;
@Before
public void setUp() {
employee = new HashMap<String, Employee>();
employee.put("1", new Employee("English", "Will"));
employee.put("2", new Employee("Cushing", "Robert"));
}
}
EmployeeServiceTest类扩展了BaseEmployeeTest类并使用employee映射,如下所示:
public class EmployeeServiceTest extends BaseEmployeeTest {
EmployeeService service;
@Before
public void setUp() {
service = new EmployeeServiceImpl();
}
@Test
public void someTest() throws Exception {
for(Employee emp:employee.values()) {
service.doSomething(emp);
}
}
}
测试执行失败,抛出NullPointerException。以下是 JUnit 输出:

为了解决这个问题,请在setUp()方法中调用super.setUp()。以下是在EmployeeServiceTest中修改后的setUp()方法:
@Before
public void setUp() {
super.setUp();
service = new EmployeeServiceImpl();
}
避免副作用
不要编写影响其他测试用例数据的测试用例,例如,您正在使用内存中的HashMap检查 JDBC API 调用,而另一个测试用例清除了该映射,或者您正在测试数据库集成,而另一个测试用例从数据库中删除了数据。这可能会影响其他测试用例或外部系统。当一个测试用例从数据库中删除数据时,任何使用这些数据的应用程序都可能失败。在测试的最后块中回滚更改非常重要,而不仅仅是测试结束时。
与区域设置一起工作
在使用NumberFormat、DateFormat、DecimalFormat和TimeZones时要注意国际化。如果在一个具有不同区域设置的机器上运行单元测试,测试可能会失败。
以下示例演示了国际化上下文。
假设您有一个格式化货币的类。当您传递 100.99 时,它将金额四舍五入到 101.00。以下格式化器类使用NumberFormat添加货币符号并格式化金额:
class CurrencyFormatter{
public static String format(double amount) {
NumberFormat format =NumberFormat.getCurrencyInstance();
return format.format(amount);
}
}
以下 JUnit 测试验证了格式化:
public class LocaleTest {
@Test
public void currencyRoundsOff() throws Exception {
assertEquals("$101.00", CurrencyFormatter.format(100.999));
}
}
如果您在不同的区域设置下运行此测试,测试将失败。我们可以通过更改区域设置并恢复到默认区域设置来模拟这种情况,如下所示:
public class LocaleTest {
private Locale defaultLocale;
@Before
public void setUp() {
defaultLocale = Locale.getDefault();
Locale.setDefault(Locale.GERMANY);
}
@After
public void restore() {
Locale.setDefault(defaultLocale);
}
@Test
public void currencyRoundsOff() throws Exception {
assertEquals("$101.00", CurrencyFormatter.format(100.999));
}
}
在测试执行之前,默认的区域设置值被存储到 defaultLocale,默认的区域设置为 GERMANY,在测试执行之后,默认的区域设置被恢复。以下是在 GERMANY 中 JUnit 执行失败的输出。在 GERMANY 中,货币将被格式化为 101,00 €,但我们的测试期望的是 $101.00:

你可以将你的代码修改为始终返回美元格式,或者你可以将测试修改为在 US 区域设置下运行,通过将默认区域设置更改为 US,并在测试执行后将其恢复到默认设置。同样,在处理日期和小数格式化时也要小心。
使用日期
如果使用不当,日期在测试中可能会表现得异常。在使用硬编码的日期进行单元测试时要小心。你正在处理日期,并使用未来的日期来检查业务逻辑。在 2014 年 1 月 1 日,你将未来的日期设置为 2014 年 4 月 10 日。测试在 4 月 9 日之前运行正常,之后开始失败。
不要使用硬编码的日期。相反,使用 Calendar 获取当前日期和时间,并添加 MONTH、DATE、YEAR、HOUR、MINUTE 或 SECOND 来获取未来的日期时间。以下自解释的代码片段演示了如何创建动态的未来日期:
Calendar cal = Calendar.getInstance ();
Date now = cal.getTime();
//Next month
cal.add(Calendar.MONTH,1);
Date futureMonth = cal.getTime();
//Adding two days
cal.add(Calendar.DATE,2);
Date futureDate = cal.getTime();
//Adding a year
cal.add(Calendar.YEAR,1);
Date futureYear = cal.getTime();
//Adding 6 hours
cal.add(Calendar.HOUR,6);
Date futureHour = cal.getTime();
//Adding 10 minutes
cal.add(Calendar.MINUTE,10);
Date futureMinutes = cal.getTime();
//Adding 19 minutes
cal.add(Calendar.SECOND,19);
Date futureSec = cal.getTime();
以下是在 2014 年 4 月 16 日运行程序时的未来日期:

使用断言
断言是一个用于验证程序员假设(期望)与程序实现的实际结果的谓词。例如,程序员可以期望两个正数的相加将得到一个正数。因此,程序员可以编写一个程序来相加两个数,并用实际结果断言预期的结果。
org.junit.Assert 包提供了用于断言所有原始类型、对象和数组的预期和实际值的静态重载方法。
本节介绍了 Assertion API 的正确用法。以下是一些最佳实践。
使用正确的断言
使用正确的断言方法。JUnit 支持许多断言选项,例如 assertEquals、assertTrue、assertFalse、assertNull、assertNotNull、assertSame 和 assertThat。使用最合适的一个。以下是一些示例:
-
使用
assertTrue(yourClass.someMethod())而不是使用assertEquals(true, yourClass.someMethod()) -
使用
assertFalse(yourClass.someMethod())而不是调用assertTrue(!yourClass.someMethod()) -
使用
assertNull(yourClass.someMethod())而不是assertEquals(null, yourClass.someMethod()) -
使用
assertEquals(expected, yourClass.someMethod())而不是使用assertTrue(expected.equals(yourClass.someMethod())) -
assertThat(age, is(30))方法比assertEquals(30, age)更易读。 -
同样,
assertThat(age, is(not(33)))比起assertTrue(age != 33)更易读。
维护 assertEquals 参数顺序
assertEquals 方法是一个非常有用的方法来验证期望。assertEquals 方法具有 assertEquals(Object expected, Object actual) 签名。
维护参数顺序:首先是期望值,然后是实际结果。以下 JUnit 碎片颠倒了顺序,首先传递实际值,然后是期望结果:
@Test
public void currencyRoundsOff() throws Exception {
assertEquals(CurrencyFormatter.format(100.999), "$101.00");
}
passes a meaningful error message:
@Test
public void currencyRoundsOff() throws Exception {
assertEquals("Currency formatting failed", $101.00", CurrencyFormatter.format(100.999));
}
以下是一个带有信息性消息的 Assertion 失败输出:

力求每个测试一个断言
力求每个测试方法一个断言。当你检查每个测试的一个断言并且单元测试失败时,确定出了什么问题要容易得多。当一个单元测试有多个断言,并且其中一个断言失败时,需要额外的努力来确定哪个失败了;对于每个测试一个断言,不需要额外的努力。
当单元测试执行多个断言并且抛出运行时异常时,异常之后的断言不会得到验证;JUnit 框架将单元测试标记为错误并继续下一个测试方法。
以下 JUnit 测试断言了三个条件——格式化的金额不为空,格式化的金额包含一个 $ 符号,以及精确的格式:
@Test
public void currencyRoundsOff() throws Exception {
assertNotNull(CurrencyFormatter.format(100.999));
assertTrue(CurrencyFormatter.format(100.999).contains("$"));
assertEquals("$101.00", CurrencyFormatter.format(100.999));
}
当任何断言失败时,输出不会告诉你哪里出了问题(尽管你会在源代码文件中得到行号,但这并不非常方便)。以下是 JUnit 输出:

而不是使用三个断言,你可以创建三个测试,或者你可以向断言方法传递有意义的错误信息。以下修改后的 JUnit 测试传递了错误信息:
@Test
public void currencyRoundsOff() throws Exception {
assertNotNull("Currency is NULL", CurrencyFormatter.format(100.999));
assertTrue("Currency is not USD($)", CurrencyFormatter.format(100.999).contains("$"));
assertEquals("Wrong formatting", "$101.00", CurrencyFormatter.format(100.999));
}
现在,失败的测试为你提供了关于失败额外的信息。以下是测试输出。它读取为货币不是 USD($),这意味着第二个断言失败了:

处理异常
异常处理是 Java 编码的重要部分。Java 社区遵循一套关于异常处理的最佳实践。以下是一些单元测试的异常处理最佳实践:
-
不要编写 catch 块来通过单元测试。考虑以下示例,其中有一个
Calculator程序,它有一个divide方法。它接受两个整数,进行除法并返回结果。当divide遇到除以零时,程序应该抛出异常。以下是其代码:public class Calculator { public int divide(int op1, int op2) { return op1/op2; } }以下是一个测试:
@Test public void divideByZero_throws_exception() throws Exception { try { calc.divide(1, 0); fail("Should not reach here"); } catch (ArithmeticException e) { } }而不是捕获
ArithmeticException,我们可以应用 JUnit 4 模式如下:@Test(expected = ArithmeticException.class) public void divideByZero_throws_exception() throws Exception { calc.divide(1, 0); }一个更优雅的方法是检查
ExpectedException规则。以下是使用ExpectedException修改后的测试:public class CalculatorTest { @Rule public ExpectedException expectedException= ExpectedException.none(); Calculator calc = new Calculator(); @Test public void divideByZero_throws_exception(){ expectedException.expect(ArithmeticException.class); expectedException.expectMessage("/ by zero"); calc.divide(1, 0); } }ExpectedException期望一个异常和一个错误消息。如果未抛出异常或消息不匹配,则测试失败。 -
不要编写 catch 块来使测试失败;JUnit 框架负责处理运行时异常。以下是一个不必要的 catch 块的示例:
@Test public void fails_when_an_exception_is_thrown() { try { calc.divide(1, 0); }catch(Exception ex) { fail("Should not throw an exception"); } }相反,只需写下以下几行。如果抛出任何异常,测试将自动失败:
@Test public void fails_when_an_exception_is_thrown() { calc.divide(1, 0); } -
不要捕获异常并断言失败以通过测试。以下测试代码捕获
ArithmeticException并设置一个布尔标志,最后断言该标志。如果没有抛出异常,则标志保持为 false,测试失败:@Test public void fails_when_an_exception_is_thrown() { boolean isFailed = false; try { calc.divide(1, 0); }catch(Exception ex) { isFailed = true; } assertTrue(isFailed); }使用前面示例中解释的 JUnit 4 模式。
-
不要向抛出检查异常的方法添加捕获块。以下示例解释了问题。
sum(int... arg)方法在整数溢出时抛出检查异常NumberOverflowException:public int sum(int... args) throws NumberOverflowException{ int sum = 0; for(int val:args) { if(Integer.MAX_VALUE - sum < val) { throw new NumberOverflowException("Number overflow"); } sum+=val; } return sum; }使用捕获块来捕获检查异常并编译测试,如下所示:
@Test public void fails_when_an_exception_is_thrown() { try { int sum = calc.sum(1,2,3); assertEquals(6, sum); } catch (NumberOverflowException e) { } }不要遵循这个模式;相反,使用
throws Exception。以下 JUnit 测试使用了throws Exception子句:@Test public void fails_when_an_exception_is_thrown() throws Exception { int sum = calc.sum(1,2,3); assertEquals(6, sum); } -
不要从你的测试中抛出特定的
Exceptions。相反,使用通用的throws Exception。以下示例抛出了特定的
NumberOverflowException异常:public void fails_when_an_exception_is_thrown() throws NumberOverflowException{ }假设代码被更改,可能会抛出
NumberOverflowException或ParseException。在这种情况下,我们必须更改测试方法以抛出这两个异常来编译测试。如果我们使用通用的throws Exception子句,那么这个问题就不会出现。
与测试异味(test smells)一起工作
代码异味是一个技术债务或症状,表明存在更深层次的问题。异味不是错误,或者它们不会使测试失败。相反,它们表明设计或代码中存在一个问题,使得僵化的代码无法增强或可能创建维护问题。本节涵盖了应该重构以进行维护和可读性的测试异味。以下主题被涵盖:
-
测试代码重复
-
测试代码中的条件
-
生成代码中的测试逻辑
-
过度设计
重构重复
Person objects for check in, a patient johnPeterson, and his guarantor johnsDad:
Person johnsDad = new Person();
Address newYorkBayArea = new Address();
newYorkBayArea.setAddressType(AddressType.Residential);
newYorkBayArea.setCountry("US");
newYorkBayArea.setState("NY");
newYorkBayArea.setZip("49355");
newYorkBayArea.setStreet("12/e xyz Avenue");
johnsDad.addAddress(newYorkBayArea);
johnsDad.setEmail("dontDisturb@my.org");
johnsDad.setFirstName("Freddy");
johnsDad.setLastName("Peterson");
daddy.setPerson(johnsDad);
Person johnPeterson = new Person();
Address mavernPhilly = new Address();
mavernPhilly.setAddressType(AddressType.Residential);
mavernPhilly.setCountry("US");
mavernPhilly.setState("PA");
mavernPhilly.setZip("19355");
mavernPhilly.setStreet("123 Frazer");
johnPeterson.addAddress(mavernPhilly);
johnPeterson.setEmail("johnYou12345@gmail.com");
johnPeterson.setFirstName("John");
johnPeterson.setLastName("Peterson");
创建并初始化了两个Person对象和两个Address对象。它们是逻辑上重复的语句。许多其他测试可以编写类似的重复语句。提取方法以重构重复异味。按照以下方式提取Person和Address对象的构建方法:
protected Person newPerson(Address newYorkBayArea, StringlastName, String email, String firstName) {
Person person = new Person();
person.addAddress(newYorkBayArea);
person.setEmail(email);
person.setFirstName(firstName);
person.setLastName(lastName);
return person;
}
protected Address newAddress(String street, String country, String state, String zip, AddressType residential) {
Address address = new Address();
address.setAddressType(residential);
address.setCountry(country);
address.setState(state);
address.setZip(zip);
address.setStreet(street);
return address;
}
从测试代码中,只需传递所需的值并按照以下方式调用构建方法:
Address newYorkBayArea = newAddress("12/e xyz Avenue", "US", "NY","49355", AddressType.Residential);
Person johnsDad = newPerson(newYorkBayArea, "Peterson","dontDisturb@my.org", "Freddy");
Address mavernPhilly = newAddress("123 Frazer", "US", "PA", "19355", AddressType.Residential);
Person johnPeterson = newPerson(mavernPhilly, "Peterson", "johnYou12345@gmail.com", "John");
我们可以通过将公共代码移动到基测试类或辅助类来重构许多测试类中的重复代码。
重构测试控制逻辑
单元测试代码验证被测试代码的行为,通常不会编写条件逻辑来验证代码。然而,当测试包含基于某些条件的执行代码时,对读者来说会变得复杂。测试执行良好,但会创建维护问题。
当我们将 JMS 消息发布到目的地(如 TIBCO 企业消息服务)时,JMS 提供商内部会发布管理消息,例如消息接收、消息发送和消息确认。然而,每个消息都包含相同的 JMS 消息 ID。如果我们创建一个消息记录程序来监听 JMS 事件(包括管理事件),并将所有事件记录到数据库以供审计跟踪,那么记录器将保存许多具有相同 JMS 消息 ID 的消息。
以下是一个测试控制逻辑的示例。消息定义如下:
public class Message {
private String jmsMessageID;
private String header;
private Object payload;
private int eventType;
}
eventType 变量表示管理消息类型(接收的是 1,发送的是 2,确认的是 3)。
MessagingService 接口定义如下:
public interface MessagingService {
String publish(Object message);
List<Message> retrieveByMessageId(String jmsMessageId);
}
我们将按照以下方式验证日志功能:
@RunWith(MockitoJUnitRunner.class)
public class MessagingServiceTest {
MessagingService service = new MessagingServiceImpl();
@Test
public void logs_messages() throws Exception {
String msgId = service.publish(new String("hello world"));
for(Message msg:service.retrieveByMessageId(msgId)) {
if(msg.getEventType() == 2) {
assertEquals("hello world", msg.getPayload());
break;
}
}
}
}
Test 方法遍历消息,找到一条消息,然后验证负载。测试包含逻辑。我们是否需要为这个测试再进行另一个测试?这很令人困惑。
要重构我们的测试,可以将逻辑移动到被测试的代码中。API 应该有一个方法来返回特定类型的消息。这样,我们就可以直接检查消息对象,而不是循环检查。
从生产代码中移除测试逻辑
编写可测试的代码是一种质量。通常,我们将测试逻辑放入生产代码中进行单元测试,例如新的构造函数或新方法。为了使代码可测试,测试需要在生产代码中添加额外的逻辑,以便访问代码的内部状态进行测试配置或结果验证。生产代码中的测试逻辑是一种问题,尽管它不会破坏被测试的代码,但会增加代码的复杂性,这可能会在配置错误的情况下造成严重的维护问题或系统故障。
在以下条件下,将测试逻辑插入到生产代码中:
-
在测试期间添加条件逻辑以返回硬编码的值。被测试的代码作为动态存根,如下例所示:
public final class EncounterManager { public boolean isHack = false; public boolean save(Map data) { if(isHack) { return true; } Encounter enc = new EncounterServiceImpl().checkIn(buildCheckinRqst(data)); return enc != null; } }EncounterManager不能被覆盖,因为该类被声明为final;因此,您不能创建该类的模拟或伪造对象。如果您的测试代码需要存根save()行为,那么您需要绕过在EncounterServiceImpl方法中进行的数据库调用,以将签到数据持久化到数据库中。因此,save()方法有一个isHack条件逻辑。这个布尔变量是为了测试目的而添加的。从测试中,布尔变量isHack被设置为true。如果意外地将此变量设置为true,则生产中不会创建遭遇。 -
仅编写用于测试执行的额外代码,或者将私有变量公开。以下是一个示例:
public final class EncounterManager { private List<Encounter> retrieveEncounters() { if (encounters == null) { Patient patient = new Patient(); patient.setPatientId(patientId); new EncounterServiceImpl().retreiveBy(patient); } return encounters; } public List<Encounter> encounters; public void setEncounters(List<Encounter> encounters) { this.encounters = encounters; } }retrieveEncounters()方法是一个私有方法,用于encounters List的延迟实例化。然而,出于测试目的,encounters List被公开暴露,并使用了一个公开的 setter 方法。从测试中,要么使用硬编码的List调用 setter 方法,要么直接设置encounters List。如果意外地将encounters List设置在生产环境中,用户将在 UI 中看到错误的数据。 -
Mockito 不允许模拟
equals()和hashcode()方法,因为除非逻辑可理解,否则不应重写它们。然而,对于测试,我们经常重写equals()和hashcode()方法并执行测试逻辑或返回硬编码的值。这是非常危险的。在生产环境中,如果我们需要将对象放入集合或需要执行相等性检查,那么系统将以奇怪的方式运行。以下代码片段重写了hashcode()和equals()方法:@Override public int hashCode() { return isHack ? HACKED_NUMBER : 0; } @Override public boolean equals(Object obj) { if (obj instanceof EncounterManager) { return isHack && ((EncounterManager) obj).isHack; } return false; }
在生产代码中,equals() 方法返回 false,hashcode() 返回 0。EncounterManager 类不能与 Java 集合框架一起使用。
为了重构生产代码,移除 final 关键字,在测试上下文中覆盖类,并返回硬编码的值。然而,永远不要在测试中触摸 equals() 和 hashcode() 方法。
对过度设计的测试进行重构
测试是系统文档。它们应该告诉读者正在执行什么。通常,我们添加过多的文档,使得读者更难理解意图。有时,我们重构测试并提取干净、有意义的函数,将变量传递给提取的函数,并在测试中仅调用这些函数。现在读者无法理解测试用例的效用,所有操作都在其他地方进行。
以下测试示例展示了具有较少或没有信息的 Test:
@Test
public void checks_in_patient() throws Exception {
createCheckInRequestForAPatientWithAGuarantor();
checkInaPatient();
assertResult();
}
单元测试调用了三个方法:createCheckInRequestForAPatientWithAGuarantor、checkInaPatient 和 assertResult。从测试体中,无法理解正在测试的内容、创建了什么数据以及断言了什么。测试应该配置数据、调用实际方法并断言结果。
以下是一个具有过度详细文档的测试示例:
public void checks_in_patient() throws Exception {
CheckInRequest request = new CheckInRequest();
request.setCheckInDate(new Date());
request.setDisease("Vomiting");
request.setDoctor("Dr. Mike Hussey");
String country = "US";
String johnsStreetAddress = "123 Frazer";
String johnsState = "PA";
String johnsZipCode = "19355";
Address johnsAddressMavernPhilly = buildAddress(johnsStreetAddress, country, johnsState, johnsZipCode, AddressType.Residential);
String johnsEmailId = "johnYou12345@gmail.com";
String johnsFirstName = "John";
String familyName = "Peterson";
Person johnPeterson = buildPerson(johnsAddressMavernPhilly, familyName,johnsEmailId, johnsFirstName);
request.setPerson(johnPeterson);
Guarantor daddy = new Guarantor();
daddy.setGuarantorType(GuarantorType.Person);
String dadsStreetAddress = "12/e xyz Avenue";
String dadsState = "NY";
String dadsZipCode = "49355";
Address dadsAddressNYBayArea =buildAddress(dadsStreetAddress, country, dadsState,dadsZipCode, AddressType.Residential);
String dadsEmail = "dontDisturb@my.org";
String dadsFirstName = "Freddy";
Person johnsDad = buildPerson(dadsAddressNYBayArea, familyName, dadsEmail, dadsFirstName);
daddy.setPerson(johnsDad);
request.setGuarantor(daddy);
}
测试构建了两个 Person 对象和两个 Address 对象。提取了两个构建器方法以实现代码重用。为了更好的文档,创建了变量并设置了硬编码的值,然后将这些值传递给构建器方法。这些硬编码的变量使得理解正在发生的事情变得困难。
而不是在测试类中创建自定义构建器方法,您可以修改主数据类以遵循构建器模式,并在多个步骤中构建对象。这样,我们就不必创建如 johnsStreetAddress 这样的硬编码变量,我们可以直接调用所需的函数。
Person 类已被修改;setter 方法返回 this 实例,如下所示:
public Person setFirstName(String firstName) {
this.firstName = firstName;
return this;
}
public Person setLastName(String lastName) {
this.lastName = lastName;
return this;
}
从测试中,我们可以轻松构建对象。以下测试示例只需要一个电子邮件 ID、姓名和电话号码进行测试,因此不应填充其他值。
我们可以分三步构建对象,并且不再需要硬编码的字符串来记录行为:
Person mark = new Person().setEmail("mark@gmail.com").setFirstName("Mark").setPhoneNumber1("444-999-0090");
摘要
本章涵盖了 JUnit 的最佳实践并解释了其背后的原理。最佳实践包括编写有意义的测试、自动化单元测试、测试配置、使用断言、测试用例中的异常处理、识别测试异味以及重构测试异味。
现在,你将能够编写干净且易于维护的测试用例。




































































浙公网安备 33010602011771号