1. 概述
测试是编程中最核心的主题之一。Spring 框架和 Spring Boot 通过提供测试框架扩展,引导我们编写精简、可测试的代码,并在后台实现大量自动化,从而提供了出色的测试支持。要运行 Spring Boot 集成测试,只需在测试类上添加 @SpringBootTest
注解。在 Spring Boot 测试指南 中可以找到简短介绍。即使不使用 Spring Boot 而直接使用 Spring 框架,也能高效完成 集成测试。
但开发测试越简单,踩坑的风险就越大。本文将深入探讨 Spring Boot 测试的执行机制,以及编写测试时需要特别注意的事项。
2. 陷阱示例
从一个简单案例开始:实现一个宠物管理服务(PetService
):
public record Pet(String name) {}
@Service
public class PetService {
private final Set<Pet> pets = new HashSet<>();
public Set<Pet> getPets() {
return Collections.unmodifiableSet(pets);
}
public boolean add(Pet pet) {
return this.pets.add(pet);
}
}
服务需要避免重复宠物,测试用例如下:
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Test
void shouldAddPetWhenNotAlreadyExisting() {
var pet = new Pet("Dog");
var result = service.add(pet);
assertThat(result).isTrue();
assertThat(service.getPets()).hasSize(1);
}
@Test
void shouldNotAddPetWhenAlreadyExisting() {
var pet = new Pet("Cat");
var result = service.add(pet);
assertThat(result).isTrue();
// 尝试第二次添加
result = service.add(pet);
assertThat(result).isFalse();
assertThat(service.getPets()).hasSize(1);
}
}
单独执行每个测试时一切正常,但一起运行时会失败:
为什么测试会失败?如何避免?接下来我们先理清基础概念。
3. 功能测试的设计目标
功能测试用于验证需求并确保代码正确实现。因此测试本身必须可靠且易于理解,最好能自解释。但本文重点讨论以下设计目标:
- 回归性:测试必须可重复执行,结果必须确定
- 隔离性:测试间不能相互影响,执行顺序或并行执行不应改变结果
- 性能:测试应尽可能快速且节省资源,尤其是 CI 流水线或 TDD 中的测试
⚠️ Spring Boot 测试本质是集成测试,因为它们会初始化 ApplicationContext
(即通过依赖注入初始化和装配 Bean)。因此隔离性需要特别关注——上述案例显然存在隔离问题。同时,性能也是 Spring Boot 测试的挑战。
核心结论:优先避免集成测试。对 PetService
的最佳测试方案是单元测试:
// 无注解
class PetServiceUnitTest {
PetService service = new PetService();
// ...
}
仅在必要时使用 Spring Boot 测试,例如需要验证框架处理(生命周期管理、依赖注入、事件处理)或测试特定层(HTTP 层、持久化层)时。
4. 上下文缓存机制
添加 @SpringBootTest
后,ApplicationContext
会启动并初始化 Bean。但为支持隔离,JUnit 会对每个测试方法重复此过程,导致每个用例创建一个 ApplicationContext
,严重影响性能。为解决此问题,Spring 测试框架会缓存上下文并复用:
不同 ApplicationContext
仅在配置差异时创建(如 Bean 不同或应用属性不同)。详见 Spring 测试框架文档。由于配置在类级别定义,测试类内的所有方法默认共享同一上下文。
上下文缓存作为性能优化手段,与隔离性存在矛盾。因此只有确保测试隔离时才能复用 ApplicationContext
。这也是 Spring Boot 测试在满足特定条件前不应并行执行 的关键原因。可通过不同 JVM 进程运行测试(如配置 Maven Surefire 插件的 forkMode
),但这会绕过缓存机制。
4.1. PetService 解决方案
针对 PetService
测试,有几种解决方案(因其是有状态服务):
方案一:使用 @DirtiesContext
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Test
@DirtiesContext
void shouldAddPetWhenNotAlreadyExisting() {
// ...
}
@Test
@DirtiesContext
void shouldNotAddPetWhenAlreadyExisting() {
// ...
}
}
标记上下文为"脏",测试后关闭并移除缓存。严重牺牲性能,不推荐使用。
方案二:手动重置状态
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@AfterEach
void resetState() {
service.clear(); // 清空所有宠物
}
// ...
}
最佳方案:实现无状态服务
当前内存存储方式在可扩展环境中极不推荐,应改为持久化存储。
4.2. 陷阱:上下文数量激增
避免意外创建额外 ApplicationContext
,需了解导致配置差异的因素:
- 直接配置 Bean(如
@ComponentScan
、@Import
、@AutoConfigureXXX
) - 激活 Profile(
@ActiveProfiles
) - 记录事件(
@RecordApplicationEvents
)
@SpringBootTest
// 以下每个注解都会衍生出不同上下文
@ComponentScan(basePackages = "com.baeldung.sample.blogposts")
@Import(PetServiceTestConfiguration.class)
@AutoConfigureTestDatabase
@ActiveProfiles("test")
@RecordApplicationEvents
class PetServiceIntegrationTest {
// ...
}
4.3. 陷阱:Mock 对象处理
Spring 测试框架集成了 Mockito。使用 @MockBean
时,Mock 实例会被放入 ApplicationContext
,导致上下文无法与其他测试类共享:
@SpringBootTest
class PetServiceIntegrationTest {
// 上下文无法共享
@MockBean
PetServiceRepository repository;
// ...
}
若需共享上下文,可通过 @TestConfiguration
定义 Mock 并自动重置:
@TestConfiguration
public class PetServiceTestConfiguration {
@Primary
@Bean
PetServiceRepository createRepositoryMock() {
return mock(
PetServiceRepository.class,
MockReset.withSettings(MockReset.AFTER)
);
}
}
@SpringBootTest
@Import(PetServiceTestConfiguration.class)
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Autowired // Mock
PetServiceRepository repository;
// ...
}
4.4. 配置上下文缓存
通过日志查看缓存统计:
logging.level.org.springframework.test.context.cache=DEBUG
输出示例:
org.springframework.test.context.cache:
Spring test ApplicationContext cache statistics:
[DefaultContextCache@34585ac9 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 8, missCount = 1]
默认缓存大小为 32(LRU 策略),可通过以下配置调整:
spring.test.context.cache.maxSize=50
5. 上下文配置优化
5.1. 配置自动检测
@SpringBootTest
默认从测试类所在包开始向上查找 @SpringBootConfiguration
注解类(通常是主应用类),读取配置创建上下文。
5.2. 最小化上下文
方案一:使用内部配置类
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Configuration
static class MyCustomConfiguration {
@Bean
PetService createMyPetService() {
// 自定义服务实现
}
}
// ...
}
完全禁用 @SpringBootConfiguration
自动检测。
方案二:指定加载类
@SpringBootTest(classes = PetService.class)
public class PetServiceIntegrationTest {
@Autowired
PetService service;
// ...
}
方案三:使用基础 Spring 测试
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PetService.class)
class PetServiceIntegrationTest {
@Autowired
PetService service;
// ...
}
5.3. 测试切片
Spring Boot 提供多种测试切片(如 @WebMvcTest
、@DataJpaTest
),仅加载特定层配置。详见 Spring Boot 文档。
5.4. 上下文优化 vs 缓存
优化单个测试启动速度可能增加整体执行时间(因创建更多上下文)。建议复用现有配置而非过度优化。
6. 建议:自定义切片
为平衡上下文数量与大小,可定义一组自定义切片(如按层划分),在所有测试中统一使用:
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ExtendWith(SpringExtension.class)
@ComponentScan(basePackageClasses = PetsDomainTest.class)
@Import(PetsDomainTest.PetServiceTestConfiguration.class)
@ActiveProfiles({"test", "domain-test"})
@Tag("integration-test")
@Tag("domain-test")
public @interface PetsDomainTest {
@TestConfiguration
class PetServiceTestConfiguration {
@Primary
@Bean
PetServiceRepository createRepositoryMock() {
return mock(
PetServiceRepository.class,
MockReset.withSettings(MockReset.AFTER)
);
}
}
}
使用方式:
@PetsDomainTest
public class PetServiceIntegrationTest {
@Autowired
PetService service;
@Autowired // Mock
PetServiceRepository repository;
// ...
}
7. 其他陷阱
7.1. 测试配置差异
集成测试应尽可能接近生产环境。但测试框架会自动调整应用行为(如禁用 可观测性功能)。若需测试观测能力,需通过 @AutoConfigureObservability
显式启用。
7.2. 包结构设计
测试特定层时,推荐按包结构组织组件。例如使用 MapStruct 时:
@ExtendWith(SpringExtension.class)
public class PetDtoMapperIntegrationTest {
@Configuration
@ComponentScan(basePackageClasses = PetDtoMapper.class)
static class PetDtoMapperTestConfig {}
@Autowired
PetDtoMapper mapper;
// ...
}
这会初始化同包及子包所有 Bean,因此建议按领域/层划分包结构。
8. 总结
本文深入分析了 Spring Boot 测试中的常见陷阱,重点探讨了 ApplicationContext
缓存机制与测试隔离性的平衡。关键结论包括:
✅ 优先使用单元测试而非集成测试
✅ 谨慎处理有状态 Bean 的测试隔离
✅ 合理利用上下文缓存与测试切片
✅ 统一测试配置避免上下文激增
所有代码示例可在 GitHub 获取。