SpringBoot单元测试实战:分层测试策略与Mockito避坑指南 搞单元测试这事我在SpringBoot项目里从最开始“为了覆盖率而写”到后来“为了能睡个安稳觉而写”中间踩了不少坑也沉淀下来一套自己的打法。这篇文章就结合我手头一个真实的订单服务模块把整套单元测试的实操过程、技术选型、避坑心得掰开揉碎讲清楚顺便聊聊那些文档里一般不写的东西。我默认看的你是有一定Java基础、已经在用SpringBoot写接口但测试这块要么没怎么落地、要么写起来总觉得别扭。文章里不会讲怎么从零装IDEA而是直接聚焦在“怎么把单元测试写好、写快、写稳”这件事上。1. 项目整体设计与测试思路拆解1.1 为什么单元测试在SpringBoot里“说起来简单做起来难”很多同学刚开始接触SpringBoot单元测试觉得就是给Service方法加个Test注解往里填点断言就完事了。真上手才会发现SpringBoot项目里Bean的依赖关系错综复杂一个Service往往挂着Repository、RedisTemplate、FeignClient、MQ Producer你new一个对象出来根本跑不起来或者一跑就牵连数据库、外部接口整个测试慢得跟集成测试似的。我现在的做法是默认用分层测试策略不搞“一个SpringBootTest打天下”。Controller层用WebMvcTest配合MockMvcService层用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层是整个业务逻辑的核心也是单元测试的主战场。我以一个订单创建的服务为例来拆解。假设有这样一个OrderServiceService 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); OptionalOrder 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版本至少17Mockito版本也得到5.x才行。dependencies !-- Spring Boot 单元测试核心 starter-test -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency !-- 如果你是Maven项目建议显式声明JUnit 5版本避免版本冲突 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId scopetest/scope /dependency !-- H2数据库用于Repository层测试 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scopetest/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的SQLJsonTest仅JSON序列化组件最快测试Jackson的JsonSerializer、JsonDeserializer这里要特别解释一下为什么我不建议所有测试都无脑用SpringBootTest。我曾经在一个老项目里见过一个测试类只要别的小模块启动时报错这个类就跟着一起挂跑一次全量测试要六七分钟改一行代码跑一次验证得喝杯咖啡才出结果。后来把它拆成WebMvcTest和DataJpaTest的组合单测速度直接降到40多秒开发体验完全不是一个量级。4.2 Mockito和AssertJ的几个高性价比APIMockito最基础的是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。这个用来捕获方法调用时的参数对象适合验证“服务层传给底层方法的参数里到底有没有设置正确的业务字段”。ArgumentCaptorOrder 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;MODEMySQL 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 testssurefire版本与JUnit5不兼容升级maven-surefire-plugin到2.22.2Autowired注入为null测试类没加SpringBootTest或缺少ExtendWith(SpringExtension.class)确认注解配置正确MockMvc请求报404WebMvcTest只加载指定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历史一清二楚。这个习惯我保持了好几年回头复盘的时候超级有价值。