Spring Boot: 测试策略

最后更新:2026-08-26

测试是质量的保险——不写测试的代码就像不系安全带开车,迟早出事故。

1. 你将学到


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) 三层测试策略

100%
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%+。覆盖率只是参考,关键是测试质量——覆盖边界条件、异常路径、并发场景,而不仅是正常路径。

📖 小节


📝 作业

  1. 基础题(难度⭐):为 OrderService 编写 5 个单元测试,覆盖创建订单、库存不足、取消订单、状态异常、资源不存在等场景。

  2. 进阶题(难度⭐⭐):使用 @WebMvcTest 为 OrderController 编写切片测试,覆盖正常请求、验证失败、404、401(未认证)等场景。使用 @DataJpaTest 为 ProductRepository 编写查询测试。

  3. 挑战题(难度⭐⭐⭐):集成 TestContainers(MySQL + Redis),编写端到端集成测试——从创建商品、下单扣库存、Redis 缓存命中到取消订单恢复库存的完整流程,覆盖率 ≥ 80%。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏