使用 JUnit 进行健壮单元和集成测试的指南

已发表: 2022-03-11

自动化软件测试对于软件项目的长期质量、可维护性和可扩展性至关重要,对于 Java,JUnit 是实现自动化的途径。

虽然本文的大部分内容将侧重于编写健壮的单元测试和利用存根、模拟和依赖注入,但我们还将讨论 JUnit 和集成测试。

JUnit 测试框架是一个通用的、免费的、开源的工具,用于测试基于 Java 的项目。

在撰写本文时,JUnit 4 是当前的主要版本,已在 10 多年前发布,最近一次更新是在两年多前。

JUnit 5(带有 Jupiter 编程和扩展模型)正在积极开发中。 它更好地支持 Java 8 中引入的语言特性,并包含其他新的、有趣的特性。 一些团队可能会发现 JUnit 5 已准备好使用,而其他团队可能会继续使用 JUnit 4 直到 5 正式发布。 我们将看看两者的例子。

运行 JUnit

JUnit 测试可以直接在 IntelliJ 中运行,但也可以在 Eclipse、NetBeans 甚至命令行等其他 IDE 中运行。

测试应始终在构建时运行,尤其是单元测试。 任何测试失败的构建都应该被认为是失败的,无论问题是在生产中还是在测试代码中——这需要团队的纪律和对解决失败测试给予最高优先级的意愿,但有必要遵守自动化的精神。

JUnit 测试也可以由 Jenkins 等持续集成系统运行和报告。 使用 Gradle、Maven 或 Ant 等工具的项目具有额外的优势,即能够在构建过程中运行测试。

表示兼容性的齿轮组:JUnit 4 与 NetBeans 合为一体,JUnit 5 与 Eclipse 和 Gradle 合为一体,最后一个 JUnit 5 与 Maven 和 IntelliJ IDEA 合为一体。

摇篮

作为 JUnit 5 的示例 Gradle 项目,请参阅 JUnit 用户指南的 Gradle 部分和 junit5-samples.git 存储库。 请注意,它还可以运行使用 JUnit 4 API(称为“vintage” )的测试。

可以通过菜单选项 File > Open... 在 IntelliJ 中创建项目 > 导航到junit-gradle-consumer sub-directory > OK > Open as Project > OK 以从 Gradle 导入项目。

对于 Eclipse,可以从 Help > Eclipse Marketplace... 安装 Buildship Gradle 插件。然后可以使用 File > Import... > Gradle > Gradle Project > Next > Next > 浏览到junit-gradle-consumer子目录 > Next 导入项目> 下一步 > 完成。

在 IntelliJ 或 Eclipse 中设置 Gradle 项目后,运行 Gradle build任务将包括使用test任务运行所有 JUnit 测试。 请注意,如果未对代码进行任何更改,则后续执行build时可能会跳过测试。

对于 JUnit 4,请参阅 JUnit 与 Gradle wiki 的使用。

马文

对于 JUnit 5,请参阅用户指南的 Maven 部分和 junit5-samples.git 存储库以获取 Maven 项目的示例。 这也可以运行老式测试(使用 JUnit 4 API 的测试)。

在 IntelliJ 中,使用 File > Open... > 导航到junit-maven-consumer/pom.xml > OK > Open as Project。 然后可以从 Maven Projects > junit5-maven-consumer > Lifecycle > Test 运行测试。

在 Eclipse 中,使用 File > Import... > Maven > Existing Maven Projects > Next > 浏览到junit-maven-consumer目录 > 选择pom.xml > Finish。

可以通过将项目作为 Maven 构建运行来执行测试... > 指定test目标 > 运行。

对于 JUnit 4,请参阅 Maven 存储库中的 JUnit。

开发环境

除了通过 Gradle 或 Maven 等构建工具运行测试外,许多 IDE 还可以直接运行 JUnit 测试。

IntelliJ IDEA

JUnit 5 测试需要 IntelliJ IDEA 2016.2 或更高版本,而 JUnit 4 测试应该在较旧的 IntelliJ 版本中工作。

出于本文的目的,您可能希望从我的一个 GitHub 存储库(JUnit5IntelliJ.git 或 JUnit4IntelliJ.git)在 IntelliJ 中创建一个新项目,其中包括简单Person类示例中的所有文件并使用内置JUnit 库。 可以使用 Run > Run 'All Tests' 运行测试。 该测试也可以从PersonTest类在 IntelliJ 中运行。

这些存储库是使用新的 IntelliJ Java 项目创建的,并构建了目录结构src/main/java/com/examplesrc/test/java/com/examplesrc/main/java目录被指定为源文件夹,而src/test/java被指定为测试源文件夹。 在使用带有@Test注释的测试方法创建PersonTest类后,它可能无法编译,在这种情况下,IntelliJ 建议将 JUnit 4 或 JUnit 5 添加到可以从 IntelliJ IDEA 分发版加载的类路径中(请参阅这些有关更多详细信息的 Stack Overflow 上的答案)。 最后,为所有测试添加了一个 JUnit 运行配置。

另请参阅 IntelliJ 测试方法指南。

Eclipse 中的空 Java 项目将没有测试根目录。 这已从项目属性 > Java 构建路径 > 添加文件夹... > 创建新文件夹... > 指定文件夹名称 > 完成添加。 新目录将被选为源文件夹。 在剩余的两个对话框中单击“确定”。

可以使用 File > New > JUnit Test Case 创建 JUnit 4 测试。 选择“New JUnit 4 test”和新创建的测试源文件夹。 指定“被测类”和“包”,确保包与被测类匹配。 然后,指定测试类的名称。 完成向导后,如果出现提示,请选择“将 JUnit 4 库”添加到构建路径。 然后可以将项目或单个测试类作为 JUnit 测试运行。 另请参阅 Eclipse 编写和运行 JUnit 测试。

NetBeans

NetBeans 仅支持 JUnit 4 测试。 可以使用 File > New File... > Unit Tests > JUnit Test 或 Test for Existing Class 在 NetBeans Java 项目中创建测试类。 默认情况下,测试根目录在项目目录中命名为test

简单生产类及其 JUnit 测试用例

让我们看一个非常简单的Person类的生产代码及其对应的单元测试代码的简单示例。 您可以从我的 github 项目中下载示例代码并通过 IntelliJ 打开它。

src/main/java/com/example/Person.java
 package com.example; class Person { private final String givenName; private final String surname; Person(String givenName, String surname) { this.givenName = givenName; this.surname = surname; } String getDisplayName() { return surname + ", " + givenName; } }

不可变的Person类有一个构造函数和一个getDisplayName()方法。 我们想测试getDisplayName()返回的名称是否符合我们的预期。 这是单个单元测试(JUnit 5)的测试代码:

src/test/java/com/example/PersonTest.java
 package com.example; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PersonTest { @Test void testGetDisplayName() { Person person = new Person("Josh", "Hayden"); String displayName = person.getDisplayName(); assertEquals("Hayden, Josh", displayName); } }

PersonTest使用 JUnit 5 的@Test和断言。 对于 JUnit 4, PersonTest类和方法需要是公共的,并且应该使用不同的导入。 这是 JUnit 4 示例要点。

在 IntelliJ 中运行PersonTest类后,测试通过并且 UI 指示器为绿色。

常见的 JUnit 约定

命名

虽然不是必需的,但我们在命名测试类时使用通用约定; 具体来说,我们从正在测试的类的名称( Person )开始,并在其上附加“Test”( PersonTest )。 命名测试方法是相似的,从被测试的方法开始( getDisplayName() )并在它前面加上“test”( testGetDisplayName() )。 虽然命名测试方法还有许多其他完全可以接受的约定,但在团队和项目中保持一致很重要。

生产名称测试中的名称
人员测试
getDisplayName() testDisplayName()

套餐

我们还采用在与生产代码的Person类相同的包 ( com.example ) 中创建测试代码PersonTest类的约定。 如果我们使用不同的包进行测试,我们将被要求在生产代码类、构造函数和单元测试引用的方法中使用 public 访问修饰符,即使它不合适,所以最好将它们放在同一个包中. 但是,我们确实使用单独的源目录( src/main/javasrc/test/java ),因为我们通常不希望在发布的生产版本中包含测试代码。

结构和注释

@Test注解 (JUnit 4/5) 告诉 JUnit 执行testGetDisplayName()方法作为测试方法并报告它是通过还是失败。 只要所有断言(如果有的话)都通过并且没有抛出异常,则认为测试通过。

我们的测试代码遵循 Arrange-Act-Assert (AAA) 的结构模式。 其他常见模式包括 Given-When-Then 和 Setup-Exercise-Verify-Teardown(单元测试通常不明确需要 Teardown),但我们在本文中使用 AAA。

让我们看看我们的测试示例是如何遵循 AAA 的。 第一行,“arrange”创建了一个将被测试的Person对象:

 Person person = new Person("Josh", "Hayden");

第二行,“act”,练习生产代码的Person.getDisplayName()方法:

 String displayName = person.getDisplayName();

第三行,“assert”,验证结果是否符合预期。

 assertEquals("Hayden, Josh", displayName);

在内部, assertEquals()调用使用“Hayden, Josh” String 对象的 equals 方法来验证从生产代码 ( displayName ) 返回的实际值是否匹配。 如果不匹配,则测试将被标记为失败。

请注意,对于这些 AAA 阶段中的每一个,测试通常有不止一条线。

单元测试和生产代码

现在我们已经介绍了一些测试约定,让我们将注意力转向使生产代码可测试。

我们回到我们的Person类,在那里我实现了一个方法来根据他或她的出生日期返回一个人的年龄。 代码示例需要 Java 8 才能利用新的日期和功能 API。 下面是新的Person.java类的样子:

人.java
 // ... class Person { // ... private final LocalDate dateOfBirth; Person(String givenName, String surname, LocalDate dateOfBirth) { // ... this.dateOfBirth = dateOfBirth; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, LocalDate.now()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }

运行这个课程(在撰写本文时)宣布乔伊 4 岁。 让我们添加一个测试方法:

PersonTest.java
 // ... class PersonTest { // ... @Test void testGetAge() { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); long age = person.getAge(); assertEquals(4, age); } }

它今天过去了,但是当我们从现在开始一年后运行它时呢? 该测试具有不确定性和脆弱性,因为预期结果取决于运行测试的系统的当前日期。

存根和注入价值提供者

在生产环境中运行时,我们希望使用当前日期LocalDate.now()来计算人员的年龄,但即使在一年后进行确定性测试,测试也需要提供自己的currentDate值。

这称为依赖注入。 我们不希望我们的Person对象确定当前日期本身,而是希望将此逻辑作为依赖项传入。 单元测试将使用一个已知的存根值,而生产代码将允许系统在运行时提供实际值。

让我们向Person.java添加一个LocalDate供应商:

人.java
 // ... class Person { // ... private final LocalDate dateOfBirth; private final Supplier<LocalDate> currentDateSupplier; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier) { // ... this.dateOfBirth = dateOfBirth; this.currentDateSupplier = currentDateSupplier; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, currentDateSupplier.get()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }

为了更容易测试getAge()方法,我们将其更改为使用LocalDate供应商currentDateSupplier来检索当前日期。 如果您不知道供应商是什么,我建议您阅读有关 Lambda 内置功能接口的内容。

我们还添加了依赖注入:新的测试构造函数允许测试提供自己的当前日期值。 原来的构造函数调用这个新的构造函数,传递一个LocalDate::now的静态方法引用,它提供了一个LocalDate对象,所以我们的 main 方法仍然像以前一样工作。 我们的测试方法呢? 让我们更新PersonTest.java

PersonTest.java
 // ... class PersonTest { // ... @Test void testGetAge() { LocalDate dateOfBirth = LocalDate.parse("2013-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-17"); Person person = new Person("Joey", "Doe", dateOfBirth, ()->currentDate); long age = person.getAge(); assertEquals(4, age); } }

测试现在注入了它自己的currentDate值,所以我们的测试在明年或任何一年运行时仍然会通过。 这通常称为存根,或提供要返回的已知值,但我们首先必须更改Person以允许注入此依赖项。

构造Person对象时,请注意 lambda 语法 ( ()->currentDate )。 根据新构造函数的要求,这被视为LocalDate的提供者。

模拟和存根 Web 服务

我们已经准备好让我们的Person对象(其整个存在都在 JVM 内存中)与外部世界进行通信。 我们要添加两个方法: publishAge()方法,它将发布此人的当前年龄,以及getThoseInCommon()方法,它将返回与我们的Person生日相同或年龄相同的名人的姓名。 假设有一个我们可以与之交互的 RESTful 服务,称为“人物生日”。 我们有一个 Java 客户端,它由单个类BirthdaysClient组成。

com.example.birthdays.BirthdaysClient
 package com.example.birthdays; import java.io.IOException; import java.util.Arrays; import java.util.Collection; public class BirthdaysClient { public void publishRegularPersonAge(String name, long age) throws IOException { System.out.println("publishing " + name + "'s age: " + age); // HTTP POST with name and age and possibly throw an exception } public Collection<String> findFamousNamesOfAge(long age) throws IOException { System.out.println("finding famous names of age " + age); return Arrays.asList(/* HTTP GET with age and possibly throw an exception */); } public Collection<String> findFamousNamesBornOn(int month, int dayOfMonth) throws IOException { System.out.println("finding famous names born on day " + dayOfMonth + " of month " + month); return Arrays.asList(/* HTTP GET with month and day and possibly throw an exception */); } }

让我们增强我们的Person类。 我们首先为publishAge()的期望行为添加一个新的测试方法。 为什么要从测试而不是功能开始? 我们遵循测试驱动开发(也称为 TDD)的原则,其中我们首先编写测试,然后编写代码以使其通过。

PersonTest.java
 // … class PersonTest { // … @Test void testPublishAge() { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate); person.publishAge(); } }

此时,测试代码无法编译,因为我们还没有创建它正在调用的publishAge()方法。 一旦我们创建了一个空的Person.publishAge()方法,一切都会过去。 我们现在准备好进行测试,以验证此人的年龄是否实际发布到了BirthdaysClient

添加模拟对象

由于这是一个单元测试,它应该在内存中快速运行,因此测试将使用模拟的BirthdaysClient构造我们的Person对象,因此它实际上不会发出 Web 请求。 然后测试将使用这个模拟对象来验证它是否按预期调用。 为此,我们将添加对 Mockito 框架(MIT 许可)的依赖项以创建模拟对象,然后创建一个模拟的BirthdaysClient对象:

PersonTest.java
 // ... import com.example.birthdays.BirthdaysClient; // ... import static org.mockito.Mockito.mock; class PersonTest { private BirthdaysClient birthdaysClient = mock(BirthdaysClient.class); // ... @Test void testPublishAge() { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); // ... } }

我们进一步扩充了Person构造函数的签名以获取一个BirthdaysClient对象,并更改了测试以注入模拟的BirthdaysClient对象。

添加模拟期望

接下来,我们在testPublishAge的末尾添加一个期望调用BirthdaysClient的内容。 Person.publishAge()应该调用它,如我们的新PersonTest.java所示:

PersonTest.java
 // ... class PersonTest { // ... @Test void testPublishAge() throws IOException { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); verifyZeroInteractions(birthdaysClient); person.publishAge(); verify(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); } }

我们的 Mockito 增强的BirthdaysClient会跟踪对其方法进行的所有调用,这就是我们在调用publishAge()之前使用verifyZeroInteractions()方法验证没有对BirthdaysClient进行的调用的方式。 尽管可以说没有必要,但通过这样做,我们可以确保构造函数不会进行任何恶意调用。 在verify()行中,我们指定了期望对BirthdaysClient的调用的外观。

请注意,因为 publishRegularPersonAge 在其签名中具有 IOException,所以我们也将其添加到我们的测试方法签名中。

此时,测试失败:

 Wanted but not invoked: birthdaysClient.publishRegularPersonAge( "Joe Sixteen", 16L ); -> at com.example.PersonTest.testPublishAge(PersonTest.java:40)

这是意料之中的,因为我们尚未实现对Person.java的所需更改,因为我们正在关注测试驱动的开发。 现在,我们将通过进行必要的更改来通过此测试:

人.java
 // ... class Person { // ... private final BirthdaysClient birthdaysClient; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now, new BirthdaysClient()); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier, BirthdaysClient birthdaysClient) { // ... this.birthdaysClient = birthdaysClient; } // ... void publishAge() { String nameToPublish = givenName + " " + surname; long age = getAge(); try { birthdaysClient.publishRegularPersonAge(nameToPublish, age); } catch (IOException e) { // TODO handle this! e.printStackTrace(); } } }

测试异常

我们让生产代码构造函数实例化了一个新的BirthdaysClient ,并且publishAge()现在调用了birthdaysClient 。 所有测试通过; 一切都是绿色的。 伟大的! 但请注意, publishAge()正在吞噬 IOException。 我们不想让它冒泡,而是用我们自己的 PersonException 将它包装在一个名为PersonException.java的新文件中:

PersonException.java
 package com.example; public class PersonException extends Exception { public PersonException(String message, Throwable cause) { super(message, cause); } }

我们将此场景实现为PersonTest.java中的新测试方法:

PersonTest.java
 // ... class PersonTest { // ... @Test void testPublishAge_IOException() throws IOException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); IOException ioException = new IOException(); doThrow(ioException).when(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); try { person.publishAge(); fail("expected exception not thrown"); } catch (PersonException e) { assertSame(ioException, e.getCause()); assertEquals("Failed to publish Joe Sixteen age 16", e.getMessage()); } } }

当调用publishRegularPersonAge()方法时,Mockito doThrow()调用birthdaysClient以引发异常。 如果没有抛出PersonException ,我们将无法通过测试。 否则,我们断言异常与 IOException 正确链接,并验证异常消息是否符合预期。 现在,因为我们没有在生产代码中实现任何处理,所以我们的测试失败了,因为没有抛出预期的异常。 以下是我们需要在Person.java中更改以使测试通过的内容:

人.java
 // ... class Person { // ... void publishAge() throws PersonException { // ... try { // ... } catch (IOException e) { throw new PersonException("Failed to publish " + nameToPublish + " age " + age, e); } } }

存根:时间和断言

我们现在实现Person.getThoseInCommon()方法,使我们的Person.Java类看起来像这样。

我们的testGetThoseInCommon()testPublishAge()不同,它不会验证是否对birthdaysClient方法进行了特定调用。 相反,它when调用存根返回值时使用 getThoseInCommon( getThoseInCommon()需要调用的findFamousNamesOfAge()findFamousNamesBornOn() 。 然后我们断言我们提供的所有三个存根名称都被返回。

使用assertAll() JUnit 5 方法包装多个断言允许将所有断言作为一个整体进行检查,而不是在第一个失败的断言之后停止。 我们还包含一条带有assertTrue()的消息,以识别未包含的特定名称。 下面是我们的“快乐路径”(理想场景)测试方法的样子(注意,这不是一组具有“快乐路径”性质的稳健测试,但我们稍后会讨论原因。

PersonTest.java
 // ... class PersonTest { // ... @Test void testGetThoseInCommon() throws IOException, PersonException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); when(birthdaysClient.findFamousNamesOfAge(16)).thenReturn(Arrays.asList("JoeFamous Sixteen", "Another Person")); when(birthdaysClient.findFamousNamesBornOn(1, 2)).thenReturn(Arrays.asList("Jan TwoKnown")); Set<String> thoseInCommon = person.getThoseInCommon(); assertAll( setContains(thoseInCommon, "Another Person"), setContains(thoseInCommon, "Jan TwoKnown"), setContains(thoseInCommon, "JoeFamous Sixteen"), ()-> assertEquals(3, thoseInCommon.size()) ); } private <T> Executable setContains(Set<T> set, T expected) { return () -> assertTrue(set.contains(expected), "Should contain " + expected); } // ... }

保持测试代码干净

尽管经常被忽视,但同样重要的是要避免测试代码出现恶化的重复。 干净的代码和“不要重复自己”之类的原则对于维护高质量的代码库、生产和测试代码都非常重要。 请注意,最近的 PersonTest.java 有一些重复,因为我们有几个测试方法。

为了解决这个问题,我们可以做一些事情:

  • 将 IOException 对象提取到私有 final 字段中。

  • Person对象的创建提取到它自己的方法(在本例中为createJoeSixteenJan2() ,因为大多数 Person 对象都是使用相同的参数创建的。

  • 为验证抛出的PersonExceptions的各种测试创建一个assertCauseAndMessage()

干净的代码结果可以在 PersonTest.java 文件的这个再现中看到。

测试不仅仅是快乐的道路

Person对象的出生日期晚于当前日期时,我们应该怎么做? 应用程序中的缺陷通常是由于意外输入或对角落、边缘或边界情况缺乏远见。 尽可能地预测这些情况是很重要的,而单元测试通常是这样做的合适地方。 在构建我们的PersonPersonTest时,我们包含了一些针对预期异常的测试,但这绝不是完整的。 例如,我们使用不表示或存储时区数据的LocalDate 。 但是,我们对LocalDate.now()的调用会根据系统的默认时区返回一个LocalDate ,该时区可能比系统用户的时区早一天或晚一天。 在实施适当的测试和行为时,应考虑这些因素。

边界也应该被测试。 考虑一个带有getDaysUntilBirthday()方法的Person对象。 测试应包括该人的生日是否在当年已经过去,该人的生日是否是今天,以及闰年如何影响天数。 这些场景可以通过检查人的生日前一天、生日当天和生日后一天来覆盖,其中下一年是闰年。 下面是相关的测试代码:

PersonTest.java
 // ... class PersonTest { private final Supplier<LocalDate> currentDateSupplier = ()-> LocalDate.parse("2015-05-02"); private final LocalDate ageJustOver5 = LocalDate.parse("2010-05-01"); private final LocalDate ageExactly5 = LocalDate.parse("2010-05-02"); private final LocalDate ageAlmost5 = LocalDate.parse("2010-05-03"); // ... @Test void testGetDaysUntilBirthday() { assertAll( createPersonAndAssertValue(ageAlmost5, 1, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageExactly5, 0, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageJustOver5, 365, Person::getDaysUntilBirthday) ); } private Executable createPersonAndAssertValue(LocalDate dateOfBirth, long expectedValue, Function<Person, Long> personLongFunction) { Person person = new Person("Given", "Sur", dateOfBirth, currentDateSupplier); long actualValue = personLongFunction.apply(person); return () -> assertEquals(expectedValue, actualValue); } }

集成测试

我们主要关注单元测试,但 JUnit 也可用于集成、验收、功能和系统测试。 这样的测试通常需要更多的设置代码,例如,启动服务器、使用已知数据加载数据库等。虽然我们通常可以在几秒钟内运行数千个单元测试,但大型集成测试套件可能需要几分钟甚至几小时才能运行。 通常不应使用集成测试来尝试覆盖代码中的每个排列或路径; 单元测试更适合于此。

为驱动 Web 浏览器填写表单、单击按钮、等待内容加载等的 Web 应用程序创建测试通常使用 Selenium WebDriver(Apache 2.0 许可)和“页面对象模式”(参见 SeleniumHQ github wiki和 Martin Fowler 关于页面对象的文章)。

JUnit 可以有效地使用 HTTP 客户端(如 Apache HTTP 客户端或 Spring Rest 模板)测试 RESTful API(HowToDoInJava.com 提供了一个很好的示例)。

在我们使用Person对象的情况下,集成测试可能涉及使用真实的BirthdaysClient而不是模拟的,其配置指定了 People Birthdays 服务的基本 URL。 然后,集成测试将使用此类服务​​的测试实例,验证生日是否已发布给它,并在将返回的服务中创建名人。

其他 JUnit 功能

JUnit 有许多我们尚未在示例中探索的附加特性。 我们将描述一些并为其他人提供参考。

测试夹具

应该注意的是,JUnit 为运行每个@Test方法创建了一个新的测试类实例。 JUnit 还提供了注解挂钩来在所有或每个@Test方法之前或之后运行特定方法。 这些钩子通常用于设置或清理数据库或模拟对象,并且在 JUnit 4 和 5 之间有所不同。

JUnit 4 JUnit 5 对于静态方法?
@BeforeClass @BeforeAll 是的
@AfterClass @AfterAll 是的
@Before @BeforeEach
@After @AfterEach

在我们的PersonTest示例中,我们选择在@Test方法本身中配置BirthdaysClient模拟对象,但有时需要构建涉及多个对象的更复杂的模拟结构。 @BeforeEach (在 JUnit 5 中)和@Before (在 JUnit 4 中)通常适用于此。

@After*注释在集成测试中比单元测试更常见,因为 JVM 垃圾收集处理为单元测试创​​建的大多数对象。 @BeforeClass@BeforeAll注释最常用于需要执行一次昂贵的设置和拆卸操作的集成测试,而不是针对每个测试方法。

对于 JUnit 4,请参阅测试夹具指南(一般概念仍然适用于 JUnit 5)。

测试套件

有时你想运行多个相关的测试,但不是所有的测试。 在这种情况下,可以将测试分组组成测试套件。 有关如何在 JUnit 5 中执行此操作,请查看 HowToProgram.xyz 的 JUnit 5 文章以及 JUnit 团队的 JUnit 4 文档。

JUnit 5 的 @Nested 和 @DisplayName

JUnit 5 添加了使用非静态嵌套内部类的功能,以更好地显示测试之间的关系。 这对于那些在 Jasmine for JavaScript 等测试框架中使用过嵌套描述的人来说应该非常熟悉。 内部类使用@Nested注释以使用它。

@DisplayName注释也是 JUnit 5 的新增功能,允许您以字符串格式描述测试以报告,除了测试方法标识符之外,还可以显示。

尽管@Nested@DisplayName可以相互独立使用,但它们一起可以提供描述系统行为的更清晰的测试结果。

Hamcrest 火柴人

Hamcrest 框架虽然本身不​​是 JUnit 代码库的一部分,但它提供了在测试中使用传统断言方法的替代方案,允许更具表现力和可读性的测试代码。 请参阅以下使用传统 assertEquals 和 Hamcrest assertThat 的验证:

 //Traditional assert assertEquals("Hayden, Josh", displayName); //Hamcrest assert assertThat(displayName, equalTo("Hayden, Josh"));

Hamcrest 可以与 JUnit 4 和 5 一起使用。Vogella.com 关于 Hamcrest 的教程非常全面。

其他资源

  • 单元测试、如何编写可测试代码及其重要性一文涵盖了编写干净、可测试代码的更具体示例。

  • Build with Confidence: A Guide to JUnit Tests 检查了单元和集成测试的不同方法,以及为什么最好选择一种并坚持下去

  • JUnit 4 Wiki 和 JUnit 5 用户指南始终是一个很好的参考点。

  • Mockito 文档提供了有关附加功能和示例的信息。

JUnit 是通往自动化的道路

我们已经探索了 Java 世界中使用 JUnit 进行测试的许多方面。 我们已经研究了使用 Java 代码库的 JUnit 框架进行单元和集成测试,在开发和构建环境中集成 JUnit,如何在供应商和 Mockito 中使用模拟和存根,常见约定和最佳代码实践,测试什么,以及一些其他出色的 JUnit 功能。

现在轮到读者熟练地应用、维护和收获使用 JUnit 框架的自动化测试的好处了。