Mockito + Spring 整合深度实战——@MockBean、@SpyBean、测试配置陷阱
Mockito + Spring 整合深度实战——@MockBean、@SpyBean、测试配置陷阱
适读人群:在 Spring Boot 测试里用 @MockBean 遇到奇怪问题的工程师,以及不清楚 @MockBean 和 @Mock 区别的开发者 | 阅读时长:约13分钟 | 核心价值:彻底搞懂 @MockBean/@SpyBean 的工作机制,避开常见陷阱
那个让 Spring 上下文一直重新加载的 @MockBean
有个同事跟我说他们的测试越来越慢,从原来的 1 分钟涨到了 5 分钟,而且项目没有明显变大。
我看了看他们的测试代码,问题立刻出现了:
@SpringBootTest
class OrderServiceTest {
@MockBean
private EmailService emailService;
// ...
}@SpringBootTest
class UserServiceTest {
@MockBean
private SmsService smsService;
// ...
}@SpringBootTest
class PaymentServiceTest {
@MockBean
private EmailService emailService;
@MockBean
private SmsService smsService;
// ...
}三个测试类,每一个用到的 @MockBean 组合都不同。Spring 为每一种组合创建了独立的上下文:OrderServiceTest 一个,UserServiceTest 一个,PaymentServiceTest 又是另一个。
总共启动了 3 次 Spring 上下文,每次都要 20-30 秒。
这就是 @MockBean 最常见、最隐蔽的性能陷阱。
@MockBean 和 @Mock 的根本区别
先把这两个搞清楚:
@Mock(Mockito 的):
- 在 Mockito 管理下创建一个 Mock 对象
- 不涉及 Spring 上下文
- 需要用
@ExtendWith(MockitoExtension.class)或手动mock()
@MockBean(Spring Boot Test 的):
- 在 Spring 上下文里创建一个 Mock Bean,替换掉原来的真实 Bean
- 影响整个 Spring 上下文
- 只能在有 Spring 上下文的测试里用(
@SpringBootTest、@WebMvcTest等)
// 正确:Service 层的纯单元测试,用 @Mock
@ExtendWith(MockitoExtension.class)
class OrderServiceUnitTest {
@Mock
private OrderRepository orderRepository; // 不需要 Spring 容器
@InjectMocks
private OrderService orderService;
}
// 正确:Spring Boot 集成测试,用 @MockBean
@SpringBootTest
class OrderServiceIntegrationTest {
@MockBean
private PaymentGateway paymentGateway; // 替换 Spring 容器里的真实 Bean
@Autowired
private OrderService orderService; // 真实的 OrderService
}@MockBean 如何影响 Spring 上下文缓存
Spring Test 有上下文缓存机制:相同配置的 @SpringBootTest 会共享同一个上下文。
@MockBean 会破坏这个缓存,因为每一种 @MockBean 组合都被视为不同的配置。
解决方案:把所有 @MockBean 统一放到基类里。
// 定义基类,包含所有可能用到的 @MockBean
@SpringBootTest
@ActiveProfiles("test")
abstract class BaseIntegrationTest {
// 集中定义,避免不同子类有不同的 MockBean 组合
@MockBean
protected EmailService emailService;
@MockBean
protected SmsService smsService;
@MockBean
protected PaymentGateway paymentGateway;
@MockBean
protected PushNotificationService pushNotificationService;
}
// 所有集成测试继承基类
class OrderServiceTest extends BaseIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void testCreateOrder() {
// emailService 已经是 mock 了,不会真的发邮件
when(paymentGateway.preAuth(any())).thenReturn("txn001");
Order order = orderService.createOrder(validRequest);
assertNotNull(order.getId());
}
}
class UserServiceTest extends BaseIntegrationTest {
@Autowired
private UserService userService;
// 这里的 Spring 上下文和 OrderServiceTest 共享,不会重新启动
}@SpyBean:部分 Mock 真实 Bean
@SpyBean 对应 Mockito 的 @Spy,它在 Spring 上下文里创建一个 Spy,大部分逻辑走真实实现,只对需要的方法进行拦截。
@SpringBootTest
class OrderServiceTest {
@SpyBean
private OrderRepository orderRepository; // 使用真实的 Repository,但可以拦截特定方法
@Autowired
private OrderService orderService;
@Test
void testCreateOrder_shouldSaveToDatabase() {
Order order = orderService.createOrder(validRequest);
// verify 真实方法被调用了
verify(orderRepository).save(any(Order.class));
// 真实的数据也被存到数据库了
assertTrue(orderRepository.existsById(order.getId()));
}
@Test
void testCreateOrder_whenDatabaseDown_shouldHandleGracefully() {
// 模拟数据库故障
doThrow(new DataAccessException("连接超时") {})
.when(orderRepository).save(any());
// 验证服务的降级行为
ProcessResult result = orderService.createOrder(validRequest);
assertFalse(result.isSuccess());
assertEquals("DATABASE_ERROR", result.getErrorCode());
}
}测试配置隔离:@TestConfiguration vs @Configuration
一个容易踩的坑:@Configuration 类会被全局扫描,包括在测试里。如果你在测试目录下放了一个 @Configuration 类,它可能影响所有测试(甚至影响生产代码的行为)。
正确做法是用 @TestConfiguration:
// 这个类只在测试时生效,不会被生产环境的组件扫描到
@TestConfiguration
public class TestDatabaseConfig {
@Bean
@Primary // 覆盖生产环境的 DataSource
public DataSource testDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.build();
}
}@TestConfiguration 的特点:
- 不会被
@SpringBootApplication的组件扫描捡到 - 需要显式
@Import或放在同一个包下才生效 - 适合定义测试专用的 Bean 替换
@SpringBootTest
@Import(TestDatabaseConfig.class) // 显式导入测试配置
class OrderRepositoryTest { ... }条件化配置:按 Profile 注册不同 Bean
更优雅的做法是使用 Profile 来区分测试和生产的 Bean:
// 生产环境:真实的邮件服务
@Service
@Profile("!test") // 不是 test profile 时生效
public class SmtpEmailService implements EmailService {
public void send(String to, String subject, String body) {
// 真实 SMTP 发送
}
}
// 测试环境:空实现(不真的发邮件)
@Service
@Profile("test") // test profile 时生效
public class NoOpEmailService implements EmailService {
private final List<Email> sentEmails = new ArrayList<>();
public void send(String to, String subject, String body) {
sentEmails.add(new Email(to, subject, body)); // 记录到内存,不发送
}
public List<Email> getSentEmails() {
return sentEmails;
}
}这样,测试时只需要 @ActiveProfiles("test"),不需要 @MockBean,上下文可以正常复用。
踩坑实录三则
踩坑一:@MockBean 创建的 Mock 没有在下一个测试前重置
现象:
@SpringBootTest
class ServiceTest {
@MockBean
private ExternalService externalService;
@Test
void test1() {
when(externalService.call()).thenReturn("result1");
// ...
}
@Test
void test2() {
// test1 里设的 stub 还在!externalService.call() 还是返回 "result1"
String result = service.process(); // 行为不符合预期
}
}原因:@MockBean 创建的 Mock 对象在两个测试方法之间不会自动重置。
解法:用 @BeforeEach 手动重置,或者用 Mockito.reset(mockBean) 清除之前的 stub:
@BeforeEach
void resetMocks() {
Mockito.reset(externalService);
}或者在每个测试里重新 stub,不依赖之前的状态。
踩坑二:@SpyBean 调用了真实方法,触发了副作用
现象:用 @SpyBean 包装了 EmailService,某个测试意外发送了真实邮件到生产环境的邮箱。
原因:@SpyBean 的默认行为是调用真实方法,如果没有对某个方法 stub,就会执行真实逻辑。
解法:对有副作用的方法,必须用 doNothing()、doReturn() 提前 stub:
@BeforeEach
void preventRealEmailSending() {
doNothing().when(emailService).send(anyString(), anyString(), anyString());
}更好的做法:有副作用的外部服务用 @MockBean 而不是 @SpyBean。@SpyBean 适合那些"我想用真实逻辑,但偶尔要改变某个方法行为"的场景。
踩坑三:@MockBean 在父类里,子类测试的 Mock 不被重置
现象:
abstract class BaseTest {
@MockBean
protected UserService userService;
}
class OrderTest extends BaseTest {
@Test
void test1() {
when(userService.getUser(1L)).thenReturn(mockUser);
// 这个 stub 在下一个测试里还存在!
}
}
class PaymentTest extends BaseTest {
@Test
void test2() {
// 如果这两个测试类共用同一个 Spring 上下文
// test1 留下的 stub 可能在这里还生效
}
}原因:同一个 Spring 上下文里,@MockBean 是同一个对象,stub 不会因为换了测试类而重置。
解法:上文提到的方案——在 BaseTest 的 @BeforeEach 里重置所有 @MockBean:
abstract class BaseTest {
@MockBean
protected UserService userService;
@BeforeEach
void baseReset() {
Mockito.reset(userService);
}
}@MockBean 使用原则总结
我的建议:
- @MockBean 越少越好:每多一个
@MockBean,就多一个上下文缓存污染点。能用 Profile + NoOp 实现的,不要用@MockBean。 - 统一放在基类:避免不同测试类的
@MockBean组合不同导致上下文不断新建。 - @BeforeEach 里重置:防止 stub 污染相邻测试。
- @SpyBean 谨慎使用:它会调用真实方法,有副作用的地方要提前 stub。
