qinghuan
← 工程实践 / 全部笔记

单元 98 / 工程实践

P0 · 必会主线L2 · 进阶

Spring 集成测试:用真实边界验证数据库与 HTTP 行为

用真实 MySQL 容器和需要的 HTTP 环境验证数据库与接口行为,同时处理测试数据隔离和回滚范围。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能选择测试范围,在隔离 MySQL 容器上验证约束、事务和真实 HTTP 错误响应。

学习理由、准备要求与知识点

为什么学它

模拟依赖无法证明 SQL、事务或网络路径正确,集成测试负责这些真实边界。

先读这些内容

知识点清单

  • 测试切片
  • SpringBootTest
  • 随机端口
  • 独立服务器线程
  • Testcontainers 生命周期
  • 数据库迁移
  • 隔离清理
  • 事务回滚边界

运行前先确认Spring Boot 3.5测试概念;Testcontainers源码固定1.20.6。完整算法Java17标准库。容器片段需兼容依赖与Docker环境,未作为算法自测运行。

01先弄懂它是什么

想验证什么,就选择能实际运行到那一步的测试。纯业务规则用单元测试,参数读取和错误响应可以用 Web 切片;SQL、索引和唯一约束要接对应数据库;完整 HTTP 流程再启动随机端口应用。测试范围越大通常越慢,先列出可能的失败,再选足以发现它的范围。

Testcontainers 能重复创建依赖容器,但容器启动不代表各测试的数据已经隔离。多个用例共用表、缓存键或队列时,仍可能互相影响,甚至换个执行顺序就失败。需要独立命名空间、可靠清理或按需重建环境,并运行项目真正的数据库迁移脚本,才能检查真实接入过程。

Spring 测试中的事务通常跟着测试线程走。随机端口测试会真的发送 HTTP 请求,而服务端由另一条线程处理,使用的是另外的事务。因此测试方法结束后回滚自己的事务,不会自动撤销服务端已经提交的数据。类似地,用 H2 测试通过,也不能证明 MySQL 的锁和 SQL 规则相同。报告应写清这次用了预设答案的替身、内存数据库、容器数据库,还是完整 HTTP 请求,读者才知道究竟验证到了哪一步。

文中这几个词,先认一认

切片测试
只启动某一层相关组件,例如 MVC,集中检查这一层的行为。
测试夹具
某个测试开始前准备好的数据、配置和依赖状态,让运行条件可以重复。
资源冲突
多个测试使用同一份资源,且有人在修改它,导致结果受其他测试影响。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

本题根据声明的读写集合构建测试冲突对。只读同一资源可以共存,但写与读、写与写都标记潜在冲突。资源可以是数据库表、固定缓存键或全局配置;声明粒度越粗,判断越保守,粒度太细则容易漏掉隐含关联。

先按资源列出会访问它的测试,这张“从资源找到测试”的表就是倒排表。对同一个资源,把可能相互影响的两个测试配成一对:只要其中一个会写,就记为潜在冲突;两个都只读则不记。一个测试既读又写时,按写入处理。两项测试就算共用了好几个资源,也只保留一条冲突记录。最后统一编号顺序并去重;没有写进访问声明的关联,算法无法替你发现。

冲突边可以指导隔离方案或调度,但没有边不意味着真实并行安全。测试可能还共享系统时间、静态变量、端口、连接池或外部账户。应先确保框架生命周期支持相应并行模式,再考虑按冲突图分组;不能用算法绕过框架限制。

正在加载流程图…

图中状态只描述本篇可执行机制;业务调用仍需由外层应用负责。
查看流程图源码(Mermaid)
flowchart TD
  S["选择要验证的边界"] --> C["隔离容器与动态端口"]
  C --> M["实际迁移与夹具"]
  M --> H["HTTP或仓库调用"]
  H --> A["响应与持久结果断言"]
  A --> X["清理及共享资源检查"]

按顺序看,每一步做了什么

  1. 列出每层测试的目标与证据范围。
  2. 选择与生产语义相关的依赖版本,配置动态端口。
  3. 运行实际迁移并构造最小夹具。
  4. 通过真实边界调用并断言响应与持久状态。
  5. 检查共享资源、事务线程和清理策略,保留失败日志。

03对照实现代码

Testcontainers 1.20.6 GenericContainer 源码 (新标签页)↗

Testcontainers 1.20.6源码,Spring Boot 3.5测试概念;算法Java17 · GenericContainer.start / withExposedPorts / waitingFor

原创测试生命周期片段,需要Testcontainers 1.20.6依赖、可用Docker及已核验的测试镜像;不是原样源码,不会因阅读或算法自测启动容器。

核心机制代码 · java
try (GenericContainer<?> dependency = new GenericContainer<>(verifiedImage)
        .withExposedPorts(8080)
        .waitingFor(Wait.forHttp("/ready").forStatusCode(200))) {
    dependency.start(); // 启动并等待指定就绪条件,不只是进程存在。
    String host = dependency.getHost(); // 宿主地址由运行环境提供,不能写死localhost。
    int port = dependency.getMappedPort(8080); // 使用动态映射端口,避免并行端口冲突。
    verifyApplicationContract(host, port); // 应用迁移和业务断言仍需单独执行。
}

GenericContainer.start先处理依赖启动,再启动当前容器。容器实例已存在时的生命周期分支不意味着业务数据已经清理,复用对象与用例隔离是两个问题。

withExposedPorts配置需要映射的容器端口,实际主机端口在启动后读取。固定主机端口容易与其他测试或本机服务冲突,也降低在远程Docker环境的可移植性。

waitingFor选择就绪策略;端口开始监听只能说明有限网络状态,HTTP就绪路径也只覆盖该路径定义的条件。数据库迁移、唯一约束和真实业务请求仍要另外断言。

片段中的verifyApplicationContract是应用测试函数。当前JUnit5扩展官方说明对并行执行存在支持限制,不能因为容器动态端口就直接宣称整个测试套件可以任意并行。

代码先调用谁,再调用谁

  1. 定义隔离容器和就绪条件
  2. start启动依赖
  3. 读取动态连接信息
  4. 运行实际迁移和应用请求
  5. 核对业务效果并清理测试资源

04动手写一个例子

测试读写资源的冲突对分析

输入各用例对资源的读写声明,输出至少共享一个资源且至少一方写入的用例对。用例编号由输入位置确定;纯内存Java17自测。

输入 1
用例0写orders,用例1读orders,用例2读users
输出 1
冲突0:1
输入 2
两个用例都只读orders
输出 2
无冲突

每个用例先将重复资源声明合并,写入优先于读取。再建立资源到用例模式的倒排表,避免让同用例的重复声明生成自身冲突。

对每个资源的访问者枚举不同用例对,只要至少一方写就加入结果。规范化为小编号冒号大编号,使不同资源产生的同一对可以去重。

算法输出潜在冲突而非真实竞态结论。资源事务隔离、锁和唯一命名可能使冲突实际安全,但需要额外设计依据,不能从声明里推断。

为什么这样写是对的每个资源映射中一个用例只有一个最终模式;结果集合中每条边连接两个不同用例,且至少由一个共同资源的写访问支持。

时间 O(A + Σ kᵣ² + D log D)预期时间额外空间 O(A+D)

A为去重前声明数,kᵣ为资源r的访问用例数,D为唯一冲突对数;哈希建表平均常数,最终排序使输出稳定,稠密冲突本身可能平方规模。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Access(String resource,boolean write){}
    static List<String> conflicts(List<List<Access>> tests){
        Map<String,Map<Integer,Boolean>> resources=new HashMap<>();
        for(int i=0;i<tests.size();i++)for(Access a:tests.get(i)){
            if(a.resource()==null||a.resource().isBlank())throw new IllegalArgumentException("resource");
            resources.computeIfAbsent(a.resource(),k->new HashMap<>()).merge(i,a.write(),(x,y)->x||y); // 同用例写模式覆盖读模式。
        }
        Set<String> pairs=new HashSet<>();
        for(Map<Integer,Boolean> modes:resources.values()){
            List<Integer> ids=new ArrayList<>(modes.keySet());
            for(int i=0;i<ids.size();i++)for(int j=i+1;j<ids.size();j++){
                int a=ids.get(i),b=ids.get(j);if(modes.get(a)||modes.get(b))pairs.add(Math.min(a,b)+":"+Math.max(a,b)); // 读读共存,其他共享访问标记冲突。
            }
        }
        List<String> out=new ArrayList<>(pairs);Collections.sort(out);return out; // 多资源同一对去重后稳定展示。
    }
    static void eq(List<String> e,List<String> a){if(!e.equals(a))throw new AssertionError(a);}
    public static void main(String[] args){
        Access read=new Access("orders",false),write=new Access("orders",true);
        eq(List.of("0:1"),conflicts(List.of(List.of(write),List.of(read),List.of(new Access("users",false)))));
        eq(List.of(),conflicts(List.of(List.of(read),List.of(read))));
        eq(List.of("0:1"),conflicts(List.of(List.of(read,write,write),List.of(read))));
        eq(List.of(),conflicts(List.of()));
        eq(List.of("0:1","0:2","1:2"),conflicts(List.of(List.of(write),List.of(write),List.of(write))));
        try{conflicts(List.of(List.of(new Access("",false))));throw new AssertionError();}catch(IllegalArgumentException expected){}
        System.out.println("PASS 6 tests");
    }
}

这些特殊情况也要试一试

  • 资源声明遗漏会漏报冲突。
  • 输出按字符串稳定排序,不表示调度优先级。
  • 没有冲突边不代表框架支持并行生命周期。
进阶选读 · 后续再读:冲突图测试调度

场景大型套件希望在有限工作者上缩短总时间。

做法以冲突边约束同时执行集合,结合用例耗时进行启发式分组;先通过资源命名空间降低真实共享,再考虑复杂调度。

复杂度最少颜色图着色一般为NP-hard,加入耗时后也不是简单线性分组;贪心只能给可行基线。

好处与代价为了并行增加容器可能消耗更多内存和磁盘,应同时衡量稳定性与CI资源成本。

05项目里怎么用

将容器镜像版本和数据库迁移纳入测试配置,测试连接信息由容器运行结果注入应用。不要依赖本机默认端口或已有数据库内容,否则测试通过可能只是偶然命中了残留环境。就绪检查应针对真正依赖能力,失败时保存容器日志与迁移异常。

随机端口测试使用独立数据标识,结束后按明确范围清理或销毁隔离容器。异步任务和消息消费者可能在断言后继续写入,因此需要等待可观察完成条件而非固定睡眠。清理失败应让套件显式失败,不能掩盖污染交给下一次运行。

逐步扩大测试范围:先检查业务规则,再检查序列化和数据库访问,最后跑从认证到数据库的真实 HTTP 流程。分别保存启动、迁移、请求和持久结果。容器能启动只证明其中一步,仍要断言业务规则和失败响应都符合预期。

容易踩的坑

  • RANDOM_PORT测试依赖测试方法事务自动清理服务端写入。
  • H2通过便认为MySQL语法和锁语义相同。
  • 固定端口与共享数据使测试依赖执行顺序。
  • 用固定睡眠替代可观察完成条件。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
单元与切片运行快且定位清楚覆盖不了真实数据库边界多数规则与绑定错误
容器集成接近实际驱动与数据库语义需要Docker和启动资源迁移约束与仓库契约
完整HTTP测试覆盖认证路由和持久行为隔离清理与诊断更复杂少量关键业务路径

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01容器为何不自动保证用例隔离?

展开参考答案与推导

容器可以共享同一数据和全局状态,生命周期与数据命名空间仍要设计。

02测试事务为何不回滚随机端口写入?

展开参考答案与推导

真实HTTP通常在服务端线程开启独立事务,不属于测试线程事务。

03冲突对算法证明什么?

展开参考答案与推导

只证明声明资源中存在潜在读写共享,不证明必然竞态或没有其他隐藏共享。

换个情况,还会处理吗

01单独跑过、全套失败,先查什么?

展开参考答案与推导

检查固定主键、缓存键、静态变量、异步尾部写入与清理遗漏,利用冲突声明定位共享夹具。

02端口已监听但请求失败,下一步?

展开参考答案与推导

查看应用就绪、迁移和依赖连接,监听不是业务可用证明。

03想开启JUnit扩展并行,依据是什么?

展开参考答案与推导

先阅读对应版本官方支持限制,建立独立生命周期和资源隔离;动态端口本身不构成支持证据。

写代码试一试

01读读为何不标冲突?

输入 / 条件
两个用例只读同一声明资源
输出 / 预期
空集合

提示 本题无写者。

展开参考答案与推导

按模型读取不改变资源,因此不会互相污染;现实隐含写入需补进声明。

02读写重复声明如何合并?

输入 / 条件
同用例先读后写orders
输出 / 预期
最终写模式

提示 逻辑或。

展开参考答案与推导

写模式覆盖读模式,倒排表只保留该用例一个条目,避免自身配对。

03所有用例都写一张表复杂度如何?

输入 / 条件
n个用例
输出 / 预期
n(n-1)/2冲突对

提示 输出本身平方规模。

展开参考答案与推导

无法在显式输出所有冲突对时承诺线性总时间,可以改变输出形式为资源冲突组。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能选择测试范围,在隔离 MySQL 容器上验证约束、事务和真实 HTTP 错误响应。

本篇重点加练

迁移与事务回滚影响测试隔离和真实性,测试框架中的成功不应掩盖真实提交边界。

  • 数据库迁移
  • 事务回滚边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    订单模块同时需要验证参数绑定、事务约束和应用启动。为三个目标选择测试范围,解释测试切片与完整上下文各留下哪些未验证边界。

    提交内容
    测试分层表
    怎样算完成
    • 每项都有具体待验证行为
    • 不将加载成功等同于业务正确

    练到的知识点测试切片 · SpringBootTest

  2. 作业 02代码推演

    随机端口测试通过 HTTP 新增订单,同时测试方法开启事务。画出请求线程、测试线程和数据库连接的关系,标明各自提交或回滚责任。

    提交内容
    线程及事务图
    怎样算完成
    • 端口来自运行时配置
    • 不假定跨线程共享事务

    练到的知识点随机端口 · 独立服务器线程

  3. 作业 03代码推演

    测试方法在事务内发布事件,监听器只在提交后执行,而测试默认回滚。分析哪些分支可能从未被本次测试触发。

    提交内容
    测试事务与监听时序
    怎样算完成
    • 区分注册监听和执行监听
    • 不能只检查方法已调用

    练到的知识点事务回滚边界

  4. 作业 04原理问答

    空库迁移全部通过,但生产环境由旧版本逐步升级且含历史脏数据。定义两种迁移测试各自验证的内容。

    提交内容
    迁移路径与数据前提
    怎样算完成
    • 区分建新库和升级旧库
    • 列出历史约束冲突

    练到的知识点数据库迁移

综合应用

  1. 作业 05设计评审

    多组测试共用一个本地容器,数据库迁移在启动时执行。制定容器生命周期、迁移失败处理和并行运行前提。

    提交内容
    生命周期计划
    怎样算完成
    • 记录镜像版本和可复现配置
    • 区分容器就绪与数据库迁移成功

    练到的知识点Testcontainers 生命周期 · 数据库迁移

  2. 作业 06面试场景

    测试 A 创建商户,测试 B 按名称查询,单独执行都通过,批量执行偶发失败。列出隔离、清理和事务范围需要核查的证据。

    提交内容
    调查表与隔离规则
    怎样算完成
    • 覆盖测试顺序变化
    • 清理操作仅限测试数据

    练到的知识点隔离清理 · 事务回滚边界

  3. 作业 07编程实践

    为本地最小订单服务编写 Testcontainers 集成测试,经真实 HTTP 验证唯一约束、非法输入和重复请求;执行两次完整测试集。

    提交内容
    测试工程与运行记录
    怎样算完成
    • 容器资源可回收
    • 若引擎不可用须记录未执行范围而非替身冒充

    练到的知识点测试切片 · SpringBootTest · 随机端口 · 独立服务器线程 · Testcontainers 生命周期 · 数据库迁移 · 隔离清理 · 事务回滚边界

  4. 作业 08设计评审

    清理脚本试图删除测试数据,但异步任务可能在清理后才提交。评审测试结束条件、迁移版本与晚到写入之间的关系。

    提交内容
    测试结束及清理协议
    怎样算完成
    • 包含独立线程事务
    • 清理不依赖不确定等待

    练到的知识点数据库迁移 · 事务回滚边界

  5. 作业 09编程实践

    用本地 Testcontainers 或单独测试库,从旧版种子数据执行一次迁移,再通过真实提交触发事务后事件,验证测试结束后数据归属。

    提交内容
    测试工程与执行报告
    怎样算完成
    • 引擎不可用明确报告未执行
    • 仅处理独立数据库

    练到的知识点数据库迁移 · 事务回滚边界

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    团队拟开启并行测试以缩短时间,但测试会写同一类业务数据。制定可共享与须隔离资源清单以及开启并行后的验收指标。

    提交内容
    资源冲突表与试验计划
    怎样算完成
    • 包含表数据、端口和时钟
    • 比较稳定性而非只报告速度

    练到的知识点测试切片 · Testcontainers 生命周期 · 数据库迁移 · 隔离清理 · 事务回滚边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造单测、集成测试都通过,但首次真实提交仍暴露问题的例子,要求缺陷来自测试事务生命周期而非遗漏全部断言。

    提交内容
    最小反例与测试边界
    怎样算完成
    • 说明各测试真实执行了哪些阶段
    • 不伪造测试报告

    练到的知识点数据库迁移 · 事务回滚边界

  2. 作业 12思维拓展 · 条件变化

    数据库升级要求兼容回滚到旧应用,但迁移会转换历史数据格式。设计一个能检验“回滚可用”的多版本测试协议。

    提交内容
    版本矩阵与验收计划
    怎样算完成
    • 包含旧数据、新写入和已迁移记录
    • 区分应用回退与数据恢复

    练到的知识点数据库迁移 · 事务回滚边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗