Spring Boot: 测试策略
最后更新:2026-08-26
测试是质量的保险——不写测试的代码就像不系安全带开车,迟早出事故。
1. 你将学到
@SpringBootTest集成测试与@WebMvcTest切片测试- Mockito
@MockBean/@SpyBean模拟依赖 @DataJpaTest数据层测试与内嵌 H2 数据库- TestContainers:MySQL / Redis 容器化集成测试
- Alice 将 OrderFlow 核心下单流程的测试覆盖率提升至 80% 以上
2. 一个质量工程师的真实故事
(1) 痛点:改一个 Bug 冒出三个
Alice 修改了订单取消逻辑,以为只影响取消功能,结果商品库存恢复也出了问题,连带影响报表统计。每次改代码都提心吊胆,因为没有自动化测试验证回归。Bob 经常在凌晨被紧急修复的电话叫醒,团队每周花 30% 的时间在修 Bug 上。
(2) 自动化测试的解法
写好测试,改代码时有"安全网":
JAVA
@SpringBootTest
class OrderServiceTest {
@Test
void shouldCancelOrderAndRestoreStock() {
// Given: product with stock 10, order with quantity 3
// When: cancel order
// Then: stock restored to 13, order status is CANCELLED
}
}
(3) 收益
Alice 为 OrderFlow 核心流程写了 50 个测试用例,覆盖率达 85%。每次改代码跑一遍测试,5 分钟内就能发现回归问题,Bob 再也不用凌晨修 Bug 了。
3. 测试金字塔
(1) 三层测试策略
graph LR
A["Unit Tests<br/>70% - Fast, Isolated<br/>JUnit + Mockito"] --> B["Integration Tests<br/>20% - Medium Speed<br/>@SpringBootTest + H2"]
B --> C["E2E Tests<br/>10% - Slow, Full Stack<br/>TestContainers + REST Assured"]
| 层级 | 数量占比 | 速度 | 依赖 | 测试内容 |
|---|---|---|---|---|
| 单元测试 | 70% | 毫秒级 | Mock | 业务逻辑 |
| 集成测试 | 20% | 秒级 | H2/TestContainers | 组件交互 |
| E2E 测试 | 10% | 分钟级 | 完整环境 | 业务流程 |
▶ 示例: 单元测试
JAVA
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductRepository productRepository;
@InjectMocks
private OrderServiceImpl orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// When
Order order = orderService.createOrder(1L, 3);
// Then
assertThat(order).isNotNull();
assertThat(product.getStock()).isEqualTo(7);
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowWhenInsufficientStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
verify(orderRepository, never()).save(any());
}
}
输出:
TEXT
📖 仅展示
// 执行成功
4. @WebMvcTest 切片测试
(1) Controller 层测试
▶ 示例: Controller 切片测试
JAVA
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
OrderResponse order = new OrderResponse(
1L, "bob", "PENDING", BigDecimal.valueOf(999),
List.of(), Instant.now());
when(orderService.getOrder(1L)).thenReturn(order);
mockMvc.perform(get("/api/v1/orders/1")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrder(999L))
.thenThrow(new ResourceNotFoundException("Order", 999L));
mockMvc.perform(get("/api/v1/orders/999")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
String body = """
{"productId": null, "quantity": 0}
""";
mockMvc.perform(post("/api/v1/orders")
.with(httpBasic("bob", "pass123"))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
}
输出:
TEXT
📖 仅展示
// 执行成功
| 测试注解 | 加载范围 | 适用层级 |
|---|---|---|
@WebMvcTest |
Controller + Web 层 | Controller |
@DataJpaTest |
Repository + JPA 层 | Repository |
@SpringBootTest |
完整 ApplicationContext | 集成测试 |
5. @DataJpaTest 数据层测试
▶ 示例: Repository 测试
JAVA
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByNameContaining() {
entityManager.persist(new Product("Laptop Pro", BigDecimal.valueOf(1299), 50));
entityManager.persist(new Product("Laptop Air", BigDecimal.valueOf(899), 30));
entityManager.persist(new Product("Wireless Mouse", BigDecimal.valueOf(29), 200));
List<Product> results = productRepository.findByNameContaining("Laptop");
assertThat(results).hasSize(2);
}
@Test
void shouldFindOutOfStockProducts() {
Product outOfStock = new Product("Old Model", BigDecimal.valueOf(99), 0);
entityManager.persist(outOfStock);
entityManager.persist(new Product("New Model", BigDecimal.valueOf(199), 10));
List<Product> results = productRepository.findOutOfStockProducts();
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("Old Model");
}
@Test
void shouldCheckStockBeforeDeduction() {
Product product = entityManager.persist(
new Product("Laptop", BigDecimal.valueOf(999), 5));
Product found = productRepository.findById(product.getId()).orElseThrow();
found.deductStock(3);
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(2);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
6. TestContainers 容器化集成测试
(1) 真实数据库测试
▶ 示例: MySQL TestContainer
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
输出:
TEXT
📖 仅展示
// 执行成功
JAVA
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("orderflow_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(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 OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCreateOrderAndDeductStock() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
Order order = orderService.createOrder(product.getId(), 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(7);
}
}
▶ 示例: Redis TestContainer
JAVA
@SpringBootTest
@Testcontainers
class RedisCacheIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductInRedis() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
// First call: database query
productService.getProduct(product.getId());
// Second call: Redis cache hit
Product cached = productService.getProduct(product.getId());
assertThat(cached).isNotNull();
}
}
输出:
TEXT
📖 仅展示
// 执行成功
7. 测试覆盖率
▶ 示例: JaCoCo 配置
XML
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
输出:
TEXT
📖 仅展示
// 执行成功
| 覆盖率维度 | 目标 | 说明 |
|---|---|---|
| 行覆盖率 | ≥ 80% | 核心业务代码 |
| 分支覆盖率 | ≥ 70% | if/else 逻辑 |
| 方法覆盖率 | ≥ 80% | 公共方法 |
8. 综合示例:OrderFlow 核心下单流程测试
JAVA
// OrderServiceTest.java (Unit Test)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@InjectMocks private OrderServiceImpl orderService;
@Test
void createOrder_success() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order order = orderService.createOrder(1L, 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
assertThat(product.getStock()).isEqualTo(7);
}
@Test
void createOrder_insufficientStock_throws() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
}
@Test
void cancelOrder_success_restoresStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 7);
Order order = new Order();
order.setStatus("PENDING");
order.addItem(product, 3);
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
orderService.cancelOrder(1L);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
assertThat(product.getStock()).isEqualTo(10);
}
@Test
void cancelOrder_nonPending_throws() {
Order order = new Order();
order.setStatus("SHIPPED");
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(1L))
.isInstanceOf(OrderStateException.class);
}
}
// OrderControllerTest.java (Slice Test)
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "CUSTOMER")
void createOrder_validRequest_returns201() throws Exception {
when(orderService.createOrder(any(), eq("bob"))).thenReturn(
new OrderResponse(1L, "bob", "PENDING", BigDecimal.valueOf(999), List.of(), Instant.now()));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void createOrder_unauthenticated_returns401() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isUnauthorized());
}
}
❓ 常见问题
Q @MockBean 和 @Mock 有什么区别?
A @Mock 是 Mockito 注解,创建纯 Mock 对象,不依赖 Spring。@MockBean 是 Spring Boot 注解,将 Mock 对象注入到 Spring ApplicationContext 中,用于替换容器中的 Bean。单元测试用 @Mock,集成测试用 @MockBean。
Q @SpringBootTest 和 @WebMvcTest 该选哪个?
A @WebMvcTest 只加载 Web 层(Controller + Filter),速度快,适合 Controller 单元测试。@SpringBootTest 加载完整 ApplicationContext,适合端到端集成测试。
Q TestContainers 会不会让测试很慢?
A 首次启动容器需要下载镜像,但容器在测试类间共享(static 字段),后续测试直接复用。典型开销:首次 30 秒,后续 < 5 秒。CI 环境建议预热镜像。
Q H2 和 MySQL 的行为一致吗?
A 大部分一致,但有些差异:1)MySQL 的 AUTO_INCREMENT vs H2 的 IDENTITY;2)MySQL 特有函数(GROUP_CONCAT);3)事务隔离级别默认值不同。生产环境建议用 TestContainers + MySQL。
Q 如何测试 @Async 方法?
A 1)直接测试异步方法本身(不走代理);2)使用 CompletableFuture.get() 等待结果;3)配置同步执行器(
SyncTaskExecutor)让测试更简单。Q 测试覆盖率 80% 够吗?
A 核心业务代码 80% 是最低标准,推荐 85%+。覆盖率只是参考,关键是测试质量——覆盖边界条件、异常路径、并发场景,而不仅是正常路径。
📖 小节
- 测试金字塔:70% 单元测试 + 20% 集成测试 + 10% E2E 测试
- 单元测试用
@ExtendWith(MockitoExtension.class)+@Mock - Controller 测试用
@WebMvcTest+MockMvc - Repository 测试用
@DataJpaTest+ H2 - TestContainers 用真实数据库容器测试,消除 H2 差异
- JaCoCo 监控测试覆盖率,核心业务代码 ≥ 80%
📝 作业
-
基础题(难度⭐):为 OrderService 编写 5 个单元测试,覆盖创建订单、库存不足、取消订单、状态异常、资源不存在等场景。
-
进阶题(难度⭐⭐):使用
@WebMvcTest为 OrderController 编写切片测试,覆盖正常请求、验证失败、404、401(未认证)等场景。使用@DataJpaTest为 ProductRepository 编写查询测试。 -
挑战题(难度⭐⭐⭐):集成 TestContainers(MySQL + Redis),编写端到端集成测试——从创建商品、下单扣库存、Redis 缓存命中到取消订单恢复库存的完整流程,覆盖率 ≥ 80%。