返回博客

Spring Boot 分层测试:让每一层的测试只覆盖它该负责的事

很多 Java 项目的测试策略只有两种:要么全是 @SpringBootTest,启动整个容器跑每一个测试,速度慢得令人窒息;要么根本没有测试,「等功能做完再补」,然后就再也没补。

我自己走过的弯路是长期偏向第一种——「反正测试能过就行」,不管是单元测试还是集成测试,全部加 @SpringBootTest,每次跑测试要等 30 秒以上。后来在一个重构周期里被慢测试折磨到崩溃,才认真把测试分层做了一遍。

分层不是形式,是速度和反馈密度的保障。

四个粒度,各管一段

Spring Boot 的测试工具链天然支持四个粒度:

单元测试(JUnit 5 + Mockito),不启动 Spring 容器,只测 Service 逻辑。依赖全部 Mock,聚焦「给定输入,输出是否符合预期」。

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
  @Mock OrderRepository repo;
  @InjectMocks OrderService service;
 
  @Test
  void createsOrder() {
    when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
    Order result = service.create(new CreateOrderRequest("item-1", 2));
    assertThat(result.quantity()).isEqualTo(2);
    verify(repo).save(any());
  }
}

这类测试应该是最多的,也是最快的——毫秒级。

Web 层测试(@WebMvcTest,只启动 MVC 相关 Bean,不加载 Service 实现。用 @MockBean 替换 Service,测 Controller 的路由映射、参数校验、响应码是否正确。

@WebMvcTest(OrderController.class)
class OrderControllerTest {
  @Autowired MockMvc mockMvc;
  @MockBean OrderService orderService;
 
  @Test
  void returns400WhenBodyMissing() throws Exception {
    mockMvc.perform(post("/api/orders").contentType(APPLICATION_JSON).content("{}"))
        .andExpect(status().isBadRequest());
  }
}

集成测试(@SpringBootTest + @AutoConfigureMockMvc,启动完整上下文,测端到端链路。每个功能只需要少量几个集成测试覆盖主要路径,其余交给上面两层。

持久层测试(@DataJpaTest,只加载 JPA 相关 Bean,不带 Web 层。配合 Testcontainers 启动一个真实数据库镜像,测 Repository 方法、自定义查询、索引行为。

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Import(TestContainersConfig.class)
class OrderRepositoryTest {
  @Autowired OrderRepository repo;
 
  @Test
  void findsOrderByStatus() {
    // 往真实 DB 写数据,测 JPA 查询
  }
}

为什么不能全用 SpringBootTest

@SpringBootTest 的代价是:启动时间、Bean 初始化、数据库连接——这些成本每个测试类都要付一次(除非 Spring 复用了 ApplicationContext,但一旦用了 @MockBean 就会破坏复用)。

单元测试描述的是「这段业务逻辑对不对」,这个问题不需要 HTTP 服务器来回答。@WebMvcTest 描述的是「这个接口的契约对不对」,同样不需要真实数据库。把它们混到 @SpringBootTest 里,测试本身没问题,但你付出了 10 倍的等待时间——而且每次修改都要等这 10 倍。

反过来说,越快的测试,你越愿意频繁运行;越频繁运行,越早发现问题;越早发现问题,修复成本越低。这是一个飞轮。

Test Behavior, Not Implementation

测试的对象应该是「行为」而不是「实现」。不要为 private 方法写测试,不要 Mock 自己类内部的方法,不要断言中间状态。测试只关心:给定这个输入,最终结果是否正确、是否调用了正确的外部协作者。这样重构时改了内部实现,测试不会随之崩掉。

覆盖率是护栏,不是目标

JaCoCo 设置 80% 覆盖率门禁是个合理的起点,但覆盖率高不等于测试质量高。100% 覆盖率的测试里可能全是 assertTrue(true) 这种空洞断言。

真正有价值的测试描述的是「预期行为」:正常路径、边界输入、异常路径各写一个,比用 @ParameterizedTest 凑满覆盖率更有意义。

覆盖率的意义是反向的——发现「哪里完全没被测到」,而不是「覆盖率越高系统越可靠」。

一句话心法

快的测试让你敢频繁运行,分层的测试让每次失败都能精确定位——两者加在一起,测试才真的变成反馈工具而不是心理负担。