搞单元测试这事,我在SpringBoot项目里从最开始“为了覆盖率而写”到后来“为了能睡个安稳觉而写”,中间踩了不少坑,也沉淀下来一套自己的打法。这篇文章就结合我手头一个真实的订单服务模块,把整套单元测试的实操过程、技术选型、避坑心得掰开揉碎讲清楚,顺便聊聊那些文档里一般不写的东西。
我默认看的你是有一定Java基础、已经在用SpringBoot写接口,但测试这块要么没怎么落地、要么写起来总觉得别扭。文章里不会讲怎么从零装IDEA,而是直接聚焦在“怎么把单元测试写好、写快、写稳”这件事上。
1. 项目整体设计与测试思路拆解
1.1 为什么单元测试在SpringBoot里“说起来简单做起来难”
很多同学刚开始接触SpringBoot单元测试,觉得就是给Service方法加个@Test注解,往里填点断言就完事了。真上手才会发现,SpringBoot项目里Bean的依赖关系错综复杂,一个Service往往挂着Repository、RedisTemplate、FeignClient、MQ Producer,你new一个对象出来根本跑不起来,或者一跑就牵连数据库、外部接口,整个测试慢得跟集成测试似的。
我现在的做法是:默认用分层测试策略,不搞“一个@SpringBootTest打天下”。Controller层用@WebMvcTest配合MockMvc,Service层用Mockito把底层依赖全部打桩,Repository层有条件就用@JdbcTest或者嵌入式的H2,真正要验证整合链路时才上@SpringBootTest。这样拆分下来,单测的执行速度基本能做到秒级,跑起来也不会因为外部环境不可用而红一片。
这么做背后的原因很简单:单元测试的核心价值是快速定位问题。如果测试跑一次要等Spring容器起10秒、连数据库5秒,开发者根本不愿意在写代码的过程中频繁执行测试,那这套测试方案最后一定沦为CI里的应声虫。只有让测试快起来,才能形成“改代码→跑测试→发现问题→再改”的正向循环。
1.2 方案选型:JUnit 5 + Mockito + AssertJ 的组合逻辑
Spring Boot 2.2以后的版本,默认依赖里已经从JUnit 4切换到JUnit 5,也就是Jupiter这套东西。我的实践里,正式推荐组合是:
| 组件 | 作用 | 说明 |
|---|---|---|
| JUnit 5 (Jupiter) | 测试框架基础 | 提供@Test、@ParameterizedTest、@DisplayName等核心注解 |
| Mockito | 打桩与验证 | mock掉所有外部依赖,控制方法的返回值、抛异常 |
| AssertJ | 流式断言 | 断言链可读性好,错误信息详细,比JUnit原生断言好用太多 |
| Spring Test | 测试上下文支持 | 提供@SpringBootTest、@WebMvcTest、TestRestTemplate等 |
| H2 | 嵌入式数据库 | Repository层测试时替代真实数据库,跑完即焚 |
这套组合不是我拍脑袋选的,而是从实际体验里对比出来的。早期我用过JUnit 4 + Mockito的组合,迁移到JUnit 5以后最大的感受是@ExtendWith(SpringExtension.class)比@RunWith(SpringJUnit4ClassRunner.class)写起来更自然,参数化测试的能力也强了一大截,一个@ParameterizedTest就能覆盖多条边界数据,不用再写一堆重复的@Test方法。
用Mockito打桩的时候,我个人建议配合AssertJ做断言。举个例子,assertEquals这种JUnit原生断言,失败的时候只会告诉你expected和actual不相等,但AssertJ的assertThat会直接把两个值的差打印出来,排查起来省很多时间。多一行依赖,换来的是一年下来不知道少掉多少查错时间,这笔账很划算。
1.3 测试目录与类命名的工程规范
这一小节我讲规范,因为它解决的是长期协作里的“测试可读性”问题。我在项目里强制约定:
- 测试类统一放在src/test/java目录下,包名与被测试类保持一致。比如OrderService对应的测试类就是com.example.order.service.OrderServiceTest。
- 命名统一用被测试类名加Test后缀,不用UnitTest、TestDemo这类模糊的名字。
- 每个测试方法名建议采用“行为_条件_预期结果”三段式,比如shouldCreateOrder_WhenParamValid_ReturnOrderId,虽然名字长了点,但测试挂掉的时候,光看方法名就知道是什么业务场景出了问题。
这个规范看着简单,真执行起来对团队协作的帮助非常大。有一次我们项目里一个老同事提交的测试类叫Test1,方法叫test1、test2,结果跑挂了以后根本没人看得懂他测的是哪个模块,最后花了一个多小时来回查代码才定位到是支付回调的逻辑出了问题。从那以后,我们组就硬性推行了这套命名规范,现在新人来了照着约定写就行,测试代码的可维护性提升了一大截。
2. 核心测试场景的细节解析与实操要点
2.1 Service层测试:打桩的艺术与verify的技巧
Service层是整个业务逻辑的核心,也是单元测试的主战场。我以一个订单创建的服务为例来拆解。
假设有这样一个OrderService:
@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final UserServiceClient userServiceClient; private final IdGenerator idGenerator; @Transactional public Order createOrder(OrderCreateRequest request) { // 1. 从ID生成器获取订单号 String orderNo = idGenerator.nextId(); // 2. 调用远程用户服务校验用户状态 UserInfo user = userServiceClient.getUser(request.getUserId()); if (user == null || !user.getStatus().equals(UserStatus.NORMAL)) { throw new BizException("用户状态异常,无法下单"); } // 3. 计算订单金额 BigDecimal totalAmount = request.getItems().stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getCount()))) .reduce(BigDecimal.ZERO, BigDecimal::add); // 4. 构建订单实体并保存 Order order = Order.builder() .orderNo(orderNo) .userId(request.getUserId()) .totalAmount(totalAmount) .status(OrderStatus.CREATED) .build(); return orderRepository.save(order); } }这个Service里有两个外部依赖:IdGenerator和UserServiceClient。编写单元测试的核心思路就是不启动Spring容器,手动用Mockito创建这两个依赖的mock对象,注入到OrderService里,然后只测试OrderService自己的逻辑。
@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private UserServiceClient userServiceClient; @Mock private IdGenerator idGenerator; @InjectMocks private OrderService orderService; @Test @DisplayName("创建订单成功 - 返回订单号") void shouldCreateOrder_WhenParamValid_ReturnOrderId() { // 准备 Long userId = 1001L; OrderCreateRequest request = OrderCreateRequest.builder() .userId(userId) .items(List.of( new OrderItemRequest(1L, BigDecimal.valueOf(99.9), 2), new OrderItemRequest(2L, BigDecimal.valueOf(19.9), 3) )) .build(); UserInfo mockUser = new UserInfo(userId, UserStatus.NORMAL); when(idGenerator.nextId()).thenReturn("20250101100001"); when(userServiceClient.getUser(userId)).thenReturn(mockUser); Order savedOrder = Order.builder() .id(1L) .orderNo("20250101100001") .userId(userId) .totalAmount(BigDecimal.valueOf(259.5)) .status(OrderStatus.CREATED) .build(); when(orderRepository.save(any(Order.class))).thenReturn(savedOrder); // 执行 Order result = orderService.createOrder(request); // 断言 assertThat(result).isNotNull(); assertThat(result.getOrderNo()).isEqualTo("20250101100001"); assertThat(result.getTotalAmount()).isEqualByComparingTo("259.5"); // 验证关键交互确实发生 verify(idGenerator).nextId(); verify(userServiceClient).getUser(userId); verify(orderRepository).save(any(Order.class)); } }这个测试背后有几个关键点值得一说。
第一,@ExtendWith(MockitoExtension.class)这个扩展是Mockito对JUnit 5的集成支持,它会自动初始化所有@Mock和@InjectMocks的字段,省掉了手动调用Mockito.mock的样板代码。
第二,对金额字段的断言用isEqualByComparingTo而不是isEqualTo,是因为BigDecimal的equals方法会把精度差异也算进去,比如2.0和2.00在equals眼里就是不相等,但业务上它们明明是一个数。用isEqualByComparingTo就是踩过一次坑以后学乖的,建议大家从一开始就养成这个习惯。
第三,verify方法的出现,是为了验证那些“无返回值但必须执行过”的交互。比如idGenerator.nextId()本身返回了字符串,你不verify它其实影响也不大;但像userServiceClient.getUser这种外部调用,你不仅要验证它被调用了,有时候还要用verify(userServiceClient, times(1)).getUser(userId)精确验证调用次数。这在排查“接口被重复调用”这类性能隐患时特别有用。
2.2 Controller层测试:MockMvc别和真实容器死磕
Controller层的测试,重点是验证接口的URL映射、参数绑定、HTTP状态码和响应结构,而不是真的把Tomcat跑起来发HTTP请求。Spring Boot提供的MockMvc,就是在应用上下文之上模拟一个Servlet容器,性能比真实启动快很多。
我习惯用@WebMvcTest来写Controller测试,它只会加载Controller层相关的Bean,而不会把Service、Repository、Java Config全都加载进来,这样上下文启动非常快。搭配@MockBean把Service层依赖mock掉,就能完全隔离测试Controller的URL映射和参数校验逻辑。
@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OrderService orderService; @Test void shouldReturnOrder_WhenGetExistingOrder() throws Exception { Long orderId = 1L; Order mockOrder = Order.builder() .id(orderId) .orderNo("20250101100001") .status(OrderStatus.CREATED) .build(); when(orderService.getOrder(orderId)).thenReturn(mockOrder); mockMvc.perform(get("/api/orders/{id}", orderId) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.orderNo").value("20250101100001")) .andExpect(jsonPath("$.status").value("CREATED")); verify(orderService).getOrder(orderId); } @Test void shouldReturn400_WhenOrderIdIsInvalid() throws Exception { mockMvc.perform(get("/api/orders/{id}", "abc")) .andExpect(status().isBadRequest()); } }这里容易踩的第一个坑是@MockBean和@WebMvcTest连用的时候,如果你在测试里同时需要多个@MockBean,千万不要在类上写多个@MockBean注解,直接JDK 16以后用@MockBean是一种写法,但我更推荐在@WebMvcTest(OrderController.class)后面直接用@ImportMyCustomConfig指定需要加载的配置类,测试上下文保持最小化,启动速度才会快。
第二个坑是jsonPath的表达式。很多刚上手的人拿捏不准$.orderNo和$.order_no的区别,报错之后一头雾水。这里要记住:jsonPath是严格区分大小写的,路径必须和你接口实际的JSON字段保持一致,不是跟着数据库字段走的。如果Controller返回的是Map或者是加了@JsonIgnore的字段,断言的时候就会扑空,这种问题排查起来很费劲。
第三个坑是GET请求带参数时,如果Controller里的参数是@RequestParam(required = false),测试那边不传参数和传空字符串语义完全不同,写用例时一定有意区分。比如分页接口,不传pageNum默认可能是1,传空字符串就直接参数绑定异常。这些边界情况是MockMvc测试最容易漏掉的,但恰恰是接口测试中最有价值的部分。
2.3 Repository层测试:嵌入式数据库与真实SQL之间的平衡
Repository层我分两种情况处理。
第一种情况,项目用的是Spring Data JPA,而且SQL都是JPA自动生成的。这种情况我推荐直接用@JdbcTest或者@DataJpaTest配合H2嵌入式数据库。H2有一个MySQL兼容模式,可以在内存里模拟出大部分真实数据库行为,测试完自动回滚,不会污染数据。
@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Test void shouldFindOrderByOrderNo() { Order order = Order.builder() .orderNo("TEST20250101001") .userId(1001L) .totalAmount(BigDecimal.valueOf(88.8)) .status(OrderStatus.CREATED) .build(); orderRepository.save(order); Optional<Order> found = orderRepository.findByOrderNo("TEST20250101001"); assertThat(found).isPresent(); assertThat(found.get().getTotalAmount()).isEqualByComparingTo("88.8"); } }第二种情况,项目里用了大量自定义SQL,比如@Query里写了复杂的MYSQL方言函数,这种情况H2很可能模拟不了。别死磕,直接把这类测试升级为集成测试,通过Testcontainers启动一个真实的MySQL容器,测试的时候连真库执行。代价是测试环境里要装Docker,速度也慢一些,但保真度高得多。
这里要给一个很重要的实操提醒:H2的MySQL模式并不等于MySQL,尤其是日期函数、JSON函数、窗口函数的解析差异非常大。我们曾经有一个统计报表的SQL,在MySQL里跑得好好的,在H2的MySQL模式下直接语法报错。从那以后我就立了一个规矩:涉及复杂SQL的Repository测试,一律用Testcontainers,不要在H2上浪费排查时间。
2.4 参数化测试:用一组数据覆盖一类业务规则
参数化测试是JUnit 5里非常实用但经常被人忽略的功能。它解决的核心问题是:同一段业务规则,需要对多组输入数据进行验证,但不想为每组数据写一个@Test方法。
我举一个实际的例子:订单金额计算中,需要处理满减优惠。规则是“满100减10,满200减25,满300减40”。
@ParameterizedTest @CsvSource({ "50, 0, 50", "100, 10, 90", "150, 10, 140", "200, 25, 175", "250, 25, 225", "300, 40, 260", "350, 40, 310" }) void shouldCalculateDiscountedAmount_WhenGivenOriginalAmount(BigDecimal original, BigDecimal discount, BigDecimal expected) { BigDecimal result = discountService.calculate(original); assertThat(result).isEqualByComparingTo(expected); }这个写法的价值在于:你一旦总结出业务规则是“满减阶梯”,就用一张数据表把所有的分支覆盖完,不用再复制粘贴七个几乎一样的测试方法。后面需求变更,比如“满500减80”,直接往CsvSource里加一行,跑一下就知道新规则会不会破坏老场景,回归成本极低。
除了@CsvSource,还有一个@MethodSource,适合参数是复杂对象的情况。比如你在测试“不同用户等级享受不同折扣率”时,可以用一个方法返回Stream ,每个Arguments里放用户对象和他的预期折扣率。我个人体会,参数化测试用好以后,测试代码的重复率至少能下降50%,而且可读性反而更高,因为所有输入输出都在一张表里,业务规则一目了然。
3. 实操过程与核心环节实现
3.1 工程依赖配置:一次配好,版本别乱动
先把基础设施搭好。我直接给一份已经验证过的Maven配置,用的是Spring Boot 2.7.x,这个版本和JUnit 5、Mockito的兼容性都很稳定。如果你用的是Spring Boot 3.x,需要注意Java版本至少17,Mockito版本也得到5.x才行。
<dependencies> <!-- Spring Boot 单元测试核心 starter-test --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- 如果你是Maven项目,建议显式声明JUnit 5版本,避免版本冲突 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <!-- H2数据库,用于Repository层测试 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> </dependencies>一个要特别强调的坑是spring-boot-starter-test里默认带的是JUnit 4的依赖坐标,如果你在pom里又单独引入了JUnit 5的包,同时老项目里还残留了JUnit 4的代码,就会出现测试类能编译但跑不起来、或者@Test注解来自两个包的情况。我的建议是:新项目直接用JUnit 5,不要回头看JUnit 4;老项目迁移的时候,第一件事就是把test目录里的import org.junit.Test全部换成import org.junit.jupiter.api.Test,顺便把断言全部换成AssertJ。
还有一点,Maven的maven-surefire-plugin版本和JUnit 5的兼容性也值得留意。如果你的pom里没有显式配置这个插件,那么Spring Boot的父POM会帮你带一个版本。但假如你的项目父POM不是Spring Boot,就很可能出现“测试不执行”的诡异情况——test编译通过了,但mvn test就是不跑任何用例。解决办法是显式声明maven-surefire-plugin 2.22.2及以上版本,并显式引入junit-jupiter-engine依赖。
3.2 测试基类与工具抽取:别让每个测试类重复写一堆样板
随着项目里测试类变多,我发现每个Service测试类都要重复配置审计字段、CommonResult包装类、时间格式化等等。于是抽了一个测试基类,把公共内容下沉。
@ExtendWith(MockitoExtension.class) public abstract class BaseUnitTest { protected static final String DEFAULT_TENANT = "T10086"; protected static final Long DEFAULT_USER_ID = 1001L; // 统一的对象构造工具 protected Order buildOrder(Long id, String status) { return Order.builder() .id(id) .orderNo("NO" + id) .tenant(DEFAULT_TENANT) .status(status) .build(); } @AfterEach void cleanUp() { // 每次测试结束后清理Mockito的stubbing记录 Mockito.clearAllCaches(); } }有人可能会问,Mockito.clearAllCaches()是干嘛的?这是一个被我当作血泪教训总结出来的细节。当你用了@ExtendWith(MockitoExtension.class),Mockito会在每个测试方法执行前对@Mock字段做reset,但你设置的when(...).thenReturn(...)其实不会在测试间自动清干净,如果某个测试里设置了粗粒度的stubbing(比如when(service.get(any())).thenReturn(someObj)),下一个测试又忘了覆盖,就可能拿上一个测试的数据来跑,错误信息极难排查。当然,MockitoExtension默认是strict模式的,它会自动擦掉多余的stubbing,只在误用时报警。但为了保险,加上clearAllCaches这一步,在测试数量多了以后能避免很多灵异事件。
抽取基类这事,我的原则是“抽取你重复出现了3次以上的逻辑”。一开始就做抽象的后果是你根本不知道哪些会重复,抽早了反而被自己的抽象缚住手脚,改都难改。测试基类也一样,别为了省两行代码把基类搞得巨大无比,那会让后来人看不懂哪个测试到底加载了哪些行为。
3.3 覆盖率的正确打开方式:别追求100%,追求有效性
聊聊测试覆盖率。我在早期的项目里曾经被领导要求“行覆盖率必须到90%”,结果为了凑指标,写出了大量毫无断言的测试——new一个对象,调一个方法,只要不抛异常就算通过。这种测试的价值约等于零,甚至还会误导别人觉得这块代码很稳。
我现在的看法是:覆盖率是一个参考,不是KPI。真正重要的是你测试里到底有没有断言核心业务规则,有没有覆盖异常分支的两面性。一个280行核心策略类,就算语句覆盖100%,如果后来者改了一个配置项导致分支判断错了,而你的测试根本没写那条分支的断言,覆盖率照样救不了你。
实际操作中,我更看重的是“关键异常分支有没有被覆盖”。比如Service里catch到异常以后重新抛了BizException,那你至少应该有一个测试用来验证“当底层依赖抛异常时,Service能正确转换异常”。这类测试在排查线上故障时价值极大,因为线上经常出的问题就是底层接口异常了,Result包装层没处理,最后提示给用户的是“系统内部错误”而不是一个可以理解的业务提示。
3.4 隔离性设计:一套测试数据方案在多个测试模块间怎么共享
单测很忌讳测试数据互相依赖。我在项目里吃过一次大亏:订单模块的测试会向数据库表插入一组“默认订单”,这组默认订单的orderNo是固定字符串“ORDER20250101”,而促销模块的测试也用了同样的固定字符串,两个测试模块在并行跑的时候,因为H2数据库实例是共享的,一个模块清数据时把另一个模块的数据也清了,结果就是满屏的AssertionFailedError,查了很久才发现是数据冲突。
从那以后,我给每个测试模块做了“数据分区”隔离设计,核心原则是:
- 所有测试数据的主键都用一个生成器来创建,不用固定ID,这样多个模块各自造数据互不干扰。
- 每个测试类跑完以后,@Transactional的事务会自动回滚,但如果你用了一个测试基类且它不是事务的,就要手动写清理逻辑,把insert过的东西delete掉。
- 用Testcontainers跑MySQL时,尽量给每个测试类一个独立的数据库schema,或者用数据库名的规则区分,比如order_test、promotion_test。
这个经验在单测数量超过500个以后尤为重要。测试之间的隔离性做得不好,看似每个测试都正确,但组合跑起来就会出现“偶发性失败”,而这是最浪费团队时间的事情。
4. 工具选型解析与依赖配置建议
4.1 各类测试注解对比:一张表看清什么时候用哪个
SpringBoot里提供了好几个让人看着眼花的测试注解,我根据实际项目经验把它们整理成一张表,方便你直接对照选择:
| 注解 | 加载范围 | 启动速度 | 适用场景 |
|---|---|---|---|
| @SpringBootTest | 完整应用上下文 | 慢 | 集成测试、端到端验证、配置正确性检查 |
| @WebMvcTest | 仅Controller层 | 快 | Controller的URL映射、参数绑定、JSON结果校验 |
| @DataJpaTest | 仅JPA Repository层 | 较快 | 测试Repository的CRUD、常规查询方法 |
| @JdbcTest | 仅JdbcTemplate相关 | 较快 | 测试JdbcTemplate或MyBatis Mapper的SQL |
| @JsonTest | 仅JSON序列化组件 | 最快 | 测试Jackson的JsonSerializer、JsonDeserializer |
这里要特别解释一下为什么我不建议所有测试都无脑用@SpringBootTest。我曾经在一个老项目里见过,一个测试类只要别的小模块启动时报错,这个类就跟着一起挂,跑一次全量测试要六七分钟,改一行代码跑一次验证得喝杯咖啡才出结果。后来把它拆成@WebMvcTest和@DataJpaTest的组合,单测速度直接降到40多秒,开发体验完全不是一个量级。
4.2 Mockito和AssertJ的几个高性价比API
Mockito最基础的是when和verify,但实际项目里还有一些更高频的API值得你刻意练习。
一个是doThrow。有的方法返回void,你想让它抛异常,不能直接when(mockObj.doNothing()).thenThrow(...),因为void方法是无法用when包一层的。正确姿势是:
doThrow(new RuntimeException("远程调用失败")) .when(userServiceClient).notifyUser(anyLong(), anyString());这个写法我见过很多新手卡壳,因为一写为啥编译不通过就懵了。其实记住一句话:返回非void的方法用when(...).thenThrow(...),返回void的方法用doThrow(...).when(...)。
一个是ArgumentCaptor。这个用来捕获方法调用时的参数对象,适合验证“服务层传给底层方法的参数里,到底有没有设置正确的业务字段”。
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class); verify(orderRepository).save(captor.capture()); Order capturedOrder = captor.getValue(); assertThat(capturedOrder.getStatus()).isEqualTo(OrderStatus.CREATED);AssertJ这边,我觉得最值得推荐的是assertThatThrownBy,专治异常断言。你可以精确检查异常的类型、异常消息、异常发生的位置,比JUnit的assertThrows清晰太多。
assertThatThrownBy(() -> orderService.createOrder(invalidRequest)) .isInstanceOf(BizException.class) .hasMessageContaining("用户状态异常");4.3 测试与专用profile配置
一个很多人会忽略的点:SpringBoot测试里,application.yml和测试专用的application-test.yml之间到底是什么关系?默认情况下,@SpringBootTest会优先加载src/test/resources下的配置,src/test/resources里的配置文件优先级高于src/main/resources里的同名配置文件。
这就是为什么很多项目里测试环境可以配置一个独立的数据库URL而不影响生产配置的原因。但是在实际工程里,我更建议你给测试单独建一个application-test.yml,然后在测试类上用@ActiveProfiles("test")显式启用,而不是依赖“同名文件覆盖”这种隐式行为。显式更安全,也更不容易出现“本地能跑,CI上跑挂”这种问题。
我在application-test.yml里会配这样的核心内容:
spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create-drop show-sql: true logging: level: org.hibernate.SQL: DEBUGshow-sql和hibernate的SQL日志在调试Repository测试时特别有用。刚写测试那阵子,我总是搞不清楚JPA到底生成了一条什么样的SQL,排查起来跟瞎子摸象一样。后来开了show-sql,直接在控制台看到SQL输出,顿时清晰很多。但要注意,这个配置别带到生产环境去,否则日志文件会爆炸。
5. 常见问题与排查技巧实录
5.1 SpringBoot测试里那些“看起来玄学”的问题
先给大家一份我在真实项目里遇到的、网上帖子很少系统总结的常见问题速查表。
| 现象 | 根本原因 | 解决方式 |
|---|---|---|
| 测试类不执行,mvn test跑完显示0 tests | surefire版本与JUnit5不兼容 | 升级maven-surefire-plugin到2.22.2+ |
| @Autowired注入为null | 测试类没加@SpringBootTest或缺少@ExtendWith(SpringExtension.class) | 确认注解配置正确 |
| MockMvc请求报404 | @WebMvcTest只加载指定Controller,其他Bean没加载 | 排除或@Import对应的配置类 |
| jsonPath拿不到字段 | JSON路径写错或字段被@JsonIgnore | 检查实际返回JSON结构 |
| 测试慢到怀疑人生 | 每一类测试都加载完整Spring上下文 | 改用@WebMvcTest等切片注解 |
| 测试数据相互污染 | 多个测试类共用同一H2库且未回滚 | 约定每个测试类一个schema或显式清理 |
| H2里报SQL语法错误 | 项目用了MySQL专用方言 | 复杂SQL换成Testcontainers启动真库 |
有一个我特别想展开讲的现象是“测试本机全绿,但CI上红成一片”。这种问题的常见原因有三个:一是CI机器和本地机器的时区不一样,导致时间相关的断言偶发失败;二是测试依赖了某个外部服务,本地有缓存、CI上没有;三是CI是并行跑测试任务,多个模块共享同一数据源,互相干掉了数据。我建议在项目里定一条铁规:任何测试都不允许真实调用外部HTTP接口来断言结果,一律用MockServer或Mockito打桩,这样CI环境即使没有外网也不会挂。
5.2 偶发性失败的排查思路
偶发性失败是测试体系里最讨厌的东西,它不是必然挂,而是“跑三次有一次挂”。我排查这类问题的经验是分三步走。
第一步是看失败日志里提到的变量值。很多时候问题出在随机值上,比如用了UUID当订单号,断言却写死了具体字符串。我自己的习惯就是:测试里凡是涉及随机生成的内容,要么在Mockito里打桩返回固定值,要么断言时不判断精确值只判断格式。
第二步是看是否涉及并发执行。JUnit默认是单线程跑测试方法的,但Surefire可以配置parallel执行,maven-surefire-plugin的forkCount、parallel参数如果配置得很激进,测试类之间共享的资源就容易出问题。如果你发现某个测试单独跑永远通过、和别的测试一起跑就挂,优先怀疑并行导致的共享资源竞争。
第三步是检查时间依赖。比如一个“订单超时自动关闭”的测试,真实逻辑是“当前时间减去创建时间超过30分钟就关闭”。如果你测试里真的用Thread.sleep(30分钟)那是疯了,正确做法是把时间提供方抽成一个Clock接口,测试打桩返回固定时间。这也是很多老代码测试难写的原因——时间写死在System.currentTimeMillis()里,测试根本控制不了。
5.3 测试不好写,先别硬写,看看代码能不能重构
这是我最想分享的一个理念:当单元测试非常难写的时候,往往不是测试方法不对,而是被测代码的设计有问题。
举一个真实的例子。早前我们有个Service方法,内部直接new了一个UserServiceClient实例去查用户信息,你根本没法在测试里替换这个客户端。想测这个方法,要么真连用户服务,要么引入非常重的Mockito静态mock,怎么看都别扭。后来我把这个new出来的实例抽成了构造器参数,改成依赖注入的方式,测试瞬间就能用@Mock替换了,不仅可测性提升,连代码的可读性、耦合度都跟着好了。
所以我的建议是:当你发现写测试要费老鼻子劲时,先停下来审视一下原代码是不是有太多的静态方法调用、太大段的私有逻辑、太多new对象。SpringBoot的依赖注入理念,本质上就是为可测试性服务的。如果代码设计合理,写测试是一件很顺手的活;如果测试写得痛苦,代码大概率是需要调整的。这个思维转变,是我从“写测试”到“会设计”之间最大的一个跨越。
6. 从单元测试到团队测试文化的建设心得
6.1 如何让团队成员坚持写单元测试
技术问题好解决,但“让团队成员愿意把单元测试当回事”这个问题,我花了不少心思。
首先,不能把覆盖率当成考核指标。我一向认为,指标越具体,猫腻越多。如果领导只盯着“覆盖率过90”,成员就会往测试里塞垃圾断言凑数。正确的引导是用“核心业务逻辑必须保留关键测试”这样的规范,而不是用数字去卡人。
其次,要在团队里建立“测试先行”的协作节奏。我推荐的做法是:每次提交代码前,开发人员至少得保证自己写的核心逻辑有测试覆盖;如果改了别人的公共接口,还要保证那个接口的现有测试仍然通过。我们团队用的Git钩子是:push前必须跑一次所有单测。虽然一开始大家觉得烦,但养成习惯以后,项目质量稳定了不少,线上Bug率肉眼可见地下降。
最后,定期做“测试代码评审”。我指的是测试代码也要被Review,因为它和生产代码一样需要维护。如果测试里用了大量不清晰的注解、没意义的断言、过度的stubbing,这份测试的维护成本迟早会变成团队的负债。要把它当成一种“设计约束的体现”来评审。
6.2 通过测试驱动代码设计:TDD在SpringBoot项目里的实践
最后聊一点进阶的内容:TDD(测试驱动开发)到底能不能在SpringBoot项目里落地?
我的观点是:不能完全按教科书的方式落地,但它的核心思想非常值得借鉴。教科书里的TDD是“先写测试再写实现”,但我们在实际项目里更多的时候是“先有实现,补测试,再通过测试反向优化实现”。这不是传统意义上的TDD,却是SpringBoot项目里更现实、更容易被团队接受的节奏。
具体操作起来,我尝到甜头的路径是这样:遇到一个新需求时,先不急着写实现代码,而是把接口签名先定好,然后基于接口签名写一个描述期望行为的测试类,让测试编译失败。接下来再回头写实现,让测试通过。这个“测试先行”的过程会促使你先想清楚“输入是什么、输出是什么、异常分支有哪些”,而不是上来就堆代码。这样写出来的实现往往结构更清晰,也更简洁。
拿我们最近的一个会员等级计算需求来说,我在编码前先写了一个参数化测试,把“0到100积分是普通会员、101到500是黄金会员、501以上是铂金会员”的规则全列成了数据源,然后才开始动手实现。实现完成以后,一笔测试数据都没改就直接通过了。那种感觉就像考试前先知道了答案,特别踏实。这个流程不一定适合所有人,但如果你还在为Service层逻辑混乱、改动一处崩三处而发愁,我强烈建议下一个需求你试试“测试先行”。
在我自己带项目的过程里,还有一个特别有成就感的场景:一次线上事故中,运维反馈某个服务的内存占用异常飙升,大家面面相觑排查不到具体位置。后来我打开了一个之前写好的单元测试,把某个方法的入参改成了高峰期的极端数据,一跑,立刻复现了内存溢出。那一刻我才真正领会到单元测试不只是“防回归”的工具,还是我们理解代码在极端情况下如何表现的显微镜。从那以后,我就再也不会说出“单元测试没什么用”这种话了。
最后再分享一个小技巧:把工作里那些改过Bug、踩过坑的测试方法名都加一个“bugfix_”前缀,比如bugfix_shouldReturnError_WhenStockIsZero。这样做既方便筛选,也是给后来人留的一个资料库——哪一段代码曾经出过问题,对应哪个Bug,翻开Git历史一清二楚。这个习惯我保持了好几年,回头复盘的时候超级有价值。