测试生命周期:@BeforeAll、@BeforeEach、@AfterEach、@AfterAll

讲真,很多刚接触JUnit5的人,上来就被这几个注解搞懵了。什么时候执行?执行几次?静态方法到底能不能用?我以前刚带团队的时候,就亲眼见过一个同学把数据库连接写在@BeforeEach里,结果跑100个测试用例创建了100个连接……系统直接挂了。嗯,今天咱们就把这东西彻底捋清楚。

生命周期注解是干嘛的

说白了,它们是为了帮你搭好测试的“舞台”,演完了再拆掉。你想想看,一个测试类里可能有十几个测试方法,如果每个方法都要初始化一堆对象、打开文件、建立连接,那代码得多啰嗦?生命周期注解就是把这些重复的“搭台”和“拆台”工作统一管理起来。

核心概念:四个注解对应四个不同的执行时机,我们用一个表格先直观对比一下。

注解 执行时机 执行次数 是否必须静态 典型用途
@BeforeAll 所有测试方法之前 1次 是(默认) 初始化全局资源(数据库连接、配置文件)
@AfterAll 所有测试方法之后 1次 是(默认) 释放全局资源
@BeforeEach 每个测试方法之前 N次(与测试方法数相同) 否 准备测试数据、重置mock对象
@AfterEach 每个测试方法之后 N次 否 清理测试数据、关闭临时文件

我建议你把@BeforeAll和@AfterAll想象成“剧组开机”和“杀青宴”——只办一次。而@BeforeEach和@AfterEach则是每拍一条镜头都要布置场景和收拾道具。

执行顺序,这张图就够用了

为什么要强调顺序?因为搞错了顺序,你会发现测试数据互相污染,或者资源还没准备好就调用了方法。我画了一张流程图,把整个生命周期串起来:

JUnit5 测试生命周期流程 @BeforeAll(静态方法,1次) 初始化全局资源:数据库连接、Spring容器 每个测试方法重复以下流程 @BeforeEach(实例方法,N次) 创建测试对象、注入mock、初始化环境变量 @Test 测试方法 @AfterEach(实例方法,N次) 清理临时数据、关闭打开的文件流 还有下一个 @Test 吗? 是→继续循环 @AfterAll(静态方法,1次) 释放全局资源、关闭连接池

静态方法?实例方法?怎么选

这里面有个容易出坑的地方:@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)搭了拆、拆了搭。保持每个测试方法独立,才是好测试的核心原则。