前言

说到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依赖配置

org.junit.jupiter

junit-jupiter

5.9.2

test

org.junit.vintage

junit-vintage-engine

5.9.2

test

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 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 result = userService.findById(userId);

// Then

assertTrue(result.isEmpty());

}

}

@Test

@Timeout(value = 2, unit = TimeUnit.SECONDS)

@DisplayName("批量操作应在规定时间内完成")

void bulkOperationShouldCompleteInTime() {

// 模拟批量操作

List 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

测试标签

常见迁移问题

导入包变化:从org.junit改为org.junit.jupiter.api

断言方法参数顺序:JUnit 5中消息参数放在最后

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就是我们手中最锋利的武器。掌握好这个工具,让我们的代码质量更上一层楼!