我把一个 Web、JPA、Security、Actuator、Validation 齐全的 Spring Boot 3.5.16 项目升到了 4.1.1。主代码一行没改就编译通过,出问题的全在测试和依赖上。
迁移指南推荐先用 spring-boot-starter-classic 过渡。可在 4.1.1 上,它会把 4.1 新加的 spring-boot-grpc-server 一起带进来,却不带 Spring gRPC 本身。只要 Security 的 CSRF 没关,HttpSecurity 一构建就抛 NoClassDefFoundError,应用直接起不来。同样的写法在 4.0.8 上能正常启动,4.1.1 上排除这个模块也能启动。
最后全部换成模块化 starter:主代码用 5 个 starter,测试用 3 个 test starter。打出来的 jar 里依赖从 68 个变成 83 个,总大小反而小了 2005496 字节。
模块化 starter 和 classic starter 都是 Spring Boot 4.0 引入的,从 3.5 直接跳到 4.1 也要走这一遍。主要参考是 GitHub wiki 上的 Spring Boot 4.0 Migration Guide 和 Spring Boot 4.1 Release Notes。每一步我都单独提交了一次 git,下面贴的报错都来自从干净克隆逐个提交重放的构建日志,环境是 JDK 21.0.12.1 和 Maven 3.9.9。
起点是一个普通的 3.5.16 项目
项目是一个订单接口:Order 实体、OrderRepository、带 @Valid 的 OrderController,Security 用 HTTP Basic,放行 /actuator/health。依赖是 3.x 时代最常见的写法:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
测试有三类。@WebMvcTest 切片测控制器,用 @MockBean 替掉 OrderService,用 @WithMockUser 模拟登录,注入 ObjectMapper 序列化请求体:
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerTests {
@Autowired
MockMvc mvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
OrderService service;
@Test
@WithMockUser
void createsOrder() throws Exception {
given(service.create(any())).willReturn(new Order("book", 2));
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new CreateOrderRequest("book", 2))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.product").value("book"));
}
// 另有校验失败返回 400、未登录返回 401 两个用例
}
@DataJpaTest 切片用 TestEntityManager 准备数据测查询方法,@SpringBootTest(webEnvironment = RANDOM_PORT) 加 TestRestTemplate 走一遍真实的 HTTP。3.5.16 下 6 个测试全过,编译时只有一条 @MockBean 的废弃警告:
[WARNING] src/test/java/com/example/orders/OrderControllerTests.java:[30,6] org.springframework.boot.test.mock.mockito.MockBean in org.springframework.boot.test.mock.mockito has been deprecated and marked for removal
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
@MockBean 是 3.4.0 标记废弃的,3.5 的 javadoc 写明 4.0.0 删除,替代品 @MockitoBean 来自 Spring Framework 6.2。打包后启动,接口和健康检查都正常:
:: Spring Boot :: (v3.5.16)
2026-10-19T08:23:33.196+08:00 INFO 261120 --- [orders] [ main] com.example.orders.OrdersApplication : Started OrdersApplication in 4.046 seconds (process running for 4.437)
$ curl -s localhost:8080/actuator/health
{"status":"UP","components":{"db":{"status":"UP","details":{"database":"H2","validationQuery":"isValid()"}},"ping":{"status":"UP"},"ssl":{"status":"UP","details":{"validChains":[],"invalidChains":[]}}}}
$ curl -s -u alice:secret -H "Content-Type: application/json" -d '{"product":"book","quantity":2}' localhost:8080/api/orders
{"id":1,"product":"book","quantity":2}
$ curl -s -o /dev/null -w "%{http_code}\n" -u alice:secret -H "Content-Type: application/json" -d '{"product":"","quantity":0}' localhost:8080/api/orders
400
只改 parent 版本
第一步只把 spring-boot-starter-parent 从 3.5.16 改成 4.1.1,其他依赖不动。src/main 照常编译通过,spring-boot-starter-web 在 4.1.1 里还在,它的 POM 描述写着已被 spring-boot-starter-webmvc 取代。测试编译挂了,其中缺包的几行是这样的(摘录,去掉了绝对路径前缀,下同):
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[10,38] package com.fasterxml.jackson.databind does not exist
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[13,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[14,50] package org.springframework.boot.test.mock.mockito does not exist
[ERROR] src/test/java/com/example/orders/OrderRepositoryTests.java:[7,59] package org.springframework.boot.test.autoconfigure.orm.jpa does not exist
[ERROR] src/test/java/com/example/orders/OrderRepositoryTests.java:[8,59] package org.springframework.boot.test.autoconfigure.orm.jpa does not exist
[ERROR] src/test/java/com/example/orders/OrdersApplicationTests.java:[9,48] package org.springframework.boot.test.web.client does not exist
三个测试类都没编译过。WebMvcTest、DataJpaTest、TestEntityManager、TestRestTemplate 找不到包,MockBean 已经删了,Jackson 的 com.fasterxml.jackson.databind 也没了。
加上 classic starter,import 还是要自己改
迁移指南给的过渡方案是把 spring-boot-starter 换成 spring-boot-starter-classic,spring-boot-starter-test 换成 spring-boot-starter-test-classic。我的项目没有直接声明 spring-boot-starter,就把 classic 加在最前面,测试 starter 直接替换:
commit 836bc8d Step 2b: add spring-boot-starter-classic and spring-boot-starter-test-classic (test compile still fails)
diff --git a/pom.xml b/pom.xml
index daf6eb0..5f57072 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,6 +16,10 @@
<java.version>21</java.version>
</properties>
<dependencies>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-classic</artifactId>
+ </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -43,7 +47,7 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-test</artifactId>
+ <artifactId>spring-boot-starter-test-classic</artifactId>
<scope>test</scope>
</dependency>
<dependency>
再编译,报错和上一步一模一样,还是那些行:
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[10,38] package com.fasterxml.jackson.databind does not exist
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[13,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[14,50] package org.springframework.boot.test.mock.mockito does not exist
classic starter 管的是类路径,不管包名。它把所有模块都拉进来,但不带各模块的传递依赖,让 3.x 那种“自动配置全都在”的状态回来。看依赖树就明白,spring-boot-starter-test-classic 下面挂着一个 spring-boot-test-classic-modules,各模块的测试支持都在里面(摘录):
+- org.springframework.boot:spring-boot-starter-test-classic:jar:4.1.1:test
| \- org.springframework.boot:spring-boot-test-classic-modules:jar:4.1.1:test
| +- org.springframework.boot:spring-boot-data-jpa-test:jar:4.1.1:test
| +- org.springframework.boot:spring-boot-resttestclient:jar:4.1.1:test
| +- org.springframework.boot:spring-boot-webmvc-test:jar:4.1.1:test
新的包名照着迁移指南里的命名规则在这些 jar 里找:模块叫 spring-boot-<技术>,根包是 org.springframework.boot.<技术>,测试模块再加 test。改完的 diff:
commit cfea920 Step 2c: fix moved test imports under classic starters (context fails: gRPC configurer)
diff --git a/src/test/java/com/example/orders/OrderControllerTests.java b/src/test/java/com/example/orders/OrderControllerTests.java
index 27eac97..f0e289a 100644
--- a/src/test/java/com/example/orders/OrderControllerTests.java
+++ b/src/test/java/com/example/orders/OrderControllerTests.java
@@ -7,11 +7,11 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
@@ -25,9 +25,9 @@ class OrderControllerTests {
MockMvc mvc;
@Autowired
- ObjectMapper objectMapper;
+ JsonMapper objectMapper;
- @MockBean
+ @MockitoBean
OrderService service;
@Test
diff --git a/src/test/java/com/example/orders/OrderRepositoryTests.java b/src/test/java/com/example/orders/OrderRepositoryTests.java
index 3ff192b..c496e22 100644
--- a/src/test/java/com/example/orders/OrderRepositoryTests.java
+++ b/src/test/java/com/example/orders/OrderRepositoryTests.java
@@ -4,8 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
-import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
@DataJpaTest
class OrderRepositoryTests {
diff --git a/src/test/java/com/example/orders/OrdersApplicationTests.java b/src/test/java/com/example/orders/OrdersApplicationTests.java
index d81cd8c..7b03ba2 100644
--- a/src/test/java/com/example/orders/OrdersApplicationTests.java
+++ b/src/test/java/com/example/orders/OrdersApplicationTests.java
@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
-import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@MockitoBean 在 Spring Framework 的 org.springframework.test.context.bean.override.mockito 包里。TestEntityManager 和 @DataJpaTest 不在同一个模块,前者在 spring-boot-jpa-test,后者在 spring-boot-data-jpa-test。Jackson 这里只是把 ObjectMapper 换成 tools.jackson.databind.json.JsonMapper,Jackson 3 带来的其他变化,之前那篇讲 Spring Boot 4 换 Jackson 3 的文章已经逐条走过一遍。
4.1.1 的 classic starter 让 Security 应用起不来
import 改完,测试编译过了。跑起来 6 个里挂了 5 个,只有 @DataJpaTest 那个能过。打包启动也一样失败(摘录):
:: Spring Boot :: (v4.1.1)
2026-10-19T08:24:01.595+08:00 ERROR 261969 --- [orders] [ main] o.s.boot.SpringApplication : Application run failed
Caused by: java.lang.NoClassDefFoundError: org/springframework/grpc/server/service/GrpcServiceDiscoverer
at org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer.init(GrpcDisableCsrfHttpConfigurer.java:52) ~[spring-boot-grpc-server-4.1.1.jar!/:4.1.1]
at org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer.init(GrpcDisableCsrfHttpConfigurer.java:47) ~[spring-boot-grpc-server-4.1.1.jar!/:4.1.1]
at org.springframework.security.config.annotation.AbstractConfiguredSecurityBuilder.init(AbstractConfiguredSecurityBuilder.java:371) ~[spring-security-config-7.1.1.jar!/:7.1.1]
at org.springframework.security.config.annotation.AbstractConfiguredSecurityBuilder.doBuild(AbstractConfiguredSecurityBuilder.java:333) ~[spring-security-config-7.1.1.jar!/:7.1.1]
at org.springframework.security.config.annotation.AbstractSecurityBuilder.build(AbstractSecurityBuilder.java:38) ~[spring-security-config-7.1.1.jar!/:7.1.1]
at com.example.orders.SecurityConfig.securityFilterChain(SecurityConfig.java:23) ~[!/:0.0.1-SNAPSHOT]
Caused by: java.lang.ClassNotFoundException: org.springframework.grpc.server.service.GrpcServiceDiscoverer
[ERROR] Tests run: 6, Failures: 0, Errors: 5, Skipped: 0
spring-boot-grpc-server 是 classic starter 带进来的。gRPC 支持是 Spring Boot 4.1 新加的功能,classic 按规矩把这个模块也放进了类路径。迁移指南说得明白,classic starter 提供所有模块,但排除它们的传递依赖。spring-boot-grpc-server 的 POM 里声明了 spring-grpc-core,也就被一起排除了。依赖树里 gRPC 模块下面什么都没挂(摘录):
+- org.springframework.boot:spring-boot-starter-classic:jar:4.1.1:compile
| \- org.springframework.boot:spring-boot-autoconfigure-classic:jar:4.1.1:compile
| \- org.springframework.boot:spring-boot-autoconfigure-classic-modules:jar:4.1.1:compile
| +- org.springframework.boot:spring-boot-grpc-client:jar:4.1.1:compile
| +- org.springframework.boot:spring-boot-grpc-server:jar:4.1.1:compile
这个模块在 META-INF/spring.factories 里登记了一个 Security 的配置器:
$ unzip -p spring-boot-grpc-server-4.1.1.jar META-INF/spring.factories
org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\
org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer
Spring Security 的 HttpSecurityConfiguration 创建 HttpSecurity 时会用 SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, ...) 加载这里登记的配置器,所以只要应用里有 SecurityFilterChain,GrpcDisableCsrfHttpConfigurer 的 init 就会执行。它先看 HttpSecurity 上有没有 CSRF 配置器,有的话就去查 GrpcServiceDiscoverer 类型的 bean。这个类在 Spring gRPC 的 jar 里,类加载就失败了。上面栈里 SecurityConfig.java:23 正是 http.build() 那一行。
Spring Security 默认开着 CSRF,我的配置也只是让 /api/** 忽略 CSRF。我把同一个提交里的配置临时改成 csrf.disable() 再打包,应用能正常启动,因为 CSRF 配置器不在了,这段检查根本走不到。所以准确地说,受影响的是 4.1.1 上用了 classic starter、带 SecurityFilterChain 并且没关 CSRF 的应用。
为了确认是 4.1 才有的问题,我从同一个提交拉了个分支,只把 parent 改成 4.0.8,classic 的写法完全一样。这条分支只用来对比启动,打包后正常起来了:
:: Spring Boot :: (v4.0.8)
2026-10-19T08:24:45.870+08:00 INFO 263413 --- [orders] [ main] com.example.orders.OrdersApplication : Started OrdersApplication in 4.994 seconds (process running for 5.444)
4.0.8 的 classic 依赖树里没有任何 gRPC 模块。在 4.1.1 上要继续用 classic 过渡,我的处理是把 spring-boot-grpc-server 排除掉。spring-boot-grpc-client 还留在类路径上,这个项目里没有引起问题:
commit f95ca57 Step 2d: exclude spring-boot-grpc-server from classic starter (TestRestTemplate bean missing)
diff --git a/pom.xml b/pom.xml
index 5f57072..49cc25d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,6 +19,12 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-classic</artifactId>
+ <exclusions>
+ <exclusion>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-grpc-server</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
排除以后应用能正常启动。我从干净克隆把报错、排除和 4.0.8 三种情况又各打包启动了一遍,结果和上面一致。截至 10 月 19 日,spring-boot 仓库里没有搜到针对这个问题的 issue,4.1.x 分支上 GrpcDisableCsrfHttpConfigurer 的代码也还和 4.1.1 一样。之前有一个同样由 classic 带进 gRPC 引起的问题 #50825,出在 spring-boot-starter-test-classic 带进的 spring-boot-grpc-test 上,已经在 4.1.1 修掉了。
TestRestTemplate 不会自动注入了
排除 gRPC 之后,控制器测试和 JPA 测试都过了,剩下 @SpringBootTest 的两个用例:
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.boot.resttestclient.TestRestTemplate' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
[ERROR] Tests run: 6, Failures: 0, Errors: 2, Skipped: 0
迁移指南里写得很清楚,4.0 起 @SpringBootTest 不再自动提供 TestRestTemplate,要加 @AutoConfigureTestRestTemplate:
commit f771825 Step 2e: add @AutoConfigureTestRestTemplate, classic build green
diff --git a/src/test/java/com/example/orders/OrdersApplicationTests.java b/src/test/java/com/example/orders/OrdersApplicationTests.java
index 7b03ba2..0223c34 100644
--- a/src/test/java/com/example/orders/OrdersApplicationTests.java
+++ b/src/test/java/com/example/orders/OrdersApplicationTests.java
@@ -7,10 +7,12 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.resttestclient.TestRestTemplate;
+import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+@AutoConfigureTestRestTemplate
class OrdersApplicationTests {
@Autowired
加上就全绿了,应用也能启动:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
:: Spring Boot :: (v4.1.1)
2026-10-19T08:25:18.518+08:00 INFO 264238 --- [orders] [ main] com.example.orders.OrdersApplication : Started OrdersApplication in 4.904 seconds (process running for 5.348)
$ curl -s localhost:8080/actuator/health
{"components":{"db":{"details":{"database":"H2","validationQuery":"isValid()"},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"details":{"expiringChains":[],"invalidChains":[],"validChains":[]},"status":"UP"}},"groups":["liveness","readiness"],"status":"UP"}
健康检查的输出比 3.5 多了 livenessState、readinessState 和 groups。迁移指南说明了这是 4.0 起存活和就绪探针默认开启的结果。这个阶段的 jar 里有 158 个依赖,其中 spring-boot- 开头的 99 个,classic 把整个 Boot 都搬进来了。
去掉 classic,一个一个换成模块化 starter
先删主代码那边的 spring-boot-starter-classic,测试还留着 spring-boot-starter-test-classic。主代码不受影响,@SpringBootTest 又挂了:
Caused by: org.springframework.boot.autoconfigure.condition.OnBeanCondition$BeanTypeDeductionException: Failed to deduce bean type for org.springframework.boot.resttestclient.autoconfigure.TestRestTemplateTestAutoConfiguration.testRestTemplate
Caused by: java.lang.NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder
[ERROR] Tests run: 6, Failures: 0, Errors: 2, Skipped: 0
TestRestTemplate 的自动配置要用 RestTemplateBuilder,它在 spring-boot-restclient 模块里。之前是主代码的 classic 顺带提供的,删掉就没了。迁移指南提到 TestRestTemplate 需要 spring-boot-restclient,我先按这个补一个测试范围的依赖:
commit 553a685 Step 3b: add test-scoped spring-boot-restclient, build green without main classic
diff --git a/pom.xml b/pom.xml
index 72df9d1..ccb6841 100644
--- a/pom.xml
+++ b/pom.xml
@@ -46,6 +46,11 @@
<artifactId>spring-boot-starter-test-classic</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-restclient</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
然后把 spring-boot-starter-web 换成 spring-boot-starter-webmvc。4.1.1 里这两个 starter 拉进来的是同一套东西:spring-boot-starter-jackson、spring-boot-starter-tomcat、spring-boot-http-converter 和 spring-boot-webmvc。换完测试照过,jar 里的依赖数和大小都没变:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
接下来动测试 starter。把 spring-boot-starter-test-classic 换回普通的 spring-boot-starter-test,测试切片又全部找不到了:
[ERROR] src/test/java/com/example/orders/OrderControllerTests.java:[13,58] package org.springframework.boot.webmvc.test.autoconfigure does not exist
[ERROR] src/test/java/com/example/orders/OrderRepositoryTests.java:[7,60] package org.springframework.boot.data.jpa.test.autoconfigure does not exist
[ERROR] src/test/java/com/example/orders/OrderRepositoryTests.java:[8,55] package org.springframework.boot.jpa.test.autoconfigure does not exist
[ERROR] src/test/java/com/example/orders/OrdersApplicationTests.java:[9,47] package org.springframework.boot.resttestclient does not exist
[ERROR] src/test/java/com/example/orders/OrdersApplicationTests.java:[10,61] package org.springframework.boot.resttestclient.autoconfigure does not exist
4.1.1 的 spring-boot-starter-test 里只有 spring-boot-test、spring-boot-test-autoconfigure 和 JUnit、AssertJ、Mockito 这类通用测试库。各技术的测试切片都拆到了 spring-boot-starter-<技术>-test 里,每个这样的 starter 都会传递带上 spring-boot-starter-test。按项目用到的技术换成 spring-boot-starter-webmvc-test 和 spring-boot-starter-data-jpa-test,spring-security-test 先保持 3.x 的写法不动:
commit e6ce010 Step 3e: webmvc-test + data-jpa-test starters (WebMvcTest has no HttpSecurity)
diff --git a/pom.xml b/pom.xml
index 613bfd0..83b429c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -43,7 +43,12 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-test</artifactId>
+ <artifactId>spring-boot-starter-webmvc-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
编译过了,控制器测试的上下文起不来:
APPLICATION FAILED TO START
Parameter 0 of method securityFilterChain in com.example.orders.SecurityConfig required a bean of type 'org.springframework.security.config.annotation.web.builders.HttpSecurity' that could not be found.
[ERROR] Tests run: 6, Failures: 0, Errors: 3, Skipped: 0
@WebMvcTest 切片里没有 Security 的自动配置,HttpSecurity 这个 bean 根本不存在。关键在 spring-boot-security-test 模块,它往 AutoConfigureMockMvc 的导入清单里登记了 Security 的自动配置,@WebMvcTest 有了它才会把 Security 带进切片:
$ unzip -p spring-boot-security-test-4.1.1.jar META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc.imports
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
org.springframework.boot.security.test.autoconfigure.webmvc.SecurityMockMvcAutoConfiguration
只引 spring-security-test 拿不到这个模块。迁移指南也提到 @WithMockUser 要配合 spring-boot-starter-security-test 才能正常工作。把 spring-security-test 换成 spring-boot-starter-security-test,它会传递带上 spring-security-test 7.1.1:
commit e719692 Step 3f: spring-security-test -> spring-boot-starter-security-test, green
diff --git a/pom.xml b/pom.xml
index 83b429c..8d418fc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,8 +57,8 @@
<scope>test</scope>
</dependency>
<dependency>
- <groupId>org.springframework.security</groupId>
- <artifactId>spring-security-test</artifactId>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
最后处理那个单独补的 spring-boot-restclient。spring-boot-starter-webmvc-test 只带了 spring-boot-resttestclient,没带 spring-boot-restclient。不写它,TestRestTemplate 就会回到上面 RestTemplateBuilder 找不到的错误。我把 @SpringBootTest 里的 TestRestTemplate 换成了 RestTestClient,它是 Spring Framework 7.0 新增的测试客户端(用法可以看之前那篇讲 RestTestClient 的文章),Boot 4.0 配了 @AutoConfigureRestTestClient 来注入它:
commit a72e104 Step 3g: TestRestTemplate -> RestTestClient, drop spring-boot-restclient
diff --git a/pom.xml b/pom.xml
index 8d418fc..f16ce1f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,11 +51,6 @@
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-restclient</artifactId>
- <scope>test</scope>
- </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
diff --git a/src/test/java/com/example/orders/OrdersApplicationTests.java b/src/test/java/com/example/orders/OrdersApplicationTests.java
index 0223c34..9c8d983 100644
--- a/src/test/java/com/example/orders/OrdersApplicationTests.java
+++ b/src/test/java/com/example/orders/OrdersApplicationTests.java
@@ -4,34 +4,41 @@ import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
-import org.springframework.boot.resttestclient.TestRestTemplate;
-import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
-@AutoConfigureTestRestTemplate
+@AutoConfigureRestTestClient
class OrdersApplicationTests {
@Autowired
- TestRestTemplate rest;
+ RestTestClient rest;
@Test
void createsAndListsOrders() {
- TestRestTemplate alice = rest.withBasicAuth("alice", "secret");
- ResponseEntity<String> created = alice.postForEntity("/api/orders",
- new CreateOrderRequest("book", 2), String.class);
- assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
- ResponseEntity<String> list = alice.getForEntity("/api/orders", String.class);
- assertThat(list.getBody()).contains("\"product\":\"book\"");
+ rest.post().uri("/api/orders")
+ .headers(headers -> headers.setBasicAuth("alice", "secret"))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(new CreateOrderRequest("book", 2))
+ .exchange()
+ .expectStatus().isCreated();
+ rest.get().uri("/api/orders")
+ .headers(headers -> headers.setBasicAuth("alice", "secret"))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(String.class)
+ .value(body -> assertThat(body).contains("\"product\":\"book\""));
}
@Test
void healthIsPublic() {
- ResponseEntity<String> health = rest.getForEntity("/actuator/health", String.class);
- assertThat(health.getStatusCode()).isEqualTo(HttpStatus.OK);
- System.out.println("[test] /actuator/health -> " + health.getBody());
+ rest.get().uri("/actuator/health")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(String.class)
+ .value(body -> System.out.println("[test] /actuator/health -> " + body));
}
}
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.337 s -- in com.example.orders.OrderControllerTests
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.823 s -- in com.example.orders.OrderRepositoryTests
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.767 s -- in com.example.orders.OrdersApplicationTests
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
最终的依赖
最终的依赖树顶层是这 9 项:
+- org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.1:compile
+- org.springframework.boot:spring-boot-starter-data-jpa:jar:4.1.1:compile
+- org.springframework.boot:spring-boot-starter-security:jar:4.1.1:compile
+- org.springframework.boot:spring-boot-starter-actuator:jar:4.1.1:compile
+- org.springframework.boot:spring-boot-starter-validation:jar:4.1.1:compile
+- com.h2database:h2:jar:2.4.240:runtime
+- org.springframework.boot:spring-boot-starter-webmvc-test:jar:4.1.1:test
+- org.springframework.boot:spring-boot-starter-data-jpa-test:jar:4.1.1:test
\- org.springframework.boot:spring-boot-starter-security-test:jar:4.1.1:test
Actuator 和 Validation 没有单独加对应的测试 starter,因为这个项目的测试没有用到它们的测试支持。打包启动,三个请求的结果和 3.5.16 时一样:
:: Spring Boot :: (v4.1.1)
2026-10-19T08:26:38.136+08:00 INFO 266461 --- [orders] [ main] com.example.orders.OrdersApplication : Started OrdersApplication in 4.095 seconds (process running for 4.459)
$ curl -s localhost:8080/actuator/health
{"components":{"db":{"details":{"database":"H2","validationQuery":"isValid()"},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"details":{"expiringChains":[],"invalidChains":[],"validChains":[]},"status":"UP"}},"groups":["liveness","readiness"],"status":"UP"}
$ curl -s -u alice:secret -H "Content-Type: application/json" -d '{"product":"book","quantity":2}' localhost:8080/api/orders
{"product":"book","quantity":2,"id":1}
$ curl -s -o /dev/null -w "%{http_code}\n" -u alice:secret -H "Content-Type: application/json" -d '{"product":"","quantity":0}' localhost:8080/api/orders
400
三个阶段的 jar 统计放在一起看:
BOOT-INF/lib jars: 68
spring-boot-* jars: 5
jar size bytes: 62127687
BOOT-INF/lib jars: 158
spring-boot-* jars: 99
jar size bytes: 63314897
BOOT-INF/lib jars: 83
spring-boot-* jars: 24
jar size bytes: 60122191
第一组是 3.5.16,第二组是 4.1.1 加 classic,第三组是 4.1.1 换完模块化 starter。3.5.16 里 Boot 自己只有 5 个 jar,4.1.1 拆成了 24 个按技术划分的模块,依赖个数因此多了 15 个,jar 总大小从 62127687 字节降到 60122191 字节。
一起交流
分享你的思考,让讨论更进一步。