测试类上的 @Transactional 虽然方便清理数据,却会改变业务事务的提交时机,使提交后事件、独立事务和回滚行为没有得到真实验证。本文用一个订单审计示例说明原理、修正方法与设计边界。
问题背景
很多 Spring Boot 项目会给集成测试统一加上 @Transactional:测试结束后自动回滚,不必手动清理数据库,看起来既方便又稳定。
但它也可能制造一种事务假象。假设订单创建后需要记录审计日志,代码使用 @TransactionalEventListener 在订单事务提交后执行。生产环境中,事务会在业务方法返回时提交;而在带有 @Transactional 的测试里,业务方法通常只是加入测试事务,真正的提交被推迟到测试结束。
结果是:订单在测试方法中可以查询到,断言顺利通过,但提交后监听器根本没有执行。测试验证的是“当前事务能看到未提交数据”,而不是完整的生产行为。
一个接近真实项目的示例
下面使用 JdbcTemplate 简化持久化代码。表结构如下:
create table purchase_order (
id varchar(36) primary key,
buyer varchar(100) not null
);
create table order_audit (
order_id varchar(36) primary key,
action varchar(50) not null
);订单事件只是一个不可变的数据载体:
public record OrderCreatedEvent(String orderId) {
}订单服务在同一个事务里写入订单并发布事件:
@Service
@RequiredArgsConstructor
public class OrderService {
private final JdbcTemplate jdbcTemplate;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public String create(String buyer) {
String orderId = UUID.randomUUID().toString();
jdbcTemplate.update(
"insert into purchase_order(id, buyer) values (?, ?)",
orderId, buyer
);
eventPublisher.publishEvent(new OrderCreatedEvent(orderId));
return orderId;
}
@Transactional
public void createThenFail(String buyer) {
create(buyer);
throw new IllegalStateException("模拟后续业务失败");
}
}publishEvent 并不等于监听逻辑已经完成。对于事务事件监听器,Spring 会把回调绑定到当前事务,并按照指定阶段触发:
@Component
@RequiredArgsConstructor
public class OrderCreatedListener {
private final AuditService auditService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
auditService.recordCreated(event.orderId());
}
}审计写入放在另一个 Bean 中,并显式开启新事务:
@Service
@RequiredArgsConstructor
public class AuditService {
private final JdbcTemplate jdbcTemplate;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordCreated(String orderId) {
jdbcTemplate.update(
"insert into order_audit(order_id, action) values (?, ?)",
orderId, "CREATED"
);
}
}这里的 REQUIRES_NEW 不是装饰。AFTER_COMMIT 执行时,原事务已经提交,不能再依赖它提交新的数据库修改。把审计方法拆到独立 Bean,也避免了同类内部调用绕过 Spring 事务代理的问题。
容易产生误判的测试
下面的测试只能证明订单插入语句已经执行,不能证明事务提交后的审计逻辑正常:
@SpringBootTest
@Transactional
class MisleadingOrderTest {
@Autowired
OrderService orderService;
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void createsOrder() {
String id = orderService.create("alice");
Integer count = jdbcTemplate.queryForObject(
"select count(*) from purchase_order where id = ?",
Integer.class, id
);
assertThat(count).isEqualTo(1);
}
}OrderService.create() 使用默认的 REQUIRED 传播行为,因此会加入测试框架提前开启的事务。方法返回时没有发生物理提交,AFTER_COMMIT 回调自然不会执行。随后测试结束,事务被回滚,监听器仍然不会执行。
即使给测试增加 @Commit,在测试方法内部查询审计表也可能失败,因为测试事务通常是在测试方法执行完之后才提交。@Commit 改变的是结束方式,不是方法中间的提交时机。
更贴近生产行为的测试方式
对于需要验证提交、回滚和事务事件的测试,最直接的办法是不要在测试方法上添加 @Transactional。让业务服务自己管理事务,再显式清理数据:
@SpringBootTest
class OrderCommitFlowTest {
@Autowired
OrderService orderService;
@Autowired
JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
jdbcTemplate.update("delete from order_audit");
jdbcTemplate.update("delete from purchase_order");
}
@Test
void shouldWriteAuditAfterOrderCommitted() {
String id = orderService.create("alice");
assertThat(count("purchase_order", id)).isEqualTo(1);
assertThat(count("order_audit", id)).isEqualTo(1);
}
@Test
void shouldNotWriteAnythingWhenTransactionRollsBack() {
assertThatThrownBy(() -> orderService.createThenFail("bob"))
.isInstanceOf(IllegalStateException.class);
Integer orders = jdbcTemplate.queryForObject(
"select count(*) from purchase_order",
Integer.class
);
Integer audits = jdbcTemplate.queryForObject(
"select count(*) from order_audit",
Integer.class
);
assertThat(orders).isZero();
assertThat(audits).isZero();
}
private int count(String table, String id) {
return jdbcTemplate.queryForObject(
"select count(*) from " + table + " where "
+ (table.equals("purchase_order") ? "id" : "order_id")
+ " = ?",
Integer.class, id
);
}
}示例中的表名来自测试代码内部常量,不接受外部输入。实际项目中仍应避免动态拼接用户提供的表名或字段名。
如果项目必须依赖测试事务做数据隔离,可以使用 Spring Test 提供的 TestTransaction 主动结束事务:
@Test
@Transactional
void shouldRunListenerAfterExplicitCommit() {
String id = orderService.create("alice");
TestTransaction.flagForCommit();
TestTransaction.end();
assertThat(count("order_audit", id)).isEqualTo(1);
}这种方式适合少量需要精确控制提交点的测试,但阅读成本更高。多数情况下,为提交语义单独建立一组非事务测试会更清晰。
常见陷阱与边界
第一,@TransactionalEventListener 默认只处理事务上下文中的事件。调用方没有事务时,监听器默认不会执行;只有明确设置 fallbackExecution = true 才会在无事务时回退执行,但这会让语义变得不一致,应谨慎使用。
第二,AFTER_COMMIT 不是可靠消息机制。进程可能在主事务提交后、监听逻辑执行前退出;监听失败也无法回滚已经提交的订单。如果审计记录属于必须与订单原子落库的数据,应在同一事务写入。若后续动作需要跨进程可靠投递,更适合使用事务发件箱模式,而不是只依赖内存事件。
第三,如果监听器同时标注了 @Async,即使显式提交,断言时异步任务也可能尚未完成。测试应通过可观测状态轮询或专门的同步机制等待,不要随意写固定时长的 Thread.sleep。
第四,数据库测试最好使用与生产一致的数据库类型。H2 可以提高部分测试速度,但在 SQL 方言、锁、约束和事务隔离方面不一定等同于 MySQL 或 PostgreSQL。涉及事务边界的测试尤其适合使用 Testcontainers 启动真实数据库。
实践建议
可以把测试分成两类:普通仓储测试关注 SQL、映射和约束,允许通过事务回滚保持隔离;业务事务测试则不添加测试级 @Transactional,明确验证提交、回滚、事件和独立事务。不要为了少写几行清理代码,让测试环境悄悄改变了被测对象的事务边界。
总结
测试类上的 @Transactional 并非总是安全的默认选项。它会把业务事务包进更大的测试事务,使方法返回不再代表提交,进而掩盖提交后事件、独立事务和回滚路径的问题。验证事务行为时,关键不是“能否查到数据”,而是先确认数据在什么时刻真正提交,以及提交失败后哪些副作用仍可能发生。