
1. 测试不是后端开发的备选项先算一笔成本账我在好几家公司待过见过太多项目上线前才发现接口字段对不上、数据库时间少了八小时、老功能被新需求改挂的场景。团队的第一反应往往是加个监控吧下次注意点但下一次照样翻车。原因很简单没人愿意写测试因为总觉得测试是额外工作量。但如果你真正把 SpringBoot 的测试体系用起来会发现它要解决的根本不是加不加工作量的问题而是你改代码的时候敢不敢直接部署的问题。1.1 一个没写测试的支付回调 Bug让我后悔了很久说个真实例子。之前做一个支付系统回调接口要做三件事验签、解析订单号、更新订单状态。上线当天发现当订单金额为 0 的时候数据库里金额字段被写成了 NULL而前端判断amount null直接报空指针。查了半天是团队里另一位同事在改需求时把不传金额就默认为 0的判断删了。如果当时有一个针对回调接口的测试哪怕只覆盖三个分支这个问题在mvn test阶段就能拦住而不是等用户投诉之后再去查日志。这类问题在 SpringBoot 项目里太常见了。Controller 的返回值变了、MyBatis 的 XML 里字段名对不上、Redis 序列化方式改了这些靠人工点一遍页面是很难全部发现的。而 SpringBoot Test 这套东西就是让你用 JUnit 写一批自动化的验证程序跑一次mvn test就能把核心逻辑全部过一遍。1.2 测试金字塔放在 Spring Boot 生态里长什么样测试金字塔的概念不用我多说底层是大量快速、廉价的单元测试中间是服务层的集成测试顶层是少而全的端到端测试。SpringBoot 对这三层都有原生支持层级技术选型启动成本典型场景单元测试JUnit 5 Mockito毫秒级Service 内纯逻辑、工具类切片测试WebMvcTest / DataJpaTest秒级Controller、Repository集成测试SpringBootTest Testcontainers十秒级跨模块链路、外部中间件很多人的误区是一上来就SpringBootTest往所有测试类上贴导致启动一次上下文要十几秒几百个测试跑下来花费半小时然后得出结论测试太慢了不适合我们项目。其实这恰恰不是框架的问题而是没有做测试分层。搞懂 SpringBoot 到底提供了哪些测试能力按层级去用就能既保证测试速度又保证覆盖质量。2. SpringBootTest到底干了什么注解背后的真实装配逻辑SpringBootTest是 SpringBoot 测试体系的地基。我第一次用的时候以为它只是帮你把项目跑起来但直到自己排查了一个诡异问题才真正搞明白它背后做了什么它会根据你项目的主配置类SpringBootApplication标注的类去启动整个 Spring 应用上下文把 Controller、Service、Repository、消息队列消费者甚至定时任务全部加载进来。2.1 它加载了什么不加载什么默认情况下SpringBootTest没有指定classes属性它会从当前测试类所在的包开始向上查找SpringBootConfiguration注解找到之后就把这个配置类当作启动入口然后走一遍和main方法几乎相同的自动装配流程。也就是说你在application.yml里配置的数据源、Redis、消息队列它都会去尝试连接。这个机制带来一个常见问题当你本地的 MySQL 没启动或者配置中心连不上时测试直接就Failed to load ApplicationContext。我见过很多新手在这里卡住然后觉得测试真难用。其实这不是 bug而是你还没有告诉 Spring 测试环境该怎么连这些外部组件。后面我会讲怎么用 Testcontainers 和MockBean来解这个问题。SpringBootTest还内置了一个非常有用的特性默认会查找src/test/resources下的application.yml或application-test.yml。如果你的测试配置和正式配置差异很大可以在测试资源目录里单独写一份通过ActiveProfiles(test)指定使用哪个 profile。我个人的习惯是测试环境固定一个testprofile里面把日志调成 WARN、把缓存换成本地实现、把消息队列消费关掉这样既能跑通链路又不会被外部依赖拖死。2.2 webEnvironment 四种模式怎么选SpringBootTest的webEnvironment属性默认是MOCK这个默认值其实非常关键。它表示测试时不会启动真实的 HTTP 服务器而是通过 Spring 的MockHttpServletRequest模拟 HTTP 请求。这个时候要测 Controller需要配合MockMvc手动构造请求。下面给个表格把四种模式一次说清楚webEnvironment 取值是否启动真实端口适用场景MOCK否使用 Servlet 模拟环境配合 MockMvc 做 Controller 层测试RANDOM_PORT是随机可用端口需要真实 HTTP 请求的集成测试配合 TestRestTemplateDEFINED_PORT是使用配置端口 8080基本不推荐容易端口冲突NONE否只加载上下文不涉及 Web 层的 Service/Repository 测试RANDOM_PORT 模式是我做微服务联调测试时最常用的。它启动一个真实的内嵌 Tomcat监听随机端口然后你可以用LocalServerPort把这个端口注入到测试类里再配合TestRestTemplate发送真实 HTTP 请求。这种方式连过滤器、拦截器、异常处理器都会真实走一遍比 MockMvc 更贴近生产。2.3 测试实例生命周期JUnit 5 和 JUnit 4 的差异SpringBoot 2.2 之后默认使用 JUnit 5测试类默认是每个方法新建一个实例的 PER_METHOD 生命周期。这在绝大多数情况下是没问题的但如果你在测试类里定义了成员变量来缓存某些数据就要小心了因为每次执行测试方法之前 Spring 都会重新装配一次上下文中的 Bean但测试类本身会被重新实例化之前存进去的成员变量会丢失。我踩过一个坑在测试类里放了一个ListString用来记录接口返回的错误码结果跑第二个测试方法时列表是空的。原因就是 JUnit 5 的 PER_METHOD 生命周期。后来我改用TestInstance(TestInstance.Lifecycle.PER_CLASS)配合TestMethodOrder才保证了实例复用和有序执行。当然这种做法有隐患测试之间会共享状态所以只适合确实需要复用场景的测试能用局部变量的地方尽量不要依赖成员变量。3. 别一竿子把所有测试都拉成完整上下文切片测试的边界说道测试慢90% 的原因是每个测试类都用了SpringBootTest每个类都加载一遍完整应用上下文。SpringBoot 其实提供了一套叫切片测试Slice Test的机制它只加载你当前测试目标所需要的那一部分 Bean。这才是让测试跑得快、跑得稳的关键。3.1 WebMvcTest只把 Controller 层拉起来WebMvcTest只加载 Web 层相关的内容Controller、ControllerAdvice、Filter、HandlerInterceptor、Jackson配置等。它不会加载 Service、Repository也不会连数据库。这意味着测试 Controller 时你需要把依赖的 Service 用MockBean打桩。有段时间我们项目里有个比较复杂的查询接口Controller 层要做参数校验、分页封装、异常转换。我写了一个WebMvcTest测试把所有 Service 依赖全部 mock 掉只测 Controller 的入参校验逻辑WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void shouldReturnBadRequestWhenUsernameIsBlank() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\username\:\\})) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath($.message).value(用户名不能为空)); } }这种测试跑起来非常快几十个测试类一起跑也就几秒钟。它唯一的缺点是脱离了真实 Service 实现所以它只能验证参数传错了会返回什么不能验证数据查出来之后能不能正确拼装响应。这两件事要分开测后者交给 Service 层的单元测试和集成测试。3.2 DataJpaTest只测数据库交互和数据层相关的切片是DataJpaTest。它扫描Entity和 Spring Data JPA 的 Repository 接口默认用嵌入式数据库比如 H2来代替你配置的真实数据库而且每个测试方法默认开启事务方法执行完自动回滚。这意味着你可以在测试里随便 insert、update、delete不需要担心污染真实数据。但这里有个大坑如果你的项目用了 MyBatisDataJpaTest就不好使了因为它只支持 Spring Data JPA 的自动装配。MyBatis 项目要测 Mapper我一般直接用MybatisTestmybatis-spring-boot-starter-test 提供或者干脆走SpringBootTest Testcontainers。另外就算用 JPA如果你写了原生的QuerySQL里面用了 MySQL 特有的函数比如DATE_FORMATH2 可能会不认识跑测试直接报语法错误。这种情况下要么换 H2 的兼容模式要么就直接上 Testcontainers 跑真实 MySQL。3.3 切片测试的边界和 MockBean 的正确使用姿势切片测试看似简单边界其实很容易踩过头。WebMvcTest里如果你用了EnableFeignClients或者自定义的WebMvcConfigurer有可能加载失败因为 Feign 客户端不在被加载的 Bean 集合里。解决方案一般是在测试配置里排除这些自动配置WebMvcTest(controllers UserController.class, excludeFilters ComponentScan.Filter(type FilterType.ASSIGNABLE_TYPE, classes FeignClientConfig.class))MockBean也有讲究。它可以把一个 Bean 替换成 Mockito 的 mock 对象。注意它替换的是上下文里的 Bean所以如果两个测试类对同一个 Bean 打桩的行为不一致Spring 会为每个组合缓存独立的上下文。滥用MockBean会导致上下文数量爆炸拖慢整个测试套件。我见过一个项目测试类越来越多上下文缓存了几十个跑一次全量测试光启动上下文就花了好几分钟。后来我们约定能用构造器注入的地方尽量用构造器能在一个测试类里集中打桩的就不要拆成多个类。4. 外部依赖怎么处理Mockito 模拟和 Testcontainers 真实环境写完单元测试和切片测试还会剩下一批真正需要外部依赖的集成测试。比如验证 Redis 缓存逻辑是否生效、Kafka 消费者消费消息后是否正确落库、MySQL 事务回滚是否符合预期。这些场景有两个主流方案Mockito 把外部客户端 mock 掉或者用 Testcontainers 把真实中间件拉起来。两者不是互斥关系而是互补关系。4.1 Mockito 和 Spring 上下文是怎么协同的Mockito 是一个独立的 Java mock 框架它和 Spring 的协同一般通过MockBean或SpyBean完成。MockBean会创建一个 mock 对象替换掉容器中的真实 Bean然后通过Mockito.verify()和when(...).thenReturn(...)设置行为和校验调用。我举一个实际工作中的例子。下单成功后我们要发一条 MQ 消息但测试环境没有部署消息队列所以我用MockBean把MessageProducermock 掉SpringBootTest ActiveProfiles(test) class OrderServiceTest { Autowired private OrderService orderService; MockBean private MessageProducer messageProducer; Test void shouldSendMessageAfterOrderCreated() { Order order new Order(); order.setOrderNo(20240501001); orderService.createOrder(order); verify(messageProducer, times(1)).send(any(OrderCreatedEvent.class)); } }这样测试就跑得很快不需要真的 MQ。但是请注意mock 掉的 Bean 不会再执行真实逻辑所以它验证不了消息格式是否正确。如果你要测的是序列化之后的消息体建议还是用真实 MQ 的 Testcontainers 镜像做一次真正的收发。4.2 Testcontainers用真实中间件测试而不是模拟Testcontainers 是一个很实用但很多人还没用起来的库。它能在测试启动时用 Docker 拉起一个 MySQL、Redis、Kafka 容器测试结束自动销毁保证测试环境干净且和线上行为一致。SpringBoot 3.1 之后官方支持了ServiceConnection注解可以自动把 Testcontainers 启动的容器配置注入到 Spring 环境里省去手动拼 JDBC URL 的麻烦。一个常见的 MySQL 集成测试配置长这样Testcontainers SpringBootTest ActiveProfiles(test) class UserRepositoryIntegrationTest { Container ServiceConnection static MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(testdb) .withUsername(test) .withPassword(test); Autowired private UserRepository userRepository; Test void shouldSaveAndFindUser() { User user new User(); user.setUsername(jack); userRepository.save(user); OptionalUser found userRepository.findByUsername(jack); assertThat(found).isPresent(); assertThat(found.get().getUsername()).isEqualTo(jack); } }ServiceConnection是 Spring Boot 3.1 加入的我个人非常推荐。以前我们需要手动从容器里取端口再写到SpringApplication.setDefaultProperties里写起来很啰嗦。现在这个注解会自动把容器连接信息绑定到DataSourceProperties后面的任何 JPA 或 MyBatis 配置都不需要改。4.3 本地没有 Docker 怎么办Testcontainers 依赖 Docker但 CI 环境或者部分开发机可能无法运行 Docker。这里我有两个经验第一把需要外部中间件的测试用 JUnit 的Tag标记分类比如Tag(integration)然后在pom.xml的 surefire 插件里用excludedGroups排除这些测试保证mvn test在没有 Docker 的环境下也能跑。集成测试放在单独的 profile 里执行。第二用 Maven 的 profile 区分本地快速测试和完整集成测试。本地开发时只跑非集成测试提交 CI 后再单独起一个 job 跑完整的集成测试。这样既照顾了开发体验又没有回避真实环境验证。5. 集成测试实战从 MockMvc 到数据库的完整闭环理论讲再多不如完整走一遍。下面我用一个最常见的用户注册功能展示从 Controller 到 Service 到 Repository 的测试怎么写以及每个阶段应该断言什么。5.1 先准备被测代码假设要测的功能如下RestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } PostMapping public ResponseEntityUserResponse register(Valid RequestBody RegisterRequest request) { UserResponse response userService.register(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } }Service 里做用户名唯一性校验、密码加密、保存用户Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } public UserResponse register(RegisterRequest request) { if (userRepository.existsByUsername(request.getUsername())) { throw new BusinessException(用户名已存在); } User user new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setCreatedAt(LocalDateTime.now()); userRepository.save(user); return UserResponse.from(user); } }5.2 第一层WebMvcTest 验证参数校验和响应结构写 Controller 层测试时我不关心 Service 里的用户名重复判断是否正确只关心几件事参数校验错误时是否返回 400、Service 正常返回时响应体结构是否对、异常抛出来之后全局异常处理器是否能转成统一错误格式。WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void shouldReturnCreatedWhenRegisterSucceeds() throws Exception { when(userService.register(any(RegisterRequest.class))) .thenReturn(new UserResponse(1L, jack, LocalDateTime.now())); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\username\:\jack\,\password\:\123456\})) .andExpect(status().isCreated()) .andExpect(jsonPath($.username).value(jack)) .andExpect(jsonPath($.id).value(1)); } Test void shouldReturnBadRequestWhenPasswordTooShort() throws Exception { mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\username\:\jack\,\password\:\123\})) .andExpect(status().isBadRequest()) .andExpect(jsonPath($.message).exists()); } }这里的要点是jsonPath断言它用的是 Jayway JsonPath 表达式。$.username表示根节点下的 username 字段。在实际项目中我还喜欢把响应中的时间字段单独断言因为 LocalDateTime 默认序列化成数组或者 ISO 字符串不同配置结果不一样不写清楚的话测试非常容易飘。5.3 第二层SpringBootTest 打通 Service 和真实数据库第一层测试验证的是管道但用户是不是真的存进去了要靠集成测试。这里我用SpringBootTest配合 H2或者 Testcontainers MySQL直接调用 Service验证数据库里的状态SpringBootTest ActiveProfiles(test) Transactional class UserServiceIntegrationTest { Autowired private UserService userService; Autowired private UserRepository userRepository; Test void shouldSaveUserToDatabase() { RegisterRequest request new RegisterRequest(alice, password123); UserResponse response userService.register(request); assertThat(response.getId()).isNotNull(); User saved userRepository.findByUsername(alice).orElseThrow(); assertThat(saved.getPassword()).isNotEqualTo(password123); assertThat(saved.getCreatedAt()).isNotNull(); } Test void shouldRejectDuplicateUsername() { RegisterRequest first new RegisterRequest(bob, password123); userService.register(first); RegisterRequest duplicate new RegisterRequest(bob, another123); assertThatThrownBy(() - userService.register(duplicate)) .isInstanceOf(BusinessException.class) .hasMessageContaining(用户名已存在); } }注意我加了Transactional这样每个测试方法跑完自动回滚测试之间互不影响。这里要说明的是Transactional只对事务内通过 Spring 代理调用的方法生效如果 Service 方法内部自己开了新事务比如Transactional(propagation Propagation.REQUIRES_NEW)回滚就无效了测试数据可能残留。遇到这种情况我一般改用 Testcontainers BeforeEach里手动清理表数据或者给测试容器用独立的库跑完直接销毁。5.4 第三层真实 HTTP 调用验证过滤器、拦截器和序列化如果项目里有登录校验过滤器、请求日志拦截器、全局异常处理器MockMvc 的 MOCK 环境也能覆盖大部分场景但总有些边角要真实端口才靠谱。比如你配置了ControllerAdvice处理特定异常而WebMvcTest时它可能因为自动配置的加载范围问题没有被注册这时就需要 RANDOM_PORT TestRestTemplateSpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) class UserApiE2ETest { LocalServerPort private int port; Autowired private TestRestTemplate restTemplate; Test void shouldRegisterThroughRealHttp() { RegisterRequest request new RegisterRequest(e2e_user, password123); ResponseEntityUserResponse response restTemplate.postForEntity(http://localhost: port /api/users, request, UserResponse.class); assertThat(response.getStatusCode().value()).isEqualTo(201); assertThat(response.getBody().getUsername()).isEqualTo(e2e_user); } }这种测试跑起来比 MockMvc 慢但是最接近真实。我把它们控制在核心链路场景比如登录、下单、支付回调而不是所有接口都写一遍否则 CI 时间会变得不可接受。6. 我在真实项目里踩过的测试坑以及最终的修复方式SpringBoot 测试的资料不少但大多数教程只教你怎么写没教你踩坑了怎么排查。我把这几年实际遇到的、网上搜不到现成答案的几个问题列出来每一个都查了很久才定位到根因。6.1 上下文缓存导致的两个测试类互相污染Spring 的TestContext框架默认会缓存已加载的应用上下文key 由配置类、激活的 profile、webEnvironment 等组合而成。看起来是个优化实际上会引入一个隐蔽问题如果测试 A 修改了某个 Bean 的内部状态而测试 B 复用了同一个上下文那么 B 拿到的 Bean 状态已经被污染了。我之前遇到过一个问题测试类 A 往 Redis 里写了一个 key类 B 的测试用例本来期望 key 不存在结果第一次跑失败单独跑一个类又是好的。排查后发现是SpringBootTest默认上下文缓存共享导致的。修复方式有两个一是每个测试类尽量自己清理外部状态比如AfterEach清空 Redis二是如果需要完全隔离用DirtiesContext标注测试类强制 Spring 在测试结束后关闭并清除上下文缓存。但要慎用DirtiesContext因为它会让每个标记类都重新启动上下文直接影响测试速度。我的策略是默认不标记只有当测试确实会修改容器内全局状态时才加。6.2 随机端口模式下测试并发执行抢端口做了 RANDOM_PORT 之后端口虽然是随机的但如果你用 Maven Surefire 默认的 forkCount 配置多个测试类并发执行时有可能出现同一个 JVM 里两个上下文同时启动两个 Tomcat最终端口冲突。尤其是用了DirtiesContext之后上下文销毁和重建的间隙更容易出问题。我的修复经验是不要人为调大 surefire 的 forkCount默认的单进程执行对于 SpringBoot 测试来说反而是最稳定的。如果你要缩短测试时间优先考虑减少不必要的SpringBootTest用切片测试来替换而不是靠并发执行硬扛。Surefire 里还可以显式配置禁用重跑测试类的并发plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration parallelnone/parallel forkCount1/forkCount reuseForkstrue/reuseForks /configuration /plugin6.3 测试资源配置文件覆盖不完整导致连了生产库这是我最想强调的一个坑。src/test/resources下的application.yml会覆盖src/main/resources下的同名配置但它不是替换整个文件而是按 key 合并的。也就是说如果主配置里写了spring.datasource.url指向生产数据库测试配置里只写了spring.datasource.username那么 URL 仍然会从主配置里继承。后果非常严重。有人不小心在本地跑测试因为测试配置里漏写了一项结果直连了生产库还执行了清表操作。我现在的做法是测试配置里把spring.datasource.url、spring.datasource.username、spring.datasource.password、spring.redis.*所有关键连接信息全部显式覆盖一遍然后用ActiveProfiles(test)强制激活并在 CI 环境变量里注入一个SPRING_PROFILES_ACTIVEtest做兜底。另外正规一点的做法是在主配置里把生产库密码放到环境变量或配置中心本地根本没有生产库密码自然连不上。6.4 时间字段断言总是飘问题出在序列化配置在接口测试里最烦的问题之一就是 LocalDateTime 的断言。一开始我在jsonPath里写$.createdAt等于某个固定时间但实际返回的是2024-05-01T10:20:30或者2024-05-01 10:20:30取决于你是否配置了 Jackson 的JavaTimeModule和WRITE_DATES_AS_TIMESTAMPS。后来我总结了一个稳妥做法断言时间字段时不要断言全等而是用 JsonPath 的正则匹配或者反序列化成对象后再比较。比如.andExpect(jsonPath($.createdAt, matchesPattern(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})))如果测试场景需要精确比较时间我倾向于在测试里先定义期望时间然后调用被测代码再取出数据库里的实际值用AssertJ的isEqualToIgnoringNanos比较避免毫秒和纳秒带来的误差。6.5 测试代码里的魔法值和过度 mocking最后一个坑不算技术问题是工程习惯。很多测试代码里直接写死了一堆字符串和数字比如用户名固定test、密码固定123456这些魔法值一旦业务含义变了测试就失去了可读性。我在 review 代码时遇到这种测试通常会建议抽成常量或者用一个小工具类生成随机数据。另外过度 mocking 也非常常见。一个 Service 的方法依赖了 A、B、C 三个对象你全部 mock 掉然后只测了方法内部的 if-else 分支。这种测试看起来很安全实际上和业务逻辑已经脱节一旦 A、B、C 之间的协作关系变了测试照样绿但代码已经坏了。个人建议是如果某个测试需要 mock 四个以上协作者要么重构被测类的粒度要么直接上真正的集成测试让协作者用真实实现跑起来。写在最后的个人习惯说了这么多其实 SpringBoot Test 的核心就一句话让测试快、稳、准。快依赖切片和合理分层稳依赖隔离和配置正确准依赖断言别糊弄。我现在的团队把测试分三类本地跑得快的单元测试和切片测试、CI 里跑的真实中间件集成测试、发布前跑的核心链路冒烟测试每次提交代码前一条mvn test已经成了肌肉记忆。如果这个项目你刚接手、历史代码没测试我的建议是先别急着给旧代码补全量测试挑三个最核心的链路先补登录鉴权、下单支付、数据导出。这三个链路跑通了后续重构的底气和安全边界也就有了。其他的地方边改边补比一次性补完更现实也更有效。