测试生命周期:@BeforeAll、@BeforeEach、@AfterEach、@AfterAll
讲真,很多刚接触JUnit5的人,上来就被这几个注解搞懵了。什么时候执行?执行几次?静态方法到底能不能用?我以前刚带团队的时候,就亲眼见过一个同学把数据库连接写在@BeforeEach里,结果跑100个测试用例创建了100个连接……系统直接挂了。嗯,今天咱们就把这东西彻底捋清楚。
生命周期注解是干嘛的
说白了,它们是为了帮你搭好测试的“舞台”,演完了再拆掉。你想想看,一个测试类里可能有十几个测试方法,如果每个方法都要初始化一堆对象、打开文件、建立连接,那代码得多啰嗦?生命周期注解就是把这些重复的“搭台”和“拆台”工作统一管理起来。
核心概念:四个注解对应四个不同的执行时机,我们用一个表格先直观对比一下。
| 注解 | 执行时机 | 执行次数 | 是否必须静态 | 典型用途 |
|---|---|---|---|---|
| @BeforeAll | 所有测试方法之前 | 1次 | 是(默认) | 初始化全局资源(数据库连接、配置文件) |
| @AfterAll | 所有测试方法之后 | 1次 | 是(默认) | 释放全局资源 |
| @BeforeEach | 每个测试方法之前 | N次(与测试方法数相同) | 否 | 准备测试数据、重置mock对象 |
| @AfterEach | 每个测试方法之后 | N次 | 否 | 清理测试数据、关闭临时文件 |
我建议你把@BeforeAll和@AfterAll想象成“剧组开机”和“杀青宴”——只办一次。而@BeforeEach和@AfterEach则是每拍一条镜头都要布置场景和收拾道具。
执行顺序,这张图就够用了
为什么要强调顺序?因为搞错了顺序,你会发现测试数据互相污染,或者资源还没准备好就调用了方法。我画了一张流程图,把整个生命周期串起来:
静态方法?实例方法?怎么选
这里面有个容易出坑的地方:@BeforeAll和@AfterAll默认要求方法是静态的(static)。为什么?因为它们在测试实例创建之前就要执行。每个测试类只会执行一次,而实例还没生成,所以必须是类级别的方法。
我曾经踩过一个坑:在JUnit4里@BeforeClass和@AfterClass就是静态的,很多同学迁移到JUnit5时想偷懒,把@BeforeAll写在实例方法上,结果编译报错。后来JUnit5增加了@TestInstance(Lifecycle.PER_CLASS)模式,允许非静态的@BeforeAll/@AfterAll。但我不推荐新手用这个模式——除非你完全理解它的生命周期含义。
我个人习惯:能用静态就用静态。因为静态方法更清晰地表达了“这些资源是全局共享的”意图。如果你非要在一个实例方法里初始化全局数据,那就明确加上@TestInstance(Lifecycle.PER_CLASS),让测试类每个实例只创建一次。不过要注意,这会影响测试的隔离性——如果后续测试修改了全局状态,可能产生连锁污染。
代码示例:看一遍就懂了
光说不练假把式,咱们直接上个完整例子。假设我们要测试一个用户服务,需要连接数据库和Redis缓存。
import org.junit.jupiter.api.*;
class UserServiceTest {
private static DatabaseConnection dbConn;
private UserService userService;
@BeforeAll
static void initGlobal() {
System.out.println("[@BeforeAll] 初始化数据库连接");
dbConn = new DatabaseConnection("jdbc:mysql://localhost:3306/test");
dbConn.connect();
}
@AfterAll
static void cleanGlobal() {
System.out.println("[@AfterAll] 关闭数据库连接");
dbConn.close();
}
@BeforeEach
void setUp() {
System.out.println("[@BeforeEach] 创建UserService实例并准备测试数据");
userService = new UserService(dbConn);
userService.addTestUser("test", "123456");
}
@AfterEach
void tearDown() {
System.out.println("[@AfterEach] 清理测试数据");
userService.deleteTestUser("test");
}
@Test
void testLogin() {
System.out.println("[@Test] 测试登录逻辑");
assertTrue(userService.login("test", "123456"));
}
@Test
void testLogout() {
System.out.println("[@Test] 测试登出逻辑");
userService.login("test", "123456");
assertDoesNotThrow(() -> userService.logout("test"));
}
}
运行这个测试类,控制台输出的顺序是:
[@BeforeAll] 初始化数据库连接
[@BeforeEach] 创建UserService实例并准备测试数据
[@Test] 测试登录逻辑
[@AfterEach] 清理测试数据
[@BeforeEach] 创建UserService实例并准备测试数据
[@Test] 测试登出逻辑
[@AfterEach] 清理测试数据
[@AfterAll] 关闭数据库连接
小技巧:如果你一时分不清哪个注解该用静态,记住这个口诀:“全局的、一次的,用静态;局部的、重复的,用实例”。我每次培训都让学员默念三遍,效果不错。
避坑指南:常见问题与最佳实践
- 不要把@BeforeAll当成初始化每个测试的入口。 它只跑一次,如果你在里面给某个字段赋值,后续测试修改了这个字段,下一个测试用的就是脏数据。我曾经见过一个老项目,在@BeforeAll里初始化了时间戳,结果所有测试都断言同一秒,周一跑周一过,周二跑周二挂。
- @AfterAll里不要假设对象还存在。 如果某个测试在@BeforeEach里创建的对象在测试方法里被意外删除了,@AfterAll可不会在意,它只关心静态资源。
- 注意继承场景。 如果父类有@BeforeEach,子类也有,执行顺序是:父类@BeforeEach → 子类@BeforeEach → 子类@Test → 子类@AfterEach → 父类@AfterEach。倒序执行After。这个规则在写集成测试时特别重要,别问我怎么知道的……
- 非静态方法的例外情况。 如果你使用了
@TestInstance(Lifecycle.PER_CLASS),@BeforeAll和@AfterAll就可以写在实例方法上了。但这个时候整个测试类只会创建一次实例,所有测试方法共用这个实例的成员变量——隔离性会下降。我个人只在集成测试或者需要缓存大量共享数据时才这么干。
方法论总结:把测试生命周期注解当作测试的“脚手架”。全局脚手架(@BeforeAll/@AfterAll)搭一次拆一次,每个测试用例的脚手架(@BeforeEach/@AfterEach)搭了拆、拆了搭。保持每个测试方法独立,才是好测试的核心原则。