
开头我先说个结论SpringBoot里的“测试”不是写两个断言就算完事它是一套贯穿单元测试、集成测试、接口测试和性能验证的组合拳。我这篇文章不打算从Spring的官方文档出发给你抄一遍注解说明而是按照一个真实项目从零开始补测试、跑测试、修测试的完整路径来讲覆盖环境依赖、分层测试策略、MockMvc用法、数据访问测试、安全校验测试以及实际踩坑记录。适合刚接触SpringBoot测试的开发同学也适合要把现有项目测试补齐、又不想只写“能跑通”的自测用例的人。先把最核心的事情说清楚SpringBoot的项目测试本质上是在不启动完整外部环境的前提下尽可能逼真地验证“启动上下文、请求路由、参数绑定、业务逻辑、数据持久化、安全和性能”这六个维度。这个思路和单纯写一个main方法去点接口完全不同它能自动化、能回归、能纳入CI这才是测试真正的价值。1. 测试前的环境与依赖准备1.1 spring-boot-starter-test到底帮你装了什么很多人对测试的第一印象是引入starter就完事了但starter背后装的是什么直接决定你后面写用例的姿势。spring-boot-starter-test在Spring Boot 2.x以后的版本里默认捆绑了JUnit 5Jupiter、Mockito、AssertJ、Hamcrest、JSONassert和Spring Test。其中JUnit 5是测试框架本体Mockito负责做对象mockAssertJ和Hamcrest提供断言风格JSONassert专门用来比对JSON结构。这意味着常规项目的测试依赖一个starter就能覆盖不需要额外引一堆乱七八糟的包。注意如果你用的是Spring Boot 1.x默认还是JUnit 4很多注解名和断言API都不一样。我自己在维护老项目时经常被这个问题坑所以第一步永远是确认当前Spring Boot版本对应的JUnit大版本。热搜里“springboot版本太高”的问题很多都出在这个地方。1.2 测试目录结构和命名规范测试代码的目录位置是有约定的不需要在配置文件里单独指定。Maven和Gradle默认都会扫描src/test/java所以你的测试类应该放在这个目录下包名尽量和被测类保持一致。一个标准的Spring Boot测试结构长这样src/test/java └── com.example.demo ├── controller │ └── UserControllerTest.java ├── service │ └── UserServiceTest.java ├── repository │ └── UserRepositoryTest.java └── integration └── FullFlowIntegrationTest.java命名规则我建议统一用被测类名Test比如UserServiceTest。这样在IDE里跑测试、用mvn test做回归、在CI上按类名过滤失败用例全流程都舒服。不要用什么TestUserService、UserServiceTestDemo这种自定义命名团队协作时很折磨人。1.3 环境隔离测试跑得稳不稳先看配置文件测试最大的敌人是“环境飘了”——本地连的开发库数据变了、Redis连接超时、第三方接口临时挂了都会让你的测试结果反复横跳。所以测试必须和真实环境隔离。最常用的做法是在src/test/resources下放一个application-test.yml然后在测试类上标注ActiveProfiles(test)在这个测试配置里把数据源切到H2或嵌入式数据库把Redis换成内存版本或者直接mock掉把第三方接口调用全部用Mockito挡住。这一步的目的不是“偷懒不测真实环境”而是让测试关注代码本身逻辑而不是网络和外部依赖的稳定性。我见过最典型的失败案例测试类里没有指定profile结果测试把开发库的表数据全改了第二天同事跑业务发现数据对不上。从那以后我给自己定了一条铁律所有会写库的测试必须先确认当前激活的profile。2. 单元测试与Mock实战2.1 三层架构下的测试策略Spring Boot项目最常见的架构是Controller、Service、Repository三层每一层的测试目标完全不同。Controller层的重点不是业务逻辑而是参数绑定、校验、返回值格式和状态码。Service层的重点才是真正的业务规则比如条件判断、聚合计算、异常分支。Repository层则重点关注SQL正确性、参数映射和事务边界。所以测试策略应该这样分配层级测试重点是否需要启动上下文推荐工具Controller路由、参数、HTTP响应需要但用切片MockMvcService业务规则、异常、协作对象不需要完整上下文JUnit MockitoRepositorySQL、映射、事务需要数据源DataJpaTest或TestConfiguration很多新手一上来就对Service层写SpringBootTest把整个上下文拉起来然后又没有合理的数据准备测试慢得像蜗牛。其实Service层纯逻辑测试完全可以用new一个对象加Mockito替身搞定秒级反馈情绪也稳定。2.2 Mockito把外部依赖变成你的提线木偶Mockito的核心作用就是让你不需要真正构造一个完整的依赖对象。比如UserService依赖UserRepository你测试UserService的时候不会真的去连数据库而是mock一个接口出来规定它返回什么数据。来看一个实际用例ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; InjectMocks private UserService userService; Test void testGetUserById_whenUserExists_returnUser() { User mockUser new User(); mockUser.setId(1L); mockUser.setName(小明); when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser)); User result userService.getUserById(1L); assertThat(result.getName()).isEqualTo(小明); verify(userRepository, times(1)).findById(1L); } Test void testGetUserById_whenUserNotExist_throwException() { when(userRepository.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() - userService.getUserById(99L)) .isInstanceOf(NotFoundException.class) .hasMessageContaining(用户不存在); } }这里有几个点值得展开ExtendWith(MockitoExtension.class)是JUnit 5集成Mockito的入口它会帮你初始化被Mock、InjectMocks标记的字段。InjectMocks会优先按类型注入mock对象如果构造器注入和setter注入都在它的选择顺序是构造器优先。我建议你在实际项目里用构造器注入写测试的时候特别省心。verify这行是很多人会漏的。测试不仅要验证结果对不对还要验证“这个依赖确实被调用了”这能提前发现代码里的冗余调用或者漏调用。比如你原来查了两次数据库优化后只查了一次如果没有verify断言回归的时候你根本感知不到这个变化。2.3 断言风格AssertJ确实比JUnit自带的好用JUnit自带的assertEquals系列用起来没什么大毛病但AssertJ的链式断言在可读性和排查速度上明显更胜一筹。比如比较一个集合AssertJ可以这样写assertThat(userList) .hasSize(3) .extracting(User::getName) .containsExactly(张三, 李四, 王五);如果你用原生JUnit你得写多个断言分别检查size和内容失败时只能看到第一个报错信息。而AssertJ这条链如果中间断了会明确告诉你期望和实际的差距在哪调试体验好太多。另外AssertJ还有一个很有用的特性是filteredOn和anyMatch可以直接对集合做条件过滤再断言不需要你自己写for循环。测试代码里尽量减少for循环因为每一层循环都是可读性损耗和潜在bug源。3. 集成测试与MockMvc接口验证3.1 SpringBootTest和测试切片什么时候用哪个SpringBootTest会启动完整的ApplicationContext所有Bean都会被加载。这个适合做端到端集成测试验证各个组件协同工作。但它的代价就是慢一个完整的上下文启动可能需要数秒如果你有几十个测试类都这么干一次全量测试跑下来够喝杯咖啡的。测试切片注解是解决这个问题的关键。Spring Boot提供了非常丰富的切片注解WebMvcTest只加载Controller层和Spring MVC相关配置Service和Repository都会被mock掉。DataJpaTest只加载JPA Repository、实体和DataSource相关配置默认还会用嵌入式数据库替代真实数据库。JsonTest只测试JSON序列化和反序列化。举个例子如果我只想测UserController的HTTP映射和入参校验那么用WebMvcTestWebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void testCreateUser_withValidParam_returnCreated() throws Exception { when(userService.createUser(any())).thenReturn(1L); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\name\:\小明\,\age\:20})) .andExpect(status().isCreated()) .andExpect(jsonPath($.data).value(1L)); } }用MockBean替换掉Service层之后这个测试不会触碰数据库不会触碰真实业务逻辑它的目的就是把Controller的路由和响应格式钉死。以后谁改了接口路径或者改动了状态码这个测试会第一时间报警。3.2 MockMvc请求构造核心参数和常见校验写法MockMvc的请求构造看起来就几行但里面能控制的维度非常多。我总结一下日常最常用的几个点get/post/put/delete请求方法。uri或path加参数例如/api/users/1或者.param(page, 1)。contentType和content用于POST/PUT的JSON体。session和cookie模拟登录态后面讲登录场景会用到。header模拟Token、User-Agent等请求头。校验部分常用的维度status()最常用的HTTP状态码校验。jsonPath()对返回JSON字段做断言比如$.code、$.data.list[0].name。content().json()整体比较返回的JSON结构适合接口全量返回比对的场景。header()校验响应头。实际项目里JSON比对很容易被字段顺序和空格干扰所以除非接口非常稳定我建议核心字段用jsonPath精确断言次要字段用content().json()加lenient模式容忍差异。3.3 数据访问测试用Transactional控制测试数据回滚Repository层的测试如果用真实数据库最头疼的是脏数据残留。JPA测试切片给的默认方案是每个测试方法跑在事务里方法结束自动回滚。这意味着测试方法里插入的数据测试完就消失不影响其他用例。但这里有一个非常隐蔽的坑只有DataJpaTest默认开启事务回滚。如果你手动用SpringBootTest加Transactional来做集成测试那所有测试方法都共享一个事务一旦某个断言失败标记了rollback后续测试也可能受影响。我一般建议纯数据层测试用DataJpaTest。跨模块的集成测试用SpringBootTest配合在测试方法上手动加Transactional或者用DirtiesContext控制刷新时机。给一个实际例子DataJpaTest class UserRepositoryTest { Autowired private UserRepository userRepository; Test void testFindByUsername_whenExists_returnUser() { User user new User(); user.setUsername(test_user); user.setPassword(encrypted); userRepository.save(user); OptionalUser found userRepository.findByUsername(test_user); assertThat(found).isPresent(); assertThat(found.get().getUsername()).isEqualTo(test_user); } }这里还有一个常被忽略的点DataJpaTest默认不会加载你自定义的application.yml里的完整配置它有自己的默认数据源配置策略。如果你的实体里有复杂的类型转换或者自定义数据库方言可能需要在src/test/resources下单独配置。遇到这种情况不要硬扛直接给DataJpaTest加AutoConfigureTestDatabase(replace Replace.NONE)让它使用你配置的真实数据源前提是测试库要独立不能用开发库或生产库。4. 安全校验与性能验证的测试视角4.1 登录密码是不是明文这个测试必须写热搜里有个词很扎眼——“测试:手机app登录密码是否明文存储”。在测试体系里这个属于安全测试范畴。如果密码以明文形式存储或者接口日志里打印了用户密码这都属于必须拦截的严重问题。在Spring Boot测试里你可以这样检查密码存储逻辑Test void testUserPassword_whenSaved_isNotStoredInPlaintext() { User user new User(); user.setPassword(123456); passwordEncoder.encode(user.getPassword()); userRepository.save(user); User saved userRepository.findByUsername(user.getUsername()).orElseThrow(); assertThat(saved.getPassword()) .isNotEqualTo(123456) .doesNotContain(123456); }重点不是你写了这段断言而是你要明确一个设计原则密码字段从实体到数据库任何一层都不允许明文。测试要同时覆盖“存储时加密”和“接口返回时不暴露密码”。后者很容易漏比如把User对象直接序列化返回密码字段没有加JsonIgnore那就等于把加密密码也泄露出去了。测试里用MockMvc请求用户信息接口然后断言返回JSON里不包含password字段这个用例值得写。4.2 接口性能与连接数的快速验证热搜里的“连接数测试”“网速测试”“iperf测试”大多涉及网络层在Spring Boot测试中我们通常关注的是接口的响应时间和连接池配置是否合理。最简单的做法是用spring-boot-starter-test结合StopWatch来做一次“冒烟性能测试”。不过我更推荐用JMeter或wrk去做相对真实的并发验证那属于压测范畴。但在代码测试层面有一个必须要把控的点就是数据库连接池的配置。HikariCP默认的池大小是10如果并发一上来连接池耗尽接口就会超时。排查这个问题最好的方式是在测试配置里显式设置连接池参数然后模拟并发请求Test void testConcurrentAccess_stillHealthy() throws Exception { ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch ready new CountDownLatch(20); CountDownLatch start new CountDownLatch(1); ListFutureBoolean futures new ArrayList(); for (int i 0; i 20; i) { futures.add(pool.submit(() - { ready.countDown(); start.await(); try { mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()); return true; } catch (Exception e) { return false; } })); } ready.await(); start.countDown(); long failed futures.stream() .filter(f - { try { return !f.get(); } catch (Exception e) { return true; } }) .count(); assertThat(failed).isZero(); }这里真正测的是“在连接池只有默认配置的情况下20个并发请求会不会失败”。如果失败你就要去调整HikariCP的maximumPoolSize或者检查SQL执行时间。这个用例跑一次比你在生产环境出问题再去排查要省力得多。4.3 多模块登录态与测试会话管理热搜“多个springboot项目如何一次登录其他不用登录”本质上是分布式Session或Token共享的问题。放到测试视角你需要设计一种“模拟已登录用户”的通用方式。如果用Spring Security最简单的是在测试里构造一个已认证的SecurityContextTest void testAccessUserInfo_withLoginSession_returnSuccess() throws Exception { UserPrincipal principal new UserPrincipal(test_user, List.of(ROLE_USER)); TestingAuthenticationToken token new TestingAuthenticationToken(principal, dummy, principal.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(token); mockMvc.perform(get(/api/userinfo)) .andExpect(status().isOk()); }但要注意SecurityContextHolder是线程绑定变量多线程并发测试时各线程互不可见。如果需要跨线程传递可以把认证信息放入JWT Token或Redis Session里测试时再通过header(Authorization, Bearer xxx)模拟。这也是为什么很多项目在测试阶段就要把统一的Token鉴权体系定好因为测试代码是最早一批“多模块跨域用户识别”的业务使用方。实操建议在测试基类中统一封装一个loginAndGetToken()方法返回可用的Token字符串然后在集成测试中通过请求头携带。这样既贴近真实使用场景又不污染公共Session数据。5. 高频问题与排查技巧实录5.1 SpringBoot版本太高导致的不兼容这是个非常现实的坑。Spring Boot 2.4之后测试相关的依赖和API有几次比较大的变化。最明显的一点是2.4之前spring-boot-starter-test默认的Mockito是3.xJUnit是5.6。2.6之后MockBean被标记为即将废弃官方推荐用MockitoBean。3.x之后包名从javax迁移到jakarta很多老项目升级后测试类里注入的javax.annotation全部失效。升级Spring Boot大版本之后第一件事不是跑业务功能而是跑一遍现有测试类。如果遇到大量编译错误先检查是不是jakarta导入问题如果是注解失效优先看官方迁移文档。我踩过最痛的坑是项目从2.7升到3.1所有的MockBean都还能编译但运行时失效了查了半天才发现3.x里必须使用新的Mockito注解。应对策略很简单升级版本前先打开项目的依赖树把测试相关的依赖全部列出来逐一对照新版本的兼容矩阵。不要等全部模块升级完再修测试到时候问题成山定位成本特别高。5.2 测试环境连不上外部中间件集成测试中最常见的是连不上Redis、RabbitMQ、ES这类中间件。如果测试代码里启动了完整上下文而本地没有装这些中间件启动就报错。几个处理思路用Embedded替代方案比如embedded-redis、H2替代MySQL、testcontainers起Docker容器。对非核心中间件用MockBean替换成假Bean让上下文启动时不做真实连接。把中间件相关配置放到application-test.yml设置短超时和自动重连关闭避免测试卡太久。Testcontainers是目前比较推荐的方案它能起真实Docker容器测完自动销毁环境一致性很好。但缺点是本地必须装DockerCI上也要能跑Docker。如果团队没有这个条件可以退回到Embedded方案。5.3 测试数据互相污染这类问题最隐蔽也最消耗排查时间。比如两个测试方法同时往用户表里插入用户名admin第一个跑完没清理第二个跑的时候就报唯一约束冲突。预防手段按优先级排每个测试方法内部独立准备数据方法结束事务回滚这是最干净的。测试基类统一执行DELETE FROM清理但要注意外键约束级联问题。使用测试数据工厂比如ObjectMother模式统一生成唯一命名数据。我在团队里推的是“每个测试方法自己造数据、不依赖执行顺序”的原则。任何依赖“上一个方法执行结果”的用例都是脆弱的今天跑不过去明天换个顺序又跑过了这种测试趁早删掉。5.4 MockMvc返回结果中文乱码这个坑很经典。Spring Boot默认返回的字符编码可能是ISO-8859-1如果你的接口返回中文断言时就对不上。解决办法是在MockMvc请求构造时显式指定编码mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .characterEncoding(UTF-8) .content({\name\:\小明\})) .andExpect(status().isOk());或者在测试配置里设置server.servlet.encoding.force-responsetrue。这类问题排查时看响应内容的乱码格式基本一秒定位。5.5 测试执行顺序导致的并发冲突JUnit 5默认不保证方法执行顺序如果你用了公共静态变量或共享文件就可能出现偶发失败。解决方式是尽量设计无状态测试。如果实在避不开可以给测试类加TestMethodOrder(MethodOrderer.OrderAnnotation.class)然后给方法标Order。但举例归举例我的真实体会是加执行顺序是一种妥协除非是端到端流程需要前后衔接比如先注册用户再查用户详情否则不要依赖顺序。流程测试也该尽量用数据库事务回滚来保证隔离。最后分享一点心得这几年我接手过好几个SpringBoot项目凡是测试配置混乱的后面维护成本都会翻倍。我最想提醒你的是测试代码不是一次性的辅助工具它是项目里和业务代码一样重要的资产。如果一上来就图快把SpringBootTest堆得到处都是跑一次全量要十分钟团队迟早会失去跑测试的耐心。我的建议是先建立最小可用骨架Service层用纯MockitoController层用WebMvcTest数据访问层用DataJpaTest然后留一两个完整的SpringBootTest来做端到端主流程即可。这个组合拳足以覆盖绝大多数项目的日常回归需求。测试跑得快大家才愿意频繁跑回归效果才能显现出来。最后分享一个小技巧如果你发现某个测试特别容易受环境波动影响先不要急着加大超时时间或者改断言先问自己一句这个用例是不是依赖了不该依赖的东西把环境变量、真实网络请求、固定时间戳从测试里剥离出去稳定性的提升往往立竿见影。