JUnit 5 测试生命周期深度实战——Before/After、嵌套测试、并行执行
JUnit 5 测试生命周期深度实战——Before/After、嵌套测试、并行执行
适读人群:已经在用 JUnit 5 写测试,但遇到过奇怪的测试顺序问题、状态污染、测试变慢等困扰的工程师 | 阅读时长:约13分钟 | 核心价值:彻底搞清楚 JUnit 5 的生命周期机制,让测试稳定可靠
那个在 CI 上随机失败的测试
去年帮一个团队排查问题,他们有一组测试,在本地跑永远是绿的,但在 CI 上每天都有一两个测试随机失败。
随机失败,是测试里最难排查的问题。
我拉了他们的测试代码出来看,定位到了一个典型问题:
class OrderServiceTest {
private static OrderService orderService = new OrderService();
private static int testOrderId; // 静态变量,跨测试共享
@Test
void testCreateOrder() {
Order order = orderService.create(new CreateOrderRequest());
testOrderId = order.getId(); // test1 设置这个值
assertNotNull(order.getId());
}
@Test
void testGetOrder() {
Order order = orderService.get(testOrderId); // test2 依赖 test1 设置的值
assertNotNull(order);
}
}这个代码有两个问题:
testOrderId是静态变量,测试之间通过它传递状态testGetOrder隐式依赖testCreateOrder先执行
在本地,JUnit 5 的默认执行顺序恰好让这两个测试按顺序跑,所以通过了。但 CI 环境开启了并行测试,顺序不确定,testGetOrder 可能在 testCreateOrder 之前运行,导致 testOrderId 还是 0,测试失败。
这就是不理解生命周期机制的后果。
生命周期基础:实例模型
首先要理解 JUnit 5 的测试实例模型。
JUnit 5 的默认行为:每个 @Test 方法都会创建一个新的测试类实例。
class LifecycleDemo {
public LifecycleDemo() {
System.out.println("构造函数 - hashCode: " + this.hashCode());
}
@BeforeEach
void setUp() {
System.out.println("@BeforeEach - hashCode: " + this.hashCode());
}
@Test
void test1() {
System.out.println("@Test test1 - hashCode: " + this.hashCode());
}
@Test
void test2() {
System.out.println("@Test test2 - hashCode: " + this.hashCode());
}
}输出结果类似:
构造函数 - hashCode: 1234567
@BeforeEach - hashCode: 1234567
@Test test1 - hashCode: 1234567
构造函数 - hashCode: 7654321 // 新实例!
@BeforeEach - hashCode: 7654321
@Test test2 - hashCode: 7654321每个测试方法都有自己的实例,实例变量天然隔离,这是"测试隔离"的基础保证。
改变实例模式:@TestInstance
如果你需要在同一个测试类里跨测试共享实例(比如建立代价昂贵的连接),可以改为"类级别"的实例:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DatabaseIntegrationTest {
private Connection connection; // 实例变量,跨测试共享
@BeforeAll
void setUpDatabase() {
// 不再需要 static!因为整个类共用一个实例
connection = DriverManager.getConnection("jdbc:h2:mem:testdb");
}
@AfterAll
void tearDown() throws Exception {
connection.close(); // 同样不需要 static
}
@Test
void testInsert() {
// 使用 connection
}
}注意:使用 PER_CLASS 之后,测试之间会共享状态,你需要在 @BeforeEach 里手动重置可变状态。
Before/After 的完整执行顺序
搞清楚这个执行顺序,能解决 80% 的生命周期相关问题:
@BeforeAll(类级别,static)
|
+-- 测试方法 1
| |
| @BeforeEach
| @Test
| @AfterEach
|
+-- 测试方法 2
| |
| @BeforeEach
| @Test
| @AfterEach
|
@AfterAll(类级别,static)当有父类和子类时:
父类 @BeforeAll
子类 @BeforeAll
|
+-- 每个测试
父类 @BeforeEach
子类 @BeforeEach
@Test
子类 @AfterEach
父类 @AfterEach
子类 @AfterAll
父类 @AfterAll实战:用基类封装通用的测试基础设施
// 测试基类,封装公共的 Spring 上下文配置
@SpringBootTest
@Transactional
abstract class BaseServiceTest {
@Autowired
protected EntityManager entityManager;
@BeforeEach
void baseSetUp() {
// 每个测试前清除一级缓存,避免脏读
entityManager.clear();
}
protected void flushAndClear() {
entityManager.flush();
entityManager.clear();
}
}
// 具体测试类,继承基类
class UserServiceTest extends BaseServiceTest {
@Autowired
private UserService userService;
@BeforeEach
void setUp() {
// 子类的 setUp,会在父类 setUp 之后执行
// 初始化测试数据
}
@Test
void testCreateUser() {
User user = userService.create("test@example.com", "password");
flushAndClear(); // 用父类提供的工具方法
User found = userService.findByEmail("test@example.com");
assertNotNull(found);
assertEquals(user.getId(), found.getId());
}
}@Nested:嵌套测试的正确打开方式
嵌套测试是 JUnit 5 最有特色的功能之一,能让测试结构更清晰,更好地描述被测行为。
@DisplayName("OrderService 测试")
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Nested
@DisplayName("创建订单")
class CreateOrder {
@Test
@DisplayName("正常情况:用户余额充足,库存充足")
void success() {
// ...
}
@Nested
@DisplayName("创建订单 - 异常情况")
class CreateOrderFailure {
@Test
@DisplayName("余额不足时抛出 InsufficientBalanceException")
void whenBalanceInsufficient() {
// ...
}
@Test
@DisplayName("库存不足时抛出 OutOfStockException")
void whenOutOfStock() {
// ...
}
@Test
@DisplayName("商品下架时抛出 ProductUnavailableException")
void whenProductUnavailable() {
// ...
}
}
}
@Nested
@DisplayName("取消订单")
class CancelOrder {
@Test
@DisplayName("待支付状态的订单可以取消")
void canCancelPendingOrder() {
// ...
}
@Test
@DisplayName("已发货的订单不能取消")
void cannotCancelShippedOrder() {
// ...
}
}
}测试报告里会展示层次结构,像一个行为文档。
嵌套类的生命周期:有个重要陷阱
@BeforeAll 在嵌套类里默认不能用!
因为嵌套的 @Nested 类默认是非静态内部类,而 @BeforeAll 要求方法必须是 static(除非整个类是 PER_CLASS 模式)。
@Nested
class CreateOrderTest {
// 这里不能用 @BeforeAll,会报错!
// 除非整个外部类是 @TestInstance(PER_CLASS)
@BeforeEach // 只能用 @BeforeEach
void setUp() {
// 这里做每个嵌套测试的数据准备
}
}解法:如果嵌套类需要 @BeforeAll,把外层类改成 @TestInstance(PER_CLASS),嵌套类也一起生效。
并行执行:让测试快起来
当项目测试数量达到几百个时,串行执行可能要3-5分钟甚至更久。JUnit 5 支持并行执行测试。
开启并行执行
在 src/test/resources/junit-platform.properties 里配置:
# 开启并行执行
junit.jupiter.execution.parallel.enabled=true
# 并行策略:DYNAMIC(根据 CPU 核数动态分配)
junit.jupiter.execution.parallel.mode.default=CONCURRENT
# 类级别也并行
junit.jupiter.execution.parallel.mode.classes.default=CONCURRENT
# 最大并行线程数(建议 = CPU 核数)
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4线程安全注解
开启并行后,你需要明确标记哪些测试是线程安全的:
// 这个测试可以和其他测试并行执行(默认并行时需要确保无共享状态)
@Execution(ExecutionMode.CONCURRENT)
class StatelessServiceTest {
// 每个测试方法完全独立,没有共享状态
}
// 这个测试必须串行执行(修改了全局状态)
@Execution(ExecutionMode.SAME_THREAD)
class GlobalCacheTest {
// 这个测试会修改 Redis 全局缓存,不能并行
}踩坑实录三则
踩坑一:@BeforeAll 忘了写 static,运行时报 JUnitException
现象:
org.junit.jupiter.api.extension.ExtensionConfigurationException:
@BeforeAll method 'void TestClass.setUp()' must be static
unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS)原因:@BeforeAll 和 @AfterAll 要求方法是 static,因为它们在类实例创建之前就需要执行。
解法:要么加 static,要么加 @TestInstance(Lifecycle.PER_CLASS)。我的建议是:如果用 Spring 容器,一般会配合 @SpringBootTest 使用 PER_CLASS,因为 Spring 上下文复用本来就是类级别的。
踩坑二:开启并行执行后,依赖数据库的测试互相覆盖数据
现象:并行执行后,某些数据库测试开始随机失败,错误是数据重复插入或数据不存在。
原因:多个测试同时往同一张表插数据,主键冲突;或者一个测试删了另一个测试正在用的数据。
解法:
- 数据库测试不开并行,用
@Execution(ExecutionMode.SAME_THREAD)标记 - 或者每个测试使用唯一的数据前缀(如 UUID),避免冲突
- 推荐做法:单元测试开并行,集成测试(数据库)不开并行
// 配置:类级别默认串行,但单个方法可以选择并行
junit.jupiter.execution.parallel.mode.default=SAME_THREAD
junit.jupiter.execution.parallel.mode.classes.default=CONCURRENT踩坑三:@BeforeEach 里抛异常,@AfterEach 不执行导致资源泄漏
现象:@BeforeEach 里申请了资源(打开文件、建立连接),中途抛异常,@AfterEach 没执行,资源泄漏。
原因:@BeforeEach 抛出异常时,对应的测试方法不会执行,但 @AfterEach 仍然会执行。所以实际上这里不是 JUnit 的 bug,而是资源管理本身的问题——@BeforeEach 里应该做好异常安全。
@BeforeEach
void setUp() {
try {
connection = createConnection();
// 其他初始化...
} catch (Exception e) {
// 如果初始化失败,提前清理已创建的资源
if (connection != null) {
try { connection.close(); } catch (Exception ignored) {}
}
throw e; // 重新抛出,让 JUnit 知道 setUp 失败了
}
}完整示例:整合所有生命周期特性
@DisplayName("支付服务集成测试")
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Transactional
class PaymentServiceIntegrationTest {
@Autowired
private PaymentService paymentService;
@Autowired
private AccountRepository accountRepository;
private Account testAccount;
@BeforeAll
void globalSetUp() {
// 不需要 static,因为 PER_CLASS 模式
System.out.println("整个测试类开始,可以在这里启动外部服务...");
}
@BeforeEach
void setUp() {
// 每个测试前创建干净的测试数据
testAccount = new Account();
testAccount.setBalance(BigDecimal.valueOf(1000));
testAccount.setStatus(AccountStatus.ACTIVE);
accountRepository.save(testAccount);
}
@Nested
@DisplayName("正常支付场景")
class NormalPayment {
@Test
@DisplayName("余额充足时支付成功")
void success() {
PaymentResult result = paymentService.pay(testAccount.getId(), BigDecimal.valueOf(100));
assertTrue(result.isSuccess());
Account updated = accountRepository.findById(testAccount.getId()).orElseThrow();
assertEquals(BigDecimal.valueOf(900), updated.getBalance());
}
@Test
@DisplayName("支付后余额应该正确扣减")
void balanceDeducted() {
paymentService.pay(testAccount.getId(), BigDecimal.valueOf(300));
Account updated = accountRepository.findById(testAccount.getId()).orElseThrow();
assertEquals(0, BigDecimal.valueOf(700).compareTo(updated.getBalance()));
}
}
@Nested
@DisplayName("异常支付场景")
class AbnormalPayment {
@Test
@DisplayName("余额不足时抛出异常")
void insufficientBalance() {
assertThrows(InsufficientBalanceException.class,
() -> paymentService.pay(testAccount.getId(), BigDecimal.valueOf(2000)));
}
@Test
@DisplayName("账户冻结时抛出异常")
void frozenAccount() {
testAccount.setStatus(AccountStatus.FROZEN);
accountRepository.save(testAccount);
assertThrows(AccountFrozenException.class,
() -> paymentService.pay(testAccount.getId(), BigDecimal.valueOf(100)));
}
}
@AfterAll
void globalTearDown() {
System.out.println("整个测试类结束");
}
}测试生命周期是测试稳定性的基石。掌握了这些,你的测试才能做到:本地绿,CI 也绿;单独跑绿,全部跑也绿;今天绿,明天也绿。
