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 获取。


原始标题:Pitfalls on Testing with Spring Boot