Java深入解析篇三十七之JUnit 5 详解

JUnit 5 详解

一、JUnit 5 架构

1.1 整体架构

JUnit 5 由三个顶层模块组成:

模块 职责 Maven Artifact
JUnit Platform 测试平台基础,定义 TestEngine SPI,提供 Launcher API junit-platform-launcher
JUnit Jupiter 新的编程模型和扩展模型,包含注解、断言、引擎 junit-jupiter
JUnit Vintage 兼容引擎,在 Platform 上运行 JUnit 3/4 测试 junit-vintage-engine

1.2 Maven 依赖配置

xml 复制代码
<dependencies>
    <!-- JUnit Jupiter(聚合包,包含 API + Engine + Params) -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>

    <!-- 如需运行 JUnit 4 旧测试 -->
    <dependency>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

1.3 Gradle 配置

groovy 复制代码
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

test {
    useJUnitPlatform()
}

二、测试生命周期

2.1 生命周期注解

JUnit 5 提供五个生命周期注解,控制测试执行前后的初始化和清理逻辑:

注解 执行时机 要求
@BeforeAll 所有测试方法执行前,仅一次 默认需 static
@BeforeEach 每个测试方法执行前 实例方法
@Test 标记测试方法 实例方法
@AfterEach 每个测试方法执行后 实例方法
@AfterAll 所有测试方法执行后,仅一次 默认需 static

2.2 执行顺序示例

java 复制代码
import org.junit.jupiter.api.*;

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class LifecycleDemoTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("1. @BeforeAll - 全局初始化(仅一次)");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("2. @BeforeEach - 每个测试前");
    }

    @Test
    @Order(1)
    void testFirst() {
        System.out.println("3. @Test - testFirst 执行");
    }

    @Test
    @Order(2)
    void testSecond() {
        System.out.println("3. @Test - testSecond 执行");
    }

    @AfterEach
    void afterEach() {
        System.out.println("4. @AfterEach - 每个测试后");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("5. @AfterAll - 全局清理(仅一次)");
    }
}

输出顺序:

复制代码
1. @BeforeAll - 全局初始化(仅一次)
2. @BeforeEach - 每个测试前
3. @Test - testFirst 执行
4. @AfterEach - 每个测试后
2. @BeforeEach - 每个测试前
3. @Test - testSecond 执行
4. @AfterEach - 每个测试后
5. @AfterAll - 全局清理(仅一次)

2.3 @DisplayName 自定义名称

java 复制代码
@DisplayName("用户服务测试")
class UserServiceTest {

    @Test
    @DisplayName("当用户名为空时,应抛出 IllegalArgumentException")
    void shouldThrowExceptionWhenUsernameIsNull() {
        assertThrows(IllegalArgumentException.class,
            () -> new UserService().register(null));
    }
}

三、断言(Assertions)

3.1 基本断言

java 复制代码
import static org.junit.jupiter.api.Assertions.*;

class BasicAssertionsTest {

    @Test
    void testEquals() {
        assertEquals(4, 2 + 2);
        assertEquals("hello", "hel" + "lo");
        // 带自定义失败消息
        assertEquals(100, calculateScore(), "分数计算错误");
        // 带 Supplier 延迟消息(仅在失败时构造)
        assertEquals(100, calculateScore(),
            () -> "期望100,实际: " + calculateScore());
    }

    @Test
    void testNotEquals() {
        assertNotEquals(5, 2 + 2);
    }

    @Test
    void testBoolean() {
        assertTrue(3 > 2);
        assertFalse(1 > 2);
    }

    @Test
    void testNull() {
        assertNull(null);
        assertNotNull("value");
    }

    @Test
    void testSame() {
        Object obj = new Object();
        assertSame(obj, obj);       // 引用相同
        assertNotSame(new Object(), new Object()); // 引用不同
    }

    @Test
    void testArray() {
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};
        assertArrayEquals(expected, actual);
    }

    @Test
    void testIterable() {
        List<String> expected = List.of("a", "b", "c");
        List<String> actual = List.of("a", "b", "c");
        assertIterableEquals(expected, actual);
    }

    private int calculateScore() {
        return 100;
    }
}

3.2 异常断言 assertThrows

java 复制代码
@Test
void testException() {
    // 验证异常类型
    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> Integer.parseInt("abc")
    );

    // 验证异常消息
    assertEquals("For input string: \"abc\"", ex.getMessage());
}

@Test
void testExceptionMessage() {
    Exception ex = assertThrows(
        ArithmeticException.class,
        () -> {
            int result = 1 / 0;
        },
        "除零应抛出 ArithmeticException"
    );
    assertTrue(ex.getMessage().contains("/ by zero"));
}

3.3 组合断言 assertAll

assertAll 会执行所有断言,即使前面的断言失败也不会中断,最终聚合报告所有失败:

java 复制代码
@Test
void testUserFields() {
    User user = userService.findById(1L);

    assertAll("用户属性验证",
        () -> assertEquals("张三", user.getName(), "姓名不匹配"),
        () -> assertEquals(25, user.getAge(), "年龄不匹配"),
        () -> assertEquals("zhangsan@example.com", user.getEmail(), "邮箱不匹配"),
        () -> assertNotNull(user.getCreateTime(), "创建时间不应为空")
    );
}

3.4 超时断言

java 复制代码
@Test
void testTimeout() {
    // 如果执行超过 2 秒则失败(在同一线程中等待完成)
    assertTimeout(Duration.ofSeconds(2), () -> {
        Thread.sleep(100);
        return "done";
    });
}

@Test
void testTimeoutPreemptively() {
    // 超时后立即中断执行线程
    assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
        Thread.sleep(100);
        return "completed";
    });
}

@Test
void testTimeoutWithMessage() {
    assertTimeout(Duration.ofSeconds(1), () -> {
        // 模拟耗时操作
        longRunningTask();
    }, "操作超时:应在1秒内完成");
}

四、假设(Assumptions)

4.1 基本概念

假设用于在特定条件不满足时中止测试(标记为 Aborted),而非失败(Failed)。适用于环境依赖型测试。

4.2 基本用法

java 复制代码
import static org.junit.jupiter.api.Assumptions.*;

class AssumptionsTest {

    @Test
    void testOnlyOnLinux() {
        assumeTrue(
            System.getProperty("os.name").toLowerCase().contains("linux"),
            "仅在 Linux 环境运行"
        );
        // 以下代码仅在 Linux 上执行
        assertTrue(new File("/etc/passwd").exists());
    }

    @Test
    void testOnlyWhenDbAvailable() {
        assumeFalse(isDatabaseOffline(), "数据库离线时跳过");
        // 数据库相关测试逻辑
        assertNotNull(dataSource.getConnection());
    }

    @Test
    void testAssumingThat() {
        // 条件满足时执行 lambda,不满足则跳过该部分(测试继续)
        assumingThat(
            "CI".equals(System.getenv("ENV")),
            () -> {
                // 仅在 CI 环境执行的验证
                assertEquals(4, 2 + 2);
            }
        );
        // 无论环境如何都会执行
        assertTrue(3 > 1);
    }

    private boolean isDatabaseOffline() {
        return false;
    }
}

4.3 假设 vs 断言

特性 假设(Assumption) 断言(Assertion)
失败结果 Aborted(中止) Failed(失败)
语义 前置条件不满足,跳过 预期行为不满足,报错
典型场景 环境依赖、平台限制 业务逻辑验证

五、参数化测试

5.1 基本结构

参数化测试通过 @ParameterizedTest + 数据源注解实现同一测试逻辑的多组数据验证。

xml 复制代码
<!-- 需要额外依赖(junit-jupiter 聚合包已包含) -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>

5.2 @ValueSource

适用于单参数场景,支持 intlongdoubleStringClass 等类型:

java 复制代码
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
    assertTrue(number > 0);
}

@ParameterizedTest
@ValueSource(strings = {"racecar", "madam", "level"})
void testPalindrome(String candidate) {
    assertTrue(isPalindrome(candidate));
}

@ParameterizedTest
@ValueSource(doubles = {1.1, 2.2, 3.3})
void testDoubleRange(double value) {
    assertTrue(value > 1.0 && value < 4.0);
}

private boolean isPalindrome(String s) {
    return new StringBuilder(s).reverse().toString().equals(s);
}

5.3 @CsvSource

适用于多参数场景,以 CSV 格式提供参数:

java 复制代码
@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, 20, 30",
    "-1, 1, 0"
})
void testAdd(int a, int b, int expected) {
    assertEquals(expected, a + b);
}

@ParameterizedTest
@CsvSource(value = {
    "张三:25:北京",
    "李四:30:上海",
    "王五:28:广州"
}, delimiter = ':')
void testUserCreation(String name, int age, String city) {
    User user = new User(name, age, city);
    assertAll(
        () -> assertEquals(name, user.getName()),
        () -> assertEquals(age, user.getAge()),
        () -> assertEquals(city, user.getCity())
    );
}

// 使用 null 值
@ParameterizedTest
@CsvSource({
    "apple, 1",
    "banana, 2",
    "'', 3"    // 空字符串表示 null
})
void testWithNull(String fruit, int quantity) {
    if (fruit == null) {
        assertNull(fruit);
    } else {
        assertNotNull(fruit);
    }
}

5.4 @MethodSource

通过工厂方法提供复杂参数:

java 复制代码
@ParameterizedTest
@MethodSource("provideStrings")
void testWithMethodSource(String input, boolean expected) {
    assertEquals(expected, StringUtils.isNotBlank(input));
}

// 工厂方法必须是 static(除非使用 PER_CLASS 生命周期)
static Stream<Arguments> provideStrings() {
    return Stream.of(
        Arguments.of("hello", true),
        Arguments.of("", false),
        Arguments.of("  ", false),
        Arguments.of(null, false)
    );
}

// 多参数复杂对象
@ParameterizedTest
@MethodSource("provideUsers")
void testUserValidation(User user, boolean valid) {
    assertEquals(valid, userValidator.validate(user));
}

static Stream<Arguments> provideUsers() {
    return Stream.of(
        Arguments.of(new User("张三", 25, "a@b.com"), true),
        Arguments.of(new User("", 25, "a@b.com"), false),
        Arguments.of(new User("李四", -1, "a@b.com"), false),
        Arguments.of(new User("王五", 30, "invalid"), false)
    );
}

5.5 @EnumSource

java 复制代码
enum Season { SPRING, SUMMER, AUTUMN, WINTER }

@ParameterizedTest
@EnumSource(Season.class)
void testAllSeasons(Season season) {
    assertNotNull(season.name());
}

// 排除某些枚举值
@ParameterizedTest
@EnumSource(value = Season.class, names = {"WINTER"}, mode = EnumSource.Mode.EXCLUDE)
void testNonWinterSeasons(Season season) {
    assertNotEquals(Season.WINTER, season);
}

// 正则匹配
@ParameterizedTest
@EnumSource(value = Season.class, names = "^S.*", mode = EnumSource.Mode.MATCH_ALL)
void testSeasonsStartingWithS(Season season) {
    assertTrue(season.name().startsWith("S"));
}

5.6 @CsvFileSource

java 复制代码
@ParameterizedTest
@CsvFileSource(resources = "/test-data/users.csv", numLinesToSkip = 1)
void testFromCsvFile(String name, int age, String email) {
    User user = new User(name, age, email);
    assertTrue(user.getAge() > 0);
    assertTrue(user.getEmail().contains("@"));
}

/test-data/users.csv

csv 复制代码
name,age,email
张三,25,zhangsan@example.com
李四,30,lisi@example.com

5.7 自定义参数转换

java 复制代码
@ParameterizedTest
@CsvSource({"2024-01-01, 2024", "2023-06-15, 2023"})
void testDateParsing(
        @ConvertWith(DateFormatConverter.class) LocalDate date,
        int expectedYear) {
    assertEquals(expectedYear, date.getYear());
}

// 自定义转换器
public class DateFormatConverter implements ArgumentConverter {
    @Override
    public Object convert(Object source, ParameterContext context) {
        return LocalDate.parse((String) source, DateTimeFormatter.ISO_LOCAL_DATE);
    }
}

六、嵌套测试(@Nested)

6.1 基本用法

@Nested 允许通过内部类组织相关测试,形成层次化结构:

java 复制代码
@DisplayName("Stack 测试")
class StackTest {

    private Stack<String> stack;

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

    @Nested
    @DisplayName("当 Stack 为空时")
    class WhenNew {

        @Test
        @DisplayName("size 应返回 0")
        void shouldReturnZeroSize() {
            assertEquals(0, stack.size());
        }

        @Test
        @DisplayName("pop 应抛出 EmptyStackException")
        void shouldThrowOnPop() {
            assertThrows(EmptyStackException.class, () -> stack.pop());
        }

        @Test
        @DisplayName("peek 应抛出 EmptyStackException")
        void shouldThrowOnPeek() {
            assertThrows(EmptyStackException.class, () -> stack.peek());
        }
    }

    @Nested
    @DisplayName("当 Stack 有一个元素时")
    class WithOneElement {

        @BeforeEach
        void pushElement() {
            stack.push("first");
        }

        @Test
        @DisplayName("size 应返回 1")
        void shouldReturnOneSize() {
            assertEquals(1, stack.size());
        }

        @Test
        @DisplayName("peek 应返回栈顶元素")
        void shouldReturnTopElement() {
            assertEquals("first", stack.peek());
        }

        @Test
        @DisplayName("pop 后应为空")
        void shouldBeEmptyAfterPop() {
            stack.pop();
            assertTrue(stack.isEmpty());
        }
    }

    @Nested
    @DisplayName("当 Stack 有多个元素时")
    class WithMultipleElements {

        @BeforeEach
        void pushElements() {
            stack.push("first");
            stack.push("second");
            stack.push("third");
        }

        @Test
        @DisplayName("应遵循 LIFO 顺序")
        void shouldFollowLifo() {
            assertEquals("third", stack.pop());
            assertEquals("second", stack.pop());
            assertEquals("first", stack.pop());
        }
    }
}

6.2 嵌套规则

  • @Nested 类必须是非 static 内部类
  • 可以访问外部类的实例字段和方法
  • 外部类的 @BeforeEach/@AfterEach 对嵌套类同样生效
  • 支持多层嵌套
  • 嵌套类中不能 声明 @BeforeAll/@AfterAll(除非使用 PER_CLASS 生命周期)

七、条件测试

7.1 @Disabled

java 复制代码
@Test
@Disabled("功能尚未实现,等待 v2.0 发布")
void testFutureFeature() {
    // 此测试不会执行
}

@Disabled("整个类暂时禁用")
class DisabledClassTest {
    @Test
    void testSomething() { }
}

7.2 操作系统条件

java 复制代码
@Test
@EnabledOnOs(OS.WINDOWS)
void testWindowsOnly() {
    assertTrue(System.getProperty("os.name").contains("Windows"));
}

@Test
@EnabledOnOs({OS.LINUX, OS.MAC})
void testUnixLike() {
    assertTrue(new File("/tmp").exists());
}

@Test
@DisabledOnOs(OS.WINDOWS)
void testNotOnWindows() {
    // 在非 Windows 系统执行
}

7.3 JRE 版本条件

java 复制代码
@Test
@EnabledOnJre(JRE.JAVA_17)
void testJava17Only() {
    // 仅在 Java 17 上执行
}

@Test
@EnabledForJreRange(min = JRE.JAVA_11, max = JRE.JAVA_21)
void testJava11To21() {
    // Java 11 ~ 21 范围内执行
}

@Test
@DisabledOnJre(JRE.JAVA_8)
void testNotOnJava8() {
    // Java 8 上不执行
}

7.4 系统属性和环境变量条件

java 复制代码
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void testOnlyOn64Bit() {
    // 64位架构执行
}

@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void testOnlyInCI() {
    // 仅在 CI 环境执行
}

@Test
@DisabledIfSystemProperty(named = "java.vm.name", matches = ".*GraalVM.*")
void testNotOnGraalVM() {
    // 非 GraalVM 执行
}

7.5 自定义条件 @EnabledIf / @DisabledIf

java 复制代码
@Test
@EnabledIf("isDatabaseAvailable")
void testWithDatabase() {
    // 仅在数据库可用时执行
}

boolean isDatabaseAvailable() {
    try {
        DriverManager.getConnection("jdbc:h2:mem:test");
        return true;
    } catch (SQLException e) {
        return false;
    }
}

// 基于类的条件(JUnit 5.7+)
@Test
@EnabledIf(CustomCondition.class)
void testWithCustomCondition() {
    // 自定义条件类
}

class CustomCondition implements ExecutionCondition {
    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        boolean enabled = "prod".equals(System.getProperty("app.env"));
        return enabled
            ? ConditionEvaluationResult.enabled("生产环境")
            : ConditionEvaluationResult.disabled("非生产环境,跳过");
    }
}

八、扩展模型(Extension API)

8.1 扩展模型概述

JUnit 5 的扩展模型取代了 JUnit 4 的 Runner/Rule 机制,提供统一的扩展点:

扩展接口 触发时机
BeforeAllCallback 所有测试前
AfterAllCallback 所有测试后
BeforeEachCallback 每个测试前
AfterEachCallback 每个测试后
BeforeTestExecutionCallback 测试方法执行前(@BeforeEach 之后)
AfterTestExecutionCallback 测试方法执行后(@AfterEach 之前)
ParameterResolver 参数注入
TestExecutionExceptionHandler 异常处理
TestInstancePostProcessor 实例创建后

8.2 注册方式

java 复制代码
// 方式一:声明式(@ExtendWith)
@ExtendWith(TimingExtension.class)
class MyTest { }

// 方式二:编程式(@RegisterExtension)
class MyTest {
    @RegisterExtension
    static TimingExtension timing = new TimingExtension();
}

// 方式三:ServiceLoader 自动发现
// META-INF/services/org.junit.jupiter.api.extension.Extension
// com.example.TimingExtension
// 需配置:junit.jupiter.extensions.autodetection.enabled=true

8.3 ParameterResolver 示例

java 复制代码
// 自定义扩展:自动注入当前测试的临时目录
public class TempDirExtension implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == Path.class
            && parameterContext.isAnnotated(InjectTempDir.class);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext) {
        try {
            return Files.createTempDirectory("junit5-test");
        } catch (IOException e) {
            throw new ParameterResolutionException("创建临时目录失败", e);
        }
    }
}

// 自定义注解
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface InjectTempDir { }

// 使用
@ExtendWith(TempDirExtension.class)
class FileServiceTest {

    @Test
    void testFileWrite(@InjectTempDir Path tempDir) throws IOException {
        Path file = tempDir.resolve("test.txt");
        Files.writeString(file, "hello");
        assertEquals("hello", Files.readString(file));
    }
}

8.4 生命周期回调示例

java 复制代码
public class TimingExtension implements BeforeTestExecutionCallback,
                                        AfterTestExecutionCallback {

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put("start", System.nanoTime());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long start = getStore(context).remove("start", long.class);
        long duration = System.nanoTime() - start;
        String methodName = context.getRequiredTestMethod().getName();
        System.out.printf("[%s] 执行耗时: %.2f ms%n",
            methodName, duration / 1_000_000.0);
    }

    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(
            ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()));
    }
}

@ExtendWith(TimingExtension.class)
class PerformanceTest {

    @Test
    void testSlowOperation() throws InterruptedException {
        Thread.sleep(100);
    }
}

九、自定义扩展

9.1 数据库事务回滚扩展

java 复制代码
public class TransactionalTestExtension implements BeforeEachCallback,
                                                   AfterEachCallback {

    private static final ExtensionContext.Namespace NS =
        ExtensionContext.Namespace.create(TransactionalTestExtension.class);

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        Connection conn = DataSourceProvider.getConnection();
        conn.setAutoCommit(false);
        getStore(context).put("connection", conn);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        Connection conn = getStore(context).remove("connection", Connection.class);
        if (conn != null) {
            conn.rollback();
            conn.setAutoCommit(true);
            conn.close();
        }
    }

    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(NS);
    }
}

// 使用
@ExtendWith(TransactionalTestExtension.class)
class UserRepositoryTest {

    @Test
    void testSaveUser() {
        userRepository.save(new User("测试用户"));
        assertEquals(1, userRepository.count());
        // 测试结束后自动回滚,不污染数据库
    }
}

9.2 重试扩展

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int value() default 3;
}

public class RetryExtension implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(ExtensionContext context,
                                             Throwable throwable) throws Throwable {
        Method method = context.getRequiredTestMethod();
        Retry retry = method.getAnnotation(Retry.class);

        if (retry == null) {
            throw throwable; // 无注解,直接抛出
        }

        int maxAttempts = retry.value();
        for (int attempt = 2; attempt <= maxAttempts; attempt++) {
            try {
                // 重新调用测试方法
                method.invoke(context.getRequiredTestInstance());
                return; // 成功则返回
            } catch (InvocationTargetException e) {
                if (attempt == maxAttempts) {
                    throw e.getCause(); // 最后一次仍失败
                }
            }
        }
    }
}

// 使用
@ExtendWith(RetryExtension.class)
class FlakyServiceTest {

    @Test
    @Retry(3)
    void testUnstableNetworkCall() {
        // 可能因网络波动失败,最多重试3次
        String result = httpClient.get("https://api.example.com/data");
        assertNotNull(result);
    }
}

十、测试实例生命周期

10.1 PER_METHOD(默认)

每个测试方法创建一个新的测试类实例,测试间完全隔离:

java 复制代码
// 默认行为,无需显式声明
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class PerMethodTest {

    private int counter = 0;

    @Test
    void testOne() {
        counter++;
        assertEquals(1, counter); // 始终为1(新实例)
    }

    @Test
    void testTwo() {
        counter++;
        assertEquals(1, counter); // 同样为1(另一个新实例)
    }
}

10.2 PER_CLASS

所有测试方法共享同一个实例:

java 复制代码
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {

    private int counter = 0;

    @BeforeAll
    void beforeAll() {
        // PER_CLASS 模式下 @BeforeAll 无需 static
        System.out.println("初始化(非 static)");
    }

    @Test
    void testOne() {
        counter++;
        assertEquals(1, counter);
    }

    @Test
    void testTwo() {
        counter++;
        // 注意:执行顺序不确定,此处可能为2
        assertTrue(counter >= 1);
    }

    @AfterAll
    void afterAll() {
        // 无需 static
        System.out.println("清理,counter = " + counter);
    }
}

10.3 全局配置

src/test/resources/junit-platform.properties 中配置:

properties 复制代码
# 全局默认生命周期
junit.jupiter.testinstance.lifecycle.default=per_class

# 启用扩展自动发现
junit.jupiter.extensions.autodetection.enabled=true

# 测试方法排序
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$OrderAnnotation

十一、动态测试(@TestFactory / DynamicTest)

11.1 基本动态测试

java 复制代码
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;

class DynamicTestDemo {

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        List<String> inputs = List.of("racecar", "madam", "hello", "level");
        List<Boolean> expected = List.of(true, true, false, true);

        return inputs.stream()
            .map(input -> DynamicTest.dynamicTest(
                "回文检测: " + input,
                () -> assertEquals(
                    expected.get(inputs.indexOf(input)),
                    isPalindrome(input)
                )
            ));
    }

    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        return List.of(
            DynamicTest.dynamicTest("加法: 1+1=2",
                () -> assertEquals(2, 1 + 1)),
            DynamicTest.dynamicTest("加法: 2+3=5",
                () -> assertEquals(5, 2 + 3)),
            DynamicTest.dynamicTest("乘法: 3*4=12",
                () -> assertEquals(12, 3 * 4))
        );
    }

    private boolean isPalindrome(String s) {
        return new StringBuilder(s).reverse().toString().equals(s);
    }
}

11.2 动态容器(DynamicContainer)

java 复制代码
@TestFactory
Stream<DynamicNode> dynamicTestsWithContainers() {
    Map<String, List<String>> testData = Map.of(
        "水果", List.of("apple", "banana", "cherry"),
        "蔬菜", List.of("carrot", "broccoli")
    );

    return testData.entrySet().stream()
        .map(entry -> DynamicContainer.dynamicContainer(
            entry.getKey() + " 测试",
            entry.getValue().stream()
                .map(item -> DynamicTest.dynamicTest(
                    "验证: " + item,
                    () -> assertFalse(item.isEmpty())
                ))
        ));
}

11.3 基于数据库的动态测试

java 复制代码
@TestFactory
Stream<DynamicTest> dynamicTestsFromDatabase() {
    return userRepository.findAll().stream()
        .map(user -> DynamicTest.dynamicTest(
            "验证用户: " + user.getName(),
            () -> {
                assertNotNull(user.getEmail());
                assertTrue(user.getAge() > 0);
            }
        ));
}

十二、与 Spring Boot Test 集成

12.1 基本配置

Spring Boot 2.2+ 默认使用 JUnit 5:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

12.2 @SpringBootTest 全量集成测试

java 复制代码
@SpringBootTest
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    void testCreateAndFindUser() {
        User created = userService.createUser(new UserDTO("张三", "zhang@test.com"));

        assertNotNull(created.getId());

        User found = userService.findById(created.getId());
        assertEquals("张三", found.getName());
    }
}

12.3 切片测试

java 复制代码
// Web 层测试
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void testGetUser() throws Exception {
        when(userService.findById(1L))
            .thenReturn(new User(1L, "张三", "zhang@test.com"));

        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("张三"))
            .andExpect(jsonPath("$.email").value("zhang@test.com"));
    }

    @Test
    void testCreateUserValidation() throws Exception {
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\",\"email\":\"invalid\"}"))
            .andExpect(status().isBadRequest());
    }
}

// 数据层测试
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void testFindByName() {
        entityManager.persist(new User("张三", "zhang@test.com"));
        entityManager.flush();

        Optional<User> found = userRepository.findByName("张三");
        assertTrue(found.isPresent());
        assertEquals("zhang@test.com", found.get().getEmail());
    }
}

12.4 @TestConfiguration

java 复制代码
@SpringBootTest
@Import(TestConfig.class)
class ServiceWithMockDependencyTest {

    @Autowired
    private OrderService orderService;

    @Test
    void testPlaceOrder() {
        OrderResult result = orderService.placeOrder(new OrderRequest("item-1", 2));
        assertTrue(result.isSuccess());
    }
}

@TestConfiguration
class TestConfig {

    @Bean
    @Primary
    public PaymentGateway mockPaymentGateway() {
        return new MockPaymentGateway(); // 测试用模拟实现
    }
}

十三、与 Mockito 集成

13.1 基本集成

java 复制代码
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentService paymentService;

    @InjectMocks
    private OrderService orderService;

    @Test
    void testPlaceOrderSuccess() {
        // Arrange
        Order order = new Order("item-1", 2, new BigDecimal("99.9"));
        when(paymentService.charge(any())).thenReturn(PaymentResult.success());
        when(orderRepository.save(any())).thenAnswer(inv -> {
            Order saved = inv.getArgument(0);
            saved.setId(1L);
            return saved;
        });

        // Act
        OrderResult result = orderService.placeOrder(order);

        // Assert
        assertTrue(result.isSuccess());
        assertEquals(1L, result.getOrderId());
        verify(paymentService, times(1)).charge(any());
        verify(orderRepository, times(1)).save(any());
    }

    @Test
    void testPlaceOrderPaymentFailed() {
        when(paymentService.charge(any()))
            .thenReturn(PaymentResult.failure("余额不足"));

        OrderResult result = orderService.placeOrder(
            new Order("item-1", 1, new BigDecimal("999")));

        assertFalse(result.isSuccess());
        assertEquals("余额不足", result.getMessage());
        verify(orderRepository, never()).save(any());
    }
}

13.2 @Spy 部分模拟

java 复制代码
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {

    @Spy
    private EmailTemplateEngine templateEngine = new EmailTemplateEngine();

    @Mock
    private SmtpClient smtpClient;

    @InjectMocks
    private NotificationService notificationService;

    @Test
    void testSendWelcomeEmail() {
        // templateEngine 使用真实逻辑,smtpClient 被模拟
        notificationService.sendWelcome("user@test.com", "张三");

        verify(smtpClient).send(
            eq("user@test.com"),
            eq("欢迎加入"),
            contains("张三")
        );
    }
}

13.3 Mockito 严格模式

java 复制代码
// MockitoExtension 默认使用 STRICT_STUBS
// 未使用的 stub 会报 UnnecessaryStubbingException
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class LenientMockTest {

    @Mock
    private SomeService service;

    @Test
    void testWithLenientStub() {
        // 即使此 stub 未被使用也不会报错
        when(service.getData()).thenReturn("data");
        // 测试其他逻辑...
    }
}

十四、测试覆盖率(JaCoCo)

14.1 Maven 配置

xml 复制代码
<build>
    <plugins>
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.11</version>
            <executions>
                <!-- 准备 agent -->
                <execution>
                    <id>prepare-agent</id>
                    <goals>
                        <goal>prepare-agent</goal>
                    </goals>
                </execution>
                <!-- 生成报告 -->
                <execution>
                    <id>report</id>
                    <phase>test</phase>
                    <goals>
                        <goal>report</goal>
                    </goals>
                </execution>
                <!-- 覆盖率检查 -->
                <execution>
                    <id>check</id>
                    <goals>
                        <goal>check</goal>
                    </goals>
                    <configuration>
                        <rules>
                            <rule>
                                <element>BUNDLE</element>
                                <limits>
                                    <limit>
                                        <counter>LINE</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.80</minimum>
                                    </limit>
                                    <limit>
                                        <counter>BRANCH</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.70</minimum>
                                    </limit>
                                </limits>
                            </rule>
                        </rules>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

14.2 Gradle 配置

groovy 复制代码
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.11"
}

test {
    useJUnitPlatform()
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.70
            }
        }
    }
}

14.3 排除特定类

xml 复制代码
<configuration>
    <excludes>
        <exclude>**/config/**</exclude>
        <exclude>**/dto/**</exclude>
        <exclude>**/entity/**</exclude>
        <exclude>**/*Application.*</exclude>
    </excludes>
</configuration>

14.4 运行与报告

bash 复制代码
# Maven
mvn clean test jacoco:report

# 报告位置
# target/site/jacoco/index.html

# Gradle
./gradlew test jacocoTestReport

# 报告位置
# build/reports/jacoco/test/html/index.html

十五、最佳实践

15.1 测试命名规范

java 复制代码
// 推荐:should_期望行为_when_条件
class OrderServiceTest {

    @Test
    void shouldReturnOrder_whenOrderExists() { }

    @Test
    void shouldThrowNotFoundException_whenOrderIdInvalid() { }

    @Test
    void shouldCalculateDiscount_whenUserIsVip() { }
}

// 或使用 @DisplayName 提供可读描述
@Test
@DisplayName("当库存不足时,下单应抛出 InsufficientStockException")
void shouldThrowWhenStockInsufficient() { }

15.2 AAA 模式(Arrange-Act-Assert)

java 复制代码
@Test
void shouldApplyDiscountForVipUser() {
    // Arrange - 准备测试数据和环境
    User vipUser = new User("张三", UserType.VIP);
    Order order = new Order(vipUser, new BigDecimal("100"));

    // Act - 执行被测操作
    BigDecimal finalPrice = pricingService.calculate(order);

    // Assert - 验证结果
    assertEquals(new BigDecimal("80.00"), finalPrice);
}

15.3 测试独立性

java 复制代码
// 错误:测试间存在依赖
class BadExample {
    private static Long createdId;

    @Test
    @Order(1)
    void create() { createdId = service.create(...); }

    @Test
    @Order(2)
    void delete() { service.delete(createdId); } // 依赖 create 的结果
}

// 正确:每个测试独立
class GoodExample {
    @Test
    void shouldCreateUser() {
        User user = service.create(new UserDTO("张三"));
        assertNotNull(user.getId());
    }

    @Test
    void shouldDeleteUser() {
        Long id = service.create(new UserDTO("李四")).getId(); // 自行准备数据
        service.delete(id);
        assertThrows(NotFoundException.class, () -> service.findById(id));
    }
}

15.4 合理使用 @Nested 组织

java 复制代码
@DisplayName("ShoppingCart")
class ShoppingCartTest {

    @Nested
    @DisplayName("addItem")
    class AddItem {
        @Test void shouldIncreaseQuantity() { }
        @Test void shouldThrowWhenItemNull() { }
        @Test void shouldThrowWhenQuantityExceedsLimit() { }
    }

    @Nested
    @DisplayName("removeItem")
    class RemoveItem {
        @Test void shouldDecreaseQuantity() { }
        @Test void shouldRemoveCompletelyWhenQuantityZero() { }
    }

    @Nested
    @DisplayName("checkout")
    class Checkout {
        @Test void shouldCalculateTotal() { }
        @Test void shouldApplyCoupon() { }
        @Test void shouldThrowWhenCartEmpty() { }
    }
}

15.5 参数化减少重复

java 复制代码
// 避免:多个相似测试
@Test void testAdd1Plus1() { assertEquals(2, calc.add(1, 1)); }
@Test void testAdd2Plus3() { assertEquals(5, calc.add(2, 3)); }
@Test void testAdd0Plus0() { assertEquals(0, calc.add(0, 0)); }

// 推荐:参数化
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({"1,1,2", "2,3,5", "0,0,0", "-1,1,0"})
void testAdd(int a, int b, int expected) {
    assertEquals(expected, calc.add(a, b));
}

15.6 扩展优于继承

java 复制代码
// 避免:通过继承共享测试逻辑
abstract class BaseIntegrationTest {
    // 大量共享逻辑,子类难以理解
}

// 推荐:通过扩展组合
@ExtendWith(DatabaseExtension.class)
@ExtendWith(TimingExtension.class)
@ExtendWith(CleanupExtension.class)
class MyIntegrationTest {
    // 清晰的测试逻辑,扩展各司其职
}

15.7 测试数据管理

java 复制代码
// 使用 Builder 模式构造测试数据
class UserFixture {
    static User.Builder aDefaultUser() {
        return User.builder()
            .name("默认用户")
            .email("default@test.com")
            .age(25)
            .status(UserStatus.ACTIVE);
    }
}

@Test
void testWithFixture() {
    User vip = UserFixture.aDefaultUser()
        .name("VIP用户")
        .type(UserType.VIP)
        .build();

    assertTrue(pricingService.hasDiscount(vip));
}

15.8 常见反模式

反模式 问题 改进
测试实现细节 重构即破坏测试 测试行为和输出
一个测试多个断言无关逻辑 失败时难以定位 单一职责
使用 Thread.sleep 等待 不稳定、慢 使用 Awaitility 或超时断言
共享可变状态 测试顺序敏感 每个测试独立准备数据
忽略异常测试 遗漏边界情况 使用 assertThrows 覆盖
过度 Mock 测试无实际价值 集成测试验证真实交互
相关推荐
会编程的吕洞宾4 小时前
DeepAgents In Action学习(Second)
android·java·学习
梦梦代码精4 小时前
基于UniApp+Vue3+ThinkPHP 8,这套知识付费系统的架构设计有点东西
java·低代码·docker·uni-app·开源·php
-凌凌漆-4 小时前
【freertos】Task创建(v2)
java·开发语言·算法
牛油果子哥q5 小时前
C++内存模型与深浅拷贝万字详解:栈堆静态内存布局、深浅拷贝底层差异、内存泄漏根治、拷贝崩溃踩坑、手写深拷贝实战
java·开发语言·c++
带多刺的玫瑰7 小时前
Leecode#4刷题之寻找两个正序数组的中位数
java·前端·算法
阿无,7 小时前
布隆过滤器
java·算法·哈希算法
Pocker_Spades_A7 小时前
飞算JavaAI 48分钟能做完Java全栈项目吗?
java·springboot·#ai编程·#飞算javaai·#java代码生成·#aicoding模型·#java开发
步行cgn7 小时前
MyBatis 一对多关联映射详解
java·后端
用户3126874877208 小时前
线程池到底怎么调?从源码看懂 ThreadPoolExecutor 的 7 个参数
java
SQL-First布道者8 小时前
持久层框架的评价标准:只有一个
java·spring boot·spring·tomcat·mybatis·spring jdbc