测试、打包与升级:Spring Boot 应用如何形成可交付证据
IDE 里的测试全绿,可执行 jar 却找不到自动配置资源;@SpringBootTest 跑得很慢,原因不是测试数量,而是每个类都创建了不同 Context;容器镜像只改一行业务代码却重新上传所有依赖;升级父版本后编译通过,启动时才暴露 Jackson、Servlet 或测试切片差异。这些问题属于同一条交付链:源码证据必须穿过测试装配、构建产物、启动方式和目标运行时。
Spring Boot 提供测试模块、构建插件、可执行 archive、OCI Buildpacks、JVM AOT 与 GraalVM Native 支持。官方入口分别位于 Testing 与 Packaging Spring Boot Applications。这些工具不会替团队决定测试边界和升级风险,必须用可比较产物与运行证据闭环。
测试注解决定加载多少应用,而不是测试“级别”标签
普通单元测试不启动 Spring,适合纯领域逻辑。@SpringBootTest 从主配置搜索完整应用,按 webEnvironment 决定 mock 或真实服务器;test slice 只导入目标层的典型组件与对应自动配置。@WebMvcTest、@DataJpaTest 等不是“更快版完整测试”,它们故意排除其他层。
测试切片的边界应与断言匹配。只验证 Controller 参数与响应协议时 mock 服务合理;要验证 Filter、真实序列化和网络端口则用随机端口;要证明自动配置 back-off,用 ApplicationContextRunner;要证明发布物可启动,必须对打包后的 jar 或镜像运行,不能只在 Maven/Gradle test classpath 上启动。
@MockBean 一类替换会改变 Context 定义并影响缓存键,也可能让测试绑定实现细节。边界接口少量替换是有用工具,大量 mock 通常说明切片选择错误或模块依赖混乱。共享测试配置要显式导入,避免主应用扫描碰巧发现。
Context 缓存按合并配置复用,细小差异会制造新实例
Spring Test 缓存成功加载的 ApplicationContext。缓存键包含配置类、loader、initializer、active profile、property source、context customizer、父 context 等。两个测试看似都用 @SpringBootTest,只要动态属性或 mock 集合不同,就可能无法复用。
ContextCacheDemo.java 演示相同切片命中、完整 context 形成第二个键:
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-boot/testing-packaging-upgrade/ContextCacheDemo.java examples/backend-development/spring-boot/testing-packaging-upgrade/LayeredArchiveDemo.java
java -cp examples/backend-development/spring-boot/testing-packaging-upgrade ContextCacheDemocontextLoads={web-slice|profile=test=1, full-context|profile=test=1} cacheHits=1 distinctContexts=2真实缓存是进程内有限 LRU,并可能受 fork 策略影响。过度使用 @DirtiesContext 会主动驱逐,测试框架频繁 fork 则根本无法跨进程复用。优化前先开启缓存统计,按 key 差异聚类;不要全局增大缓存掩盖每类测试都带独特属性。
共享 Context 要求测试清理可变状态。数据库、缓存、静态单例、线程和事件监听若污染后续测试,会产生顺序依赖。事务回滚不能撤销远程调用、异步线程和已提交独立事务。Fixture 应有明确所有者,使用唯一 namespace 与确定清理,必要时重建外部容器而不是污染 Spring Context。
动态端口、容器地址等值可通过 DynamicPropertySource/ServiceConnection 进入 Environment,但生成逻辑必须稳定。每个测试类创建不同值可能改变缓存键;可在 suite 级共享基础设施,同时保证数据隔离。测试秘密同样不能写入日志和失败快照。
可执行 jar 不是普通 fat jar
Boot 构建插件把应用类与依赖按可执行 archive 结构组织,加入 loader 与启动元数据。嵌套 jar 由 Boot loader 读取,而不是把所有 class 解压合并;这避免同名资源简单覆盖,也带来特定 classpath、URL 和 unpack 限制。用普通 shade 插件重新合并可能丢失 AutoConfiguration.imports、Spring metadata、签名或 service 文件。
验收产物要检查 main class、loader、依赖、自动配置 imports、配置 metadata、重复资源、许可证和 SBOM。构建应可重复:相同输入产生稳定顺序和时间元数据,避免每次无意义改变 digest。依赖锁定来自 Boot BOM 与构建文件,不能在运行镜像中临时下载未审计版本。
classpath 与 module path 不同。多数 Boot 应用仍以 classpath 运行;强行转 module path 会暴露自动模块名、反射开放和资源发现差异,需要独立设计。可执行 jar 的 classloader 也可能改变库对 java.class.path、File URL 和资源目录的假设,必须用发布物 smoke test 发现。
分层产物让稳定依赖与频繁代码分开变化
Boot archive 可以生成 layers index,常见层包括 dependencies、spring-boot-loader、snapshot-dependencies 与 application。容器镜像按层复制后,业务代码变化只重建高层,稳定依赖可复用缓存。分层是缓存与发布优化,不改变运行 classpath 语义。
LayeredArchiveDemo.java 输出代码变更只影响应用层的理想模型:
java -cp examples/backend-development/spring-boot/testing-packaging-upgrade LayeredArchiveDemolayers=[dependencies, spring-boot-loader, snapshot-dependencies, application] changedLayersForCodeOnly=1 reproducibleOrder=true如果内部模块版本每天变化并都被标 snapshot,缓存层仍会频繁失效。应按实际变更频率自定义分层,同时保持规则稳定;把秘密或环境配置打进 application layer 会让镜像不可复用且难以轮换。运行配置应外置,镜像只包含不可变程序与安全默认。
Buildpacks 能从 jar 构建 OCI image,提供 builder/run image、build cache、SBOM 和非 root 默认等能力。它不是黑盒免运维:必须固定受信 builder、审计 buildpack、控制网络、记录基础镜像 digest、扫描 CVE 并验证启动命令。Dockerfile 则提供更直接控制,团队需自行承担 JRE、用户、层、权限和信号处理。
AOT 把运行时发现前移,Native 改变观测与兼容边界
JVM AOT 在构建期分析 ApplicationContext,生成初始化代码与 hints,减少运行时动态发现;GraalVM Native Image 进一步闭世界编译成本地二进制。反射、资源、代理、序列化和 JNI 若无法从静态分析发现,需要 RuntimeHints。随意扫描 classpath 或按外部类名动态加载会成为兼容障碍。
AOT 测试应验证生成阶段与 AOT 模式启动,Native 则需构建并运行真实二进制。普通 JVM 测试通过不能证明 hints 完整。反过来,Native 启动快也不代表吞吐、内存和诊断一定更优;要在目标负载测量 build time、镜像体积、RSS、p99、峰值吞吐、profile 能力和崩溃诊断。
Native 的构建链更重,编译器、GraalVM、Native Build Tools 和容器基线必须纳入版本矩阵。动态 agent、字节码生成、JMX 和某些诊断工具可能不同或不可用。选择信号通常是启动/内存对密度与弹性价值足够高,并且依赖生态可验证;长时间高吞吐服务未必得到净收益。
JVM AOT 可作为中间方案,在保留 JVM 的情况下前移 Context 处理。无论选择哪条路径,都要保留传统 JVM 回退产物和同一契约测试,避免发生 Native 专属故障时没有回滚渠道。
跨代升级要先比较平台契约,再改业务代码
Spring Boot 管理 Spring Framework、Servlet、Jackson、日志、测试与大量第三方版本。跨代升级可能同时改变 Java 最低版本、Jakarta 包名、模块坐标、默认自动配置、配置属性、端点访问和序列化。只看编译错误会漏掉运行默认变化。
升级顺序应先到当前代最新补丁,清理弃用和告警,再进入下一代。使用官方 release notes、migration guide 和 dependency versions 生成差异;禁止在 BOM 管理下随意覆盖核心依赖到未经验证的混合组合。安全修复若要求临时 override,要记录原因、兼容测试和退出版本。
证据矩阵包括:JDK 与构建工具;managed dependency diff;自动配置 conditions diff;配置废弃/未绑定;BeanDefinition 与 endpoint 暴露;JSON golden contract;数据库迁移;启动/停机状态;性能与内存;可执行 jar、OCI、AOT/Native。每项都给回滚触发条件。
Boot 3 到 4 的迁移尤其要检查 Framework 7/Jakarta EE 11、Servlet 6.1、模块拆分、Jackson 3 生态和自定义 Starter。依赖了内部类、旧自动配置注册文件或历史测试工具的模块,应先修成公开扩展点。升级兼容不是让一个 jar 同时反射兼容所有代,而是让应用和内部平台发布明确版本线。
灰度发布必须保证数据库、消息和 API 契约允许新旧实例共存。不能回滚的 schema 变更不应和框架升级绑定在同一发布。先采用 expand/contract、双读或兼容字段,让技术升级可独立回退;观察 ready、错误率、序列化、连接和资源基线后再扩大。
交付完成要从源码一直证明到运行产物
一条完整门禁包含:单元和切片测试验证局部语义;完整 Context 测试验证装配;随机端口测试验证真实 server;发布物 smoke test 用 java -jar;镜像测试验证用户、信号、探针和文件权限;需要时运行 AOT/Native;升级测试比较关键基线。任何一层都不能替代下一层。
测试报告要记录 Context 创建数与缓存命中、最慢启动键、外部容器重用和泄漏;构建报告记录依赖、SBOM、重复资源、层 digest 和可重复性;运行报告记录启动 ready、关闭 drain、RSS/延迟和失败退出。这样才能把“测试通过”翻译成“这个具体产物在目标环境可运行并可回退”。
Spring Boot 把测试、装配和打包工具集成得很顺手,真正的工程能力仍来自边界清晰:每类测试只加载所需系统,Context 差异可解释,archive 结构受验证,镜像层稳定,AOT/Native 有回退,升级有新旧共存契约。交付证据穿过这些层,版本变化才不会在生产第一次被发现。
