测试、打包与升级:Spring Boot 应用如何形成可交付证据
启动方式会改变应用实际使用的 classpath。在 IDE 中运行测试时,测试目录和测试依赖都在场;执行 java -jar 时,JVM 只能使用发布物里携带的内容。漏打一个配置文件、选错主类,或者把依赖误标成 test,都可能让前一种运行成功、后一种启动失败。
测试因此需要分工:纯 Java 测试检查计算逻辑,MVC 切片检查接口处理,完整上下文检查装配,真实端口和打包产物再检查网络与启动方式。Spring Boot 把这些入口放在同一构建体系中,开发者负责选择与问题匹配的运行范围。
一套应用需要哪些测试
从断言对象选择上下文
| 要检查的内容 | 合适的起点 | 实际加载什么 |
|---|---|---|
| 金额计算、名称规范化等纯逻辑 | 普通 JUnit 测试 | 直接创建 Java 对象 |
| Controller 的参数、状态码、响应内容 | @WebMvcTest + MockMvc | MVC 相关配置和指定 Controller;协作者可替换 |
| 自定义自动配置的条件与默认 Bean | ApplicationContextRunner | 明确指定的一小组配置 |
| 应用 Bean 能否共同装配 | @SpringBootTest(webEnvironment = NONE) | 完整配置,但不创建 Web 服务器 |
| 真实 HTTP 连接和服务器处理 | @SpringBootTest(webEnvironment = RANDOM_PORT) | 完整应用、嵌入式服务器和随机端口 |
| 下载或发布的 JAR 是否能运行 | 单独执行 java -jar | JAR 中的应用类、资源和依赖 |
MockMvc 在进程内驱动 Spring MVC,可以执行消息转换器和测试上下文中注册的 Filter;它不建立真实 TCP 连接。检查容器 Connector、端口、TLS 或真实服务器的请求限制时,应启动服务器再发送 HTTP。各测试注解的加载范围见 Spring Boot 应用测试。
Boot 4 对测试模块做了拆分。MVC 切片示例使用 spring-boot-starter-webmvc-test,注解包为 org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest。替换 Bean 使用 Spring Framework 的 @MockitoBean,不要继续从旧文章复制 @MockBean 导入。迁移背景见 Boot 4.0 迁移指南。
用同一个接口对照三种测试
下载完整实验工程,解压后进入含 pom.xml 的目录。源码也可分别查看:应用、单元测试、MVC 切片、HTTP 集成测试 和 构建文件。
应用只有一个 GET /hello?name=reader 接口,调用 GreetingService 返回 hello reader。单元测试直接 new GreetingService(),检查正常名称与空白名称异常。MVC 切片把服务替换为 Mock,专门检查 Controller 是否返回服务提供的文本:
@WebMvcTest(GreetingController.class)
class GreetingSliceTests {
@Autowired MockMvc mvc;
@MockitoBean GreetingService service;
@Test void sliceChecksRequestAndResponse() throws Exception {
given(service.greet("reader")).willReturn("slice-reply");
mvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string("slice-reply"));
}
}真实 HTTP 测试使用相同 Controller,但不替换服务:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class HttpIT {
@LocalServerPort int port;
@Test void actualSocketUsesRealService() throws Exception {
try (var client = HttpClient.newHttpClient()) {
var request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + port + "/hello"))
.timeout(Duration.ofSeconds(5)).build();
var response = client.send(request,
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).isEqualTo("hello reader");
}
}
}这里的 slice-reply 和 hello reader 刻意不同:前一个结果来自测试替身,后一个结果来自真实服务。完整 import 和辅助类已经包含在下载工程中。
实验环境为 Linux、Docker Engine、Bash、curl 7.76 或更新版本。宿主用户需有 Docker 使用权限。构建容器使用当前 UID/GID 与单独可写缓存,运行容器使用 10001。示例固定 Spring Boot 4.1.1、Java 25、Maven 3.9.12;兼容范围见 Boot 系统要求,构建工具版本见 Maven 3.9.12 发布说明。
LAB_DIR="$(pwd -P)"
M2_DIR="$LAB_DIR/.m2"
BUILD_IMAGE='maven:3.9.12-eclipse-temurin-25'
RUNTIME_IMAGE='eclipse-temurin:25.0.4_7-jdk'
mkdir -p "$M2_DIR"
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$M2_DIR,dst=/cache" \
-w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache clean verifySurefire 执行 4 个测试:1 个纯单元、1 个 MVC 切片、2 个缓存复用测试;Failsafe 再执行 1 个真实 HTTP 集成测试。两组报告都应为零失败,最后显示 BUILD SUCCESS。报告分别在 target/surefire-reports 与 target/failsafe-reports。
示例通过 Failsafe 绑定 integration-test 和 verify,只运行 mvn test 会漏掉名为 HttpIT 的集成测试。运行完整验证时使用 verify;生命周期与失败检查见 Failsafe 使用说明。
JDK 25 上,Mockito 的动态 agent 加载可能打印告警,测试仍能成功。此告警涉及测试 JVM 的插桩方式,后续 JDK 限制可能更严格。持续集成可按 Mockito 官方 agent 配置 显式添加测试 JVM 的 -javaagent,同时保留 JaCoCo 等已有 argLine;不要给生产应用加入测试用 agent。
Context 缓存为什么有时失效
Spring Test 把成功加载的上下文放入进程内缓存。配置类、激活 profile、属性来源、上下文定制器和父上下文等共同决定缓存键;Bean 替换声明也参与其中。两个测试类的名字不同,仍可共享同一个上下文。配置看起来相似,却未必具有相同键。完整规则见 TestContext 缓存。
缓存测试源码 让两个类继承同一份 @SpringBootTest 配置,用共享 AtomicReference<ApplicationContext> 保存先运行的实例,第二次断言对象身份相同。运行次序不影响断言,Maven 默认复用的测试 JVM 为这两个类提供同一静态缓存。
诊断时可以增加日志参数重新执行构建:
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$M2_DIR,dst=/cache" \
-w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache \
-Dlogging.level.org.springframework.test.context.cache=DEBUG verify默认缓存上限为 32 个上下文,达到上限后按 LRU 策略移除较久未用的条目,可用 spring.test.context.cache.maxSize 调整。查看缓存命中数、未命中数以及上下文数量,再比较 profile、测试属性和 Mock 声明。@DirtiesContext 会移除受影响上下文;不同 fork 进程之间也不共享缓存。优先合并确实相同的配置,避免为了提高命中率把原本独立的测试强行绑在一起。
共用上下文会共用其中的单例对象。测试修改静态变量、数据库记录、线程池或缓存后,需要自行恢复;@Transactional 测试回滚只作用于对应事务。真实端口测试中的服务器通常在另一线程处理请求,客户端测试方法上的事务不能替服务器撤销已经提交的写入。
集成测试需要外部数据库或消息服务时,可用 Testcontainers 管理真实依赖。@ServiceConnection 为受支持的容器提供类型化连接信息,需要 spring-boot-testcontainers 测试依赖;容器作为 Spring Bean 管理时,其生命周期能与缓存的 Context 对齐。若测试类结束就停止容器,却继续复用连接到旧地址的 Context,后续测试会失败。Testcontainers 与服务连接
@DynamicPropertySource 则通过 supplier 注册动态属性,适合没有现成 ConnectionDetails 支持的地址或参数。缓存键包含相关定制声明,并不会因为同一个 supplier 后来返回了新端口就自动重建上下文。应让依赖服务与 Context 具有相容的生命周期,确需变更时显式失效旧 Context;测试数据另行隔离。动态属性来源
从 Maven 产物到可启动容器
先查看 JAR 的真实布局
Boot Maven 插件的 repackage 将应用类、嵌套依赖和启动器组合成可执行 JAR,同时保留带 .original 后缀的原始 JAR。其插件行为见 可执行归档打包,目录定义见 嵌套 JAR 规范。
testing-packaging-upgrade-lab-1.0.0.jar
├── META-INF/MANIFEST.MF Main-Class 是 Boot Loader
├── org/springframework/boot/loader/...
├── BOOT-INF/classes/ 应用类、application.properties
├── BOOT-INF/lib/ 嵌套依赖 JAR
├── BOOT-INF/classpath.idx 依赖顺序
└── BOOT-INF/layers.idx 分层归属Start-Class 指向应用的 DeliveryApplication。与把所有类解压混合的 shade 方式相比,嵌套 JAR 保留依赖自己的资源结构;排查资源发现问题仍要检查最终归档,尤其是自定义 Starter 的 AutoConfiguration.imports。
APP_JAR='testing-packaging-upgrade-lab-1.0.0.jar'
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,src=$LAB_DIR/target,dst=/artifacts,readonly" \
"$RUNTIME_IMAGE" jar tf "/artifacts/$APP_JAR"
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,src=$LAB_DIR/target,dst=/artifacts,readonly" \
"$RUNTIME_IMAGE" java -Djarmode=tools \
-jar "/artifacts/$APP_JAR" list-layers第二条命令应按顺序列出 dependencies、spring-boot-loader、snapshot-dependencies、application。列出分层只读取归档;它没有创建 OCI 镜像层。
接着启动真正要交付的 JAR:
docker run -d --name boot-delivery-lab --user 10001:10001 \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 127.0.0.1:18085:8080 \
--mount "type=bind,src=$LAB_DIR/target/$APP_JAR,dst=/app.jar,readonly" \
"$RUNTIME_IMAGE" java -jar /app.jar
docker logs boot-delivery-lab
curl -q --noproxy '*' --fail-with-body --show-error \
--max-time 5 'http://127.0.0.1:18085/hello'看到启动完成日志后再请求,应返回 hello reader。连接被拒绝时先查看日志和 docker inspect boot-delivery-lab 的状态;出现 no main manifest attribute 通常要检查是否运行了普通 JAR、是否执行过 repackage。出现资源文件不存在,则对照 jar tf 的路径与程序读取方式,不能只检查源码目录里有没有这个文件。
分层、Dockerfile 与 Buildpacks
示例的 Dockerfile 使用一个可执行 JAR,便于先把启动命令、用户和文件权限跑通:
docker stop --time 25 boot-delivery-lab
docker rm boot-delivery-lab
docker build -t boot-delivery-lab:local .
docker run -d --name boot-delivery-lab \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 127.0.0.1:18085:8080 boot-delivery-lab:local
docker inspect --format '{{.Config.User}}' boot-delivery-lab
curl -q --noproxy '*' --fail-with-body --show-error \
--max-time 5 'http://127.0.0.1:18085/hello'容器用户应为 10001:10001,应用启动后仍返回 hello reader。镜像使用 exec 形式的 ENTRYPOINT,Java 进程能收到容器停止信号。基础镜像版本固定在 Dockerfile 中,生产构建还应记录实际拉取的 digest。
单文件 COPY 会把整个 JAR 放入同一个镜像层。要让稳定依赖复用缓存,可通过 tools 模式提取分层后逐层复制:
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,src=$LAB_DIR/target,dst=/artifacts" \
-w /artifacts "$RUNTIME_IMAGE" \
java -Djarmode=tools -jar "$APP_JAR" \
extract --layers --destination extracted输出目录应具有前面列出的四个层目录。Boot 4 的提取布局和启动命令要配套采用 官方 Dockerfile 示例;不要把旧版 layertools 的命令、旧启动器名称和新布局拼接使用。代码变更能否只更新应用层,还取决于分层规则、依赖版本和构建元数据是否稳定。
另一条路径是使用 Maven 插件 Buildpacks 支持。它负责协调 builder、buildpack 和 run image;团队仍需选择受信镜像、管理构建缓存和仓库凭据。应用秘密、环境账号及私钥应在运行时注入,不进入任一镜像层。
AOT、Native 与升级分别改变什么
在 JVM 中验证 Spring AOT
Spring AOT 分析构建时的应用配置,生成 Bean 初始化代码及运行时提示。它与 JDK 的 AOT 缓存是两套机制:前者面向 Spring 应用装配,后者面向 JVM 启动和执行准备。名称相似,参数和产物不能混用。
在停止上一步实验容器后执行:
docker stop --time 25 boot-delivery-lab
docker rm boot-delivery-lab
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$M2_DIR,dst=/cache" \
-w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache -DskipTests \
compile spring-boot:process-aot package
docker run -d --name boot-delivery-aot --user 10001:10001 \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 127.0.0.1:18085:8080 \
--mount "type=bind,src=$LAB_DIR/target/$APP_JAR,dst=/app.jar,readonly" \
"$RUNTIME_IMAGE" java -Dspring.aot.enabled=true -jar /app.jar
docker logs boot-delivery-aot
curl -q --noproxy '*' --fail-with-body --show-error \
--max-time 5 'http://127.0.0.1:18085/hello'target/spring-aot 下会出现生成内容,日志应显示使用 AOT 处理的应用,接口仍返回 hello reader。这里的 -DskipTests 只用于已经完成 clean verify 后的生成实验,发布流水线应安排对应的测试阶段。生成过程与插件参数见 Maven AOT 支持。
构建时决定的 Bean 图需要与运行时配置匹配。例如某个 profile 在构建时未启用,其条件 Bean 不会凭空出现在生成后的图中。运行时仍可为既有 Bean 绑定适用的属性,但切换条件装配要重新分析。第三方库使用反射、资源、代理或 JNI 时,还需要检查它们的运行时提示支持。
Native 是独立的交付目标
GraalVM Native Image 将程序编译为目标平台的本地可执行文件,运行时不使用普通 JVM。构建成本、跨平台方式、动态类加载、诊断手段和内存表现都与 JAR 有差异,完整限制见 GraalVM Native Image 入门。
上面的实验实际生成的是 AOT 处理后的 JAR。若选择 Native,必须另外完成 Native 编译、运行该二进制,并验证业务契约和所需诊断能力。比较启动耗时、稳定吞吐和内存时,要保持请求负载、容器资源与观测口径一致。
框架升级先建立差异清单
Boot 父版本牵动的不只是一个库。Framework、Servlet、Jackson、测试模块和大量自动配置会共同变化。升级前在当前版本执行完整 verify 与产物启动,保存依赖树;改版本后重复同样操作,比较 Boot 管理依赖清单 和迁移指南。
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$M2_DIR,dst=/cache" \
-w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache dependency:treeBoot 3 到 4 尤其应检查 Java 基线、Framework 7、Servlet 6.1、Jackson 3 默认路线与模块拆分。服务依赖的库可能仍使用不同 JSON API,需要逐一验证请求字段、日期、枚举、空值和异常响应。自定义 Starter 则要检查条件匹配、Bean 退让、配置属性和模块依赖。
技术升级尽量与破坏性数据库迁移分开。灰度期间新旧实例会同时处理消息、读取记录;若新版本已经写入旧版本无法理解的数据,回滚镜像也无法恢复兼容。为格式变化保留兼容字段或分阶段迁移,再扩大流量。
测试失败、产物失败与回滚如何处理
按失败阶段缩小检查范围
| 现象 | 先检查什么 | 下一步 |
|---|---|---|
| MVC 切片找不到服务 Bean | 被测 Controller 的构造器和切片配置 | 为协作者提供 @MockitoBean 或明确导入必要配置 |
| 同一组测试越来越慢 | 缓存统计、profile、属性与 Mock 声明、fork 策略 | 合并同义配置,去掉无必要的 @DirtiesContext |
mvn test 成功,接口却未测到 | 测试命名和 Failsafe 配置 | 运行 verify 并检查集成测试报告 |
| 测试 classpath 启动成功,JAR 失败 | manifest、依赖 scope、资源路径 | 检查最终归档并单独 java -jar |
| 镜像运行时报权限错误 | 镜像用户、挂载属主、只读目录和临时目录 | 只为实际需要的目录授权,保留非 root 运行 |
| AOT 启动缺 Bean 或资源 | 构建 profile、生成输出、RuntimeHints | 修正生成条件后重建,必要时运行普通 JVM 产物 |
| 升级后 JSON 或状态码变化 | 新旧响应样本、消息转换器、异常处理器 | 补契约断言后修复;超出发布阈值时回退兼容版本 |
性能回归要对照相同负载与预热条件。仅比较一次启动日志的毫秒数,很容易把依赖缓存、磁盘或 CPU 抢占当成框架差异。响应错误、内存增长和关闭超时,也应分别设置与业务相符的观测区间。
清理实验与保留可回退产物
完成 AOT 请求后停止对应容器:
docker stop --time 25 boot-delivery-aot
docker rm boot-delivery-aot若中途失败,先用 docker ps -a --filter name=boot-delivery 确认容器名,再处理本实验的容器。target、提取目录和 Maven 缓存保留在当前工程内;需要重建普通 JAR 时重新执行开头的 clean verify。删除构建结果前应确认不再有容器挂载其中的文件。
生产发布保留具体 JAR 或镜像摘要、对应配置版本和必要的数据库兼容条件。恢复时选择经过验证的旧产物,避免在故障处理中临时重新编译“同一个版本”。测试、打包和运行使用同一份可识别产物,故障排查才有稳定的比较对象。
权威资料与规范地址
测试装配、构建插件、归档布局和升级兼容资料如下。
