Skip to content

门泊吴船亦已谋

Spring Boot 测试经验总结

总结一下 Spring Boot 中写单元测试和集成测试的经验。

无状态与不可变

无论是 Controller 还是 Service,它们最好设计成无状态的。 简单说就是做成不可变的:不使用可变的字段;不使用字段注入;并给所有的字段打上 final;使用构造器注入。 这样的优势在于降低心智负担,天生支持多线程,把异步的锅都丢给下层的数据库。

当然看一下本文的标题,我们现在最关心的问题是这样便于测试,带有状态的模块无论是单元测试还是集成测试都非常操蛋。

这个时候不得不提一种说法是,如果一个模块不好写测试那多半这个模块设计的有问题。 不过我不记得出处了。

测试实例的生命周期

第一次写测试的时候大概率就会疑惑为什么 @BeforeAll 的方法少写一个 static 就会报错。 于是就会发现每个 @Test 的方法在测试时局部变量是不共享的,也就是说每个方法各自运行在一个测试类的新实例中。 所以显而易见,@BeforeAll 的方法必须是 static 的。

于是我们尝试去修改它的生命周期,使得每个 @Test 的方法跑在同一个实例里。 这个时候 @BeforeAll 的方法就可以不是 static 的了。

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class XxxTests {
    @BeforeEach
    void init() {}

    // ...
}

并行测试

适当的并行可以大大提高测试的速度,但是会存在一些限制。 根据 Parallel Test Execution,使用了 @DirtiesContext@MockBean 或者 @SpyBean 的测试是不能并行的。

Gradle 中并行测试的最小粒度是类,大概像这样配置并行数量。

test {
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
}

JUnit 的并行最小粒度是方法,大概像这样配置 classpath 下的 junit-platform.properties,具体参考 Parallel Execution

junit.jupiter.execution.parallel.enabled = false
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent

Repository 测试

要测 JPA 的 Repository 但是又不想搞爆数据库,还希望测试之间隔离,这个时候就可以直接把 @DataJpaTest 注解打到测试类上。

这个注解会禁用完整的自动配置,只会配置 JPA 测试相关的东西,性能很好。 它默认会使用一个内嵌的内存数据库,也可以改配置来使用别的数据库。 每个 @Test 运行的时候,会开启事务,并在结束时自动回滚。

事务处理

在集成测试中开启事务处理是非常省事的,便于控制初始的状态,测完一个还直接回滚,能把各个测试隔离开。 但是它实际上存在很多隐患。 根据 Don’t Use @Transactional in Tests,不应当使用 @Transactional 的理由很多:

  • 业务本身通常都需要事务处理,在测试的时候再套一个会造成干扰。
  • 事务会隐藏懒加载时会话关闭的问题。
  • 开启事务时 JPA 会把修改的持久化对象自动存回去。
  • 因为直接回滚,不方便查数据库调试。
  • 不互相冲突的集成测试并不难写。

所以应当尽可能写不会冲突的用例,用例数量过多的时候就需要考虑手动清除数据库的内容。

需要注意的是,这与之前说的 @DataJpaTest 并不矛盾,这里说的是集成测试。

重置上下文

经常发现有人问为什么用例一个一个跑没事但是一起跑就炸了这类问题。 然后发现打上了 @DirtiesContext 之后突然世界和平。 简单地说就是用例之间产生了干扰,比如数据库里面键值冲突,模块不是无状态的然后被改了。 打了这个注解之后就能在指定的时候清空上下文,对于这种问题简单来说就是它清掉了内存数据库,也抹掉了模块的状态。

实际上这是隔离用例的一种极端方法,不过确实非常简单无脑。 问题主要是性能太差,重启上下文需要花费很长时间。 另外显然清上下文只能清内存数据库,如果是写进硬盘的那还是参考之前关于事务处理的章节。

如果模块设计是正常的,大部分情况下不需要打这个注解,只有在真的有必要的情况下再考虑干这个。