测试类上的 @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 并非总是安全的默认选项。它会把业务事务包进更大的测试事务,使方法返回不再代表提交,进而掩盖提交后事件、独立事务和回滚路径的问题。验证事务行为时,关键不是“能否查到数据”,而是先确认数据在什么时刻真正提交,以及提交失败后哪些副作用仍可能发生。

最后修改:2026 年 08 月 20 日
如果觉得我的文章对你有用,请随意赞赏