开头我先说个结论: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 5(Jupiter)、Mockito、AssertJ、Hamcrest、JSONassert和Spring Test。其中JUnit 5是测试框架本体,Mockito负责做对象mock,AssertJ和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响应 | 需要,但用切片 | MockMvc |
| Service | 业务规则、异常、协作对象 | 不需要完整上下文 | JUnit + Mockito |
| Repository | SQL、映射、事务 | 需要数据源 | @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映射和入参校验,那么用@WebMvcTest:
@WebMvcTest(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); Optional<User> 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); List<Future<Boolean>> 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,最简单的是在测试里构造一个已认证的SecurityContext:
@Test 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.x,JUnit是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容器,测完自动销毁,环境一致性很好。但缺点是本地必须装Docker,CI上也要能跑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-response=true。这类问题排查时看响应内容的乱码格式,基本一秒定位。
5.5 测试执行顺序导致的并发冲突
JUnit 5默认不保证方法执行顺序,如果你用了公共静态变量或共享文件,就可能出现偶发失败。解决方式是尽量设计无状态测试。如果实在避不开,可以给测试类加@TestMethodOrder(MethodOrderer.OrderAnnotation.class),然后给方法标@Order。
但举例归举例,我的真实体会是:加执行顺序是一种妥协,除非是端到端流程需要前后衔接(比如先注册用户再查用户详情),否则不要依赖顺序。流程测试也该尽量用数据库事务回滚来保证隔离。
最后分享一点心得
这几年我接手过好几个SpringBoot项目,凡是测试配置混乱的,后面维护成本都会翻倍。我最想提醒你的是,测试代码不是一次性的辅助工具,它是项目里和业务代码一样重要的资产。如果一上来就图快,把@SpringBootTest堆得到处都是,跑一次全量要十分钟,团队迟早会失去跑测试的耐心。
我的建议是先建立最小可用骨架:Service层用纯Mockito,Controller层用@WebMvcTest,数据访问层用@DataJpaTest,然后留一两个完整的@SpringBootTest来做端到端主流程即可。这个组合拳足以覆盖绝大多数项目的日常回归需求。测试跑得快,大家才愿意频繁跑,回归效果才能显现出来。
最后分享一个小技巧:如果你发现某个测试特别容易受环境波动影响,先不要急着加大超时时间或者改断言,先问自己一句:这个用例是不是依赖了不该依赖的东西?把环境变量、真实网络请求、固定时间戳从测试里剥离出去,稳定性的提升往往立竿见影。