一、学习目标
- 掌握 Spring Boot 中 单元测试 与 Web 层测试 的分工与选型。
- 会用 MockMvc 对 Controller 做切片测试,验证状态码、JSON 结构与 Problem Details。
- 理解 @WebMvcTest 、@SpringBootTest 、Testcontainers 的适用场景。
- 能为第38天的 幂等、分页、错误体 编写可回归的测试用例。
- 掌握 Logback 结构化日志、MDC 与 traceId 在接口链路中的传递。
- 了解 Actuator 健康检查与测试、CI 中如何跑测试。
二、测试分层与金字塔
2.1 常见分层
| 层级 | 测什么 | 工具倾向 | 速度 |
|---|---|---|---|
| 单元 | Service 纯逻辑、工具类 | JUnit 5 + Mockito | 快 |
| Web 切片 | Controller 映射、参数校验、异常转 ProblemDetail | MockMvc + @WebMvcTest | 较快 |
| 集成 | 连真实 DB、Redis、消息 | @SpringBootTest + Testcontainers | 慢 |
| 契约 | 对外 API 形状不变 | springdoc + 快照或 Pact | 中 |
原则:多写快测,少写全量启动;第38天设计的 API 优先用 MockMvc 锁住 HTTP 契约。
2.2 依赖(Maven)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
已含 JUnit 5、Mockito、AssertJ、MockMvc。
三、Service 单元测试(Mockito)
3.1 示例:订单查询服务
java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OrderQueryServiceTest {
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderQueryService orderQueryService;
@Test
void findPage_capsSizeAt100() {
OrderEntity entity = new OrderEntity();
entity.setId(1L);
entity.setStatus("PAID");
Page<OrderEntity> page = new PageImpl<>(List.of(entity), PageRequest.of(0, 200), 1);
when(orderRepository.findByStatusOptional(any(), any())).thenReturn(page);
Page<OrderSummaryDTO> result = orderQueryService.findPage(0, 200, null);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getStatus()).isEqualTo("PAID");
}
}
要点:只 Mock 边界依赖(Repository),不启动 Spring 容器。
四、MockMvc 测试 Controller
4.1 @WebMvcTest 切片
只加载 Web 层相关 Bean,Service 用 @MockBean 替换。
java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(controllers = OrderApiController.class)
class OrderApiControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderQueryService orderQueryService;
@MockBean
private OrderCommandService orderCommandService;
@Test
void list_returns200AndPage() throws Exception {
OrderSummaryDTO dto = new OrderSummaryDTO();
dto.setId(1L);
dto.setStatus("CREATED");
Page<OrderSummaryDTO> page = new PageImpl<>(List.of(dto));
when(orderQueryService.findPage(0, 20, null)).thenReturn(page);
mockMvc.perform(get("/api/v1/orders")
.param("page", "0")
.param("size", "20")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].id").value(1))
.andExpect(jsonPath("$.content[0].status").value("CREATED"));
}
@Test
void create_returns201() throws Exception {
CreateOrderRequest req = new CreateOrderRequest();
req.setProductId(100L);
OrderDetailDTO detail = new OrderDetailDTO();
detail.setId(99L);
when(orderCommandService.create(any(), isNull())).thenReturn(detail);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(99));
}
}
4.2 测 Problem Details(第38天错误体)
java
@Test
void create_invalidBody_returns400ProblemDetail() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").exists());
}
需在测试中引入 GlobalExceptionHandler:在 @WebMvcTest 里 @Import(GlobalExceptionHandler.class),或把异常处理放在被扫描的配置中。
五、幂等与 Idempotency-Key 的测试
5.1 Mock 幂等服务
java
@Test
void create_withSameIdempotencyKey_returnsSameBody() throws Exception {
String key = "550e8400-e29b-41d4-a716-446655440000";
OrderDetailDTO cached = new OrderDetailDTO();
cached.setId(1L);
when(orderCommandService.create(any(), eq(key))).thenReturn(cached);
String body = objectMapper.writeValueAsString(new CreateOrderRequest());
mockMvc.perform(post("/api/v1/orders")
.header("Idempotency-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1));
mockMvc.perform(post("/api/v1/orders")
.header("Idempotency-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1));
}
集成测试里可换真实 Redis,断言第二次请求 未重复写库 (可用 @DataJpaTest 或 Testcontainers Redis)。
六、集成测试与 Testcontainers(可选进阶)
6.1 何时用 @SpringBootTest
- 要验证 Repository SQL、事务、Flyway 迁移。
- 要验证 Security 过滤器链 与 JWT 全流程。
java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void registerProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private TestRestTemplate restTemplate;
@Test
void healthOrListSmoke() {
ResponseEntity<String> resp = restTemplate.getForEntity("/api/v1/orders?page=0&size=5", String.class);
assertThat(resp.getStatusCode()).isIn(HttpStatus.OK, HttpStatus.UNAUTHORIZED);
}
}
本地无 Docker 时可先用 H2 + test profile,上线前再用 Testcontainers 对齐 MySQL 行为。
七、测试配置与多环境
7.1 application-test.yml
yaml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
data:
redis:
host: localhost
port: 6379
logging:
level:
com.company: DEBUG
测试类上:@ActiveProfiles("test")。
7.2 随机端口与 MockMvc
@SpringBootTest(webEnvironment = RANDOM_PORT) 配合 @AutoConfigureMockMvc 可同时测 Filter(如第38天 ApiV1DeprecationFilter):
java
@Test
void v1ResponsesIncludeDeprecationHeader() throws Exception {
mockMvc.perform(get("/api/v1/orders"))
.andExpect(header().string("Deprecation", "true"))
.andExpect(header().exists("Sunset"));
}
八、日志与可观测性(对接第38天 API)
8.1 访问日志与 MDC
在 Filter 或 Interceptor 中放入 traceId 、userId,便于日志关联:
java
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
response.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}
Controller 或 Service 日志:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// logger.info("create order productId={}", body.getProductId());
禁止在日志中打印密码、完整 Token、银行卡号。
8.2 logback-spring.xml 片段(JSON 可选)
xml
<configuration>
<springProperty name="APP_NAME" source="spring.application.name" defaultValue="app"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} traceId=%X{traceId} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
生产可接 ELK:同一 traceId 串联网关、应用、下游 Feign 调用(下游请求头继续传 X-Trace-Id)。
8.3 Actuator 与健康检查
yaml
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when_authorized
测试示例:
java
@WebMvcTest(controllers = {})
@Import(HealthEndpoint.class) // 或完整 @SpringBootTest
class ActuatorHealthTest {
// 对 /actuator/health 做 perform get,期望 status UP(视依赖而定)
}
K8s liveness/readiness 探针常指向 /actuator/health/liveness 与 /actuator/health/readiness(Spring Boot 2.3+ 可分组配置)。
九、OpenAPI 与测试的衔接
- 改 Controller 注解或 DTO 后,跑 MockMvc 测试 防止字段被删。
- 可在 CI 导出
v3/api-docs,与上一版本 JSON diff(破坏性变更则失败构建)。 - springdoc 测试环境可关闭 UI:
springdoc.swagger-ui.enabled=false,仅保留/v3/api-docs供流水线拉取。
yaml
# application-test.yml
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: false
十、CI 中运行测试
bash
mvn -B test
# 或
./mvnw test -Dspring.profiles.active=test
建议:
- PR 必跑 单元 + WebMvcTest ,夜间或合并主分支再跑 Testcontainers 集成测。
- 失败时保留 Surefire 报告 与最后 200 行日志。
- 覆盖率可用 JaCoCo,但 不追求 100%;优先覆盖创建订单、支付、鉴权等关键路径。
十一、学习总结
- 分层测试:Service 用 Mockito,HTTP 契约用 MockMvc,全链路才用 SpringBootTest + 容器。
- MockMvc:断言 status、jsonPath、Problem Details、Deprecation 响应头。
- 幂等测试:相同 Idempotency-Key 应得到一致响应,集成环境验证无重复写库。
- 日志:MDC + traceId,敏感信息不落日志。
- Actuator:健康检查纳入测试与部署探针。
- CI:快速反馈靠 MockMvc,契约文档可与 OpenAPI 快照联动。
十二、实践建议
- 为第38天
OrderApiController补 3 个 MockMvc 用例:分页列表、创建 201、校验失败 400 ProblemDetail。 - 为
ApiV1DeprecationFilter写一个断言Deprecation头的测试。 - 加上
TraceIdFilter,用日志确认同一次请求各条 log 带相同 traceId。 - 在
application-test.yml建 test profile,本地执行mvn test全部通过后再提交。