JUnit5 生命周期注解:掌控测试的执行节奏

说实话,我刚接触单元测试那会儿,最头疼的就是测试用例之间的资源管理。数据库连接要提前建,测试数据要提前准备,跑完了还得清理现场。搞得每个测试方法里都有一大堆重复代码,看着就烦。

后来我发现了JUnit5的生命周期注解,问题一下子就解决了。说白了,这些注解就是给你几个“钩子”,让你能在测试生命周期的特定时刻插入自己的代码。用得好了,测试代码的质量能提升一个档次。

为什么需要生命周期?

你想想看,一个测试类里通常有十几个测试方法。每个方法执行前都要初始化Mock对象、准备测试数据。如果把这些操作分散到每个方法里,出了问题你都不知道该查哪。

个人习惯是:利用生命周期注解,把这些公共操作统一管理。一来代码简洁,二来逻辑清晰。

核心思想:把测试的“准备 - 执行 - 清理”三个阶段,用注解标记清楚。这样维护起来特别省心。

四个关键注解

JUnit5提供了四个生命周期注解,覆盖了整个测试流程。我先用个表格快速过一遍,后面再详细讲。

注解 执行时机 执行次数 典型用途
@BeforeAll 所有测试方法前 整个类执行一次 创建全局资源、建立数据库连接
@AfterAll 所有测试方法后 整个类执行一次 释放全局资源、关闭连接
@BeforeEach 每个测试方法前 每个方法执行一次 初始化Mock对象、设置测试数据
@AfterEach 每个测试方法后 每个方法执行一次 重置状态、清理临时数据

这个执行顺序是固定的。每个测试方法执行时,都是先走 @BeforeAll,然后 @BeforeEach,再跑测试方法,接着 @AfterEach,最后等所有方法都跑完了才轮到 @AfterAll。

执行流程图

我画了一张图,把执行过程展示得更直观一些。你可以对照着看,理解起来更容易。

JUnit5 生命周期执行流程 测试类开始执行 @BeforeAll 方法 (执行一次:初始化全局资源) 循环 每@Test @BeforeEach 方法 @Test 测试方法 @AfterEach 方法 有更多@Test? @AfterAll 方法 (执行一次:释放全局资源) 测试类执行完毕 是(继续下个@Test) 否 关键点说明 1. @BeforeAll/@AfterAll 整个测试类只执行一次 2. @BeforeEach/@AfterEach 每个测试方法都执行一次 3. 方法必须是 static

@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)。测试方法在中间。

具体的顺序是:

  1. 类加载后,立即执行 @BeforeAll(一次)
  2. 进入循环:执行 @BeforeEach(当前测试方法前)
  3. 执行 @Test 测试方法
  4. 执行 @AfterEach(当前测试方法后)
  5. 如果有下一个测试方法,跳回步骤2;如果没有,继续
  6. 最后执行 @AfterAll(一次)

这个顺序是可以确定的,不用担心线程安全问题。不过要注意,如果是在并行测试场景下,每个线程都有自己的执行序列,情况会更复杂一些。但在单体测试里,你放心大胆用就行。

典型用途总结

我用表格把实际项目中的典型用法归纳一下,方便你照着写。

注解 我在项目中怎么用
@BeforeAll 初始化Spring容器、建立数据库连接、加载配置文件
@AfterAll 关闭数据库连接、释放文件句柄、清理全局日志
@BeforeEach 创建Mock对象、重置测试数据、设置测试上下文
@AfterEach 清理临时文件、重置共享状态、输出日志信息

一些高级技巧

最后分享几个实战经验:

  • 不要在 @BeforeAll 里做耗时操作:如果全局初始化太慢,整个测试类的执行时间都会变长。可以考虑懒加载或缓存。
  • @AfterEach 里的清理一定要稳定:即使测试方法抛出了异常,清理操作也应该能正常执行。JUnit5保证了这一点。
  • 巧用继承:如果多个测试类有相同的生命周期逻辑,可以写一个基类,把 @BeforeEach 和 @AfterEach 放在里面。子类直接继承就好,省得重复写。

我记得有次重构时,把所有测试类的数据库清理逻辑都扔到了 @AfterAll 里。结果跑测试时,一个测试类里十几个方法互相污染数据,找bug找了一下午。后来才明白,全局清理只跑一次,每个方法还是要单独清理的。

好了,生命周期注解的核心用法就是这些。在实际工作中,你会慢慢体会到它们的好处。用好了,测试代码会变得特别清爽,维护起来也省劲儿。