Java 基础体系 · 第 29/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring Boot 测试:Slice、上下文缓存、MockMvc、容器和契约测试

Spring Boot 测试的难点不在于“能不能发起一个请求”,而在于:一次测试到底加载了哪些组件、请求经过了哪些真实边界、数据是否真的写入了外部系统、多个测试之间是否共享状态,以及测试失败时能否定位到正确的层次。

可以先把测试对象分成四类:

类型 主要验证内容 是否启动完整应用 是否访问真实外部系统
单元测试 一个类的局部逻辑
Slice 测试 某一技术切片,如 MVC、JPA 否,通常只加载切片 通常否
Spring 集成测试 多个 Spring 组件的协作 可选 可选
容器与契约测试 真实基础设施或服务边界 通常是 是,或验证服务协议

它们不是“快、中、慢”三个标签的简单排列,而是对不同故障路径的覆盖。一个 @WebMvcTest 能发现控制器映射错误,却不能证明 SQL 在真实 PostgreSQL 上可执行;一个 Testcontainers 测试能发现数据库方言问题,却不能自动证明第三方服务的 JSON 契约没有破坏。


一、先区分单元测试、Spring 测试和端到端测试

1. 单元测试不需要 Spring 容器

例如订单价格计算:

public final class PriceCalculator {

    public Money total(List<Money> itemPrices, Money discount) {
        Money subtotal = itemPrices.stream()
                .reduce(Money.zero(), Money::add);

        return subtotal.subtract(discount).max(Money.zero());
    }
}

其测试只需要 JUnit 5 和 AssertJ:

class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void should_not_return_negative_total() {
        Money result = calculator.total(
                List.of(Money.of(10)),
                Money.of(20)
        );

        assertThat(result).isEqualTo(Money.zero());
    }
}

这里没有 Spring 上下文、代理、配置文件和数据库,因此失败原因集中在业务逻辑本身。若为了测试这个类使用 @SpringBootTest,测试边界反而变宽了:启动失败可能来自无关的数据库配置,而不是价格计算。

2. Spring 测试验证容器装配和组件协作

当问题变成“控制器是否调用了正确的服务”“事务是否生效”“配置属性是否绑定”“异常处理器是否被注册”,就需要 Spring 测试。

@SpringBootTest 的核心作用是使用 Spring Boot 的方式创建 ApplicationContext。它可以加载完整应用,也可以根据 webEnvironment 选择 Web 环境:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
class ApplicationContextTest {
}

常见模式有:

  • MOCK:创建模拟 Web 环境,不监听真实端口;
  • RANDOM_PORT:启动真实嵌入式服务器,绑定随机端口;
  • DEFINED_PORT:使用配置中定义的端口;
  • NONE:不创建 Web 环境。

MOCKRANDOM_PORT 验证的边界不同。前者适合配合 MockMvc,后者适合使用真实 HTTP 客户端验证网络层、Servlet 容器和端口绑定。


二、Slice 测试:只加载目标技术切片

1. Slice 的定义

Slice 测试是一种受限制的 Spring 应用上下文。它不是简单地“少启动几个 Bean”,而是通过特定的自动配置和组件扫描规则,只加载某一层需要的基础设施。

例如:

  • @WebMvcTest:MVC 控制器、MVC 基础设施、JSON 转换、校验、异常处理等;
  • @WebFluxTest:WebFlux 控制器和响应式 Web 基础设施;
  • @DataJpaTest:JPA、实体管理器、事务和嵌入式数据库相关配置;
  • @JdbcTest:JDBC、事务和 JDBC 测试配置;
  • @JsonTest:JSON 序列化和反序列化相关组件;
  • @RestClientTest:REST 客户端及其测试支持。

具体切片包含哪些自动配置会随 Spring Boot 版本变化,应以对应版本的 Spring Boot Reference 为准。不能把某个版本中的自动配置列表当成永久 API 保证。

2. @WebMvcTest 的组件边界

假设应用包含以下代码:

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderService service;

    OrderController(OrderService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    OrderResponse get(@PathVariable long id) {
        return service.find(id);
    }
}

使用 @WebMvcTest

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    OrderService service;

    @Test
    void should_return_order() throws Exception {
        given(service.find(42L))
                .willReturn(new OrderResponse(42L, "PAID"));

        mockMvc.perform(get("/orders/42"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.status").value("PAID"));

        then(service).should().find(42L);
    }
}

这个测试中:

  1. Spring 创建 OrderController
  2. OrderService 不从完整应用中加载,而由 Mockito 创建的 Bean 替代;
  3. Spring MVC 注册请求映射、参数解析、消息转换器、校验器和异常处理器;
  4. MockMvc 在不启动真实端口的情况下执行 MVC 请求;
  5. 断言 HTTP 状态、JSON 响应和服务调用。

如果删除 @MockitoBean,而应用又没有其他方式提供 OrderService,上下文启动会失败。这不是 MockMvc 的问题,而是 Slice 明确没有加载普通业务服务 Bean。

@MockitoBean 是 Spring Framework 新版本提供的测试替身注解。较老的 Spring Boot 项目常见 @MockBean;在较新的 Spring Boot/Spring Framework 组合中,@MockBean 已进入弃用迁移阶段,具体替代关系取决于项目版本。维护旧项目时应以当前依赖的 API 文档和编译器提示为准,不应混用不兼容版本。

3. Slice 不是“控制器单元测试”

@WebMvcTest 仍然会启动 Spring 上下文,因此它比纯 Mockito 测试更接近运行时,但也意味着:

  • 配置类可能导致上下文启动失败;
  • 自定义 HandlerMethodArgumentResolver 是否被注册可以被发现;
  • @ControllerAdvice 是否生效可以被发现;
  • Bean Validation、Jackson 配置和 Spring Security 过滤器可能影响结果;
  • 数据库 Repository 不会因为控制器测试而自动可用。

因此,下面这种写法通常违背 Slice 的边界:

@WebMvcTest(OrderController.class)
class WrongTest {

    @Autowired
    OrderRepository repository; // 通常无法注入
}

如果测试需要真实 Repository,应改为服务集成测试或完整应用测试,而不是不断向 @WebMvcTest 中导入所有业务配置。

4. JPA Slice 的事务边界

@DataJpaTest 常用于验证实体映射、查询和 Repository:

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository repository;

    @Test
    void should_find_paid_orders() {
        repository.save(new OrderEntity(null, OrderStatus.PAID));

        List<OrderEntity> result =
                repository.findByStatus(OrderStatus.PAID);

        assertThat(result).hasSize(1);
    }
}

JPA Slice 通常为测试方法提供事务,并在测试结束时回滚。这个行为适合隔离测试数据,但不能据此推出生产事务行为全部正确。

例如:

@Test
void rollback_does_not_mean_production_transaction_is_correct() {
    // 测试结束后的自动回滚只影响当前测试事务。
    // 它不能证明跨多个服务调用的分布式事务能够回滚。
}

还要注意持久化上下文的缓存。repository.save() 后立即通过同一个 EntityManager 查询,可能命中一级缓存;要验证真正执行的 SQL,常需要:

entityManager.flush();
entityManager.clear();

否则某些映射错误、数据库约束错误可能被推迟或被缓存掩盖。


三、Spring 上下文缓存:测试为什么时快时慢

1. 缓存的对象是什么

Spring TestContext Framework 会在测试 JVM 进程内缓存 ApplicationContext。缓存键不是测试类名,而是决定上下文内容的一组配置属性,通常包括:

  • 测试使用的配置类;
  • @SpringBootTest 或 Slice 的配置;
  • 激活的 Profile;
  • @TestPropertySource 和测试属性;
  • @DynamicPropertySource 提供的动态属性;
  • Context Customizer;
  • Context Loader;
  • 父上下文关系。

可以把它抽象成:

K=(C,P,A,R,D,L,H)K = (C, P, A, R, D, L, H)

其中:

  • CC 是配置类集合;
  • PP 是 Profile;
  • AA 是测试属性;
  • RR 是 Context Customizer;
  • DD 是动态属性;
  • LL 是加载器;
  • HH 是父上下文关系。

若两个测试的键相等:

K1=K2K_1 = K_2

测试框架就有机会复用同一个上下文;若不相等,就需要创建新的上下文。

这解释了两个看似矛盾的现象:

  • 测试类很多,但上下文可能只创建几次;
  • 只改了一个 Profile 或一个测试属性,启动次数就明显增加。

2. 缓存不是全局服务

上下文缓存通常只存在于当前测试 JVM。重新启动 Maven Surefire fork、重新运行 IDE 测试、使用不同进程执行测试,都会重新创建缓存。

缓存也不是无限的。Spring 测试框架有缓存上限,超过后会按缓存策略移除旧上下文。具体默认值和版本实现应以当前 Spring Framework 版本为准,不应在构建脚本中依赖未经确认的内部细节。

3. @DirtiesContext 的因果关系

如果测试改变了上下文本身,例如:

  • 修改了单例 Bean 的内部全局状态;
  • 动态注册或移除了 Bean;
  • 改变了应用级配置;
  • 通过测试代码破坏了不可恢复的容器状态;

可以使用:

@DirtiesContext
class ContextMutatingTest {
}

它的代价是使相关上下文失效,后续测试需要重新创建。数据库数据变化通常不应使用 @DirtiesContext 解决,因为数据库状态和 Spring Bean 状态是两个不同层次。数据库问题应使用事务回滚、清理表、独立 Schema 或容器生命周期管理。

4. 共享上下文导致的隐蔽污染

以下测试可能互相影响:

@SpringBootTest
class FirstTest {

    @Autowired
    FeatureFlags flags;

    @Test
    void enable_new_flow() {
        flags.enable("new-flow");
    }
}
@SpringBootTest
class SecondTest {

    @Autowired
    FeatureFlags flags;

    @Test
    void should_use_old_flow() {
        // 可能观察到 FirstTest 修改后的状态
    }
}

即使测试方法执行顺序通常不应被依赖,共享上下文中的可变单例仍可能产生污染。解决方法不是给测试排序,而是:

  • 每个测试自行构造初始状态;
  • 使用不可变配置;
  • 将状态放到可清理的测试资源中;
  • 必要时使用 @DirtiesContext
  • 不要在共享单例中保存测试级可变状态。

5. 并行测试的额外风险

JUnit 5 可以配置并行执行,但“同一个 Spring 上下文可缓存”不等于“上下文中的 Bean 都线程安全”。

并行测试可能同时:

  • 修改共享 Mockito Mock 的 stubbing;
  • 操作同一张数据库表;
  • 使用相同固定用户或订单 ID;
  • 访问同一个 Testcontainers 服务;
  • 改变共享系统属性或环境变量。

因此并行执行需要满足更强条件:

安全并行=Bean 线程安全测试数据隔离外部资源隔离无顺序依赖\text{安全并行} = \text{Bean 线程安全} \land \text{测试数据隔离} \land \text{外部资源隔离} \land \text{无顺序依赖}

只满足其中一项都不够。发现并行下偶发失败时,应记录测试用例、随机种子、容器日志和数据库状态,而不是简单关闭所有测试并行来掩盖竞态。


四、MockMvc:不启动端口的 MVC 请求执行器

1. MockMvc 模拟了什么

MockMvc 在 Spring MVC 的请求处理链内构造并执行请求。典型路径是:

RequestBuilder
  -> DispatcherServlet
  -> HandlerMapping
  -> HandlerAdapter
  -> Controller
  -> ReturnValueHandler
  -> HttpMessageConverter
  -> MockHttpServletResponse

它通常不会:

  • 监听 TCP 端口;
  • 经过真实操作系统网络栈;
  • 验证反向代理、TLS、连接池和真实服务器线程模型;
  • 证明客户端与服务端之间的序列化兼容性。

但它可以验证:

  • URL 映射;
  • Path Variable、Request Parameter、Request Body 绑定;
  • Bean Validation;
  • Jackson JSON 转换;
  • MVC Filter、Interceptor、ControllerAdvice;
  • Spring Security 的请求授权规则;
  • 控制器返回的 HTTP 状态和响应头。

2. 端到端 MVC 示例

定义请求对象:

public record CreateOrderRequest(
        @NotBlank String product,
        @Positive int quantity
) {
}

控制器:

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderService service;

    OrderController(OrderService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<OrderResponse> create(
            @Valid @RequestBody CreateOrderRequest request) {

        OrderResponse created = service.create(request);
        return ResponseEntity
                .created(URI.create("/orders/" + created.id()))
                .body(created);
    }
}

测试:

@WebMvcTest(OrderController.class)
class OrderControllerCreateTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockitoBean
    OrderService service;

    @Test
    void should_create_order() throws Exception {
        var request = new CreateOrderRequest("book", 2);
        var response = new OrderResponse(7L, "CREATED");

        given(service.create(request)).willReturn(response);

        mockMvc.perform(post("/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(request)))
                .andExpect(status().isCreated())
                .andExpect(header().string(
                        HttpHeaders.LOCATION, "/orders/7"))
                .andExpect(jsonPath("$.id").value(7))
                .andExpect(jsonPath("$.status").value("CREATED"));
    }

    @Test
    void should_reject_invalid_quantity() throws Exception {
        var request = new CreateOrderRequest("book", 0);

        mockMvc.perform(post("/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(request)))
                .andExpect(status().isBadRequest());

        then(service).shouldHaveNoInteractions();
    }
}

第二个测试的关键因果链是:

  1. JSON 能被解析为 CreateOrderRequest
  2. @Valid 触发校验;
  3. quantity = 0 不满足 @Positive
  4. MVC 在调用控制器方法前返回 400;
  5. 因此服务层不会被调用。

如果测试中忘记 contentType(MediaType.APPLICATION_JSON),失败可能不是校验失败,而是消息转换器无法选择正确的请求解析方式。测试输入必须包含真实客户端会提供的必要信息。

3. 异常处理必须测试实际边界

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ResponseEntity<ProblemDetail> handle(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }
}

测试异常处理时,不应只测试一个普通方法,而要让控制器实际抛出异常:

@Test
void should_map_not_found_to_404() throws Exception {
    given(service.find(42L))
            .willThrow(new OrderNotFoundException("order 42 not found"));

    mockMvc.perform(get("/orders/42"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.detail")
                    .value("order 42 not found"));
}

这样才能发现 @RestControllerAdvice 是否被扫描、异常类型是否匹配以及响应格式是否正确。

4. MockMvc 的边界与真实 HTTP

若需要验证真实服务器:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RealHttpTest {

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void should_serve_over_real_http() {
        ResponseEntity<String> response =
                restTemplate.getForEntity("/orders/42", String.class);

        assertThat(response.getStatusCode())
                .isEqualTo(HttpStatus.OK);
    }
}

这类测试会创建嵌入式服务器并绑定随机端口,因而可以发现端口、Servlet 容器、网络客户端和部分过滤器链问题,但启动成本更高。

不要把以下等式当成事实:

MockMvc 测试通过真实 HTTP 一定通过\text{MockMvc 测试通过} \Rightarrow \text{真实 HTTP 一定通过}

更准确的关系是:

MockMvc 通过Spring MVC 处理链在测试配置下通过\text{MockMvc 通过} \Rightarrow \text{Spring MVC 处理链在测试配置下通过}

它不包含真实网络边界。

5. MVC 与 WebFlux 不能混为一谈

MockMvc 属于 Spring MVC Servlet 模型。如果应用使用 WebFlux,应优先使用 @WebFluxTestWebTestClient

@WebFluxTest(ReactiveOrderController.class)
class ReactiveOrderControllerTest {

    @Autowired
    WebTestClient client;

    @Test
    void should_return_order() {
        client.get()
                .uri("/orders/42")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.id").isEqualTo(42);
    }
}

Servlet MVC 的阻塞调用、Servlet Filter 和线程模型,不能通过 WebFlux 测试工具推导;同样,WebFlux 的背压、响应式错误信号和异步生命周期,也不能用 MockMvc 完整证明。


五、Mockito 替身和 Spring Bean 替身的差别

纯 Mockito 测试:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    PaymentGateway paymentGateway;

    @InjectMocks
    OrderService service;
}

这里 Mockito 负责创建对象,Spring 不参与依赖注入。

@MockitoBean 或旧项目中的 @MockBean 会把 Mock 注册到 Spring ApplicationContext,从而替换或覆盖某个 Bean。其价值在于:被测试对象仍由 Spring 创建,构造器注入、代理和配置装配仍然存在。

但 Mock 的验证也有边界:

given(repository.findById(1L))
        .willReturn(Optional.of(entity));

这只能证明服务在预设行为下如何处理结果,不能证明:

  • Repository 查询方法名是否符合实际数据库语义;
  • SQL 是否兼容目标数据库;
  • 事务是否提交;
  • 序列化或网络重试是否正确。

因此,Mockito 是控制依赖行为的工具,不是外部系统真实性的替代品。


六、Testcontainers:让测试面对真实基础设施

1. 为什么内存替代品会隐藏问题

H2 与 PostgreSQL 并不等价。以下问题可能在 H2 中不暴露:

  • PostgreSQL 特有的数据类型或函数;
  • 大小写和标识符规则;
  • JSONB、数组、枚举等类型;
  • 唯一约束和索引行为;
  • SQL 方言差异;
  • 事务隔离和锁行为;
  • 连接初始化脚本。

Testcontainers 的基本思想是:测试启动真实的 Docker 容器,将容器地址、端口和凭据注入应用。

2. PostgreSQL 示例

使用 @Testcontainers 和动态属性:

@SpringBootTest
@Testcontainers
class OrderDatabaseIT {

    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("orders")
                    .withUsername("test")
                    .withPassword("test");

    @DynamicPropertySource
    static void databaseProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",
                postgres::getJdbcUrl);
        registry.add("spring.datasource.username",
                postgres::getUsername);
        registry.add("spring.datasource.password",
                postgres::getPassword);
    }

    @Autowired
    OrderRepository repository;

    @Test
    void should_persist_against_postgresql() {
        OrderEntity saved =
                repository.save(new OrderEntity(null, OrderStatus.PAID));

        assertThat(saved.getId()).isNotNull();
    }
}

执行过程是:

  1. Testcontainers 在测试前启动 PostgreSQL 容器;
  2. 容器分配可访问端口;
  3. @DynamicPropertySource 将动态 JDBC 属性提供给 Spring;
  4. Spring 创建数据源、实体管理器和 Repository;
  5. 测试执行真实 SQL;
  6. 测试结束后容器停止,具体生命周期由测试容器配置决定。

这里不能把 postgres:16-alpine 视为永远固定的生产等价物。镜像标签、初始化脚本和 Docker 运行时都是测试前置条件。CI 环境必须具备可用的 Docker 或兼容运行时。

较新的 Spring Boot 支持通过 @ServiceConnection 自动从容器识别连接信息:

@Testcontainers
@SpringBootTest
class OrderDatabaseWithServiceConnectionIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");
}

@ServiceConnection 需要相应版本的 Spring Boot 支持;如果项目版本较旧,应使用 @DynamicPropertySource,不要仅因为示例可见就假设当前项目存在该注解。

3. 容器状态和测试隔离

容器是真实外部状态,因此需要定义生命周期:

测试 JVM 启动
    -> 创建容器
    -> 等待数据库健康可用
    -> Spring 创建连接池
    -> 测试读写数据库
    -> 清理数据或销毁容器

若多个测试共享一个容器,则必须隔离数据。可采用:

  • 每个测试使用唯一业务 ID;
  • 每个测试启动事务并回滚;
  • 每个测试清理自己的数据;
  • 使用独立 Schema;
  • 每个测试类使用独立容器。

容器共享通常能减少启动成本,但增加了状态污染风险。开启容器复用或在开发机长期保留容器时尤其如此:旧表、旧消息和旧配置可能导致测试在本机通过、在 CI 失败。

4. 数据库测试不能证明分布式行为

PostgreSQL 容器可以验证数据库边界,但不能证明:

  • Kafka 消息一定送达;
  • 第三方支付服务一定按预期重试;
  • 多服务之间的事务具备原子性;
  • 生产网络策略允许连接。

若测试目标是消息中间件、对象存储或外部 API,应使用对应容器、WireMock 类模拟服务,或专门的集成环境。测试替身必须与测试目标匹配。


七、上下文、容器和数据库事务的交互

这三个状态经常被误认为是同一个生命周期,实际上它们不同:

状态 所属系统 常见生命周期
Spring Bean 单例 Spring ApplicationContext 上下文缓存期间
数据库连接和事务 DataSource/数据库 测试方法或事务边界
Docker 容器 Docker/Testcontainers 测试类、测试套件或显式生命周期

例如,测试方法回滚事务不会销毁 Spring Bean;销毁 Spring 上下文也不必然清空一个独立数据库容器。诊断失败时要先确定污染发生在哪一层。

一个典型错误是:

@SpringBootTest
@Testcontainers
class SharedDatabaseTest {

    @Container
    static PostgreSQLContainer<?> postgres = ...;

    @Test
    void first() {
        // 插入数据但不清理
    }

    @Test
    void second() {
        // 假设数据库为空,结果失败
    }
}

这里失败的根因不是上下文缓存,而是两个测试共享了数据库状态。即便加上 @DirtiesContext,数据库中的行仍然存在。


八、契约测试:验证服务边界,而不是内部实现

1. 契约是什么

契约是服务边界上双方共同依赖的可验证约定,至少包括:

  • HTTP 方法和路径;
  • 请求头、查询参数和请求体;
  • 响应状态码;
  • 响应头;
  • 响应 JSON 的字段结构和类型;
  • 错误响应格式;
  • 有时还包括消息主题、消息键和事件结构。

契约测试不等同于端到端测试。端到端测试验证完整业务链路;契约测试验证某个服务是否遵守边界协议。

2. 消费者驱动契约的推导

设消费者 CC 依赖提供者 PP 的接口 II。消费者测试中记录它实际需要的交互集合:

RC={r1,r2,,rn}R_C = \{r_1, r_2, \ldots, r_n\}

提供者验证的是:

rRC,Pr\forall r \in R_C,\quad P \models r

也就是说,提供者必须满足消费者提出的每个请求—响应约束,而不是仅仅证明“提供者自己的单元测试通过”。

例如,消费者要求:

{
  "request": {
    "method": "GET",
    "path": "/orders/42"
  },
  "response": {
    "status": 200,
    "body": {
      "id": 42,
      "status": "PAID"
    }
  }
}

提供者若将 status 改成数字 1,即使语义上认为“已支付”没有变化,依赖字符串字段的消费者仍会失败。这就是契约测试发现的兼容性问题。

3. 契约中的匹配强度

契约不应无意中锁死所有无关字段。通常要区分:

  • 精确匹配:字段名、HTTP 状态、固定枚举值必须一致;
  • 类型匹配:字段只要求是字符串、数字或数组;
  • 正则匹配:例如订单号满足某种格式;
  • 最小结构匹配:允许提供者增加向后兼容字段。

如果消费者只需要:

{
  "id": 42,
  "status": "PAID"
}

却把整个响应中所有时间戳、内部标识和展示字段都精确写入契约,那么提供者每增加一个无关字段都可能导致无意义的失败。

反过来,若把所有字段都声明为“任意值”,契约就失去了保护作用。契约强度应与消费者真实依赖一致:

有效契约=必要约束+可演化空间\text{有效契约} = \text{必要约束} + \text{可演化空间}

4. 提供者验证不应只调用 Controller 方法

提供者验证契约时,应让请求经过实际 Web 层,例如:

  • 使用 MockMvc 验证 Spring MVC 提供者;
  • 使用 WebTestClient 验证 WebFlux 提供者;
  • 使用真实端口验证真实 HTTP 边界;
  • 在需要时连接真实数据库或外部容器。

直接调用:

controller.get(42L);

只能验证 Java 方法调用结果,无法验证:

  • 路径是否正确映射;
  • JSON 字段是否正确序列化;
  • HTTP 状态码是否正确;
  • Filter、Advice 和校验是否生效。

因此,契约测试经常位于 MockMvc/WebTestClient 和真实基础设施之间:它需要真实的协议处理链,但不一定需要完整生产环境。

5. 契约测试与 Spring Cloud Contract、Pact

Spring Cloud Contract 和 Pact 都能帮助生成、发布或验证契约,但模型有所不同,具体插件、注解和构建配置强烈依赖版本,不能脱离项目版本直接复制配置。

无论使用哪种工具,都应明确三件事:

  1. 契约由谁拥有和修改;
  2. 契约如何发布、版本化和在 CI 中验证;
  3. 提供者何时可以删除旧字段或旧接口。

契约测试不是把 API 文档换成另一种文件格式,而是把跨服务兼容性纳入自动化验证。


九、一个分层测试流程

以“创建订单并写入数据库”为例,可以按故障路径设计测试:

flowchart TD
    A[纯业务单元测试] --> B[Web MVC Slice]
    B --> C[JPA/数据库 Slice]
    C --> D[Spring 集成测试]
    D --> E[Testcontainers 真实数据库]
    E --> F[契约提供者验证]
    F --> G[真实 HTTP 或端到端测试]

每一步覆盖的故障不同:

  1. 纯业务单元测试
    验证金额计算、状态转换和异常规则。

  2. Web MVC Slice
    验证请求绑定、校验、JSON、状态码和异常响应。

  3. JPA Slice
    验证实体映射、Repository 查询和事务行为。

  4. Spring 集成测试
    验证 Controller、Service、Repository 的装配和协作。

  5. Testcontainers
    验证真实数据库方言、约束、索引和事务。

  6. 契约验证
    验证服务对消费者承诺的接口结构。

  7. 真实 HTTP 或端到端测试
    验证网络、服务器、多个服务和外部基础设施的组合行为。

测试层次越靠后,启动成本和故障定位成本通常越高,但边界真实性也越强。重要的不是把所有测试都放在最底层,而是让每种测试只承担它能够可靠证明的结论。


十、常见误解和失败诊断

误解一:@SpringBootTest 是最可靠的所有测试

它只能证明“在当前完整上下文和当前测试环境中,组件能够协作”。如果所有依赖都被 Mockito 替代,它仍然不能证明真实数据库或第三方服务可用;如果没有真实端口,也不能证明网络边界。

诊断方式是列出测试中的真实组件:

Controller -> Service -> Repository -> PostgreSQL

如果 Repository 被 Mock,测试就没有验证数据库;如果 PostgreSQL 是 H2,测试就没有验证目标数据库方言。

误解二:上下文缓存会让测试互相共享所有数据

缓存的是 ApplicationContext,不是自动共享每个测试方法的数据库事务。Bean 状态、数据库状态、容器状态分别检查,才能找到污染源。

误解三:MockMvc 是真正的 HTTP 客户端

MockMvc 不经过真实端口。若问题与端口绑定、Servlet 容器、TLS、反向代理或网络超时有关,应增加真实 HTTP 测试。

误解四:使用容器就等于生产环境一致

容器只提高基础设施真实性。镜像版本、配置、初始化脚本、资源限制、网络拓扑和生产部署方式仍可能不同。容器测试发现的是“在该容器配置下的问题”,不是对生产环境的数学证明。

误解五:契约测试通过就代表业务正确

契约只约束边界交互。服务可能返回结构正确但业务含义错误的响应。例如订单接口返回 200 和合法 JSON,却把已取消订单标为 PAID。业务规则仍需要单元测试和集成测试。

失败诊断顺序

遇到 Spring 测试失败时,可以按以下因果顺序排查:

  1. 上下文是否创建成功
    查看最早的 BeanCreationException 或配置绑定错误,不要只看最后一行。

  2. 测试属于哪个 Slice
    确认目标 Bean 是否在该切片的自动配置和组件扫描范围内。

  3. 是否缺少测试替身
    @WebMvcTest 中常见服务 Bean 缺失;@DataJpaTest 中常见 Web Bean 缺失。

  4. 缓存是否复用了污染状态
    检查可变单例、Mock stubbing、Profile 和 @DirtiesContext

  5. 外部资源是否可用
    检查 Docker、容器日志、动态属性、数据库健康状态和端口。

  6. 测试边界是否选错
    若要验证真实 SQL,不应只看 Mock;若要验证真实 HTTP,不应只看 MockMvc;若要验证消费者兼容性,不应只看提供者内部单测。


十一、构建与运行示例

一个使用 JUnit 5、AssertJ、Mockito 和 Testcontainers 的 Maven 测试依赖示意如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

运行普通测试:

./mvnw test

运行集成测试时,常见做法是使用 Maven Failsafe 将命名为 *IT 的测试放入集成测试阶段。具体插件配置取决于项目构建约定,关键是不要让需要 Docker 的测试在没有 Docker 的普通单元测试阶段被意外执行。

预期结果应至少包含:

Tests run: ...
Failures: 0
Errors: 0

若 Testcontainers 失败,日志中通常会出现 Docker 连接、镜像拉取、容器启动或健康检查错误。此时先验证:

docker version
docker ps

这两个命令只能证明 Docker 客户端和守护进程基本可访问,不能证明目标镜像能启动;仍需查看 Testcontainers 输出的容器日志和退出状态。


十二、如何选择测试方式

可以使用以下判断逻辑:

  • 只验证算法或业务分支:纯 JUnit 5 + AssertJ;
  • 验证控制器、校验、JSON 和异常:@WebMvcTest + MockMvc;
  • 验证 WebFlux:@WebFluxTest + WebTestClient;
  • 验证 Repository:@DataJpaTest@JdbcTest
  • 验证多个 Spring 层的装配:@SpringBootTest
  • 验证真实数据库行为:Spring 测试 + Testcontainers;
  • 验证服务间 API 兼容性:契约测试;
  • 验证真实端口和网络边界:RANDOM_PORT + HTTP 客户端;
  • 验证完整业务链路:少量端到端测试。

最终的测试结论必须带有边界:

@WebMvcTest 通过
= MVC 切片中的请求处理通过

MockMvc 通过
= Spring MVC 处理链在无真实端口条件下通过

@DataJpaTest 通过
= Repository 在当前数据库配置和事务边界下通过

Testcontainers 通过
= 当前测试使用的真实容器基础设施通过

契约测试通过
= 已声明的消费者交互约束未被提供者破坏

只有把这些结论分别放回各自的边界,Slice、上下文缓存、MockMvc、容器和契约测试才能组成清晰的 Spring Boot 测试体系,而不是一组互相替换、却无法说明覆盖范围的注解。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。