1. 为什么你需要掌握JUnit5核心注解
记得去年我接手一个金融项目,第一次看到测试覆盖率要求85%以上的时候,整个人都是懵的。之前在小公司写代码,能跑通就行,哪管什么单元测试。结果第一次代码评审就被打回来十几个测试用例,原因都是"测试覆盖率不足"和"测试用例设计不合理"。那段时间天天加班补测试,硬是把JUnit5的文档翻了个底朝天。
现在回头看,其实单元测试没想象中那么难,关键是要掌握JUnit5的核心玩法。SpringBoot项目里90%的测试场景,用好几个核心注解就能搞定。比如:
@BeforeEach/@AfterEach:处理测试前后的资源初始化和清理@BeforeAll/@AfterAll:一次性准备测试环境@ParameterizedTest:用不同参数反复测试同一个逻辑@RepeatedTest:验证代码的稳定性
这些注解组合起来用,能让你少写30%的重复代码。举个例子,测试用户服务时,用@BeforeEach初始化用户数据,用@ParameterizedTest测试不同年龄段的用户权限,再用@RepeatedTest验证并发场景下的稳定性,一套组合拳下来,测试覆盖率轻松达标。
2. 测试环境搭建与基础配置
2.1 依赖配置的正确姿势
新手最容易踩的坑就是依赖版本问题。我见过有人照着三年前的博客配依赖,结果注解死活不生效。现在SpringBoot 2.7+项目,只需要这一个依赖就够了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <version>3.1.0</version> <!-- 根据实际版本调整 --> </dependency>这个starter包含了:
- JUnit5核心引擎
- Mockito用于模拟对象
- AssertJ用于流式断言
- Hamcrest用于匹配器
- JSONassert用于JSON验证
2.2 测试类结构设计
好的测试类应该像这样组织:
@SpringBootTest class UserServiceTest { @Autowired private UserService userService; @MockBean private UserRepository userRepository; @BeforeAll static void initDatabase() { // 初始化测试数据库连接 } @BeforeEach void setup() { // 每个测试方法前的数据准备 Mockito.when(userRepository.findById(1L)) .thenReturn(Optional.of(new User("测试用户"))); } @Test void shouldGetUserById() { User user = userService.getUserById(1L); assertThat(user.getName()).isEqualTo("测试用户"); } @AfterEach void cleanup() { // 清理测试数据 } }关键点:
- 用
@SpringBootTest标记需要Spring上下文的测试 @MockBean自动替换Spring容器中的Bean- 生命周期方法用static修饰(BeforeAll/AfterAll)
- 每个测试方法保持独立,不依赖执行顺序
3. 生命周期注解的实战技巧
3.1 BeforeEach/AfterEach 的正确用法
很多人以为这两个注解就是用来初始化和清理资源的,其实它们还能做更多事。我在测试订单服务时发现一个典型场景:
public class OrderServiceTest { private Order testOrder; private List<OrderItem> testItems; @BeforeEach void prepareTestData() { // 准备基础测试数据 testOrder = new Order("ORDER_001"); testItems = List.of( new OrderItem("ITEM_001", 100), new OrderItem("ITEM_002", 200) ); // 模拟外部服务 Mockito.when(inventoryService.checkStock(any())) .thenReturn(true); } @Test void shouldCalculateTotalAmount() { testOrder.setItems(testItems); BigDecimal total = orderService.calculateTotal(testOrder); assertThat(total).isEqualByComparingTo("300.00"); } @AfterEach void verifyMocks() { // 验证所有mock交互是否完成 Mockito.verifyNoMoreInteractions(inventoryService); } }最佳实践:
- 在
@BeforeEach中准备测试数据+配置mock行为 - 在
@AfterEach中验证mock交互+清理状态 - 避免在
@BeforeEach中做耗时操作(如数据库连接)
3.2 BeforeAll/AfterAll 的高阶用法
这两个注解最适合做重量级初始化。我在测试文件上传功能时这样用:
@SpringBootTest class FileServiceTest { private static Path tempDirectory; @BeforeAll static void setupAll() throws IOException { // 创建临时目录(整个测试类共享) tempDirectory = Files.createTempDirectory("file_test_"); // 初始化MinIO测试容器 new GenericContainer<>("minio/minio") .withExposedPorts(9000) .withEnv("MINIO_ROOT_USER", "minio") .withEnv("MINIO_ROOT_PASSWORD", "minio123") .start(); } @Test void shouldUploadFile() { Path testFile = tempDirectory.resolve("test.txt"); Files.write(testFile, "test content".getBytes()); String url = fileService.upload(testFile); assertThat(url).isNotBlank(); } @AfterAll static void cleanupAll() throws IOException { // 删除整个临时目录 FileUtils.deleteDirectory(tempDirectory.toFile()); } }特别注意:
- 方法必须是static的
- 适合初始化数据库连接池、启动测试容器等操作
- 避免在这里放非线程安全的操作
4. 参数化测试的进阶玩法
4.1 基础参数注入
@ParameterizedTest配合不同数据源,能让测试代码减少50%重复。测试支付服务时我是这样用的:
@ParameterizedTest @ValueSource(strings = {"alipay", "wechat", "unionpay"}) void shouldSupportPaymentMethods(String method) { assertThat(paymentService.supports(method)).isTrue(); }4.2 复杂参数组合
当需要多参数时,可以用@MethodSource:
@ParameterizedTest @MethodSource("provideDiscountCases") void shouldCalculateDiscount(UserType userType, BigDecimal amount, BigDecimal expected) { BigDecimal actual = orderService.calculateDiscount(userType, amount); assertThat(actual).isEqualByComparingTo(expected); } private static Stream<Arguments> provideDiscountCases() { return Stream.of( Arguments.of(UserType.NORMAL, new BigDecimal("100"), new BigDecimal("100")), Arguments.of(UserType.VIP, new BigDecimal("1000"), new BigDecimal("900")), Arguments.of(UserType.SVIP, new BigDecimal("500"), new BigDecimal("400")) ); }4.3 CSV数据源实战
对于大量测试数据,推荐用CSV文件:
test-data/discount_cases.csv userType,amount,expected NORMAL,100,100 VIP,1000,900 SVIP,500,400@ParameterizedTest @CsvFileSource(resources = "/test-data/discount_cases.csv") void shouldCalculateDiscountWithCsv( UserType userType, BigDecimal amount, BigDecimal expected ) { BigDecimal actual = orderService.calculateDiscount(userType, amount); assertThat(actual).isEqualByComparingTo(expected); }5. 重复测试与稳定性验证
5.1 基础重复测试
@RepeatedTest特别适合验证随机数生成或并发安全:
@RepeatedTest(100) void shouldGenerateUniqueId() { String id1 = idGenerator.generate(); String id2 = idGenerator.generate(); assertThat(id1).isNotEqualTo(id2); }5.2 带上下文的重复测试
可以通过RepetitionInfo获取当前重复信息:
@RepeatedTest(value = 5, name = "第{currentRepetition}次/共{totalRepetitions}次") void shouldHandleConcurrentAccess(RepetitionInfo repetitionInfo) { int userId = repetitionInfo.getCurrentRepetition(); assertThatNoException() .isThrownBy(() -> userService.concurrentUpdate(userId)); }5.3 结合参数化重复测试
两者组合可以产生更强大的测试矩阵:
@ParameterizedTest @ValueSource(ints = {10, 100, 1000}) @RepeatedTest(3) void shouldHandleBatchInsert(int batchSize) { List<User> users = generateTestUsers(batchSize); assertThatNoException() .isThrownBy(() -> userService.batchInsert(users)); }6. 测试代码优化技巧
6.1 自定义显示名称
用@DisplayName让测试报告更友好:
@Test @DisplayName("当用户余额不足时,支付应该失败") void shouldFailWhenBalanceInsufficient() { // 测试逻辑 }6.2 嵌套测试组织
用@Nested分层组织测试用例:
class OrderServiceTest { @Nested class CreateOrder { @Test void shouldSuccessWithNormalUser() {} @Test void shouldFailWhenStockInsufficient() {} } @Nested class CancelOrder { @Test void shouldSuccessWithin30Minutes() {} @Test void shouldFailAfter30Minutes() {} } }6.3 条件化测试执行
根据环境动态启用测试:
@Test @EnabledOnOs(OS.LINUX) void shouldRunOnLinuxOnly() {} @Test @DisabledIfEnvironmentVariable(named = "CI", matches = "true") void shouldSkipInCI() {}7. 常见坑与解决方案
坑1:@BeforeAll方法不是static的
- 现象:测试启动时报错
- 解决:添加static修饰符
坑2:@ParameterizedTest方法有返回值
- 现象:参数无法注入
- 解决:方法返回值必须为void
坑3:测试顺序依赖
- 现象:单独运行成功,整体运行失败
- 解决:用
@TestMethodOrder显式控制顺序,或确保测试完全独立
坑4:数据库污染
- 现象:测试间数据互相影响
- 解决:在
@AfterEach中清理数据,或使用@Transactional自动回滚
坑5:Mock对象被意外重置
- 现象:在
@BeforeEach中配置的mock在测试中失效 - 解决:检查是否在其他地方调用了
Mockito.reset()