ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot测试分层实战:MockMvc到Testcontainers

SpringBoot测试分层实战:MockMvc到Testcontainers 要说SpringBoot项目里最容易被敷衍对待的部分测试绝对排得上号。很多人写测试就是给Service方法加个Test跑一下发现绿灯然后就没有然后了。可等到接口被改、数据库切换、并发上来之后最先崩溃的往往就是那堆形同虚设的测试。我早年也吃过这个亏后来才慢慢把一个SpringBoot项目的测试体系从“会跑”折腾到“能救命”。这篇文章就把我在实战里沉淀下来的那套打法完整梳理一遍覆盖从依赖选型、分层策略到MockMvc、Mockito、Testcontainers的落地细节以及那些文档里不会写的坑。1. 测试在SpringBoot项目里的定位与分层思路1.1 为什么SpringBoot测试常常“形同虚设”很多项目的测试代码看起来不少覆盖率报表也好看但一遇到重构就稀里哗啦碎一地。我见过最常见的病根是把测试写成了“实现细节的复读机”。比如Service层刚调了userMapper测试里就mock一堆when(userMapper.selectById()).thenReturn(...)然后断言返回值。一旦把Mapper换成别的数据源测试全部红掉可业务逻辑明明没变。这种测试的问题在于它验证的是“代码是怎么写的”而不是“行为是否正确”。所以我在项目里反复强调一个原则测试要对着契约写不要对着实现写。接口的输入输出、异常规则、状态流转才是契约至于底层用的什么框架、什么ORM都不该是单测关心的重点。这个认知不纠正后面所有工具和技术都是在错误的路上加速。1.2 测试金字塔在SpringBoot里的落地方式测试金字塔这个概念不新鲜但在SpringBoot里落地时很多人会跑偏。理论上应该是底层单元测试最多、中间集成测试适中、顶层端到端测试最少。可实际上因为SpringBoot的SpringBootTest太方便了很多团队直接把所有测试都写成全量上下文启动一个项目跑下来测试要十几分钟于是大家越来越不想写测试。我在实践中会把测试分成三类来管控单元测试针对Service、工具类、领域对象不启动Spring容器用Mockito隔离依赖。切片测试只加载某一层需要的Bean比如WebMvcTest只加载Controller和MVC相关配置DataJpaTest只加载Repository和数据源相关配置。集成测试启动完整上下文必要时用Testcontainers拉起真实MySQL、Redis等中间件验证跨模块交互。这三类的比例我会控制在7:2:1左右。单元测试和切片测试跑得快合并请求时全量执行没压力集成测试数量少、价值高放在发版流水线里跑。1.3 一套可持续的测试策略长什么样可持续的关键不是覆盖率数字而是反馈速度和稳定性的平衡。我见过太多测试绿了但其实是假绿原因无非是断言写得宽松、数据没清理干净、随机端口冲突等等。所以我会给项目定几个硬规矩所有测试必须能重复执行跑十次结果一致不允许依赖执行顺序。测试之间不允许共享可变状态数据库、Redis、文件系统必须在每个用例结束后恢复。单元测试和切片测试总耗时控制在两分钟以内超过就要拆分。任何测试失败先解决测试本身不允许用Disabled掩盖问题。这些规矩刚定的时候会有人觉得麻烦但坚持下来之后测试是真的敢在发版前给人信心的。2. 测试环境搭建与依赖选型2.1 spring-boot-starter-test里都有什么SpringBoot的测试依赖非常省心起步就在pom.xml里加一个spring-boot-starter-testdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这个starter做了很多底层整合不是简单地把JUnit扔给你。它默认带了一套组合好的测试工具箱JUnit 5Jupiter现在的基础测试框架Test、ParameterizedTest、RepeatedTest都靠它。Spring Test Context提供SpringBootTest、Transactional传播、TestContextManager等能力。MockitoMock、InjectMocks、when/verify的核心来源。AssertJ流式断言库assertThat(...).isEqualTo(...)比JUnit原生的assertEquals可读性好太多。Hamcrest老牌匹配器虽然AssertJ更常用但一些遗留代码里还会见到。JSONassert用于对比JSON结构接口测试断言返回体时很有用。还有个容易被忽略的是spring-boot-test-autoconfigure所有WebMvcTest、DataJpaTest这类切片注解都来自它。它保证切片测试自动装配配置时不会误加载其他层的Bean。需要单独补依赖的话一般是加mockito-inline用于mock静态方法和Testcontainers相关的模块这个后面细说。2.2 测试目录与命名规范目录结构直接用Maven/Gradle的标准test目录就行我习惯把测试类按照被测试类的包结构镜像并且保持一套统一的命名规范团队成员看着不迷路Service层单元测试UserServiceTestController层切片测试UserControllerWebMvcTestRepository层切片测试UserRepositoryDataJpaTest集成测试UserServiceIntegrationTest命名这件事看起来细枝末节但作用非常大。跑测试的时候看到类名就知道它在测哪一层、需不需要容器环境排错效率直接翻倍。还有个小技巧把单元测试、切片测试、集成测试通过Maven Surefire的groups或命名后缀区分开方便在本地只跑某类测试。2.3 测试配置文件的隔离这是非常关键又容易被忽略的一环。application.yml里往往配置了真实环境或开发环境的数据源、缓存、消息队列地址测试如果直接复用轻则连错库重则把测试数据写到生产环境旁边的关系库里去。我一般会建一个src/test/resources/application.yml用配置覆盖的方式只保留测试需要的内容spring: datasource: url: jdbc:tc:mysql:8.0.36:///test_db driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver jpa: hibernate: ddl-auto: create-drop redis: host: localhost port: 16379注意这里的数据库地址不是真实的MySQL地址而是Testcontainers的JDBC驱动地址后面集成测试部分再展开。关键是要理解SpringBoot配置文件加载时有优先级src/test/resources下的文件优先级高于主目录下的application.yml这样测试项目可以放心覆盖配置而不用改动主代码的配置。3. 容器测试与切片测试怎么选SpringBootTest用前先想清楚3.1 全量上下文启动的利与弊SpringBootTest是SpringBoot测试里的“重量级选手”它的作用是启动完整的Spring容器把几乎所有的Bean都装配起来。好处是贴近真实运行环境坏处也是显而易见的启动一次要加载数据源、Redis连接、消息队列、各种初始化逻辑一个中型项目跑单个测试可能就要十几秒到几十秒测试一多就完全跑不动了。我见过一个项目光启动容器就要40秒90个测试类跑完接近一个小时。这个成本导致开发人员完全不想在本地跑测试最后测试形同虚设。所以我在新项目里对SpringBootTest的使用非常谨慎只有下面几种场景才用它跨模块的端到端业务链路验证比如从Controller入口一直打到数据库。配置类的加载验证比如自定义的ConfigurationProperties是否绑定正确。需要完整容器环境的第三方对接联调。其他场景能用切片就用切片。这个观念转变之后测试的反馈速度能提升一个数量级。3.2 切片测试更快但不是万能SpringBoot为每个常见层次都提供了专门用于测试的注解。我这里先列几个最常用的切片注解加载范围典型用途WebMvcTestController、Filter、ControllerAdvice、MVC配置不加载Service和Repository测试接口参数校验、路由映射、返回值结构DataJpaTestRepository、Entity、数据源配置默认使用内嵌数据库并开启事务回滚测试仓储层的CRUD、查询规则、分页排序JsonTestJackson/Gson的ObjectMapper相关配置测试JSON序列化和反序列化规则RestClientTestRestTemplateBuilder相关的RestClient配置测试远程HTTP客户端调用逻辑MybatisTestMyBatis的Mapper和配置需引入第三方starter测试MyBatis的SQL语句映射WebMvcTest是我每天都会用到的。它只加载Web层Service用MockBean新版本里推荐MockitoBean或MockBean具体看Spring版本打桩。这样启动速度非常快一个Controller相关的测试类通常在几百毫秒内就能跑起来。不过切片测试也不是万能药。WebMvcTest不会加载你自定义的拦截器之外的全局配置如果拦截器依赖了Service里的数据就需要额外把相关Bean补进测试上下文DataJpaTest默认用内嵌H2替换真实数据库如果项目里用了MySQL特有的JSON字段或全文索引H2大概率模拟不出来这时候就得考虑Testcontainers。3.3 不同场景下的选型对照我在实际项目中形成了一张很实用的对照表每个新开发者进来我都会先发这张表防止他们凭感觉选测试方式测试目标推荐方式启动速度真实度是否要mockController参数校验、路由WebMvcTest快中mock ServiceService业务规则纯JUnit Mockito极快低mock Repository/外部ClientRepository查询逻辑DataJpaTest较快中取决于数据库类型不需要跨层主流程SpringBootTestTransactional慢高看情况外部中间件真实联测SpringBootTest Testcontainers慢极高一般不需要这个表格背后真正的判断标准是三个问题测的是哪一层这层最关心的风险是什么需要多高的环境保真度想清楚这三个问题选型就不会错。4. Controller层测试实战MockMvc的关键细节4.1 MockMvc 基本套路Controller层的测试最常用MockMvc配合WebMvcTest使用不需要真正启动Web容器。直接发起HTTP请求然后对返回的响应做断言。下面这段代码是典型的用法WebMvcTest(UserController.class) class UserControllerWebMvcTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void shouldReturnUserWhenIdExists() throws Exception { UserVO mockUser new UserVO(1L, 张三, zhangsanexample.com); given(userService.findById(1L)).willReturn(mockUser); mockMvc.perform(get(/api/users/1) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三)) .andExpect(jsonPath($.email).value(zhangsanexample.com)); } }given是Mockito BDD风格的写法等价于when(...).thenReturn(...)只是可读性更好。很多人写Controller测试时习惯直接调Controller的方法这样其实绕过了路由解析、参数绑定、序列化这些真正的风险点测试价值大打折扣。用MockMvc走一趟完整请求链路才档得住真实的调用问题。4.2 断言比调用更重要测试的核心不是把请求发出去而是做足够严谨的断言。我见过不少同事写完andExpect(status().isOk())就收工了根本没验证返回内容。这种测试跟没写差不多接口里面随便改点东西都会照常绿。我给团队定的标准是至少覆盖状态码status().isOk()、isCreated()、isBadRequest()等。业务字段用jsonPath逐一断言核心字段不允许只断一个$.code。关键时序涉及异步或重定向时检查响应头里的Location或状态流转。异常路径参数缺失、资源不存在、权限不足每一种都得有对应的用例。JSONPath断言是接口测试里的利器。假设返回体是{data: {items: [{id: 2}, {id: 5}]}}可以这样写.andExpect(jsonPath($.data.items.length()).value(2)) .andExpect(jsonPath($.data.items[1].id).value(5))注意length()这个函数式写法很多人在第一次接触JSONPath时会漏掉导致断言怎么写都不对。4.3 常见翻车点我在接口测试里踩过不少坑挑几个典型分享MockBean的返回值类型不一致如果UserService.findById返回的是OptionalUserVO但代码里处理的是null判断mock时容易返回Optional.empty()而忘掉对应逻辑分支然后测试通过的代码拿真实数据就出问题。建议mock时先确认生产代码的判空路径。日期时间格式LocalDateTime默认序列化格式是数组比如[2025, 5, 1, 10, 30, 0]。如果接口要对前端返回yyyy-MM-dd HH:mm:ssController测试里必须验证这个格式否则到联调时才暴露就晚了。分页参数的默认值Spring Data的分页接口如果没传page和size会走默认值。测试里如果不测默认值后面前端传错参数也没人发现。静态资源处理WebMvcTest默认不会加载静态资源映射如果Controller里有重定向到静态页面的接口测试里可以通过手动添加资源处理器或者改用SpringBootTest。还有一点很多人不知道MockMvc的perform其实会在内部走完整的DispatcherServlet所以Valid注解触发的参数校验在WebMvcTest里是真实生效的。换句话说Controller层的参数校验测试完全可以在切片环境里做不需要跑到集成测试里再补。5. Service层单元测试Mockito的正确姿势5.1 基础三件套Service层是业务逻辑的聚集地测试价值最大但也是最容易被写坏的。我常用的基础模式是MockInjectMocksExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; Mock private PasswordEncoder passwordEncoder; InjectMocks private UserService userService; Test void shouldCreateUserWithEncodedPassword() { String rawPassword abc12345; given(passwordEncoder.encode(rawPassword)).willReturn(encoded-value); User created userService.createUser(zhangsan, rawPassword); assertThat(created.getPassword()).isEqualTo(encoded-value); verify(userRepository).save(any(User.class)); } }ExtendWith(MockitoExtension.class)是JUnit 5与Mockito集成的入口没有它Mock和InjectMocks不会生效。InjectMocks会优先按构造器注入其次是setter和字段注入这要求被测类的依赖尽量通过构造器声明否则容易注入不进去。5.2 别mock一切很多人写Service测试时有个坏毛病把Service内部依赖的私有方法也通过mock去绕开。实际上Mock只能作用于类的公开协作对象私有逻辑该测还是得测。更糟糕的是有些人会把UserRepository.save(...)的返回值mock成一个带ID的复杂对象结果Service里后续依赖的字段没设置测试和真实行为对不上。我踩过一次最深的坑是某个下单流程里OrderRepository.save()返回的订单对象里有个冻结金额字段我mock的时候忘了set值导致后续计算一直为0测试却绿了。上线后真实数据一跑就出状况。那之后我定了个规则能少mock就少mock能走真实对象就走真实对象。Repository里的方法如果有清晰的返回规则我宁愿写个真实的实体对象传进去也不想mock出个残缺对象。另外verify经常被用错。verify(userRepository).save(...)验证的是这个调用发生过一次但如果你想要的是“最终调用了两次”这种次数断言需要配合times(2)。不要滥用verifyNoMoreInteractions()它会把测试绑死在实现细节上。5.3 静态方法与构造方法Mockito的老版本不支持mock静态方法SpringBoot新版本里如果项目引用了mockito-inline就可以搞定try (MockedStaticIdGenerator mocked mockStatic(IdGenerator.class)) { mocked.when(() - IdGenerator.nextId()).thenReturn(42L); // 执行被测代码 }mockStatic返回的对象要记得关闭否则会影响同进程里其他测试。一般用try-with-resources包住即可。对new出来的对象可以用mockConstruction但这两个功能我建议尽量少用。遇到静态方法调用优先考虑通过注入接口来消除静态依赖而不是依赖Mockito的扩展能力。因为静态mock一旦用多测试会变得非常隐晦出问题也难排查。6. 真实数据库联测从H2到Testcontainers6.1 H2的甜蜜与陷阱DataJpaTest默认会用H2代替真实数据库如果项目用的是Hibernate大部分CRUD能正常跑通。可一旦上了MySQL的方言特性H2就会开始背叛你。我在项目里遇到的典型问题包括H2对JSON类型的支持是模拟的序列化/反序列化行为跟MySQL不一致。MySQL的ON DUPLICATE KEY UPDATE在H2里是另一套语法。分页查询的LIMIT ? OFFSET ?表现有差异。字段默认值、字符集排序规则不同可能导致测试通过但线上失败。所以我的经验是H2只适合验证查询逻辑的雏形凡是涉及数据库特性的项目真正该做的是用Testcontainers跑真实数据库。6.2 Testcontainers 落地方式Testcontainers是一个极其好用的库它能在测试时用Docker临时拉起容器测试结束自动销毁保证环境干净。SpringBoot生态里甚至有专门为它准备的starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-testcontainers/artifactId scopetest/scope /dependency有了这个依赖测试里可以这样用Testcontainers class UserRepositoryDataJpaTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0.36) .withDatabaseName(test_db) .withUsername(test) .withPassword(test); // 通过动态属性注册把数据源地址指到容器 DynamicPropertySource static void configureDatasource(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } }Container标注的静态字段只启动一次所有测试类共享同一套数据库容器速度还能接受。DynamicPropertySource用来动态注入配置比写死application.yml里的连接地址要灵活得多。更省事的写法是直接用JDBC URL里的tc:前缀这样连Container都不用单独声明spring: datasource: url: jdbc:tc:mysql:8.0.36:///test_db driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver这种玩法会自动按URL拉起容器对已有代码改动最小。我最近几个新项目都用的这个方式唯一需要保证的是CI机器上有可用的Docker环境。6.3 事务回滚与数据污染数据库测试最大的敌人是数据污染。Spring对测试方法上有Transactional的方法是会默认回滚的DataJpaTest本身也自带这个行为。但如果测试里用的是SpringBootTest想回滚就得自己加Transactional或者借助BeforeEach清理数据。我得提醒一个容易踩的坑测试方法里有异步操作或者数据库操作发生在独立线程时事务回滚会失效。比如Service里用了Async测试方法结束后异步线程还在写数据回滚根本拦不住它。这也是为什么异步逻辑的测试要用真实数据库加显式清理或者用Mockito把异步逻辑改成同步执行具体要看业务场景。另外Testcontainers容器虽然会销毁但它拉起容器可能要花十几秒。如果测试类特别多容器资源会互相占用。我建议给CI机器配置足够的Docker资源或通过compose.yml的方式复用一套容器不要每个测试类都另起一个MySQL实例。7. 测试执行效率与CI集成7.1 上下文缓存Spring的TestContext框架默认会缓存应用上下文只要配置相同多个测试类可以复用同一个上下文。这对性能影响巨大因为一个全量SpringBootTest上下文可能耗时几十秒复用后多个测试类共享一次启动成本。但缓存有几个失效因素要特别注意MockBean或新版本的MockitoBean改变了上下文的定义用了不同mock的测试类会各自创建一根上下文。DynamicPropertySource会作为缓存key的一部分每个测试类如果注入不同的属性缓存也失效。ContextConfiguration中的locations或initializers不同同样会产生新上下文。所以在写测试时我会尽量让同一批测试的配置保持一致尤其是切片测试中声明mock的方式不要一会儿一变。7.2 随机测试带来的确定性接口测试里最怕的是随机性。比如测试依赖某个ID主键自增跑过一次后数据变了第二次执行就失败。解决办法是不要让测试依赖任何来自外部环境的可变值。可以用固定数据、显式清理、或者在断言前先重置数据库状态。DirtiesContext这个注解我一般很谨慎地用。它会让测试结束之后销毁上下文下次再创建一个新的。这个成本非常高通常只在测试会修改Bean状态、影响后续测试时才需要。如果是为了解决数据污染就上DirtiesContext那是用大炮打蚊子会导致整个测试套件变得奇慢无比。7.3 CI里怎么跑测试在CI里的运行策略和本地不完全一样。我一般把Maven Surefire配置成默认跑所有单元测试和切片测试集成测试另用failsafe插件单独跑放在发版管道里plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration includes include**/*Test.java/include include**/*WebMvcTest.java/include include**/*DataJpaTest.java/include /includes excludes exclude**/*IntegrationTest.java/exclude /excludes /configuration /plugin对应地集成测试类命名为XxxIntegrationTest交给failsafe插件去跑。这样本地可以快速跑完单元测试CI的合并请求阶段也不会被重量级集成测试拖慢。CI环境里还常见一个问题测试偶发失败。偶发失败比稳定失败更折磨人常见来源是端口冲突、外部服务超时、并发测试争抢资源。我强烈建议把Surefire配置成parallel时只对无状态的纯单元测试开启并行涉及容器或数据库的测试不要并行否则排查成本比省下来的时间还高。8. 常见问题排查速查表最后整理一张我这些年攒下来的排查表基本上项目里踩过的测试坑都在这了。遇到问题先对着这张表查一遍很多坑可以直接跳过。现象常见原因处理方式SpringBootTest启动失败报数据源URL为空测试配置没有正确覆盖主配置确认src/test/resources/application.yml存在且数据源配置完整测试方法内开了事务但数据没有被回滚方法里启动了新线程或调用了Async改用真实数据库显式清理或mock掉异步逻辑MockMvc测试报404WebMvcTest没有包含目标Controller在注解里显式列出WebMvcTest(UserController.class)Mockito报UnnecessaryStubbingException打桩没有被用到删除多余的given(...)或使用lenient()Testcontainers起不来提示连接Docker失败CI机器没有Docker或Docker版本不兼容确保Docker daemon存活检查测试机的socket权限DataJpaTest中SQL语法错误H2与MySQL方言不一致如果涉及数据库特性切换Testcontainers真实数据库测试之间数据相互污染没有清理数据或事务回滚失效在每个用例前清理目标数据表或使用Transactional保证回滚jsonPath($.data).value(...)一直失败返回体结构与预期不一致先用andReturn().getResponse().getContentAsString()打印返回体再比对测试上下文缓存未生效每次都重启MockBean或DynamicPropertySource使用不一致导致缓存key变化尽量统一同一批测试类的mock声明和配置注入排查时有个非常实用的招式别急着改代码先看测试失败时的完整日志和返回体。MockMvc测试失败时经常会打印完整的响应内容很多人忽略了这行输出直接去翻生产代码浪费大量时间。我个人在实际操作中的体会是SpringBoot测试这事的核心并不在于会用几个注解而在于建立起“分层验证、快慢分离、持续可跑”的测试理念。从最初的SpringBootTest一把梭到现在每层各司其职项目的回归速度和质量稳定性都有了根本性的变化。这个内容后续还可以继续扩展成一套团队内部的测试规范文档把切片选型、命名规范、容器管理、CI流水线配置都沉淀成模板让每个新加入项目的同事都能少踩我踩过的那几年坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进