上周把项目升到 4.1.1,改完 pom 跑 mvn test,红了一片。
不是断言挂,是编译不过。
ini
[ERROR] OldMockBeanTest.java:[5,50] 程序包 org.springframework.boot.test.mock.mockito 不存在
[ERROR] OldMockBeanTest.java:[10,6] 找不到符号
符号: 类 MockBean
@MockBean 找不到了。
我把 spring-boot-test-4.1.1.jar 解开看了一眼,org/springframework/boot/test/mock/mockito/ 这个包整个没了,BOM 里也翻不到任何跟 mockito 有关的 Boot 模块。

它跑到 spring-test 去了

新注解在 Spring Framework 里。spring-test-7.0.9.jar 的 org/springframework/test/context/bean/override/mockito/ 下面有 MockitoBean、MockitoBeans、MockitoSpyBean、MockitoSpyBeans、MockReset,还有一个 MockitoBeanOverrideProcessor。
这就带出一个麻烦:名字改了,import 也得跟着改。
依赖树也一点没变。我对比过 3.5.16 和 4.1.1 的 spring-boot-starter-test,两边都是 16 个依赖,逐项一样;动的是 spring-boot-test-autoconfigure 那个 jar------3.5.16 里面有 13 个 autoconfigure 子包,4.1.1 只剩 2 个。
所以升级完别指望"看看依赖缺没缺"能发现问题,直接编译,让它报错。
改法就两处
java
// 旧(Boot 3)
import org.springframework.boot.test.mock.mockito.MockBean;
@MockBean
DiscountClient discountClient;
// 新(Boot 4)
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@MockitoBean
DiscountClient discountClient;
有个地方要留神:IDE 自动补全有时候会把旧的静态导入又给你补回来(这条我没复现过,算经验之谈)。真遇到"编译过了但真实服务还是被调了",先全局搜 boot.test.mock.mockito,搜到就是没换干净。
我没信文档,写了个小工程试
场景很简单:PriceService 依赖 DiscountClient,真实实现 RemoteDiscountClient 一被调用就抛异常(假装远程服务挂了)。要是 mock 没替换成功,测试会立刻炸,不用猜。
java
@SpringBootTest
class MockitoBeanTest {
@MockitoBean DiscountClient discountClient;
@Autowired PriceService priceService;
@Test void mockReplacesBean() {
given(discountClient.discountPercent(7L)).willReturn(20);
assertThat(priceService.total(7L, 2)).isEqualTo(160); // 100*0.8*2
}
}
@SpringBootTest
class MockitoSpyBeanTest {
@MockitoBean DiscountClient discountClient;
@MockitoSpyBean PriceService priceService;
@Autowired DiscountClient injected;
@Test void spyKeepsRealLogic() {
given(discountClient.discountPercent(1L)).willReturn(0);
assertThat(priceService.total(1L, 3)).isEqualTo(300); // 真实计算
verify(discountClient).discountPercent(1L); // 调用被记录
}
@Test void spyCanStub() {
given(priceService.isVip(1L)).willReturn(false); // 只盖这一个方法
assertThat(priceService.isVip(1L)).isFalse(); // 真逻辑 userId<1000 会返回 true
}
}
跑完看三行输出:
[@MockitoBean] total(7,2) = 160,桩生效了,真实客户端一次都没被调(调了就抛异常);注入的是不是 mock 实例: true,说明它换的是容器里的 bean,不是给你字段塞个新对象;打桩后 isVip(1) = false,说明 spy 留着真 bean,只盖你点名的那个方法。
三个测试全绿,一次过。
三个场景别写混

我这次三个都碰到了,区别就在"上下文里到底有没有这个 bean"。@WebMvcTest 切片里 Service 根本不存在,@MockitoBean 是补一个上去,不写上下文起不来;@SpringBootTest 里真实 bean 在,但它会发网络请求,@MockitoBean 把它换掉。
要保留真实逻辑、只想固定某几个方法或者统计调用次数,才用 @MockitoSpyBean。
另外 javap 里还看到两个属性,@MockReset(BEFORE / AFTER / NONE)和 @MockitoBean(enforceOverride = ...)。这两个我只看到了签名,行为没试过,你要用先在测试里跑一遍再信。
再记一句我自己的规矩:一个测试里替换到第四个 bean,就别硬测了,那个类该拆。@MockitoBean 太好用,很容易把"依赖五个外部服务"的类测成绿的,实际调用链一条都没验。
编译过了但行为不对,先查两处
报错缺包最好办,真正费时间的是"编译通过、跑起来不对"。我一般先查两个地方:
一是搜 import,看还有没有 boot.test.mock.mockito 残留;二是看注入进来的到底是不是同一个对象------这次我用 injected 和 discountClient 做了同一性判断,输出 true 才叫替换成功。
两步都对还不对,那就是上下文装配的问题,回去看 @MockitoBean 有没有落在字段上。它换的是容器里的 bean,不是给某个方法塞参数。
清单

按类名搜 @MockBean、@SpyBean(搜文本会漏掉静态导入),改名,换 import,不用补依赖(spring-test 跟着 spring-boot-starter-test 已经在 classpath 上了),然后全量跑一遍。桩在方法之间共享的话,顺手确认下重置策略。
工程:Spring Boot 4.1.1 / JDK 17.0.12 / Maven 3.9.16。这是《Boot 4 迁移实录》第 2 篇,上一篇是测试切片拆成 106 个模块那次。