测试全绿、上线就错:Spring 测试事务让结果失真的五种方式
集成测试类上加了
@Transactional,每个测试结束自动回滚,数据库永远是干净的。几百个测试全绿。上线后,重复注册的邮箱报了唯一约束错误,订单详情页抛出LazyInitializationException,而这两个场景的测试一直是通过的。
在 Spring 的测试类上加 @Transactional,测试框架会为每个测试开启一个事务,测试结束时回滚。它解决了测试数据清理的问题,也带来了一个容易被忽视的后果:被测代码运行在一个永远不会提交的事务里。生产环境里,服务方法的事务在方法返回时提交;测试里,它加入了测试的外层事务,直到测试结束都没有提交。所有依赖「提交」这个时刻的行为,在测试里都和生产不一样。
本文用 Spring Boot 4.1.1 和 H2,把同一组断言分别放在加了测试事务和不加测试事务的测试类里,看结果有什么不同。实验见文末。
一、约束推迟到提交:测试里不报错
同一邮箱注册两次,断言不抛异常
测试事务(回滚) 通过
不加测试事务 失败:DataIntegrityViolationException … Unique index or primary key violation … MEMBER(EMAIL …)会员的 id 由应用指定,Hibernate 把 INSERT 缓存在持久化上下文里,等到 flush 时才发给数据库,而 flush 通常发生在提交前。测试事务从不提交,两条相同邮箱的 INSERT 都没有真正执行,唯一约束也就没有机会检查。
生产环境里,第二次注册在服务方法返回时提交,唯一约束立刻报错。测试里看到的「两次注册都成功」从来没有在生产上发生过。
在测试事务里要检查数据库约束,需要在断言前显式调用 flush()(或 saveAndFlush)。更根本的做法是:关心约束、关心提交后行为的测试,不使用测试事务,而是用其他方式隔离数据(每个测试使用唯一的数据、测试后显式清理)。测试数据怎样隔离,见测试数据归谁。
二、懒加载:测试里总能读到
加载订单后,在服务方法之外读订单行
测试事务(回滚) 通过
不加测试事务 错误:Cannot lazily initialize collection of role 'lab.App$PurchaseOrder.lines' with key 'o-b' (no session)服务方法 load 带着只读事务,返回订单后事务就结束了。生产环境里,控制器拿到的是一个脱离了会话的对象,访问懒加载的订单行时没有会话可用,抛出异常。
测试事务让会话一直开着,直到测试结束。服务方法的事务只是加入了外层事务,返回之后会话还在,懒加载照常工作。测试里的「订单行读得到」依赖的是测试自己开的那个事务,生产上并不存在。
修法是让服务方法返回调用方需要的数据(用 join fetch 或实体图加载订单行,或者直接返回 DTO),而不是依赖调用方在事务外触发懒加载。集合在模型里怎样加载,见领域模型里的集合,背后是一次查询。
三、提交后的回调:测试里不执行
注册后断言通知已发出
测试事务(回滚) 失败:Expecting CopyOnWriteArrayList: [] to contain …
不加测试事务 通过通知由 @TransactionalEventListener 发送,默认在事务提交之后执行。这是正确的设计:注册没有成功提交,就不应该发通知。测试事务永远不提交,通知也就永远不会发出。
这个方向的失真是「假红」:代码是对的,测试却失败。常见的反应是在测试里把监听器换成同步执行,或者干脆替换掉事件发布,这样测试就绿了,但它验证的不再是「提交后才发通知」。需要验证提交后行为的测试,同样应该去掉测试事务;Spring 也提供了 TestTransaction.flagForCommit() 和 TestTransaction.end(),可以在测试中途真正提交。
四、另一个线程:看不到未提交的数据
下单后,另一个线程能否看到订单
测试事务(回滚) 失败:Expecting value to be true but was false
不加测试事务 通过异步任务、消息消费者、@Async 方法都在另一个线程、另一个事务里运行。测试事务里写入的数据没有提交,其他事务看不到。测试里如果要验证「下单后异步处理能读到订单」,加了测试事务就一定失败。
反过来也会出问题:如果测试为了「让它过」改成在同一个线程里同步调用,测的就不是生产上的那个并发场景了。
五、新事务里的写入:回滚撤销不了
前一个测试类全部回滚之后,审计表里的行数:expected: 0L but was: 3L审计日志用 REQUIRES_NEW 在独立的事务里写入,这是常见的设计:业务失败回滚了,审计记录也要留下来。它在测试里的后果是:测试事务回滚撤销不了这些写入,它们留在了数据库里。前一个测试类的三次注册留下了三条审计,后一个测试类一开始就看到了它们。
依赖「每个测试结束后数据库是干净的」的测试,会因为前面某个测试碰巧调用了带 REQUIRES_NEW 的方法而失败,而且失败与否取决于测试的执行顺序。同样的情况也出现在异步线程写入的数据、调用外部系统产生的副作用上:测试事务只能回滚它自己的那个事务。
六、替换 Bean:每次都多一个上下文
A、B、Z 三个测试类配置相同,C 替换了一个 Bean;整个测试过程中创建的应用上下文:2 个这一条不是事务的问题,但同样会让测试的结果和成本偏离预期。Spring 测试框架按配置缓存应用上下文,配置相同的测试类复用同一个上下文。@MockitoBean 改变了配置,C 就需要一个新的上下文。项目里每个测试类都替换一两个不同的 Bean 时,上下文的数量会接近测试类的数量,测试套件越来越慢;缓存中的多个上下文还可能共享同一个内存数据库或端口,彼此干扰。
把替换集中到少数几个共享的测试配置里,让大多数测试类使用相同的配置,可以显著减少上下文的数量。
七、怎样选择
| 测试关心的是 | 做法 |
|---|---|
| 纯业务逻辑 | 不启动 Spring,直接测领域对象和服务 |
| 查询是否正确 | 可以用测试事务,断言前 flush |
| 约束、提交后回调、异步、懒加载边界 | 不用测试事务;每个测试使用唯一的数据,测试后显式清理 |
| 跨服务的副作用 | 用真实的提交,并检查副作用的去向 |
一个简单的检查:把测试类上的 @Transactional 去掉,再跑一次。结果发生变化的测试,就是依赖了测试事务的那些,值得逐个确认它们验证的到底是什么。
八、检查清单
- 知道测试事务改变了哪些行为:flush 时机、会话生命周期、提交后回调、其他事务的可见性。
- 检查约束的测试显式 flush,或者不使用测试事务。
- 服务方法返回调用方需要的数据,不依赖事务外的懒加载。
- 提交后行为、异步处理的测试使用真实的提交。
REQUIRES_NEW和异步写入的数据需要显式清理。- Bean 替换集中到少数共享配置,控制上下文数量。
配套实验
- codesphere-labs/testing/spring-test-transactions:Spring Boot 4.1.1、H2 2.4.240、JDK 25.0.4.1:同一组断言在测试事务与真实提交下的结果,REQUIRES_NEW 写入的残留,以及替换 Bean 后的上下文个数(验证记录)
参考资料