JUnit 5 完全指南:从入门到实战

前言

说到Java单元测试,JUnit绝对是绕不开的话题!从JUnit 4到JUnit 5的跨越,这可不是简单的版本升级那么简单。JUnit 5带来的变化是革命性的,架构重构、注解升级、断言增强...这些改进让我们的测试代码写起来更爽、更灵活。

今天就来深入聊聊JUnit 5,从基础概念到实际应用,保证让你彻底搞懂这个强大的测试框架!

JUnit 5 架构揭秘

三大核心模块

JUnit 5采用了全新的模块化架构设计,主要由三个子项目组成:

JUnit Platform(平台基础)

  • 提供测试引擎的基础API
  • 负责启动测试框架
  • 支持在JVM上运行各种测试框架

JUnit Jupiter(木星引擎)

  • 全新的编程和扩展模型
  • 提供新的注解和断言API
  • 这是我们日常开发中用得最多的部分

JUnit Vintage(兼容引擎)

  • 向后兼容JUnit 3和JUnit 4
  • 让老项目平滑迁移成为可能

这种设计真的很巧妙!把平台、新特性、兼容性完全分离,既保证了创新又照顾了历史包袱。

环境搭建与配置

Maven依赖配置

<dependencies>
    <!-- JUnit 5 核心依赖 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- 如果需要兼容JUnit 4 -->
    <dependency>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle配置

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
    testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.9.2'
}

test {
    useJUnitPlatform()
}

配置完成后就可以开始愉快的测试之旅了!

注解大全:从基础到高级

基础注解

JUnit 5的注解体系相比JUnit 4有了很大变化,让我们逐个击破:

import org.junit.jupiter.api.*;

class BasicAnnotationTest {
    
    @BeforeAll
    static void initAll() {
        // 所有测试方法执行前运行一次
        System.out.println("初始化测试环境");
    }
    
    @BeforeEach
    void init() {
        // 每个测试方法执行前都会运行
        System.out.println("准备单个测试");
    }
    
    @Test
    void basicTest() {
        // 基础测试方法
        assertEquals(2, 1 + 1);
    }
    
    @Test
    @DisplayName("这是一个有意义的测试名称")
    void meaningfulTestName() {
        // 自定义测试显示名称
        assertTrue(true);
    }
    
    @AfterEach
    void tearDown() {
        // 每个测试方法执行后都会运行
        System.out.println("清理单个测试");
    }
    
    @AfterAll
    static void tearDownAll() {
        // 所有测试方法执行后运行一次
        System.out.println("清理测试环境");
    }
}

高级注解

条件执行注解

class ConditionalTest {
    
    @Test
    @EnabledOnOs(OS.LINUX)
    void onLinuxOnly() {
        // 只在Linux系统上运行
    }
    
    @Test
    @EnabledOnJre(JRE.JAVA_8)
    void onJava8Only() {
        // 只在Java 8上运行
    }
    
    @Test
    @EnabledIfSystemProperty(named = "env", matches = "prod")
    void onProdEnvironment() {
        // 只在生产环境运行
    }
    
    @Test
    @Disabled("暂时禁用此测试")
    void disabledTest() {
        // 被禁用的测试
    }
}

重复和参数化测试

class AdvancedTest {
    
    @RepeatedTest(5)
    void repeatedTest(RepetitionInfo repetitionInfo) {
        // 重复执行5次
        System.out.println("执行第 " + repetitionInfo.getCurrentRepetition() + " 次");
    }
    
    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "junit"})
    void parameterizedTest(String word) {
        // 参数化测试
        assertNotNull(word);
        assertTrue(word.length() > 2);
    }
    
    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "10, 20, 30",
        "100, 200, 300"
    })
    void csvSourceTest(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
}

断言:让测试更精准

JUnit 5的断言API比之前强大太多了!不仅功能更丰富,错误信息也更详细。

基础断言

class AssertionTest {
    
    @Test
    void basicAssertions() {
        // 基础等值断言
        assertEquals(2, 1 + 1);
        assertNotEquals(3, 1 + 1);
        
        // 布尔断言
        assertTrue(2 > 1);
        assertFalse(1 > 2);
        
        // 空值断言
        assertNull(null);
        assertNotNull("not null");
        
        // 数组断言
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};
        assertArrayEquals(expected, actual);
    }
    
    @Test
    void assertionWithMessage() {
        // 带自定义错误信息的断言
        assertEquals(2, 1 + 1, "1 + 1 应该等于 2");
        
        // 使用Lambda延迟生成错误信息(性能更好)
        assertEquals(2, 1 + 1, () -> "计算结果错误:" + (1 + 1));
    }
}

高级断言

class AdvancedAssertionTest {
    
    @Test
    void groupedAssertions() {
        // 分组断言 - 一次性执行多个断言
        assertAll("用户信息验证",
            () -> assertEquals("John", user.getName()),
            () -> assertEquals(25, user.getAge()),
            () -> assertTrue(user.isActive())
        );
    }
    
    @Test
    void exceptionTesting() {
        // 异常测试
        Exception exception = assertThrows(IllegalArgumentException.class, 
            () -> new User(-1, "invalid"));
        
        assertEquals("年龄不能为负数", exception.getMessage());
        
        // 验证不抛出异常
        assertDoesNotThrow(() -> new User(25, "valid"));
    }
    
    @Test
    void timeoutTesting() {
        // 超时测试
        assertTimeout(Duration.ofSeconds(2), () -> {
            // 模拟耗时操作
            Thread.sleep(1000);
            return "完成";
        });
        
        // 抢占式超时(会立即终止)
        assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
            Thread.sleep(500);
            return "快速完成";
        });
    }
}

实战案例:用户服务测试

让我们通过一个完整的用户服务测试案例,看看JUnit 5在实际项目中的应用:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("用户服务测试套件")
class UserServiceTest {
    
    private UserService userService;
    private UserRepository mockRepository;
    
    @BeforeAll
    void setupAll() {
        // 初始化测试数据库连接等
        System.out.println("初始化测试环境");
    }
    
    @BeforeEach
    void setup() {
        mockRepository = mock(UserRepository.class);
        userService = new UserService(mockRepository);
    }
    
    @Nested
    @DisplayName("用户创建测试")
    class UserCreationTest {
        
        @Test
        @DisplayName("创建有效用户应该成功")
        void shouldCreateValidUser() {
            // Given
            CreateUserRequest request = new CreateUserRequest("张三", "zhangsan@email.com");
            User expectedUser = new User(1L, "张三", "zhangsan@email.com");
            
            when(mockRepository.save(any(User.class))).thenReturn(expectedUser);
            
            // When
            User actualUser = userService.createUser(request);
            
            // Then
            assertAll("用户创建验证",
                () -> assertEquals(expectedUser.getId(), actualUser.getId()),
                () -> assertEquals(expectedUser.getName(), actualUser.getName()),
                () -> assertEquals(expectedUser.getEmail(), actualUser.getEmail())
            );
        }
        
        @ParameterizedTest
        @DisplayName("无效用户信息应该抛出异常")
        @ValueSource(strings = {"", "   ", "a", "这个名字实在是太长了超过了系统允许的最大长度限制"})
        void shouldThrowExceptionForInvalidUserName(String invalidName) {
            CreateUserRequest request = new CreateUserRequest(invalidName, "test@email.com");
            
            IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> userService.createUser(request)
            );
            
            assertTrue(exception.getMessage().contains("用户名"));
        }
    }
    
    @Nested
    @DisplayName("用户查询测试")
    class UserQueryTest {
        
        @Test
        @DisplayName("根据ID查询存在的用户")
        void shouldFindUserById() {
            // Given
            Long userId = 1L;
            User expectedUser = new User(userId, "李四", "lisi@email.com");
            when(mockRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
            
            // When
            Optional<User> result = userService.findById(userId);
            
            // Then
            assertTrue(result.isPresent());
            assertEquals(expectedUser, result.get());
        }
        
        @Test
        @DisplayName("查询不存在的用户应返回空")
        void shouldReturnEmptyForNonExistentUser() {
            // Given
            Long userId = 999L;
            when(mockRepository.findById(userId)).thenReturn(Optional.empty());
            
            // When
            Optional<User> result = userService.findById(userId);
            
            // Then
            assertTrue(result.isEmpty());
        }
    }
    
    @Test
    @Timeout(value = 2, unit = TimeUnit.SECONDS)
    @DisplayName("批量操作应在规定时间内完成")
    void bulkOperationShouldCompleteInTime() {
        // 模拟批量操作
        List<User> users = IntStream.range(1, 1001)
            .mapToObj(i -> new User((long)i, "User" + i, "user" + i + "@test.com"))
            .collect(Collectors.toList());
            
        assertDoesNotThrow(() -> userService.batchCreateUsers(users));
    }
}

测试生命周期深度解析

JUnit 5提供了灵活的测试实例生命周期管理:

PER_METHOD vs PER_CLASS

// 默认模式:每个测试方法都创建新的测试实例
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class PerMethodTest {
    private int counter = 0;
    
    @Test
    void test1() {
        counter++;
        assertEquals(1, counter); // 总是通过
    }
    
    @Test
    void test2() {
        counter++;
        assertEquals(1, counter); // 总是通过
    }
}

// 整个测试类只创建一个实例
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
    private int counter = 0;
    
    @Test
    void test1() {
        counter++;
        assertEquals(1, counter); // 通过
    }
    
    @Test
    void test2() {
        counter++;
        assertEquals(2, counter); // 通过,counter被保留
    }
}

扩展机制:让测试更强大

JUnit 5的扩展机制是真正的亮点!通过扩展可以实现各种强大的功能。

自定义扩展

// 执行时间记录扩展
public class TimingExtension implements BeforeEachCallback, AfterEachCallback {
    
    @Override
    public void beforeEach(ExtensionContext context) {
        getStore(context).put("start-time", System.currentTimeMillis());
    }
    
    @Override
    public void afterEach(ExtensionContext context) {
        long startTime = getStore(context).get("start-time", Long.class);
        long duration = System.currentTimeMillis() - startTime;
        System.out.printf("方法 [%s] 执行耗时: %d ms%n", 
            context.getDisplayName(), duration);
    }
    
    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(ExtensionContext.Namespace.create(
            getClass(), context.getRequiredTestMethod()));
    }
}

// 使用扩展
@ExtendWith(TimingExtension.class)
class TimedTest {
    
    @Test
    void quickTest() throws InterruptedException {
        Thread.sleep(100);
        assertTrue(true);
    }
    
    @Test
    void slowTest() throws InterruptedException {
        Thread.sleep(500);
        assertTrue(true);
    }
}

迁移指南:从JUnit 4到JUnit 5

如果你的项目还在使用JUnit 4,迁移到JUnit 5其实并不复杂:

注解映射关系

JUnit 4 JUnit 5 说明
@Before @BeforeEach 每个测试前执行
@After @AfterEach 每个测试后执行
@BeforeClass @BeforeAll 所有测试前执行
@AfterClass @AfterAll 所有测试后执行
@Ignore @Disabled 禁用测试
@Category @Tag 测试标签

常见迁移问题

  1. 导入包变化:从org.junit改为org.junit.jupiter.api
  2. 断言方法参数顺序:JUnit 5中消息参数放在最后
  3. Expected异常测试:改用assertThrows方法

最佳实践与建议

经过这么多项目的实战经验,总结几个JUnit 5的使用建议:

测试命名规范

class CalculatorTest {
    
    // 好的命名:清楚描述测试场景和期望结果
    @Test
    void shouldReturnZeroWhenBothNumbersAreZero() {
        // 测试实现
    }
    
    @Test
    void shouldThrowExceptionWhenDivideByZero() {
        // 测试实现
    }
}

合理使用嵌套测试

class OrderServiceTest {
    
    @Nested
    @DisplayName("订单创建")
    class OrderCreation {
        // 订单创建相关测试
    }
    
    @Nested
    @DisplayName("订单支付") 
    class OrderPayment {
        // 订单支付相关测试
    }
    
    @Nested
    @DisplayName("订单取消")
    class OrderCancellation {
        // 订单取消相关测试
    }
}

善用参数化测试

@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void shouldCalculateCorrectly(int input1, int input2, int expected) {
    assertEquals(expected, calculator.add(input1, input2));
}

总结

JUnit 5真的是Java测试领域的一次重大升级!从架构设计到API设计,从扩展机制到生命周期管理,每一个改进都让我们的测试代码变得更好维护、更灵活、更强大。

特别是参数化测试、动态测试、嵌套测试这些新特性,大大提高了我们编写测试的效率。而且向后兼容的设计让老项目迁移变得非常平滑。

如果你还在犹豫要不要升级到JUnit 5,我的建议是:赶紧上车!新项目直接用JUnit 5,老项目也可以逐步迁移。相信我,一旦你体验过JUnit 5的强大功能,就再也回不去了。

测试驱动开发(TDD)已经成为现代软件开发的标配,而JUnit 5就是我们手中最锋利的武器。掌握好这个工具,让我们的代码质量更上一层楼!

posted @ 2025-09-30 15:30  ctooffice  阅读(179)  评论(0)    收藏  举报