Skip to content

测试全绿、上线就错:Spring 测试事务让结果失真的五种方式 ​

集成测试类上加了 @Transactional,每个测试结束自动回滚,数据库永远是干净的。几百个测试全绿。上线后,重复注册的邮箱报了唯一约束错误,订单详情页抛出 LazyInitializationException,而这两个场景的测试一直是通过的。

在 Spring 的测试类上加 @Transactional,测试框架会为每个测试开启一个事务,测试结束时回滚。它解决了测试数据清理的问题,也带来了一个容易被忽视的后果:被测代码运行在一个永远不会提交的事务里。生产环境里,服务方法的事务在方法返回时提交;测试里,它加入了测试的外层事务,直到测试结束都没有提交。所有依赖「提交」这个时刻的行为,在测试里都和生产不一样。

本文用 Spring Boot 4.1.1 和 H2,把同一组断言分别放在加了测试事务和不加测试事务的测试类里,看结果有什么不同。实验见文末。

测试事务(回滚)真实提交同一邮箱注册两次,断言不抛异常通过唯一约束冲突服务外读懒加载的订单行通过LazyInitializationException注册后通知已发出失败:列表为空通过另一个线程能看到订单失败:看不到通过回滚后审计表应为空—实际 3 行(REQUIRES_NEW)红色:测试结论与生产相反(假绿);黄色:代码正确但测试失败(假红)替换一个 Bean(@MockitoBean)改变了测试配置:4 个测试类创建了 2 个应用上下文检查方法:去掉测试类上的 @Transactional 再跑一次,结果变化的测试都依赖了测试事务
图 1 · 实测(Spring Boot 4.1.1、H2 2.4.240):测试事务从不提交,被测代码的事务只是加入了它。依赖提交时刻的行为在测试里全部变了样:唯一约束没有检查,懒加载一直有会话,提交后的通知不发,其他线程看不到数据;而 REQUIRES_NEW 已经提交的写入,回滚也撤销不了。

一、约束推迟到提交:测试里不报错 ​

text
同一邮箱注册两次,断言不抛异常
  测试事务(回滚)   通过
  不加测试事务       失败:DataIntegrityViolationException … Unique index or primary key violation … MEMBER(EMAIL …)

会员的 id 由应用指定,Hibernate 把 INSERT 缓存在持久化上下文里,等到 flush 时才发给数据库,而 flush 通常发生在提交前。测试事务从不提交,两条相同邮箱的 INSERT 都没有真正执行,唯一约束也就没有机会检查。

生产环境里,第二次注册在服务方法返回时提交,唯一约束立刻报错。测试里看到的「两次注册都成功」从来没有在生产上发生过。

在测试事务里要检查数据库约束,需要在断言前显式调用 flush()(或 saveAndFlush)。更根本的做法是:关心约束、关心提交后行为的测试,不使用测试事务,而是用其他方式隔离数据(每个测试使用唯一的数据、测试后显式清理)。测试数据怎样隔离,见测试数据归谁。

二、懒加载:测试里总能读到 ​

text
加载订单后,在服务方法之外读订单行
  测试事务(回滚)   通过
  不加测试事务       错误:Cannot lazily initialize collection of role 'lab.App$PurchaseOrder.lines' with key 'o-b' (no session)

服务方法 load 带着只读事务,返回订单后事务就结束了。生产环境里,控制器拿到的是一个脱离了会话的对象,访问懒加载的订单行时没有会话可用,抛出异常。

测试事务让会话一直开着,直到测试结束。服务方法的事务只是加入了外层事务,返回之后会话还在,懒加载照常工作。测试里的「订单行读得到」依赖的是测试自己开的那个事务,生产上并不存在。

修法是让服务方法返回调用方需要的数据(用 join fetch 或实体图加载订单行,或者直接返回 DTO),而不是依赖调用方在事务外触发懒加载。集合在模型里怎样加载,见领域模型里的集合,背后是一次查询。

三、提交后的回调:测试里不执行 ​

text
注册后断言通知已发出
  测试事务(回滚)   失败:Expecting CopyOnWriteArrayList: [] to contain …
  不加测试事务       通过

通知由 @TransactionalEventListener 发送,默认在事务提交之后执行。这是正确的设计:注册没有成功提交,就不应该发通知。测试事务永远不提交,通知也就永远不会发出。

这个方向的失真是「假红」:代码是对的,测试却失败。常见的反应是在测试里把监听器换成同步执行,或者干脆替换掉事件发布,这样测试就绿了,但它验证的不再是「提交后才发通知」。需要验证提交后行为的测试,同样应该去掉测试事务;Spring 也提供了 TestTransaction.flagForCommit() 和 TestTransaction.end(),可以在测试中途真正提交。

四、另一个线程:看不到未提交的数据 ​

text
下单后,另一个线程能否看到订单
  测试事务(回滚)   失败:Expecting value to be true but was false
  不加测试事务       通过

异步任务、消息消费者、@Async 方法都在另一个线程、另一个事务里运行。测试事务里写入的数据没有提交,其他事务看不到。测试里如果要验证「下单后异步处理能读到订单」,加了测试事务就一定失败。

反过来也会出问题:如果测试为了「让它过」改成在同一个线程里同步调用,测的就不是生产上的那个并发场景了。

五、新事务里的写入:回滚撤销不了 ​

text
前一个测试类全部回滚之后,审计表里的行数:expected: 0L but was: 3L

审计日志用 REQUIRES_NEW 在独立的事务里写入,这是常见的设计:业务失败回滚了,审计记录也要留下来。它在测试里的后果是:测试事务回滚撤销不了这些写入,它们留在了数据库里。前一个测试类的三次注册留下了三条审计,后一个测试类一开始就看到了它们。

依赖「每个测试结束后数据库是干净的」的测试,会因为前面某个测试碰巧调用了带 REQUIRES_NEW 的方法而失败,而且失败与否取决于测试的执行顺序。同样的情况也出现在异步线程写入的数据、调用外部系统产生的副作用上:测试事务只能回滚它自己的那个事务。

六、替换 Bean:每次都多一个上下文 ​

text
A、B、Z 三个测试类配置相同,C 替换了一个 Bean;整个测试过程中创建的应用上下文:2 个

这一条不是事务的问题,但同样会让测试的结果和成本偏离预期。Spring 测试框架按配置缓存应用上下文,配置相同的测试类复用同一个上下文。@MockitoBean 改变了配置,C 就需要一个新的上下文。项目里每个测试类都替换一两个不同的 Bean 时,上下文的数量会接近测试类的数量,测试套件越来越慢;缓存中的多个上下文还可能共享同一个内存数据库或端口,彼此干扰。

把替换集中到少数几个共享的测试配置里,让大多数测试类使用相同的配置,可以显著减少上下文的数量。

七、怎样选择 ​

测试关心的是做法
纯业务逻辑不启动 Spring,直接测领域对象和服务
查询是否正确可以用测试事务,断言前 flush
约束、提交后回调、异步、懒加载边界不用测试事务;每个测试使用唯一的数据,测试后显式清理
跨服务的副作用用真实的提交,并检查副作用的去向

一个简单的检查:把测试类上的 @Transactional 去掉,再跑一次。结果发生变化的测试,就是依赖了测试事务的那些,值得逐个确认它们验证的到底是什么。

八、检查清单 ​

  • 知道测试事务改变了哪些行为:flush 时机、会话生命周期、提交后回调、其他事务的可见性。
  • 检查约束的测试显式 flush,或者不使用测试事务。
  • 服务方法返回调用方需要的数据,不依赖事务外的懒加载。
  • 提交后行为、异步处理的测试使用真实的提交。
  • REQUIRES_NEW 和异步写入的数据需要显式清理。
  • Bean 替换集中到少数共享配置,控制上下文数量。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权