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 环境。
MOCK 和 RANDOM_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);
}
}
这个测试中:
- Spring 创建
OrderController; OrderService不从完整应用中加载,而由 Mockito 创建的 Bean 替代;- Spring MVC 注册请求映射、参数解析、消息转换器、校验器和异常处理器;
MockMvc在不启动真实端口的情况下执行 MVC 请求;- 断言 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;
- 父上下文关系。
可以把它抽象成:
其中:
- 是配置类集合;
- 是 Profile;
- 是测试属性;
- 是 Context Customizer;
- 是动态属性;
- 是加载器;
- 是父上下文关系。
若两个测试的键相等:
测试框架就有机会复用同一个上下文;若不相等,就需要创建新的上下文。
这解释了两个看似矛盾的现象:
- 测试类很多,但上下文可能只创建几次;
- 只改了一个 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 服务;
- 改变共享系统属性或环境变量。
因此并行执行需要满足更强条件:
只满足其中一项都不够。发现并行下偶发失败时,应记录测试用例、随机种子、容器日志和数据库状态,而不是简单关闭所有测试并行来掩盖竞态。
四、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();
}
}
第二个测试的关键因果链是:
- JSON 能被解析为
CreateOrderRequest; @Valid触发校验;quantity = 0不满足@Positive;- MVC 在调用控制器方法前返回 400;
- 因此服务层不会被调用。
如果测试中忘记 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 容器、网络客户端和部分过滤器链问题,但启动成本更高。
不要把以下等式当成事实:
更准确的关系是:
它不包含真实网络边界。
5. MVC 与 WebFlux 不能混为一谈
MockMvc 属于 Spring MVC Servlet 模型。如果应用使用 WebFlux,应优先使用 @WebFluxTest 和 WebTestClient:
@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();
}
}
执行过程是:
- Testcontainers 在测试前启动 PostgreSQL 容器;
- 容器分配可访问端口;
@DynamicPropertySource将动态 JDBC 属性提供给 Spring;- Spring 创建数据源、实体管理器和 Repository;
- 测试执行真实 SQL;
- 测试结束后容器停止,具体生命周期由测试容器配置决定。
这里不能把 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. 消费者驱动契约的推导
设消费者 依赖提供者 的接口 。消费者测试中记录它实际需要的交互集合:
提供者验证的是:
也就是说,提供者必须满足消费者提出的每个请求—响应约束,而不是仅仅证明“提供者自己的单元测试通过”。
例如,消费者要求:
{
"request": {
"method": "GET",
"path": "/orders/42"
},
"response": {
"status": 200,
"body": {
"id": 42,
"status": "PAID"
}
}
}
提供者若将 status 改成数字 1,即使语义上认为“已支付”没有变化,依赖字符串字段的消费者仍会失败。这就是契约测试发现的兼容性问题。
3. 契约中的匹配强度
契约不应无意中锁死所有无关字段。通常要区分:
- 精确匹配:字段名、HTTP 状态、固定枚举值必须一致;
- 类型匹配:字段只要求是字符串、数字或数组;
- 正则匹配:例如订单号满足某种格式;
- 最小结构匹配:允许提供者增加向后兼容字段。
如果消费者只需要:
{
"id": 42,
"status": "PAID"
}
却把整个响应中所有时间戳、内部标识和展示字段都精确写入契约,那么提供者每增加一个无关字段都可能导致无意义的失败。
反过来,若把所有字段都声明为“任意值”,契约就失去了保护作用。契约强度应与消费者真实依赖一致:
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 都能帮助生成、发布或验证契约,但模型有所不同,具体插件、注解和构建配置强烈依赖版本,不能脱离项目版本直接复制配置。
无论使用哪种工具,都应明确三件事:
- 契约由谁拥有和修改;
- 契约如何发布、版本化和在 CI 中验证;
- 提供者何时可以删除旧字段或旧接口。
契约测试不是把 API 文档换成另一种文件格式,而是把跨服务兼容性纳入自动化验证。
九、一个分层测试流程
以“创建订单并写入数据库”为例,可以按故障路径设计测试:
flowchart TD
A[纯业务单元测试] --> B[Web MVC Slice]
B --> C[JPA/数据库 Slice]
C --> D[Spring 集成测试]
D --> E[Testcontainers 真实数据库]
E --> F[契约提供者验证]
F --> G[真实 HTTP 或端到端测试]
每一步覆盖的故障不同:
-
纯业务单元测试
验证金额计算、状态转换和异常规则。 -
Web MVC Slice
验证请求绑定、校验、JSON、状态码和异常响应。 -
JPA Slice
验证实体映射、Repository 查询和事务行为。 -
Spring 集成测试
验证 Controller、Service、Repository 的装配和协作。 -
Testcontainers
验证真实数据库方言、约束、索引和事务。 -
契约验证
验证服务对消费者承诺的接口结构。 -
真实 HTTP 或端到端测试
验证网络、服务器、多个服务和外部基础设施的组合行为。
测试层次越靠后,启动成本和故障定位成本通常越高,但边界真实性也越强。重要的不是把所有测试都放在最底层,而是让每种测试只承担它能够可靠证明的结论。
十、常见误解和失败诊断
误解一:@SpringBootTest 是最可靠的所有测试
它只能证明“在当前完整上下文和当前测试环境中,组件能够协作”。如果所有依赖都被 Mockito 替代,它仍然不能证明真实数据库或第三方服务可用;如果没有真实端口,也不能证明网络边界。
诊断方式是列出测试中的真实组件:
Controller -> Service -> Repository -> PostgreSQL
如果 Repository 被 Mock,测试就没有验证数据库;如果 PostgreSQL 是 H2,测试就没有验证目标数据库方言。
误解二:上下文缓存会让测试互相共享所有数据
缓存的是 ApplicationContext,不是自动共享每个测试方法的数据库事务。Bean 状态、数据库状态、容器状态分别检查,才能找到污染源。
误解三:MockMvc 是真正的 HTTP 客户端
MockMvc 不经过真实端口。若问题与端口绑定、Servlet 容器、TLS、反向代理或网络超时有关,应增加真实 HTTP 测试。
误解四:使用容器就等于生产环境一致
容器只提高基础设施真实性。镜像版本、配置、初始化脚本、资源限制、网络拓扑和生产部署方式仍可能不同。容器测试发现的是“在该容器配置下的问题”,不是对生产环境的数学证明。
误解五:契约测试通过就代表业务正确
契约只约束边界交互。服务可能返回结构正确但业务含义错误的响应。例如订单接口返回 200 和合法 JSON,却把已取消订单标为 PAID。业务规则仍需要单元测试和集成测试。
失败诊断顺序
遇到 Spring 测试失败时,可以按以下因果顺序排查:
-
上下文是否创建成功
查看最早的BeanCreationException或配置绑定错误,不要只看最后一行。 -
测试属于哪个 Slice
确认目标 Bean 是否在该切片的自动配置和组件扫描范围内。 -
是否缺少测试替身
@WebMvcTest中常见服务 Bean 缺失;@DataJpaTest中常见 Web Bean 缺失。 -
缓存是否复用了污染状态
检查可变单例、Mock stubbing、Profile 和@DirtiesContext。 -
外部资源是否可用
检查 Docker、容器日志、动态属性、数据库健康状态和端口。 -
测试边界是否选错
若要验证真实 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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Data 数据访问:Repository、事务、分页、审计和缓存边界
- 下一篇:Java 微服务治理:HTTP、gRPC、配置、发现、限流、熔断和幂等
- 延伸:Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试
- 延伸:Spring Web MVC 与 WebFlux:请求链、校验、异常、流式和选择边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论