Spring Boot测试实战指南:单元测试、切片测试与集成测试 在Spring Boot项目里测试的地位一直有点尴尬。老项目普遍没测试新项目写了测试也经常停在“能跑通”的水平——启动一个SpringBootTest调几个接口看到绿灯就算完事。直到前阵子我在一个几乎没有测试的模块里改方法签名编译通过、自测没问题上线当晚接口却直接报错我才重新审视Spring Boot测试这件事它真正难的不是“会调用几个注解”而是搞清楚每一层测试到底在测什么、该用什么方式去测。这里不打算从零讲JUnit语法而是把Spring Boot项目里真正用得上的测试方案串起来单元测试怎么把依赖摘干净Web层切片测试怎么写才不拖泥带水数据层的回滚机制是怎么运作的集成测试拉起完整容器时有哪些决策要做。同时把我在实际项目里踩过的坑一并复盘。适合正在给业务项目补测试的人也适合想搞清楚SpringBootTest、WebMvcTest、DataJpaTest该在什么场景下用的人。1. 测试金字塔与 Spring Boot 测试的三种打开方式1.1 为什么单元测试、切片测试、集成测试的边界经常被搞混刚接触Spring Boot测试的人最容易出现的状态是不管测什么都写一个SpringBootTest把整个Spring容器拉起来然后再用MockMvc或者TestRestTemplate去调接口。这样做的好处是“真实”坏处是慢而且慢的代价会在项目规模变大之后指数级上升。我见过一个中等规模项目完整启动一次上下文要四十多秒。如果每个测试类都这么干全量测试跑下来要二十分钟不止大家慢慢就不愿意跑测试了CI上也开始跳过测试。这其实不是测试本身的问题而是没有想清楚“我这一条用例到底要验证什么逻辑需要多大的上下文”。Spring Boot官方的测试文档实际上把测试分成了几个层次纯单元测试完全不启动容器切片测试只加载某一层需要的Bean集成测试才拉起完整上下文。理解这个分层不是为了追求形式上的干净而是为了控制测试的运行成本和维护成本。1.2 三种测试形态的适用场景与成本对比我把它们放在一起对比这样更直观测试形态典型注解验证目标启动范围运行速度单元测试ExtendWith(MockitoExtension.class)Service/工具类内的业务规则无Spring容器毫秒级切片测试WebMvcTest / DataJpaTestController/Repository单层行为只加载相关Bean秒级集成测试SpringBootTest跨层协作、配置、真实外部依赖完整应用上下文十秒以上判断用什么方式测我一般问三个问题这条用例是否依赖Spring的依赖注入是否依赖HTTP协议行为是否依赖真实的数据库或其他中间件如果三个都是否那就应该写成纯单元测试如果只依赖其中一项优先考虑切片测试如果都依赖才用集成测试。这三者的比例在实际项目里我倾向于让单元测试和切片测试占大头集成测试只覆盖关键的跨层流程和接口冒烟场景。原因很现实运行速度决定了测试能不能频繁被跑起来而频繁执行才是测试能发挥重构保护作用的前提。2. 单元测试Mockito 让 Service 层彻底摆脱容器依赖2.1 脱离容器写测试到底图什么很多人会问Service里就用到了Spring管理的Bean不启动容器怎么测答案是用Mockito这种Mock框架把依赖的对象替换成“替身”由测试代码来规定替身的行为。比如UserService依赖UserRepository我不需要真的连接数据库只需要告诉Mockito“当调用findById(1L)时返回一个构造好的User对象”。这么做的第一个好处是快。没有容器启动、没有数据库连接一条用例执行时间是以毫秒计的。第二个好处是精准。测试只关注UserService里的业务逻辑比如找到了用户之后要做什么处理、找不到要抛什么异常而不需要去折腾数据库里有没有这条数据。第三个好处是隔离。外部依赖一旦出了问题不会连累这条单元测试挂掉定位问题的时候也清楚是业务逻辑错了还是底层接口变了。2.2 Mockito 三板斧mock、stub、verifyMockito的实际操作可以浓缩成三件事。第一是创建Mock对象用Mock注解标记依赖配合ExtendWith(MockitoExtension.class)自动初始化。第二是打桩stub用when(...).thenReturn(...)规定Mock对象的某方法在被调用时的返回。第三是校验verify用verify(...)确认某个方法确实被调用过以及调用次数是否符合预期。这里面我特别想强调verify的使用。很多人的单元测试只做了打桩、断言结果却从不verify。结果就是有些分支代码根本没被执行到但因为返回值恰好一样测试还是绿的。比如一个方法内部调用了发送消息的接口你只断言了返回true却没验证messageSender.send()有没有被真正调用这条测试的价值就打折了。2.3 一个标准 Service 单元测试的完整范例直接看代码更清楚。假设现在有一个UserService提供根据ID查询用户并做脱敏处理的能力ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; InjectMocks private UserService userService; Test void shouldReturnMaskedUserWhenUserExists() { // given User user new User(1L, 测试用户, testexample.com); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); // when UserVO result userService.getUserById(1L); // then assertThat(result).isNotNull(); assertThat(result.getEmail()).isEqualTo(tes***example.com); verify(userRepository).findById(1L); } Test void shouldThrowExceptionWhenUserNotFound() { // given when(userRepository.findById(99L)).thenReturn(Optional.empty()); // when / then assertThatThrownBy(() - userService.getUserById(99L)) .isInstanceOf(UserNotFoundException.class) .hasMessageContaining(用户不存在); } }注意第二段用例它验证的是异常路径。项目里常见的偷懒方式是只测正常路径异常路径全靠“应该不会发生吧”撑着可往往出问题的就是这些边界场景。补充异常路径的代价并不高多写十几个用例就能把Service层大部分分支覆盖住。2.4 单元测试里最容易被忽略的验证点实践里我发现几个高频问题。一是Mockito默认不校验参数when(userRepository.findById(1L))被打桩之后就算被测代码传了2LMockito因为没匹配到桩会返回默认值null而不是报错导致测试抛出奇怪的NPE而不是业务异常。排查的时候要先确认打桩参数和实际调用参数是否一致。二是连续调用同一个方法需要返回不同值时要用thenReturn(值1, 值2)或者doReturn(值1).doReturn(值2)这种写法不要想着在测试里放一个可变变量去骗过Mockito。三是如果被测对象内部使用了final类或final方法Mockito在旧版本上会打桩失败。解决办法是引入mockito-inline扩展或者依赖Spring Boot官方的spring-boot-starter-test所带的Mockito版本新版本已经默认支持多数场景。3. Web 层切片测试MockMvc 模拟请求的正确姿势3.1 WebMvcTest 只加载哪一部分上下文切片测试是Spring Boot给我最实用的设计之一。以Web层测试为例WebMvcTest(UserController.class)只会加载MVC相关的组件Controller、过滤器、拦截器、参数解析器以及Jackson消息转换器。至于Service、Repository这些Bean并不会被加载进来需要通过MockBean新版本里叫MockitoBean去Mock。这样做能明显拉低上下文启动成本。我曾经把一个用SpringBootTest写的Controller测试改为WebMvcTest整个过程从十几秒降到两秒左右而且测试的意图也更清晰了我就是要测HTTP层的行为比如参数绑定正不正确、校验器有没有生效、返回的JSON结构长什么样至于业务逻辑单元测试已经覆盖过了。3.2 MockMvc 从请求构造到断言的完整链路一个完整的MockMvc测试包含三部分构造请求、执行请求、断言响应。先看一个例子WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void shouldReturnUserDetail() throws Exception { UserVO vo new UserVO(1L, 测试用户, testexample.com); when(userService.getUserById(1L)).thenReturn(vo); mockMvc.perform(get(/api/users/1) .header(X-Request-Id, test-trace-id)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(0)) .andExpect(jsonPath($.data.name).value(测试用户)) .andExpect(jsonPath($.data.email).value(testexample.com)); } }这里有几个细节值得注意。一是MockMvc的perform是同步执行的不需要真正起一个HTTP服务所以它测的是Spring MVC的处理器链路而不是网络层。二是jsonPath断言非常实用专门用来校验JSON结构里的某个节点比把整个响应转成字符串再去contains要可靠得多。三是如果控制器里依赖了当前登录用户信息比如用了一个自定义的HandlerMethodArgumentResolver去解析用户上下文记得在测试里通过匿名Mock或设置SecurityContext来准备用户数据否则很多用例会挂在空指针上。3.3 文件上传与异常路径的测试细节Web层测试另外一个常见场景是文件上传。MockMvc对这种multipart请求支持得很顺手Test void shouldUploadFileSuccessfully() throws Exception { MockMultipartFile file new MockMultipartFile( file, hello.txt, text/plain, spring boot test.getBytes(StandardCharsets.UTF_8)); mockMvc.perform(multipart(/api/files/upload).file(file)) .andExpect(status().isOk()) .andExpect(jsonPath($.data.fileName).value(hello.txt)); }异常路径也建议在Web层测一次。比如请求参数缺失时Controller上如果挂了Valid和NotNull返回的状态码和错误信息结构是否符合前后端约定这些只有通过MockMvc才能真正验证。我习惯为每个Controller测试类补充一个“参数错误”用例专门用来锁定全局异常处理器RestControllerAdvice的输出格式。这个设计一旦变了测试立刻能暴露出来不用等联调的时候前端过来找。4. 数据层切片测试DataJpaTest 的回滚机制与内存库陷阱4.1 为什么数据层测试要用独立的事务数据层测试最常见的问题就是数据残留。一次测试往user表插入了一条记录下次测试再跑表里就有了不该存在的脏数据导致“第一次跑是绿的第二次跑就红了”的诡异现象。DataJpaTest这个切片注解之所以好用就是因为它默认给每个测试方法包了一层事务并且测试结束自动回滚——数据层在测试里做的增删改不会真正落库。这里要注意回滚的前提是测试方法不能自己把事务状态搞坏。比如在用例里调用了某个Service方法而该方法内部自带了Transactional(propagation Propagation.REQUIRES_NEW)数据就会被提交到新事务里回滚就不生效了。遇到这类方法要么把它从数据层测试里隔离出去要么单独准备清理数据的逻辑。4.2 用 H2 内存库做 JPA 测试的方言问题DataJpaTest默认会替换掉项目配置的数据源换成一个内存数据库最常见的是H2。但这里有个著名的坑生产用MySQL测试用H2两边方言不一致。比如MySQL支持json类型、支持某些索引语法H2不一定支持反过来H2认的写法MySQL也不一定认。结果就是测试环境跑得好好的SQL到了生产环境就报错或者反过来。我的建议是分两步走。第一如果只是验证Repository的基本CRUD方法和Spring Data JPA的命名查询H2够用但要把spring.jpa.hibernate.ddl-auto设为create-drop并且让H2运行在MySQL兼容模式jdbc:h2:mem:test;MODEMySQL。第二凡是涉及MySQL特有函数、复杂查询、或者需要在真实索引下验证性能的用例就别勉强用H2了直接走Testcontainers。如果坚持在数据层配合Testcontainers记得加AutoConfigureTestDatabase(replace Replace.NONE)否则默认内存库还是会接管数据源。4.3 结合 Sql 构造测试数据的推荐写法切片测试里要准备数据我推荐用Sql注解在测试方法执行前执行指定的SQL脚本。好处是数据是显式可见的另一个是SQL脚本本身可以复用。比如DataJpaTest Sql(scripts /sql/user-data.sql) class UserRepositoryTest { Autowired private UserRepository userRepository; Test void shouldFindUserByEmail() { OptionalUser user userRepository.findByEmail(initexample.com); assertThat(user).isPresent(); assertThat(user.get().getName()).isEqualTo(初始化用户); } }这里有个容易忽略的地方Sql脚本的执行是发生在事务里的它和测试方法处于同一个事务上下文。如果脚本里有INSERT语句测试方法里没有显式flush可能查询时数据还没真正写进持久化上下文。保险的做法是在断言前调用entityManager.flush()或者直接在Repository的查询方法上强制flush。这个细节不处理就会出现“脚本明明执行了但是查不到”的灵异现象。5. 集成测试SpringBootTest 拉起完整容器要做的三个决策5.1 端口选择MOCK、RANDOM_PORT 与 DEFINED_PORT什么时候该用集成测试我一般保留给跨层协作比如注册流程要从Controller一路通到Repository、Spring配置里某个Bean的初始化条件对不对、过滤器链和拦截器是否影响了业务接口。这类用例需要的是“真实运行起来”的应用所以SpringBootTest的webEnvironment是第一个要做的决策。webEnvironment有三个常用值MOCK默认不启动真实Web服务适合配合MockMvc做全上下文测试RANDOM_PORT启动真实的嵌入式容器并分配随机端口适合用TestRestTemplate或RestClient发真实HTTP请求DEFINED_PORT则绑定到配置里的固定端口一般只在调试或特殊场景下用。我几乎总是用RANDOM_PORT因为固定端口容易跟本地正在运行的服务冲突随机端口完全避开这个问题。要拿到端口号注入LocalServerPort字段即可。5.2 Testcontainers 替换 H2 的完整接入过程集成测试里最大的变量是数据库。H2能在用例里跑通但它和生产MySQL的差异始终是个隐患。Testcontainers是另一个主流方案它在测试时启动一个真实的MySQL容器用完自动销毁行为和线上几乎一致。接入步骤其实不复杂。测试类上挂Testcontainers注解定义一个静态的MySQLContainer然后用DynamicPropertySource把动态JdbcUrl注入Spring配置SpringBootTest(webEnvironment WebEnvironment.RANDOM_PORT) Testcontainers class UserRegisterIntegrationTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(test_db) .withUsername(test) .withPassword(test); DynamicPropertySource static void registerDataSourceProperties(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } Test void shouldRegisterUserThroughFullStack() { // 使用TestRestTemplate发起注册请求 // 断言返回结果 // 再通过Repository查询数据库确认落库 } }第一次启动容器因为要拉镜像会慢一些但之后容器复用速度完全可以接受。需要提醒的是跑Testcontainers的前提是本机有Docker环境。在CI环境里只要Runner支持Docker就能正常跑如果团队里有人本地没装Docker可以在本地环境自动降级回H2或者要求统一使用Docker。5.3 异步任务与外部服务在集成测试中的处理集成测试还经常被异步操作困扰。比如注册成功后要发一封欢迎邮件邮件发送是异步的测试请求已经返回200了但邮件任务可能还在执行队列里。这时候如果测试里立刻断言“邮件已发送”大概率是失败的。处理异步断言我推荐引入Awaitility这个库它允许用一个轮询的方式等待某个条件满足await().atMost(Duration.ofSeconds(5)) .untilAsserted(() - assertThat(mailSender.getSentCount()).isEqualTo(1));至于外部服务比如对接支付、短信这些第三方API集成测试里千万不要真调。常规做法是把对外Client定义成Bean测试中用MockBean或MockitoBean替换掉再通过verify去断言请求参数。Spring Boot项目里这是很成熟的做法唯一要注意的是Mock粒度要放在“服务边界”上而不是把Controller里的依赖全Mock掉否则集成测试就退化成切片测试了。6. 连续踩坑后的排错实录测试翻车现场复盘6.1 坑一上下文加载慢一次改动引发的连锁反应有一次我们给项目加了一个全局配置类这个配置类里有比较重的初始化逻辑结果全量测试从原来的不到一分钟暴涨到十几分钟。排查了很久才发现几乎每个集成测试类都触发了上下文刷新而且因为配置类里引入了部分外部系统的Client Bean导致上下文根本没法复用每个测试类都重建一次。这次的教训有两个。一是配置类里不要让初始化逻辑默认加载尽量用懒加载或者在特定Profile下才激活。二是一个项目里应该尽量保证测试上下文是同一份Spring的上下文缓存机制是全局共享的只要配置完全一致多个测试类会复用同一个ApplicationContext。如果你看到测试类之间上下文频繁重建多半是它们的SpringBootTest参数、ActiveProfiles、MockBean的声明不完全一致。MockBean本身会污染上下文缓存用多了会让缓存的上下文不断重建所以能用MockitoBean或者切片测试代替的就尽量别用。6.2 坑二测试数据串场导致偶发失败另一个经典问题测试数据互相污染。例子是我们在UserService的单元测试里Mock了一个静态工具类的返回值另一个测试却改写了同一个静态配置结果测试时而绿时而红。排查到最后是静态状态没有隔离。数据污染的来源一般有三个静态状态、磁盘文件、数据库表。应对的思路分别是单元测试尽量不依赖静态状态必须依赖时用MockedStatic文件读写放到临时目录JUnit的TempDir可以自动清理数据库数据要么靠事务回滚要么保证每个测试用独立的数据主键避免互相影响。6.3 坑三Mock 了依赖却忘了校验行为还有一个很隐蔽的问题就是Mock了之后不校验导致测试出现“假绿”。举个例子某个接口要求调用统计服务来上报访问量我们当时Mock了统计服务并打桩了返回断言了接口返回200却完全没有verify统计服务的方法有没有被调用。直到后来统计需求改造调用逻辑被误删接口仍然返回200测试全绿但数据上报已经悄悄没了。从那以后我在代码评审里加了一条约定凡是Mock过的外部依赖测试里必须有对应的verify凡是Mock过的方法要么断言了返回值要么验证了交互行为。没有交互验证的Mock只是让测试能跑过去并没有让测试真正证明什么。6.4 坑四聚合根上写业务逻辑切片测试无从下手最后一个坑属于设计层面的。有阵子我们代码里Controller特别薄Service也特别薄大部分业务规则都堆在了实体类的扩展方法上。这时候去写单元测试发现无从下手因为实体类的依赖是直接通过静态工具类拿的没法轻易Mock。这让我意识到测试难度其实是一面镜子照出的是代码结构的问题。如果代码写起来难测大概率是职责分配有问题。后来我们把静态工具方法改成了由Service注入的组件业务逻辑迁回Service层测试立刻变得顺手。所以如果你觉得测试写不下去别急着怪框架先回头看看被测代码的设计。7. 写完测试之后维护阶段的一些实在建议测试写出来不是终点维护才是大头。我把这个阶段的经验总结成几条供参考。一是测试的命名要说清楚“行为”而不是“方法”。shouldReturnMaskedUserWhenUserExists这种命名比testGetUserById强得多。看测试名就能还原业务规则这本身就是文档价值。二是测试代码也是代码要参与评审。我们项目里测试代码不走评审的时候没过多久就出现了大量无断言的空测试、注释掉的旧用例、只测正常路径的假覆盖。后来把测试纳入评审情况立刻好转。三是不要迷信覆盖率数字。行覆盖率90%不代表分支都被验证了更不代表交互行为都对。我见过覆盖率很高的模块因为大量测试都是走了一遍正常流程、没有任何负面用例重构时照样踩雷。覆盖率可以参考但更应该关注每个核心业务规则是否有对应的正面和负面用例。四是全量测试跑得慢的时候先优化结构不要急着删测试。把重上下文的用例往切片测试迁移把真正需要完整容器的用例压到最少速度自然就上来了。我个人编写测试的习惯是每天晚上下班前把涉及本次改动的测试单独跑一遍每周在CI上跑一次全量。跑了这半年最直观的感受是重构的时候再也不用靠胆量和记忆力了改完代码跑一下测试哪里被影响立刻就知道。这种底气是任何代码评审都替代不了的。