ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot测试策略:从分层选型到Testcontainers实战总结

Spring Boot测试策略:从分层选型到Testcontainers实战总结 做了这么多年 Spring Boot 项目我越来越觉得Spring Boot 测试真正难的不是 API 不会用而是很多人根本不知道该把测试写到哪一层、该怎么保证它能稳定跑下去。日常开发里最常见的一类问题就是本地测试全绿代码合进去之后 CI 挂了或者仅仅是改了一个实体字段十几个测试类跟着一起失败还有一种是全量测试越跑越慢最后跑一次要十来分钟团队只能选择性忽略。这些问题的根源基本不在“写测试代码”这个动作而在“测试策略选型”和“测试环境治理”上。这篇文章我会直接围绕 Spring Boot 测试这个主题从怎么分层、怎么选注解、怎么搭数据层测试、怎么处理 Controller 安全上下文再到我实际踩过的坑和排查链路一次讲清楚。无论你是刚接触 Spring Boot 项目的新手还是写了很多年代码但没有认真整理过测试套件的老手都可以拿来当一份可落地的参考。1. 测试分层选型先想明白这一层到底要验证什么1.1 三种测试范围的区别我接手过不少项目打开测试目录一看整齐划一全是SpringBootTest。这种做法的代价非常直接Spring 容器会被完整启动所有真实 Bean 都会加载外部依赖只要有一个连不上测试就起不来。这本质上是把集成测试当单元测试写。如果项目只有二十个用例这么写倒也没太大问题一旦用例上百个光是等容器启动、等事务回滚就够让人崩溃。我自己的取舍逻辑其实很简单验证单个 Service 方法里的复杂分支、参数校验、计算结果用 JUnit Mockito不启动 Spring 容器验证 Repository 的 SQL 和实体映射用DataJpaTest验证 Controller 的请求解析、参数校验、响应结构用WebMvcTest只有需要验证跨组件事务边界、消息队列真实投递、外部依赖联合调用时才上SpringBootTest。每往上一层覆盖范围更广但代价也更慢、更脆弱。所以不是测试写得越多越好而是“每一层测试只测这一层该管的事”。1.2 测试注解怎么选一张表看清楚刚开始接触 Spring Boot 测试时我也被一堆注解绕晕过。后来我习惯用一张表来给团队定“哪个场景用哪个注解”现在分享出来可以直接参考测试诉求推荐主力注解启动范围注意点Service / 工具类逻辑无JUnit Mockito不启动容器外部依赖全部 mock专注业务分支Controller 接口层WebMvcTest只装配 MVC 相关用MockitoBean替换 Service 依赖JPA 数据层DataJpaTest只装配 JPA 相关默认内嵌数据库自带事务回滚JSON 序列化JsonTest只装配 JSON 相关验证字段名、日期格式、null 处理完整链路集成SpringBootTest完整上下文建议随机端口 Testcontainers控制用例数量特别提醒一下如果你用的是 Spring Boot 3.4原来的MockBean开始向MockitoBean迁移。项目升级时这个细节很容易漏直接照老代码抄的人会发现注解找不到或者 IDE 提示 deprecated。1.3 为什么不要一上来就 SpringBootTest很多人的习惯是先写SpringBootTest测试启动报错再一点点加配置。这种“先启动再调整”的套路会把大量时间耗在环境问题上。我把SpringBootTest的几个真实问题列出来大家感受一下启动慢。一个中等规模的 Spring Boot 项目完整启动上下文 10 到 20 秒很常见。如果开发机性能一般一次全量测试跑几分钟毫不意外。外部依赖耦合强。项目里只要有一个 Bean 依赖 Redis、Nacos、消息队列测试环境就得先保证这些组件可用。CI 一旦网络不稳测试结果就会随机失败。上下文缓存压力大。不同测试类只要配置少数不一致Spring 就会为它们各自启动一个上下文。上下文越多内存占用和启动耗时都线性上涨。错误定位模糊。上下文里几十个 Bean 一起初始化时一个 Bean 配错了会导致所有“不相关”的测试类一起挂掉你根本没法快速定位。所以我的建议是写测试前先圈定要验证的问题边界用边界去选注解而不是先写注解再根据报错补配置。2. 测试环境上下文缓存、随机端口和外部依赖的“防抖”2.1 上下文缓存复用机制以及为什么你总觉得没生效Spring TestContext 框架在启动时是有缓存的只要多个测试类的上下文配置完全一致就会复用同一个ApplicationContext。这个特性是测试套件能高效运行的底层保障。但很多人不知道Spring 生成缓存 key 时会考虑这些因素加载上下文的主配置类激活的 profileActiveProfilesproperties或TestPropertySource里的配置webEnvironment模式各种ContextCustomizer包括MockBean/MockitoBean带来的自定义器。我见过最典型的场景A 测试类在SpringBootTest里写了properties spring.datasource.urljdbc:h2:mem:aB 测试类写了properties spring.datasource.urljdbc:h2:mem:b。开发者的本意是隔离数据结果却让两个类的缓存 key 不同Spring 被迫启动两个上下文。表面上“加了缓存”实际缓存完全没生效。我的做法是把所有测试类共用配置统一放到src/test/resources/application-test.yml在测试类上用统一的ActiveProfiles(test)。尽量避免在测试类注解里零散塞 properties。这样 cache key 的一致性更容易保证测试套件也更容易提速。2.2 RANDOM_PORT 才是适合并行和 CI 的选择如果集成测试确实需要启动内嵌服务器我强烈建议使用webEnvironment WebEnvironment.RANDOM_PORT。用DEFINED_PORT固定端口本地跑单个测试类可能没问题但 CI 并发执行或者同一台机器上多个测试进程同时跑时端口冲突就会立刻出现而且是那种“时而成功时而失败”的偶发问题排查起来特别烦。用随机端口的标准姿势是这样SpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderFlowIntegrationTest { LocalServerPort int port; Test void shouldPlaceOrder() { TestRestTemplate rest new TestRestTemplate(); ResponseEntityString resp rest.postForEntity( http://localhost: port /orders, {\productId\:1,\quantity\:2}, String.class ); assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue(); } }如果你是 WebFlux 项目可以用WebTestClient.bindToServer().baseUrl(http://localhost: port).build()。核心逻辑一样端口不写死环境起来之后动态拿。2.3 外部依赖用 Mock 服务替代别让测试结果“看天吃饭”很多业务代码都要调用第三方 HTTP API如果测试时直接连真实第三方接口结果基本不可控。对方接口抖动、超时、字段调整都会让你的测试随机失败。更麻烦的是有些测试为了能跑通会去真实下单、真实发消息这已经完全不是自动化测试了。我在项目里更常用 MockWebServer 来模拟第三方服务。它跟 OkHttp 是同一个生态用法非常简单class ExternalApiClientTest { private MockWebServer server; private ExternalApiClient client; BeforeEach void setUp() throws IOException { server new MockWebServer(); server.start(); String baseUrl server.url(/).toString(); client new ExternalApiClient(baseUrl); } AfterEach void tearDown() throws IOException { server.shutdown(); } Test void shouldMapResponse() { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader(Content-Type, application/json) .setBody({\code\:0,\data\:{\name\:\zhang\}})); // 调用 client 的方法断言结果 } }要点是Mock 服务返回的数据是提前设计好的不会访问外网所以结果可重放、可预测。如果涉及签名、token 获取可以把固定 token 也交给 Mock 处理。真正的三方联调放到 dev 或 staging 环境去做不要在自动化测试里依赖真实网络。3. 数据层测试H2 内存库和 Testcontainers 怎么选3.1 DataJpaTest 默认做了什么DataJpaTest是 Spring Boot 提供的数据层切片测试注解。它会扫描 JPA 相关组件只加载和 JPA 相关的配置同时自带事务回滚。默认情况下如果项目依赖中有 H2 这种内嵌数据库它会替换成一个内嵌数据源测试方法跑完自动 rollback。所以对于简单的 CRUD、findByXxx查询这个注解用起来非常顺手。但这里有一个容易被忽略的边界H2 不是 MySQL也不是 PostgreSQL。如果项目里用了 JSON 字段、FOR UPDATE、ON DUPLICATE KEY UPDATE或者业务 SQL 依赖特定数据库方言H2 上未必能跑通甚至会给你“正确”的结果但和生产环境 SQL 行为不一致。这种不一致是最可怕的因为本地测试全绿上线后才出问题。所以我现在的习惯是分情况对待只做简单的增删改查、普通查询条件测试用 H2 没问题快而且简单涉及复杂 SQL、数据库私有函数、JSON 字段操作、事务隔离级别时用 Testcontainers 跑真实数据库。3.2 Testcontainers 的最小可用配置Testcontainers 是目前解决“测试数据库和生产环境不一致”的主流方案。它会在 Docker 里启动一个指定版本的数据库容器测试结束后自动销毁数据不残留。最小配置长这样以 PostgreSQL 为例Testcontainers SpringBootTest class OrderRepositoryIntegrationTest { Container static PostgreSQLContainer? postgres new PostgreSQLContainer(postgres:15-alpine) .withDatabaseName(testdb) .withUsername(test) .withPassword(test); DynamicPropertySource static void dataSourceProps(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, postgres::getJdbcUrl); registry.add(spring.datasource.username, postgres::getUsername); registry.add(spring.datasource.password, postgres::getPassword); } Autowired private OrderRepository orderRepository; Test void shouldQueryOrdersByStatus() { // 写入数据再查询 } }几个实操细节值得留意容器对象要声明为 static不能每个测试方法 new 一个。否则每个方法都会启动一个新容器耗时和资源开销都很可怕。如果多个测试类都需要数据库容器建议做一个抽象基类统一创建容器并统一注册动态属性不要让每个测试类各自启动一个库。CI 环境必须确保 Docker 可用。如果确实没有 Docker可以考虑设置跳过规则但我不建议把过多测试直接跳过否则测试对项目的保护作用就下降了。3.3 测试数据造数要“用完即走”数据层测试常见的一个坏习惯是在BeforeEach里用save()一条一条插入数据。数据量小还好数据量一多测试耗时立刻上去了。更好的做法DataJpaTest Sql(statements insert into t_user (id, email, name) values (1, aexample.com, A), (2, bexample.com, B) , executionPhase Sql.ExecutionPhase.BEFORE_TEST_METHOD) class UserRepositoryTest { Autowired private UserRepository userRepository; Test void shouldCountUsers() { assertThat(userRepository.count()).isEqualTo(2); } }Sql可以把前置数据集中管理SQL 脚本更接近生产环境。注意executionPhase的配置默认是BEFORE_TEST_METHOD如果设置为AFTER_TEST_METHOD则用于清理数据。另一个坑是DataJpaTest默认会回滚每个测试方法的事务但如果你开了Rollback(false)或者换成了不支持回滚的方式插入的数据就可能残留在库里影响后续测试。所以尽量依赖事务回滚机制让测试方法之间彼此独立不要依赖执行顺序。4. Controller 和接口测试从 MockMvc 到 WebTestClient4.1 WebMvcTest 到底测了什么WebMvcTest是用来做 Controller 切片测试的注解。它只装配 Controller 层和 MVC 基础设施不加载 Service、Repository 等 Spring 容器里的其他 Bean。所以你要用MockitoBean老版本是MockBean把 Service 依赖替换成 Mock 对象。Controller 测试的核心不是验证 Service 逻辑而是验证 HTTP 层面的表现请求参数怎么解析、Valid校验是否返回 400、响应 JSON 的结构和字段名对不对、状态码是否符合预期、统一异常处理器是否按约定返回结构。示例WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserService userService; Test void createUser_shouldReturn201() throws Exception { given(userService.create(any())) .willReturn(new UserVO(1L, zhang)); mockMvc.perform(post(/users) .contentType(MediaType.APPLICATION_JSON) .content({\name\:\zhang\})) .andExpect(status().isCreated()); } }注意如果你的项目是 WebFlux 技术栈对应的是WebFluxTestWebTestClient用法思路一样只是执行器从 MockMvc 换成了 WebTestClient。4.2 Spring Security 上下文怎么办一旦项目里引入了 Spring SecurityWebMvcTest会把 Spring Security 的过滤链也加载进来。这时候你经常会遇到一个现象接口本身逻辑没问题但测试请求直接返回 401 或 403完全没法往下走。如果测试的目标是接口的参数校验和响应结构不是权限控制最简单的做法是在测试类或测试方法上加WithMockUserWebMvcTest(UserController.class) WithMockUser(username zhangsan, roles USER) class UserControllerTest { // ... }加了之后SecurityContext 里会存在一个模拟认证对象Controller 里通过AuthenticationPrincipal或SecurityContextHolder获取当前用户就不会失败。如果测试的目标就是验证权限规则就不能随便用WithMockUser大而化之地跳过这时候应该构造真实用户并对SecurityFilterChain的运行结果做断言。否则你只是在“假装测权限”。4.3 文件上传、异常响应这些特殊场景文件上传是 Controller 测试里比较容易写错的地方。关键是要用MockMultipartFile构造上传文件Test void shouldUploadFile() throws Exception { mockMvc.perform(multipart(/files) .file(new MockMultipartFile( file, hello.txt, MediaType.TEXT_PLAIN_VALUE, hello.getBytes(StandardCharsets.UTF_8)))) .andExpect(status().isOk()); }这里要注意请求参数名必须和 Controller 方法里的RequestPart(file)对应。很多初学者写的是fileName或者随便起名结果一直 400实际上就是参数名不匹配。异常响应测试也很重要。我的习惯是让 Controller 层尽量不做 try-catch而是抛业务异常由RestControllerAdvice统一处理。测试时通过 mock Service 抛出对应异常再验证最终的响应体结构Test void shouldReturnBadRequestWhenNameBlank() throws Exception { given(userService.create(any())) .willThrow(new BusinessException(名称不能为空)); mockMvc.perform(post(/users) .contentType(MediaType.APPLICATION_JSON) .content({\name\:\\})) .andExpect(status().isBadRequest()) .andExpect(jsonPath($.code).value(INVALID_ARGUMENT)); }这样测试才是对“对外契约”真正形成约束而不是只测了个 200。5. 测试套件崩溃排查一次“从 20 秒变 2 分钟”的完整追踪5.1 现象全量测试突然越跑越慢我最近处理过一个真实项目原本跑完整套测试大概 20 秒某天开始突然变成 2 分钟。第一次听到这个反馈我第一反应不是“加机器”而是先怀疑上下文缓存失效。排查链路大概是这样的先抽单个测试类出来跑比如mvn test -DtestSomeTest发现单类启动时间并不慢问题应该出在多个类协作时。再跑全量留意日志里有没有大量Closing org.springframework.context...的输出。一旦发现上下文频繁创建和关闭说明每次测试类都重新启动了完整的 Spring 上下文。逐个检查测试类上的注解配置最终发现有一半的测试类单独写了spring.datasource.url而且值各不相同。每个不同的值都会生成不同的 context cache key缓存自然失效。修复方式很粗暴但有效把数据库连接信息统一放到application-test.yml所有测试类统一ActiveProfiles(test)移除测试类上零散的 properties。改完以后全量时间直接回到 20 多秒。这个案例说明排查测试慢的问题不是靠猜而是靠观察 Spring 上下文生命周期。看到“上下文启停次数”明显高于预期就往 cache key 不一致的方向查。5.2 现象MockBean 一堆测试套件仍然巨慢还有一种很常见的慢是MockBean用太多了。MockBean会在底层注册一个ContextCustomizerSpring 在计算 context cache key 时会把所有ContextCustomizer一起算进去。也就是说只要两个测试类 mock 的 Bean 不同它们的上下文 cache key 就不同Spring 就会各自启动一个完整上下文。这和“每个测试类都启动一次容器”几乎等价。所以如果某个项目里散落着几十个MockBean全量测试慢是很正常的。处理方式我推荐这么几种尽可能用切片测试比如WebMvcTest本身启动的上下文就比SpringBootTest轻量很多即使重新创建代价也可控。如果必须在同一个大上下文里做多个模块的测试尽量让不同测试类的MockBean集合保持一致这样 context cache key 还能复用。考虑用Import一个专门的TestConfiguration在配置里统一声明 mock而不是每个测试类各写各的。5.3 现象测试启动时端口冲突、连接被拒端口冲突的经典场景是集成测试用了固定端口DEFINED_PORT。本地跑单个类没问题但 CI 并发或者同一台机器上多个测试进程同时跑8080 端口就打架了。处理方法上面已经说过统一用RANDOM_PORT端口通过LocalServerPort动态获取。连接被拒的另一种情况是代码在启动时连接外部的 Redis、数据库但测试环境没有对应服务。这种问题不是“改端口”能解决的而是要先把外部依赖 mock 掉或者用 Testcontainers 把中间件拉起来。测试代码里永远不要直连生产环境或开发环境的中间件否则测试结果会被环境状态绑架。5.4 怎么判断是环境问题还是真实代码问题我摸索出的一个快速判断技巧先用-Dspring.profiles.activetest明确指定测试 profile把运维相关的配置都隔离干净然后跑单个测试方法如果单方法通过但全量跑就挂先怀疑测试之间的数据污染或上下文复用问题如果单个方法就不通过再看日志里是 Bean 装配失败还是数据条数不对。另一个有效动作是开启 SQL 输出观察测试到底执行了什么语句。很多时候你以为是代码逻辑错了实际是Sql脚本重复插入或者回滚没生效导致数据残留。6. 长期维护测试代码的几条实操建议6.1 测试命名和包结构要统一命名看似小事但对团队协作影响很大。我推荐用shouldExpectResult_whenCondition这种格式比如shouldReturnUser_whenUserIdExists、shouldThrowException_whenStockNotEnough。这种命名会把“前置条件”和“预期结果”都带出来后期看测试报告时不用点开代码就能知道业务行为。包结构上测试类尽量和被测试类包路径对齐比如com.example.controller.UserControllerTest放在com.example.controller包下。这样 IDE 跳转、Maven 报告、代码覆盖率工具都不容易错乱。6.2 并行执行要谨慎只稳定时才开JUnit 5 支持并行执行可以在junit-platform.properties里配置junit.jupiter.execution.parallel.enabledtrue junit.jupiter.execution.parallel.mode.defaultconcurrent以及 Maven Surefire 插件的配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration parallelmethods/parallel threadCount4/threadCount /configuration /plugin但我自己的经验是并行执行只对纯单元测试比较安全。切片测试和集成测试并行之后会遇到上下文创建竞争、随机端口分配、数据库连接数超限等问题。如果一定要并行建议用Tag或按目录拆分把纯单测和集成测试分开执行不要一把梭全并行。6.3 测试代码的 review 不能只看“绿不绿”我见过太多假测试方法里只调用了目标接口没有任何断言或者用assertDoesNotThrow包住一切实际什么都没验证。这种测试看着“绿”实际毫无保护力。写测试时至少补足三类场景正常路径、异常路径、边界值。比如参数校验测试不仅要测name正常的情况还要测name为空、超长、null 的情况。空集合、单元素集合、大量数据也要有对应测试。另外测试代码要像生产代码一样 review。review 时重点看几个点断言是否有效、是否依赖执行顺序、是否依赖真实外部网络、是否有不必要的等待或睡眠。一旦发现这些问题尽早修掉别让测试变成团队的负债。这几年我接手项目时有个很直观的感受只要一个项目的测试套件跑得又快又稳团队对代码改动的信心就高重构、升级依赖都敢做。反过来测试一跑就挂、各种环境依赖团队就会越来越不信任测试最后用“我本地跑得通”来掩盖真实风险这是最危险的。如果你想从今天开始改善自己的 Spring Boot 测试我个人的建议很直接先挑最薄弱的 Controller 测试或 Repository 测试开刀统一一个测试基类把测试配置收拢到同一个 profile再把测试代码当作正式代码来评审。每修复一个又慢又脆弱的测试后面改代码的底气就会多一分。
返回列表