JUnit5 生命周期注解:掌控测试的执行节奏
说实话,我刚接触单元测试那会儿,最头疼的就是测试用例之间的资源管理。数据库连接要提前建,测试数据要提前准备,跑完了还得清理现场。搞得每个测试方法里都有一大堆重复代码,看着就烦。
后来我发现了JUnit5的生命周期注解,问题一下子就解决了。说白了,这些注解就是给你几个“钩子”,让你能在测试生命周期的特定时刻插入自己的代码。用得好了,测试代码的质量能提升一个档次。
为什么需要生命周期?
你想想看,一个测试类里通常有十几个测试方法。每个方法执行前都要初始化Mock对象、准备测试数据。如果把这些操作分散到每个方法里,出了问题你都不知道该查哪。
个人习惯是:利用生命周期注解,把这些公共操作统一管理。一来代码简洁,二来逻辑清晰。
核心思想:把测试的“准备 - 执行 - 清理”三个阶段,用注解标记清楚。这样维护起来特别省心。
四个关键注解
JUnit5提供了四个生命周期注解,覆盖了整个测试流程。我先用个表格快速过一遍,后面再详细讲。
| 注解 | 执行时机 | 执行次数 | 典型用途 |
|---|---|---|---|
| @BeforeAll | 所有测试方法前 | 整个类执行一次 | 创建全局资源、建立数据库连接 |
| @AfterAll | 所有测试方法后 | 整个类执行一次 | 释放全局资源、关闭连接 |
| @BeforeEach | 每个测试方法前 | 每个方法执行一次 | 初始化Mock对象、设置测试数据 |
| @AfterEach | 每个测试方法后 | 每个方法执行一次 | 重置状态、清理临时数据 |
这个执行顺序是固定的。每个测试方法执行时,都是先走 @BeforeAll,然后 @BeforeEach,再跑测试方法,接着 @AfterEach,最后等所有方法都跑完了才轮到 @AfterAll。
执行流程图
我画了一张图,把执行过程展示得更直观一些。你可以对照着看,理解起来更容易。
@BeforeAll 与 @AfterAll:一次性的全局操作
这两个注解标记的方法,在整个测试类里只执行一次。注意了,方法必须是 static 的,这在JUnit5里是强制要求。
举个例子,我在项目中用它们来初始化一个全局的Mock配置。比如要给所有测试方法提供一个统一的HttpClient模拟对象。
@BeforeAll
static void initGlobalMocks() {
// 初始化一个全局的Mock对象
httpClient = Mockito.mock(HttpClient.class);
// 设置一个通用行为
Mockito.when(httpClient.getResponse(Mockito.anyString()))
.thenReturn("200 OK");
System.out.println("全局HttpClient初始化完成");
}
@AfterAll
static void cleanUp() {
// 清理全局资源
httpClient = null;
System.out.println("全局HttpClient已释放");
}
这里要提醒一句,两个方法必须是静态的。我之前遇到过有人忘记加 static,结果方法根本没执行,调了半天才找到原因。
避坑指南:我有个同事在用 @BeforeAll 初始化数据库连接时,忘了加 static,结果每个测试方法都新建了一个连接。测试跑完服务器直接炸了。嗯,这个教训有点惨痛。
@BeforeEach 与 @AfterEach:每个测试的必备流程
这两个注解更常用一些。每个测试方法执行前都会跑一遍 @BeforeEach,执行后再跑 @AfterEach。它们不用 static 修饰,所以可以轻松访问当前实例的成员变量。
我个人习惯是把Mock对象的初始化放在 @BeforeEach 里。你想想看,每个测试方法都需要一个干净的Mock对象,如果共用一个,测试之间就会相互影响。
// 声明一个成员变量
private List<String> mockList;
@BeforeEach
void setUp() {
// 每个测试前,重新创建一个Mock对象
mockList = Mockito.mock(List.class);
System.out.println("Mock对象已重置");
}
@AfterEach
void tearDown() {
// 每个测试后,可以做一些清理
System.out.println("测试结束,清理工作完成");
}
@Test
void testAdd() {
mockList.add("hello");
Mockito.verify(mockList).add("hello");
}
@Test
void testSize() {
Mockito.when(mockList.size()).thenReturn(5);
assertEquals(5, mockList.size());
}
你看,两个测试方法用的是不同的Mock对象。如果没有 @BeforeEach 重置,testAdd 里调用了 add,到了 testSize 里可能就会影响到 size 的行为。这就会导致测试之间相互耦合,你想想看是不是很麻烦。
执行顺序的确定
有过一段时间,我经常记不清先跑哪个后跑哪个。后来自己总结了个口诀:
口诀:类前类后跑一次(BeforeAll/AfterAll),每个方法跑两次(BeforeEach/AfterEach)。测试方法在中间。
具体的顺序是:
- 类加载后,立即执行 @BeforeAll(一次)
- 进入循环:执行 @BeforeEach(当前测试方法前)
- 执行 @Test 测试方法
- 执行 @AfterEach(当前测试方法后)
- 如果有下一个测试方法,跳回步骤2;如果没有,继续
- 最后执行 @AfterAll(一次)
这个顺序是可以确定的,不用担心线程安全问题。不过要注意,如果是在并行测试场景下,每个线程都有自己的执行序列,情况会更复杂一些。但在单体测试里,你放心大胆用就行。
典型用途总结
我用表格把实际项目中的典型用法归纳一下,方便你照着写。
| 注解 | 我在项目中怎么用 |
|---|---|
| @BeforeAll | 初始化Spring容器、建立数据库连接、加载配置文件 |
| @AfterAll | 关闭数据库连接、释放文件句柄、清理全局日志 |
| @BeforeEach | 创建Mock对象、重置测试数据、设置测试上下文 |
| @AfterEach | 清理临时文件、重置共享状态、输出日志信息 |
一些高级技巧
最后分享几个实战经验:
- 不要在 @BeforeAll 里做耗时操作:如果全局初始化太慢,整个测试类的执行时间都会变长。可以考虑懒加载或缓存。
- @AfterEach 里的清理一定要稳定:即使测试方法抛出了异常,清理操作也应该能正常执行。JUnit5保证了这一点。
- 巧用继承:如果多个测试类有相同的生命周期逻辑,可以写一个基类,把 @BeforeEach 和 @AfterEach 放在里面。子类直接继承就好,省得重复写。
我记得有次重构时,把所有测试类的数据库清理逻辑都扔到了 @AfterAll 里。结果跑测试时,一个测试类里十几个方法互相污染数据,找bug找了一下午。后来才明白,全局清理只跑一次,每个方法还是要单独清理的。
好了,生命周期注解的核心用法就是这些。在实际工作中,你会慢慢体会到它们的好处。用好了,测试代码会变得特别清爽,维护起来也省劲儿。