订单测试通过,提交后的通知仍然失败

订单方法在事务中插入数据,并注册一个 afterCommit 回调。测试调用该方法,检查插入成功,最后由 Spring Test 自动回滚。整组测试通过,但真实 HTTP 请求一提交就抛异常。这两个结果可以同时成立:测试从未走过提交路径,回调里的错误也从未执行。

测试的价值取决于实际经过的边界。纯函数断言证明金额规则;MockMvc 证明参数绑定和异常到响应的映射;真实服务器加独立数据库连接证明请求线程完成了提交。把三类结果都写成“订单接口测试通过”,会丢掉最需要保留的条件。

实验固定 Spring Framework 6.2.11、Spring Boot 3.5.6、JDK 21 和 PostgreSQL 18.0。Boot BOM 管理本模块依赖,数据库使用独立 spring_final schema。JUnit 实际执行 8 个测试方法,产生 15 条具名断言,没有将手写演示输出当作 JUnit 报告。依赖清单、XML 报告与原始输出随实验工程保留。

测试对象、请求线程与数据库观察者

从同一个金额错误区分三种证明

业务规则 Store.validate(amount) 拒绝零和负数。Chapter41PureTest 只有 JUnit 注解,不注册 SpringExtension,不创建 ApplicationContext,也不连接数据库。它直接传入零,断言 IllegalArgumentException。该检查适合穷举边界值,失败原因也集中:规则本身发生了变化。

纯单测不能说明异常变成了 HTTP 400。Controller 可以漏掉异常处理器,也可以把查询参数绑定成错误类型。第二层使用 MockMvcBuilders.standaloneSetup(new Orders(store)),分别提交 amount=0 与 amount=not-an-int。前者进入业务规则后由异常处理器返回 400;后者在整数参数转换阶段被拒绝,尚未调用订单方法。

这里的 standaloneSetup 使用显式提供的 Controller。它搭建 Spring MVC 测试设施,但没有打开 TCP 监听端口,也没有自动证明完整应用的扫描、Filter 链、容器错误派发和网络协议行为。实验中的 Store 由测试上下文装配,因此这组 MockMvc 断言也不能被称为完全无 Spring、无数据库的纯单测。

第三层启动 OrderApp 和随机端口的嵌入式服务器,通过 JDK HttpClient 发出真实请求。金额为零仍然返回 400;合法金额返回 201。随后另建 DriverManagerDataSource 连接查询订单,断言存在一行。这个观察者没有借用请求线程绑定的连接,避免了“同一事务能读到自己的写入”冒充提交可见性。

三层都经过金额为零这个故障,但各自增加的证据不同。规则测试适合快速确定非法输入的定义,MVC 测试解释状态码的形成位置,真实集成检查覆盖套接字、容器、装配与资源边界。不是每个金额组合都要启动服务器;至少保留一个真实请求,才能确认组合后的系统确实经过预期路径。

Spring Test 的事务先于测试方法开始

@SpringJUnitConfig 将 JUnit Jupiter 与 TestContext 框架连接。框架根据合并后的配置建立上下文,执行测试监听器,并提供依赖注入。测试方法上标注 @Transactional 后,TransactionalTestExecutionListener 从上下文取得事务管理器,在测试方法之前开启测试事务。

本例 Store 使用 TransactionTemplate,默认传播行为为 REQUIRED。测试线程已经存在事务时,业务调用参加这个事务。订单方法返回,仅代表内部逻辑正常返回,并不代表最外层事务已经提交。方法中的同步回调仍然等待测试事务结束。

固定版本源码的 isDefaultRollback 在没有覆盖注解时返回 true。测试框架的默认清理方向就是回滚。@Commit、@Rollback(false) 或 TestTransaction.flagForCommit() 可以改变这个决定,但真正的结果要在事务结束以后观察。TransactionalTestExecutionListener 固定源码

这类事务绑定在当前线程。真实 HTTP 服务在服务器线程处理请求,不能指望测试线程的 @Transactional 把服务器写入一起回滚。实验显式删除测试创建的键;它没有使用“测试框架默认回滚”作为远端请求的清理策略。抢占式超时若把测试代码切换到其他线程,也需要重新确认写入究竟参加哪个事务。测试事务与线程边界

回滚如何隐藏一个确定存在的错误

rollbackMasksCallback 先设置 brokenCallback=true。订单写入后,注册的回调会先递增计数,再抛出 IllegalStateException。如果真的执行提交,错误必然出现;测试没有依赖随机条件或外部服务故障。

调用 create("test41-hidden", 10, false) 之后,回调计数仍为零。独立数据库连接也看不到订单,因为当前写入没有提交。随后测试明确调用 TestTransaction.flagForRollback() 和 TestTransaction.end(),确认计数仍为零、记录仍不存在。

这组绿色断言准确地证明“回滚不调用 afterCommit”,却完全没有证明“afterCommit 里的代码能工作”。若一个项目只运行这种测试,而验收要求包含提交后的通知,缺少的是场景,不是断言数量。把同样的插入查询断言复制十次,也不会进入缺失的分支。

actualCommitExposesCallback 使用相同代码,唯一关键差异是把结束方向设为提交。TestTransaction.end() 抛出预期异常,回调计数变成一,独立连接查到一行已提交订单。异常出现在提交之后,不能通过外层捕获异常再把已提交的数据撤回。

事务管理器的 processCommit 先完成底层提交,再触发 afterCommit,最后触发 afterCompletion 并清理资源。该顺序解释了“调用返回异常,数据库却已有记录”的结果。实验使用的是直接注册的 TransactionSynchronization;不能把其异常传播方式直接套到所有事务事件监听器或异步监听器上。AbstractPlatformTransactionManager 固定源码

上下文缓存复用了配置,也复用了可变单例

创建上下文有成本。Spring Test 根据配置类、加载器、活动 profile、属性源、上下文定制器等信息构造缓存键;配置相同的测试可以复用上下文。缓存位于当前进程,测试拆到不同 JVM 并不能共享同一份缓存。上下文缓存

配置相同不代表 Bean 的当前内部状态相同。本例注册一个 AtomicInteger 单例。第一个测试把值改成 99,第二个测试拿到的仍然是这个单例,观察到 99。数据库事务的回滚不会重置这个内存对象,重新创建测试类实例也不会重新创建 ApplicationContext 中的 Bean。

第二个测试结束后,@DirtiesContext(methodMode = AFTER_METHOD) 使当前上下文从缓存失效并关闭。下一个测试重新装配上下文,计数恢复为零。三个方法通过显式顺序稳定展示污染与修复;这种排序用于复现实验,不是建议让生产测试相互依赖。

更小的修复通常是让业务 Bean 保持无状态,或在测试边界恢复明确的可变状态。确实改变了上下文结构、替换了单例、关闭了内部资源时,DirtiesContext 更合适。对每个测试无条件使用它会增加启动次数,也会掩盖哪些测试真正污染了共享状态。

复现实验与判断失败的位置

完整代码位于系列下载工程的 final-lab/。在工程根目录执行以下命令,JDK 需为 21,数据库需已启动且账号允许在实验库创建 schema。

1
2
3
4
5
export JAVA_HOME=/path/to/jdk-21
export SPRING_LAB_JDBC_URL=jdbc:postgresql://127.0.0.1:55432/spring_lab
export SPRING_LAB_DB_USER=spring_lab
export SPRING_LAB_DB_PASSWORD=''
python3 final-lab/verify.py 41

脚本通过根 Maven Wrapper 显式指定独立模块 pom,执行 Chapter41PureTest,Chapter41Test。evidence/41/local-20261002/run.txt 应包含 15 行 CHECK PASS,退出码文件应为零,Surefire XML 合计 8 个方法、零失败、零错误。数量用于核对是否漏跑场景,不能替代逐条语义。

连接拒绝说明数据库前提未满足;纯单测规则断言失败说明业务约束变了;MockMvc 返回 500 而非 400 时,应检查异常处理器与参数转换;真实请求返回 201 但外部连接看不到记录时,应检查事务结束和观察连接的隔离级别。分类来自测试的实际边界,不能仅按最外层堆栈名字判断。

修改一个条件观察证据变化

删除 cachedSingletonPollution 上的 DirtiesContext 注解,再执行 41。预期第三个缓存实验仍读到 99,DirtiesContext rebuilds singleton 断言失败。恢复注解后应重新通过。这是读者修改练习,保存的基线证据没有替这次修改预先运行。

另一项练习是把实际提交测试的 flagForCommit 改为 flagForRollback。预期 assertThrows 失败,因为坏回调不再执行;数据库也不应留下提交记录。不要把断言改成“不抛异常”后宣布修复成功,那只是把提交场景改成了回滚场景。

若要新增 ORM 检查,还需要考虑 flush 时机;如果错误只在 flush 或提交时出现,事务内查询成功不能替代这两个阶段的断言。该扩展未包含在本章 15 条结果中,ORM 的资源与事务关系见 深入 Spring 24:JDBC、ORM 与多个事务管理器的资源边界。

参考资料