跳转至

Android 测试框架 —— JUnit5

Junit 5 的官网文档:https://junit.org/junit5/ 或者下载 PDF 文档。

介绍

与之前的 JUnit 4 不同,JUnit 5 由三个不同的子项目的多个模块组成。

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

JUnit Platform 作为在 JVM 上启动测试框架的基础,定义了 TestEngine API,用于开发可在该平台上运行的测试框架。 此外,平台还提供了一个控制台启动器,可以通过命令行启动平台,并提供了 JUnit 平台套件引擎, 用于在平台上使用一个或多个测试引擎运行自定义测试套件。 JUnit Platform 在流行的集成开发环境(如 IntelliJ IDEA、Eclipse、NetBeans 和 Visual Studio Code)以及构建工具(如 Gradle、Maven 和 Ant)中也得到了支持。

JUnit Jupiter 是编写 JUnit 5 测试和扩展的编程模型与扩展模型的结合。Jupiter 用于在平台上运行基于 Jupiter 的测试。 JUnit Vintage 用于在平台上运行基于 JUnit 3 和 JUnit 4 的测试,但是要求引用依赖上必须包含 JUnit 4.12 或更高版本。

  • JUnit 平台 (JUnit Platform):这是 JUnit 5 的基础,负责启动测试并提供支持其他测试引擎(如 Jupiter 和 Vintage)的框架。JUnit 平台还提供了一个控制台启动器,可以通过命令行启动平台,并提供了 JUnit 平台套件引擎,用于在平台上使用一个或多个测试引擎运行自定义测试套件;
  • JUnit Jupiter:这是 JUnit 5 的编程模型和扩展模型,是编写和执行 JUnit 5 测试的核心;
  • JUnit Vintage:这个模块允许 JUnit 4 和 JUnit 3 的测试在 JUnit 5 平台上运行,因此 JUnit 4 的测试可以无缝迁移到 JUnit 5 环境中。

三者的关系可以总结如下:

JUnit 5 的模块关系
JUnit 5 的模块关系

Note

开发者可以按自己的节奏逐步迁移,JUnit 团队仍然会继续为 JUnit 4 提供维护和修复。

相比与 JUnit 4 的变化

很多 JUnit 4 的特性在 JUnit 5 上已经不再支持,但是可以通过 JUnit Vintage 框架提供支持。由于所有特定于 JUnit Jupiter 的类和注解都位于 org.junit.jupiter 基础包中,因此将 JUnit 4 和 JUnit Jupiter 同时放在类路径中不会导致任何冲突。因此,可以安全地将现有的 JUnit 4 测试与 JUnit Jupiter 测试共存。在迁移 JUnit 4 到 JUnit 5 过程中以下这些应该注意:

  1. 注解位于 org.junit.jupiter.api 包中。
  2. 断言方法位于 org.junit.jupiter.api.Assertions。 注意,你仍然可以继续使用 org.junit.Assert 中的断言方法,或者使用其他断言库,如 AssertJ、Hamcrest、Truth 等。
  3. 假设方法位于 org.junit.jupiter.api.Assumptions。 注意,JUnit Jupiter 5.4 及后续版本支持 JUnit 4 中 org.junit.Assume 类的方法进行假设操作。具体来说,JUnit Jupiter 支持 JUnit 4 中的 AssumptionViolatedException,用来表示测试应被中止,而不是标记为失败。
  4. @Before@After 不再存在;请使用 @BeforeEach@AfterEach
  5. @BeforeClass@AfterClass 不再存在;请使用 @BeforeAll@AfterAll
  6. @Ignore 不再存在;请使用 @Disabled 或其他内置的执行条件。
  7. @Category 不再存在;请使用 @Tag
  8. @RunWith不再存在;已被 @ExtendWith 取代。
  9. @Rule@ClassRule 不再存在;已被 @ExtendWith@RegisterExtension 取代。
  10. @Test(expected = …)ExpectedException 规则不再存在;请改用 Assertions.assertThrows(…)
  11. JUnit Jupiter 中的断言和假设方法将失败消息作为最后一个参数,而不是第一个参数。

下载和安装

可以从 JUnit 5 的 MVN Repository 仓库获取到所有版本的 Maven、Gradle 依赖。

Note

JUnit 5 需要 Java 8 或者更高的版本。

Maven

<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>

Gradle

Gradle
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.11.4'
Gradle(Short)
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.4'
Gradle(Kotlin)
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4")

Hello World

在项目中添加 JUnit 5 的相关依赖后,开发一个 Hello World 体验一下 JUnit 5 的魅力吧:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("Hello World Test")
public class HelloWorldTest {
    @Test
    public void test() {
        System.out.println("Hello World");
    }
}

通过 @DisplayName 修改用例的显示名字,点击运行按钮:

Hello World

基本概念

概念 描述
测试类 任何包含至少一个测试方法的顶级类、静态成员类或 @Nested 类,即容器。测试类不能是抽象类,并且必须有一个构造函数。Java 记录类也被支持。
测试方法 任何实例方法,直接使用 @Test@RepeatedTest@ParameterizedTest@TestFactory@TestTemplate 注解或元注解。除了 @Test 外,这些注解会在测试树中创建一个容器,用于组织测试,或者(对于 @TestFactory)可能创建其他容器。
容器 测试树中的一个节点,包含其他容器或测试作为其子节点(例如,测试类)。
测试 测试树中的一个节点,当执行时验证预期行为(例如,一个 @Test 方法)。
生命周期方法 任何直接使用 @BeforeAll@AfterAll@BeforeEach@AfterEach 注解或元注解的方法。

Warning

测试方法和生命周期方法可以在当前测试类中局部声明,也可以从超类或接口继承(参见测试接口和默认方法)。 此外,测试方法和生命周期方法不得是抽象的,并且不得返回值(除了 @TestFactory 方法需要返回值)。

Warning

测试类、测试方法和生命周期方法不要求是 public,但不能是 private。 一般情况下,除非出于技术原因,建议省略测试类、测试方法和生命周期方法的 public 修饰符。 例如,当测试类被另一个包中的测试类继承时,可能需要使用 public。另一个将类和方法设为 public 的技术原因是,在使用 Java 模块系统时,简化模块路径上的测试。

对于一个标准的 JUnit 5 的测试类来说,可以参考如下的写法:

import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class StandardTests {

    @BeforeAll
    static void initAll() {
    }

    @BeforeEach
    void init() {
    }

    @Test
    void succeedingTest() {
    }

    @Test
    void failingTest() {
        fail("a failing test");
    }

    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        // not executed
    }

    @Test
    void abortedTest() {
        assumeTrue("abc".contains("Z"));
        fail("test should have been aborted");
    }

    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
    }

}

注解

JUnit Jupiter 支持以下注解,用于配置测试和扩展框架功能。除非另有说明,所有核心注解都位于 junit-jupiter-api 模块的 org.junit.jupiter.api 包中。

注解 描述
@Test 表明该方法是一个测试方法。与 JUnit 4 中的 @Test 注解不同,该注解不声明任何属性,因为 JUnit Jupiter 中的测试扩展基于各自的专用注解运行。此类方法会被继承,除非被重写。
@ParameterizedTest 表明该方法是一个参数化测试。此类方法会被继承,除非被重写。
@RepeatedTest 表明该方法是一个重复测试的测试模板。此类方法会被继承,除非被重写。
@TestFactory 表明该方法是一个动态测试的测试工厂。此类方法会被继承,除非被重写。
@TestTemplate 表明该方法是一个测试用例模板,设计为根据注册的提供者返回的调用上下文数量多次调用。此类方法会被继承,除非被重写。
@TestClassOrder 用于配置注解的测试类中 @Nested 测试类的执行顺序。此类注解会被继承。
@TestMethodOrder 用于配置注解的测试类的测试方法执行顺序,类似于 JUnit 4 中的 @FixMethodOrder。此类注解会被继承。
@TestInstance 用于配置注解测试类的测试实例生命周期。此类注解会被继承。
@DisplayName 声明一个自定义的显示名称用于测试类或测试方法。此类注解不会被继承。
@DisplayNameGeneration 声明一个自定义的显示名称生成器用于测试类。此类注解会被继承。
@BeforeEach 表示注解的方法应该在当前类中的每个 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之前执行;类似于 JUnit 4 的 @Before。这些方法是可以继承的,除非被重写。
@AfterEach 表示注解的方法应该在当前类中的每个 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之后执行;类似于 JUnit 4 的 @After。这些方法是可以继承的,除非被重写。
@BeforeAll 表示注解的方法应该在当前类中的所有 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之前执行;类似于 JUnit 4 的 @BeforeClass。这些方法是可以继承的,除非被重写,并且除非使用 "每类" 测试实例生命周期,否则必须是 static
@AfterAll 表示注解的方法应该在当前类中的所有 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之后执行;类似于 JUnit 4 的 @AfterClass。这些方法是可以继承的,除非被重写,并且除非使用 "每类" 测试实例生命周期,否则必须是 static
@Nested 表示注解的类是一个非静态的嵌套测试类。在 Java 8 至 Java 15 中,除非使用 "每类" 测试实例生命周期,否则不能直接在 @Nested 测试类中使用 @BeforeAll@AfterAll 方法。从 Java 16 开始,可以在 @Nested 测试类中将 @BeforeAll@AfterAll 方法声明为 static,无论使用何种测试实例生命周期模式。这些注解不会被继承。
@Tag 用于声明标签以便于过滤测试,可以在类或方法级别使用;类似于 TestNG 中的测试组或 JUnit 4 中的类别。这些注解在类级别是可以继承的,但在方法级别不会继承。
@Disabled 用于禁用测试类或测试方法;类似于 JUnit 4 的 @Ignore。这些注解不会被继承。
@AutoClose 表示注解的字段代表一个资源,该资源将在测试执行后自动关闭。
@Timeout 用于在测试、测试工厂、测试模板或生命周期方法的执行超过给定持续时间时使其失败。这些注解是可以继承的。
@TempDir 用于通过字段注入或参数注入在生命周期方法或测试方法中提供临时目录;位于 org.junit.jupiter.api.io 包中。这些字段是可以继承的。
@ExtendWith 用于声明性地注册扩展。
@RegisterExtension 用于通过字段编程式地注册扩展。这些字段是可以继承的。

元注解和组合注解

JUnit Jupiter 注解可以作为元注解使用。通过自定义组合注解,将元注解重新构造成一个新的注解。自定义后的注解将自动继承其元注解的语义。

例如,代替在代码中复制粘贴 @Tag("fast"),你可以创建一个名为 @Fast 的自定义组合注解。然后,@Fast 可以作为 @Tag("fast") 的替代品直接使用:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Tag;

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface Fast {
}

以下的 @Test 方法展示了如何使用 @Fast 注解:

@Fast
@Test
void myFastTest() {
    // ...
}

甚至可以进一步扩展,通过引入一个自定义的 @FastTest 注解,它可以作为 @Tag("fast")@Test 的替代品使用。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
@Test
public @interface FastTest {
}

JUnit 会自动识别以下内容作为一个标记为“fast”的 @Test 方法:

@FastTest
void myFastTest() {
    // ...
}

@DisplayName

测试类和测试方法可以通过 @DisplayName 注解声明自定义显示名称——包括空格、特殊字符,甚至表情符号——这些名称将在测试报告、测试运行器和 IDE 中显示。

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("A special test case")
class DisplayNameDemo {

    @Test
    @DisplayName("Custom test name containing spaces")
    void testWithDisplayNameContainingSpaces() {
    }

    @Test
    @DisplayName("╯°□°)╯")
    void testWithDisplayNameContainingSpecialCharacters() {
    }

    @Test
    @DisplayName("😱")
    void testWithDisplayNameContainingEmoji() {
    }

}

Note

JUnit Jupiter 支持通过 @DisplayNameGeneration 注解配置自定义显示名称生成器。 通过 @DisplayName 注解提供的值始终优先于 DisplayNameGenerator 生成的显示名称。 详见 JUnit 5 官网《2.4.1. Display Name Generators》

@Disabled

可以通过 @Disabled 注解、条件测试执行中讨论的注解之一,或者自定义的 ExecutionCondition 来禁用整个测试类或单个测试方法。 当 @Disabled 注解应用于类级别时,该类中的所有测试方法都会被自动禁用。

如果一个测试方法通过 @Disabled 注解被禁用,这将阻止该测试方法的执行,以及方法级别的生命周期回调(如 @BeforeEach 方法、@AfterEach 方法和相应的扩展 API)。然而,这并不会阻止测试类的实例化,也不会阻止类级别的生命周期回调(如 @BeforeAll 方法、@AfterAll 方法和相应的扩展 API)的执行。

@Disabled("Disabled until bug #99 has been fixed")
class DisabledClassDemo {
    @Test
    void testWillBeSkipped() {
    }
}

或者:

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class DisabledTestsDemo {

    @Disabled("Disabled until bug #42 has been resolved")
    @Test
    void testWillBeSkipped() {
    }

    @Test
    void testWillBeExecuted() {
    }

}

@(En|Dis)abledOnOs

可以通过 @EnabledOnOs@DisabledOnOs 注解,在特定的操作系统、架构或二者的组合下启用或禁用容器或测试。

Conditional execution based on operating system
@Test
@EnabledOnOs(MAC)
void onlyOnMacOs() {
    // ...
}

@TestOnMac
void testOnMac() {
    // ...
}

@Test
@EnabledOnOs({ LINUX, MAC })
void onLinuxOrMac() {
    // ...
}

@Test
@DisabledOnOs(WINDOWS)
void notOnWindows() {
    // ...
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@EnabledOnOs(MAC)
@interface TestOnMac {
}
Conditional execution based on architecture
@Test
@EnabledOnOs(architectures = "aarch64")
void onAarch64() {
    // ...
}

@Test
@DisabledOnOs(architectures = "x86_64")
void notOnX86_64() {
    // ...
}

@Test
@EnabledOnOs(value = MAC, architectures = "aarch64")
void onNewMacs() {
    // ...
}

@Test
@DisabledOnOs(value = MAC, architectures = "aarch64")
void notOnNewMacs() {
    // ...
}

@En(Dis)abledOnJre 和 @En(Dis)abledForJreRange

可以通过 @EnabledOnJre@DisabledOnJre 注解,在特定版本的 Java 运行时环境(JRE)上启用或禁用容器或测试,或者通过 @EnabledForJreRange@DisabledForJreRange 注解,在特定版本范围的 JRE 上启用或禁用。该范围默认以 JRE.JAVA_8 作为下限(最小值),JRE.OTHER 作为上限(最大值),允许使用半开区间。

@Test
@EnabledOnJre(JAVA_8)
void onlyOnJava8() {
    // ...
}

@Test
@EnabledOnJre({ JAVA_9, JAVA_10 })
void onJava9Or10() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9, max = JAVA_11)
void fromJava9to11() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9)
void fromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@EnabledForJreRange(max = JAVA_11)
void fromJava8To11() {
    // ...
}

@Test
@DisabledOnJre(JAVA_9)
void notOnJava9() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9, max = JAVA_11)
void notFromJava9to11() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9)
void notFromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@DisabledForJreRange(max = JAVA_11)
void notFromJava8to11() {
    // ...
}

@En(Dis)abledIfSystemProperty

可以通过 @EnabledIfSystemProperty@DisabledIfSystemProperty 注解,根据指定的 JVM 系统属性的值来启用或禁用容器或测试。通过 matches 属性提供的值将被解释为正则表达式。

@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void onlyOn64BitArchitectures() {
    // ...
}

@Test
@DisabledIfSystemProperty(named = "ci-server", matches = "true")
void notOnCiServer() {
    // ...
}

@En(Dis)abledIfEnvironmentVariable

可以通过 @EnabledIfEnvironmentVariable@DisabledIfEnvironmentVariable 注解,根据底层操作系统中指定的环境变量的值来启用或禁用容器或测试。通过 matches 属性提供的值将被解释为正则表达式。

@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "staging-server")
void onlyOnStagingServer() {
    // ...
}

@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = ".*development.*")
void notOnDeveloperWorkstation() {
    // ...
}

@EnabledIf

作为实现 ExecutionCondition 的替代方案,可以通过 @EnabledIf@DisabledIf 注解,根据条件方法来启用或禁用容器或测试。条件方法必须返回布尔值,并且可以不接受任何参数或只接受一个 ExtensionContext 参数。

以下测试类演示了如何通过 @EnabledIf@DisabledIf 配置一个名为 customCondition 的本地方法:

@Test
@EnabledIf("customCondition")
void enabled() {
    // ...
}

@Test
@DisabledIf("customCondition")
void disabled() {
    // ...
}

boolean customCondition() {
    return true;
}

另外,条件方法也可以位于测试类之外。在这种情况下,必须通过其全限定名进行引用,正如以下示例所示:

package example;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;

class ExternalCustomConditionDemo {

    @Test
    @EnabledIf("example.ExternalCondition#customCondition")
    void enabled() {
        // ...
    }

}

class ExternalCondition {

    static boolean customCondition() {
        return true;
    }

}

Warning

在以下几种情况下,条件方法需要是静态的:

  • 当 @EnabledIf 或 @DisabledIf 被用在类级别时
  • 当 @EnabledIf 或 @DisabledIf 被用在 @ParameterizedTest 或 @TestTemplate 方法上时
  • 当条件方法位于外部类中时

在其他情况下,可以使用静态方法或实例方法作为条件方法。

@Tag

测试类和方法可以通过 @Tag 注解进行标记。标记了 @Tag 注解的类和方法,可以在执行测试时选择特定的用例进行执行。

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("fast")
@Tag("model")
class TaggingDemo {

    @Test
    @Tag("taxes")
    void testingTaxCalculation() {
    }

}

@Nested

@Nested 测试为测试编写者提供了更多的能力,以表达多个测试组之间的关系。这样的嵌套测试利用了 Java 的嵌套类,并有助于以层次化的方式思考测试结构。以下是一个详细的示例,包括源代码和在 IDE 中执行的截图。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.EmptyStackException;
import java.util.Stack;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("A stack")
class TestingAStackDemo {

    Stack<Object> stack;

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("throws EmptyStackException when popped")
        void throwsExceptionWhenPopped() {
            assertThrows(EmptyStackException.class, stack::pop);
        }

        @Test
        @DisplayName("throws EmptyStackException when peeked")
        void throwsExceptionWhenPeeked() {
            assertThrows(EmptyStackException.class, stack::peek);
        }

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when popped and is empty")
            void returnElementWhenPopped() {
                assertEquals(anElement, stack.pop());
                assertTrue(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when peeked but remains not empty")
            void returnElementWhenPeeked() {
                assertEquals(anElement, stack.peek());
                assertFalse(stack.isEmpty());
            }
        }
    }
}

执行此示例时,在 IDE 中的测试执行树将在图形用户界面中呈现类似以下的结构:

内部类执行顺序
内部类执行顺序

@RepeatedTest

JUnit Jupiter 提供了通过在方法上添加 @RepeatedTest 注解并指定希望的重复次数来重复执行测试的功能。每次重复执行的测试都像常规的 @Test 方法一样执行,并完全支持相同的生命周期回调和扩展。

以下示例演示了如何声明一个名为 repeatedTest() 的测试方法,该方法将被自动重复执行 10 次:

@RepeatedTest(10)
void repeatedTest() {
    // ...
}

@ParameterizedTest

@ParameterizedTest 标记方法时表明该方法是一个参数化测试。参数化测试使得能够使用不同的参数多次运行同一个测试。它们的声明方式与常规的 @Test 方法相同。@ParameterizedTest 的方法必须声明至少一个数据源,用于提供每次调用的参数,然后在测试方法中使用这些参数。

以下示例演示了一个参数化测试,它使用 @ValueSource 注解指定一个字符串数组作为参数源:

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@TestTemplate

TestInfo

在所有之前的 JUnit 版本中,测试构造函数或方法不允许带有参数(至少在标准的 Runner 实现中是如此)。作为 JUnit Jupiter 的主要变化之一,现在测试构造函数和方法都允许带有参数。这为测试提供了更大的灵活性,并支持构造函数和方法的依赖注入。

ParameterResolver 定义了一个 API,用于那些希望在运行时动态解析参数的测试扩展。如果测试类的构造函数、 测试方法或生命周期方法接受参数,则该参数必须通过注册的 ParameterResolver 在运行时进行解析。

目前有三个内置的解析器,它们会自动注册:TestInfoParameterResolverRepetitionExtensionTestReporterParameterResolver

TestInfoParameterResolver

TestInfoParameterResolver:如果构造函数或方法的参数类型是 TestInfoTestInfoParameterResolver 将提供一个与当前容器或测试对应的 TestInfo 实例,作为该参数的值。然后,可以使用 TestInfo 来检索有关当前容器或测试的信息,例如显示名称、测试类、测试方法和关联的标签。显示名称可以是技术名称,如测试类或测试方法的名称,或者是通过 @DisplayName 配置的自定义名称。

TestInfo 作为 JUnit 4 中 TestName 规则的替代方案。以下示例演示了如何将 TestInfo 注入到测试构造函数、@BeforeEach 方法和 @Test 方法中。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;

@DisplayName("TestInfo Demo")
class TestInfoDemo {

    TestInfoDemo(TestInfo testInfo) {
        assertEquals("TestInfo Demo", testInfo.getDisplayName());
    }

    @BeforeEach
    void init(TestInfo testInfo) {
        String displayName = testInfo.getDisplayName();
        assertTrue(displayName.equals("TEST 1") || displayName.equals("test2()"));
    }

    @Test
    @DisplayName("TEST 1")
    @Tag("my-tag")
    void test1(TestInfo testInfo) {
        assertEquals("TEST 1", testInfo.getDisplayName());
        assertTrue(testInfo.getTags().contains("my-tag"));
    }

    @Test
    void test2() {
    }

}

RepetitionExtension

如果 @RepeatedTest@BeforeEach@AfterEach 方法的参数类型是 RepetitionInfoRepetitionExtension 会提供一个 RepetitionInfo 实例。然后,可以使用 RepetitionInfo 来检索当前重复次数的信息、总重复次数、失败的重复次数以及对应 @RepeatedTest 的失败阈值。不过需要注意的是,RepetitionExtension 只在 @RepeatedTest 上下文中注册。

TestReporterParameterResolver

如果构造函数或方法的参数类型是 TestReporterTestReporterParameterResolver 会提供一个 TestReporter 实例。 TestReporter 可用于发布当前测试运行的附加数据。这些数据可以通过 TestExecutionListener 中的 reportingEntryPublished()方法进行消费,从而可以在 IDE 中查看或包含在报告中。

class TestReporterDemo {

    @Test
    void reportSingleValue(TestReporter testReporter) {
        testReporter.publishEntry("a status message");
    }

    @Test
    void reportKeyValuePair(TestReporter testReporter) {
        testReporter.publishEntry("a key", "a value");
    }

    @Test
    void reportMultipleKeyValuePairs(TestReporter testReporter) {
        Map<String, String> values = new HashMap<>();
        values.put("user name", "dk38");
        values.put("award year", "1974");

        testReporter.publishEntry(values);
    }

}

断言(Assertion)

JUnit Jupiter 提供了许多与 JUnit 4 相同的断言方法,并新增了一些特别适合与 Java 8 lambda 表达式配合使用的断言方法。所有

JUnit Jupiter 的断言方法都是 org.junit.jupiter.api.Assertions 类中的静态方法。断言方法可以选择性地接受第三个参数作为断言消息, 该参数可以是 String 类型或 Supplier<String> 类型。

当使用 Supplier<String>(例如 lambda 表达式)时,消息会延迟求值。这可以带来性能优势,特别是在消息构建复杂或耗时的情况下, 因为消息仅在断言失败时才会被评估。

import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.concurrent.CountDownLatch;

import example.domain.Person;
import example.util.Calculator;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

class AssertionsDemo {

    private final Calculator calculator = new Calculator();

    private final Person person = new Person("Jane", "Doe");

    @Test
    void standardAssertions() {
        assertEquals(2, calculator.add(1, 1));
        assertEquals(4, calculator.multiply(2, 2),
                "The optional failure message is now the last parameter");

        // Lazily evaluates generateFailureMessage('a','b').
        assertTrue('a' < 'b', () -> generateFailureMessage('a','b'));
    }

    @Test
    void groupedAssertions() {
        // In a grouped assertion all assertions are executed, and all
        // failures will be reported together.
        assertAll("person",
            () -> assertEquals("Jane", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName())
        );
    }

    @Test
    void dependentAssertions() {
        // Within a code block, if an assertion fails the
        // subsequent code in the same block will be skipped.
        assertAll("properties",
            () -> {
                String firstName = person.getFirstName();
                assertNotNull(firstName);

                // Executed only if the previous assertion is valid.
                assertAll("first name",
                    () -> assertTrue(firstName.startsWith("J")),
                    () -> assertTrue(firstName.endsWith("e"))
                );
            },
            () -> {
                // Grouped assertion, so processed independently
                // of results of first name assertions.
                String lastName = person.getLastName();
                assertNotNull(lastName);

                // Executed only if the previous assertion is valid.
                assertAll("last name",
                    () -> assertTrue(lastName.startsWith("D")),
                    () -> assertTrue(lastName.endsWith("e"))
                );
            }
        );
    }

    @Test
    void exceptionTesting() {
        Exception exception = assertThrows(ArithmeticException.class, () ->
            calculator.divide(1, 0));
        assertEquals("/ by zero", exception.getMessage());
    }

    @Test
    void timeoutNotExceeded() {
        // The following assertion succeeds.
        assertTimeout(ofMinutes(2), () -> {
            // Perform task that takes less than 2 minutes.
        });
    }

    @Test
    void timeoutNotExceededWithResult() {
        // The following assertion succeeds, and returns the supplied object.
        String actualResult = assertTimeout(ofMinutes(2), () -> {
            return "a result";
        });
        assertEquals("a result", actualResult);
    }

    @Test
    void timeoutNotExceededWithMethod() {
        // The following assertion invokes a method reference and returns an object.
        String actualGreeting = assertTimeout(ofMinutes(2), AssertionsDemo::greeting);
        assertEquals("Hello, World!", actualGreeting);
    }

    @Test
    void timeoutExceeded() {
        // The following assertion fails with an error message similar to:
        // execution exceeded timeout of 10 ms by 91 ms
        assertTimeout(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            Thread.sleep(100);
        });
    }

    @Test
    void timeoutExceededWithPreemptiveTermination() {
        // The following assertion fails with an error message similar to:
        // execution timed out after 10 ms
        assertTimeoutPreemptively(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            new CountDownLatch(1).await();
        });
    }

    private static String greeting() {
        return "Hello, World!";
    }

    private static String generateFailureMessage(char a, char b) {
        return "Assertion messages can be lazily evaluated -- "
                + "to avoid constructing complex messages unnecessarily." + (a < b);
    }
}

尽管 JUnit Jupiter 提供的断言功能足以应对许多测试场景,但有时可能需要更强大的功能和额外的功能,如匹配器。在这种情况下,JUnit 团队推荐使用第三方断言库,如 AssertJ、Hamcrest、Truth 等。因此,开发人员可以自由选择自己喜欢的断言库。

例如,匹配器与流式 API 的组合可以使断言更加描述性和可读。尽管如此,JUnit Jupiter 的 org.junit.jupiter.api.Assertions 类并没有像 JUnit 4 中 org.junit.Assert 类那样提供接受 Hamcrest Matcher 的 assertThat() 方法。 相反,开发人员被鼓励使用第三方断言库提供的匹配器内建支持。

以下示例演示了如何在 JUnit Jupiter 测试中使用 Hamcrest 的 assertThat() 支持。只要将 Hamcrest 库添加到类路径中,就可以静态导入 assertThat()is()equalTo() 等方法,并像下面的 assertWithHamcrestMatcher() 方法那样在测试中使用它们。

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;

import example.util.Calculator;

import org.junit.jupiter.api.Test;

class HamcrestAssertionsDemo {

    private final Calculator calculator = new Calculator();

    @Test
    void assertWithHamcrestMatcher() {
        assertThat(calculator.subtract(4, 1), is(equalTo(3)));
    }

}

假设(Assumptions)

假设(Assumptions)通常用于在某些测试的条件不满足时,抛出 org.opentest4j.TestAbortedException 类型的异常,从而中止测试的执行,而不是将其标记为失败。 比如,当测试依赖于系统的某个配置属性时,当这个属性当前又不存在于系统中,此时继续进行测试就会变得毫无意义。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;

import example.util.Calculator;

import org.junit.jupiter.api.Test;

class AssumptionsDemo {

    private final Calculator calculator = new Calculator();

    @Test
    void testOnlyOnCiServer() {
        assumeTrue("CI".equals(System.getenv("ENV")));
        // remainder of test
    }

    @Test
    void testOnlyOnDeveloperWorkstation() {
        assumeTrue("DEV".equals(System.getenv("ENV")),
            () -> "Aborting test: not on developer workstation");
        // remainder of test
    }

    @Test
    void testInAllEnvironments() {
        assumingThat("CI".equals(System.getenv("ENV")),
            () -> {
                // perform these assertions only on the CI server
                assertEquals(2, calculator.divide(4, 2));
            });

        // perform these assertions in all environments
        assertEquals(42, calculator.multiply(6, 7));
    }

}

Note

也可以使用 JUnit 4 中 org.junit.Assume 类的方法来进行假设。具体来说,JUnit Jupiter 支持 JUnit 4 的 AssumptionViolatedException 异常,用于表示测试应该中止,而不是被标记为失败。

异常处理

JUnit Jupiter 提供了强大的支持来处理测试中出现的异常。在 JUnit Jupiter 中,如果测试方法、生命周期方法或扩展中抛出异常且该异常没有在方法内部被捕获,框架将把该测试或测试类标记为失败。 在以下示例中,failsDueToUncaughtException() 方法抛出一个 ArithmeticException 异常。由于该异常没有在测试方法内部被捕获,JUnit Jupiter 将把该测试标记为失败。

private final Calculator calculator = new Calculator();

@Test
void failsDueToUncaughtException() {
    // The following throws an ArithmeticException due to division by
    // zero, which causes a test failure.
    calculator.divide(1, 0);
}

JUnit Jupiter 提供了一些异常的断言方法,用于测试在预期条件下是否抛出了特定的异常。assertThrows()assertThrowsExactly() 断言是验证代码是否通过抛出适当的异常来正确响应错误条件的关键方法。

  • assertThrows() 方法用于验证在执行提供的可执行代码块时是否抛出了特定类型的异常。 它不仅检查抛出异常的类型,还检查其子类,因此适用于更为广泛的异常处理测试。 assertThrows() 断言方法返回抛出的异常对象,以便对其进行进一步的断言操作;
  • assertThrowsExactly() 方法用于当需要断言抛出的异常恰好是特定类型,而不允许其是预期异常类型的子类时使用。 这在需要验证精确的异常处理行为时非常有用。与 assertThrows() 类似,assertThrowsExactly() 断言方法也会返回抛出的异常对象, 以便对其进行进一步的断言操作。

以下是这两个断言方法的示例;

@Test
void testExpectedExceptionIsThrown() {
    // The following assertion succeeds because the code under assertion
    // throws the expected IllegalArgumentException.
    // The assertion also returns the thrown exception which can be used for
    // further assertions like asserting the exception message.
    IllegalArgumentException exception =
        assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("expected message");
        });
    assertEquals("expected message", exception.getMessage());

    // The following assertion also succeeds because the code under assertion
    // throws IllegalArgumentException which is a subclass of RuntimeException.
    assertThrows(RuntimeException.class, () -> {
        throw new IllegalArgumentException("expected message");
    });
}

@Test
void testExpectedExceptionIsThrown() {
    // The following assertion succeeds because the code under assertion throws
    // IllegalArgumentException which is exactly equal to the expected type.
    // The assertion also returns the thrown exception which can be used for
    // further assertions like asserting the exception message.
    IllegalArgumentException exception =
        assertThrowsExactly(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("expected message");
        });
    assertEquals("expected message", exception.getMessage());

    // The following assertion fails because the assertion expects exactly
    // RuntimeException to be thrown, not subclasses of RuntimeException.
    assertThrowsExactly(RuntimeException.class, () -> {
        throw new IllegalArgumentException("expected message");
    });
}

尽管任何从测试方法中抛出的异常都会导致测试失败,但在某些情况下,明确断言某个代码块在测试方法中不抛出异常是有益的。 assertDoesNotThrow() 断言可以在你想验证特定代码段没有抛出任何异常时使用。这种断言确保了在执行给定代码块时没有出现意外的异常。

@Test
void testExceptionIsNotThrown() {
    assertDoesNotThrow(() -> {
        shouldNotThrowException();
    });
}

void shouldNotThrowException() {
}

测试顺序

方法的执行顺序

尽管真正的单元测试通常不应该依赖于执行顺序,但在某些情况下,确实需要强制指定测试方法的执行顺序——例如, 在编写集成测试或功能测试时,测试的顺序很重要,尤其是与 @TestInstance(Lifecycle.PER_CLASS) 配合使用时。

要控制测试方法的执行顺序,可以在测试类或测试接口上使用 @TestMethodOrder 注解,并指定所需的 MethodOrderer 实现。

  • MethodOrderer.DisplayName:根据测试方法的显示名称按字母数字顺序对测试方法进行排序。
  • MethodOrderer.MethodName:根据测试方法的名称和形式参数列表按字母数字顺序对测试方法进行排序。
  • MethodOrderer.OrderAnnotation:根据 @Order 注解中指定的值按数字顺序对测试方法进行排序。
  • MethodOrderer.Random:伪随机地对测试方法进行排序,并支持自定义种子的配置。
  • MethodOrderer.Alphanumeric:根据测试方法的名称和形式参数列表按字母数字顺序对测试方法进行排序;已弃用,建议使用 MethodOrderer.MethodName,并将在 6.0 版本中移除。

以下示例演示了如何确保测试方法按照 @Order 注解中指定的顺序执行:

import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

@TestMethodOrder(OrderAnnotation.class)
class OrderedTestsDemo {

    @Test
    @Order(1)
    void nullValues() {
        // perform assertions against null values
    }

    @Test
    @Order(2)
    void emptyValues() {
        // perform assertions against empty values
    }

    @Test
    @Order(3)
    void validValues() {
        // perform assertions against valid values
    }

}

类的执行顺序

尽管测试类通常不应依赖于其执行顺序,但在某些情况下,强制执行特定的测试类执行顺序是有必要的。 比如希望以随机顺序执行测试类,以确保测试类之间没有意外的依赖关系。再比如,可能希望按特定顺序排列测试类,以优化构建时间,如以下场景所示:

  • 首先运行先前失败的测试和较快的测试:即“快速失败”模式。
  • 在启用并行执行的情况下,优先安排较长的测试:即“最短测试计划执行时间”模式。
  • 其他各种使用场景。

要全局配置整个测试套件的测试类执行顺序,可以使用 junit.jupiter.testclass.order.default 配置参数,指定希望使用的 ClassOrderer 的完全限定类名。ClassOrderer 需要应用在所有顶层的测试类(包括静态嵌套测试类)和 @Nested 测试类。 提供的类必须实现 ClassOrderer 接口。或使用以下内置的 ClassOrderer 实现之一。

  • ClassOrderer.ClassName:根据测试类的完全限定类名按字母数字顺序对测试类进行排序。
  • ClassOrderer.DisplayName:根据测试类的显示名称按字母数字顺序对测试类进行排序。
  • ClassOrderer.OrderAnnotation:根据 @Order 注解中指定的值按数字顺序对测试类进行排序。
  • ClassOrderer.Random:伪随机地对测试类进行排序,并支持自定义种子的配置。

要为 @Nested 测试类在本地配置测试类执行顺序,可以在包含 @Nested 测试类的外部类上声明 @TestClassOrder 注解,并在 @TestClassOrder 注解中直接提供要使用的 ClassOrderer 实现的类引用。配置的 ClassOrderer 将递归应用于 @Nested 测试类及其内部的 @Nested 测试类。需要注意的是,本地的 @TestClassOrder 声明始终会覆盖通过 junit.jupiter.testclass.order.default 配置参数全局配置的 @TestClassOrder 声明或继承的 @TestClassOrder 声明。

以下示例演示了如何保证 @Nested 测试类按照通过 @Order 注解指定的顺序执行:

@TestClassOrder(ClassOrderer.OrderAnnotation.class)
class OrderedNestedTestClassesDemo {

    @Nested
    @Order(1)
    class PrimaryTests {
        @Test
        void test1() {
        }
    }

    @Nested
    @Order(2)
    class SecondaryTests {
        @Test
        void test2() {
        }
    }
}

测试接口和默认方法

JUnit Jupiter 允许在接口的默认方法上声明 @Test@RepeatedTest@ParameterizedTest@TestFactory@TestTemplate@BeforeEach@AfterEach 注解。@BeforeAll@AfterAll 可以声明在测试接口的静态方法上,或者在测试接口或测试类上使用 @TestInstance(Lifecycle.PER_CLASS) 注解时,声明在接口的默认方法上(参见测试实例生命周期)。

@TestInstance(Lifecycle.PER_CLASS)
interface TestLifecycleLogger {

    static final Logger logger = Logger.getLogger(TestLifecycleLogger.class.getName());

    @BeforeAll
    default void beforeAllTests() {
        logger.info("Before all tests");
    }

    @AfterAll
    default void afterAllTests() {
        logger.info("After all tests");
    }

    @BeforeEach
    default void beforeEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("About to execute [%s]",
            testInfo.getDisplayName()));
    }

    @AfterEach
    default void afterEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("Finished executing [%s]",
            testInfo.getDisplayName()));
    }

}
interface TestInterfaceDynamicTestsDemo {

    @TestFactory
    default Stream<DynamicTest> dynamicTestsForPalindromes() {
        return Stream.of("racecar", "radar", "mom", "dad")
            .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }

}

@ExtendWith 和 @Tag 可以声明在测试接口上,这样实现该接口的类就会自动继承它的标签和扩展。

@Tag("timed")
@ExtendWith(TimingExtension.class)
interface TimeExecutionLogger {
}

class TestInterfaceDemo implements TestLifecycleLogger,
        TimeExecutionLogger, TestInterfaceDynamicTestsDemo {

    @Test
    void isEqualValue() {
        assertEquals(1, "a".length(), "is always equal");
    }

}