SpringBoot单元测试实战:从@Test注解到Mockito的完整指南

📅 发布时间:2026/10/8 2:39:28
SpringBoot单元测试实战:从@Test注解到Mockito的完整指南
昨天整理博客摘录翻到一段关于SpringBoot项目中单元测试的笔记正好和团队最近讨论的问题撞上了。SpringBoot、单元测试、Test这几个词放在一起几乎是每个后端项目都绕不开的话题但说实话很多项目里的测试代码也就是“能跑”的程度离“有用”还差得远。这篇笔记想聊的不是教科书式的JUnit教程而是我在SpringBoot项目里实操单元测试时怎么理解Test、怎么把测试写成真正能保护代码的东西以及中间踩过的一堆坑。适合正在写SpringBoot项目、想给核心逻辑补测试或者刚把测试跑起来但总感觉哪里不对的开发者。内容会尽量掰开揉碎讲清楚能直接照着操作。1. 为什么SpringBoot项目里单元测试不是可选项1.1 从“能跑”到“敢改”单测解决的真实痛点说句大实话在SpringBoot项目里真正让我意识到单元测试价值的时候不是赶工写代码那会儿而是接手一个老项目准备大改的那几天。Controller里塞了好几千行逻辑Service互相调用Repository层换了个数据源结果没人敢碰那段代码。为什么因为没有人知道改完了之后原本能用的功能是不是被碰坏了。手动启动项目、调接口、看数据库这一趟流程走下来快则几分钟慢则半天还不能保证覆盖到所有的分支。单元测试解决的就是这个“敢不敢改”的问题。一个Test方法就是一个小型验证器它不启动整条业务链路不去连真实的数据库和第三方服务只验证某一个类、某一个方法在给定输入下的输出是否符合预期。你改了A方法跑一下A对应的测试绿了基本就能说明这次改动没有把A原有的行为弄坏。这种反馈速度是手动验证完全给不了的。SpringBoot项目里写单元测试看起来只是加几个注解和断言但背后的核心思路是“把验证从人肉回归变成自动化回归”。项目越大依赖越多这个价值就越明显。你想想一个服务里十几个类每个类又有十几个公共方法手动把所有分支都点一遍得花多久测试跑一遍可能就几秒钟。1.2 测试金字塔与SpringBoot项目的实际取舍业界常说的测试金字塔底层是大量的单元测试中间是集成测试顶层是端到端测试。金字塔的意思是越底层数量越多执行越快维护成本越低越顶层数量越少执行越慢维护成本越高。SpringBoot项目落地的时候这条金字塔会被稍微改一改。因为SpringBoot本身是一个容器化的框架你很难完全脱离Spring上下文去测一个Controller或者一个Service如果硬要全部用纯单元测试的玩法Mock东西会非常痛苦。所以实际项目里常见的组合是核心业务逻辑用纯单元测试覆盖Controller层用SpringBoot提供的切片测试比如WebMvcTest跨服务的流程用集成测试去验证端到端测试只留少数几条关键链路。很多人容易走进一个误区觉得SpringBoot的单元测试就是务必启动完整的ApplicationContext于是每写一个测试就丢一个SpringBootTest。结果呢整个测试套件跑一次要十几分钟越来越多的人不愿意跑测试测试就慢慢烂掉了。正确的做法是尽量缩小测试范围能用切片解决的就不要启动整个项目。1.3 常见误区把“启动整个项目”当成单元测试我见过一种特别典型的代码测试类里几乎什么都没干就一个空的Test方法加上SpringBootTest注解。问为什么要这样写回答说“我想验证项目能正常启动”。听起来有道理但这个测试真正保护了什么它能发现Bean缺少、配置写错之类的问题不过一旦项目里引入新的中间件依赖比如消息队列、流程引擎、或者一个Flink任务这种测试就会变得特别重甚至成为团队CI的负担。真正有价值的单元测试关注的是“某一个行为在给定输入下是否正确”而不是“整个项目能不能起来”。SpringBoot项目启动问题的验证交给CI的构建流程就足够了没必要用单元测试来承担这个职责。所以我的建议是写测试之前先问自己一句这个测试要是失败了能告诉我哪段逻辑出了问题吗如果答案很含糊那这个测试大概率设计得有问题。2. Test背后的骨架注解机制、断言与Mock2.1 Test到底做了什么为什么它如此轻量在JUnit 5的体系里Test标注的方法就是一个测试用例。JUnit 5本身分了三块JUnit Platform负责在IDE和构建工具里发现并执行测试JUnit Jupiter提供了Test这些注解和具体的执行引擎还有JUnit Vintage是为了兼容老项目的JUnit 4。Tenst注解标注的方法有几个硬性规则不能有返回值不能有参数在Java里可以是public或者包级私有。JUnit发现测试类的时候会自动找到所有标注了Test的方法逐个执行每个方法独立成一次“执行单元”。测试方法抛了异常或者断言失败那这个方法就标记为失败不影响其他测试方法继续跑。这种设计背后其实有个很实用的理念每个Test方法都应该足够小、足够自包含。我以前写测试的时候总喜欢一个方法里塞七八个断言后来发现一旦失败得花时间分析究竟是哪一步出了岔子。JUnit 5支持了DisplayName可以给测试方法起一个人类可读的中文或英文描述团队协作的时候点开测试报告就能一眼看出哪个行为没有通过比看一堆testMethodName舒服多了。2.2 断言与Mock让单测既完整又不依赖外部环境有了Test注解只是告诉JUnit“这是一个要执行的方法”。真正判断“对不对”靠的是断言。JUnit自带的断言有assertEquals、assertTrue、assertNotNull这几种基础款但后来我越来越偏向用AssertJ。AssertJ的断言是流式的比如assertThat(result).isEqualTo(...).isNotNull()写起来更像是自然语言而且失败时的错误信息比JUnit原生断言详细得多定位问题的时候省了不少力气。单测里另一个绕不开的环节是Mock。SpringBoot项目里的Service往往要操作Repository、调用外部HTTP接口、发送消息到队列。如果没有Mock你要让单元测试跑起来要么起一个MySQL要么起一个Redis甚至还要连一个消息中间件。这哪儿还是单元测试已经是集成测试了。Mockito就是来解决这个问题的它能生成一个假的对象你在测试里定义好“当调用某个方法时返回什么结果”然后被测代码拿到的就是这样一个受你控制的对象。Mockito常用的有三板斧mock()用来创建假对象when(...).thenReturn(...)用来设置行为verify(...)用来验证某个方法有没有被调用、调用了几次。配合Mockito的注解Mock和InjectMocks可以自动地把假对象注入到被测类里省去手动new和set的代码。这套组合用下来测试才真正做到了“只测当前类的逻辑不掺和别人的行为”。2.3 Spring Boot测试中的几个关键注解怎么选Spring Boot在spring-boot-starter-test里打包好了JUnit、Mockito、AssertJ这些常用的测试库不需要你一个个去引。你还需要理解几个和Spring测试相关的注解用错了会把测试写得很重。SpringBootTest会启动完整的ApplicationContext适合做集成测试或者跨层的冒烟测试。WebMvcTest只加载Web层相关的BeanController、过滤器、拦截器这些会加载但Service、Repository这些不会需要你用Mock去补。DataJpaTest只加载JPA相关的配置和Repository适合测数据访问层。MybatisTest类似专门服务MyBatis。选型原则很简单测哪一层就用哪一层的切片注解。全量启动的SpringBootTest是最后的兜底方案不是首选。把这句话刻脑子里你的测试跑起来会快一个数量级同事也会感谢你的。3. 实操一个完整的SpringBoot单测从依赖到三层用例3.1 环境准备pom依赖与测试目录结构先看一下Maven项目的pom.xml。现在大多数SpringBoot项目只要引入了spring-boot-starter-parent再加上一个spring-boot-starter-test就行了。Spring Boot 3.x的写法如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependencyscope是test意思就是这个依赖只在编写和运行测试的时候生效不会打进最终的jar包。spring-boot-starter-test里面包含了JUnit Jupiter、Mockito、AssertJ、JsonPath等一系列测试需要用到的库。如果你在工程里还用到Spring Security这类框架往往还需要引入spring-security-test用来构造认证信息、做安全相关的断言。测试代码放在src/test/java目录下包名建议和被测代码保持一致这样同一个包内就可以访问那些包级私有的方法和字段测试写起来更顺手。测试资源文件放在src/test/resources目录它和src/main/resources是隔离的你可以放专门的application-test.yml而不会干扰生产环境的配置。3.2 Service层测试BeforeEach与事务回滚接下来写一个实际的例子。假设有一个UserService依赖UserRepository核心方法是从数据库按ID查用户再对用户状态做判断然后返回一个处理结果。我先展示一段原始版本的代码Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } public UserDetail getUserDetailById(Long id) { User user userRepository.findById(id) .orElseThrow(() - new RuntimeException(用户不存在)); if (VIP.equals(user.getLevel())) { return new UserDetail(user.getNickname(), true); } return new UserDetail(user.getNickname(), false); } }我要测的是UserService里的getUserDetailById方法我不希望真的去连一个MySQL数据库所以UserRepository就用Mockito来模拟。class UserServiceTest { Mock private UserRepository userRepository; InjectMocks private UserService userService; BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } Test DisplayName(VIP用户返回VIP标识) void shouldReturnVipFlagWhenUserIsVip() { User user new User(); user.setId(1L); user.setNickname(张三); user.setLevel(VIP); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); UserDetail detail userService.getUserDetailById(1L); assertThat(detail.isVip()).isTrue(); assertThat(detail.getNickname()).isEqualTo(张三); } Test DisplayName(普通用户不返回VIP标识) void shouldReturnNotVipWhenUserIsNormal() { User user new User(); user.setId(2L); user.setNickname(李四); user.setLevel(NORMAL); when(userRepository.findById(2L)).thenReturn(Optional.of(user)); UserDetail detail userService.getUserDetailById(2L); assertThat(detail.isVip()).isFalse(); } }这里有几个细节值得展开说。BeforeEach标注的方法会在每个Test方法执行前先跑一遍适合用来做通用的初始化工作。早期版本里我见过有人用Before这个方法那是JUnit 4的写法JUnit 5换了名字叫BeforeEach功能不同但语义更清晰。Mock创建的假对象是空的方法的返回值默认是null。如果被测代码里调了mock对象的方法但你没有用when去定义返回值调用结果就是null接下来就容易抛出NullPointerException然后测试莫名其妙就挂了。所以每次写测试的时候要顺着被测代码的执行路径把所有依赖分支的mock行为都定义清楚。Transactional和单元测试的关系经常被误解。很多人在SpringBootTest里加上Transactional希望测试结束后数据自动回滚不污染开发库。这个做法在一定范围内是有效的但需要明确两点第一它只对真实数据库的连接有效你mock掉的对象不走事务第二如果你测的是异步方法、或者方法内部自己开了新线程事务回滚就未必生效了。所以能用Mock解决的别连数据库必须要连数据库的场景再考虑事务回滚。3.3 Controller层测试WebMvcTest与MockMvcService测完之后接下来测Controller。Controller的职责本身不复杂接收请求参数调Service包装成响应。但Controller牵涉HTTP协议、参数校验、异常处理手动验证起来特别麻烦MockMvc就是专门干这个的。WebMvcTest只会加载Controller层的东西不会加载Service和Repository。所以需要把Controller依赖的Service给mock掉。Spring Boot 3.x里旧版的MockBean已经标记为过时官方推荐用MockitoBean但很多存量项目里还是能见到MockBean能看懂就行。WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserService userService; Test DisplayName(GET请求返回用户详情) void shouldReturnUserDetailByGetRequest() throws Exception { when(userService.getUserDetailById(1L)) .thenReturn(new UserDetail(张三, true)); mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.nickname).value(张三)) .andExpect(jsonPath($.vip).value(true)); } }MockMvc的思路是模拟一个HTTP请求进来经过DispatcherServlet分发到对应的Controller方法再把返回结果拿到做状态码、响应体这些断言。它不需要真的起一个Tomcat端口所以测试的启动速度比SpringBootTest快很多。写Controller测试的时候有个容易忽略的点如果你的Controller里用了参数校验比如Validated配合RequestBody那测试里就得多考虑校验失败的场景构造一个非法请求断言返回的HTTP状态码是400而不是200。这些都是先有需求再补测试时容易漏掉的分支。3.4 测试中如何组织多环境配置SpringBoot项目一般会有多套环境dev、test、prod对应的配置文件是application-dev.yml、application-test.yml、application-prod.yml。单元测试执行的时候用哪一套配置默认情况下测试加载的是application.yml或application.properties配合application-test.yml需要在测试类上加ActiveProfiles(test)或者通过test/resources目录下的application.yml来指定spring.profiles.activetest。实际项目里我通常会在src/test/resources下面放一个独立的application.yml里面只保留测试需要的最小配置比如内存数据库连接、日志级别。这样测试不会读取开发环境里的真实数据库地址也不会误连到生产环境服务上。这一点在团队协作里特别重要不然某天同事的本地配置和你的不一致测试跑出来的结果千奇百怪。4. 单元测试避坑实录常见问题与排查技巧4.1 数据被改、Mock不生效、启动太慢单测跑多了会遇到一些比较典型的坑。我把遇到最多的问题整理成一个速查表供大家参考。常见问题具体表现根本原因解决方案测试跑完数据库里多了条数据本地开发库被污染第二次跑测试报了唯一键冲突测试方法里真实调用了Repository.save但没有回滚如果是SpringBootTest看是否加了Transactional更推荐直接Mock掉RepositoryMock方法返回值一直是nullwhen条件明明写了但被测代码拿到的还是nullMock对象传入的不是同一个实例或者参数不匹配检查InjectMocks注入是否成功使用any()或者特定参数匹配器测试启动要几分钟每个测试都启动完整Spring上下文SpringBootTest用得太泛滥换成WebMvcTest、DataJpaTest等切片测试测试方法间相互影响前面跑过的测试改了静态状态后面测试行为异常静态变量、单例对象被共享用AfterEach清理状态或者用MockedStatic等方式处理静态资源中文乱码出现在测试报告里断言中的中文和预期不一致控制台或文件编码不是UTF-8在pom.xml里设置project.build.sourceEncoding为UTF-8Mock不生效那类问题是大多数新手最容易扑街的地方。当你发现when定义的行为没有生效第一步先确认被测类里真正调用的对象是不是你mock的那个对象。Spring容器里如果已经有一个真实Bean你又通过MockitoBean替换了它那确实没问题但如果你自己new了一个service对象mock注入的方向错了自然就失效了。4.2 上下文加载慢怎么办以及端口冲突怎么处理前面提到SpringBootTest越少越好但有些跨模块联合逻辑绕不开它。这种时候可以优化一个方面测试上下文缓存。Spring Test框架会把ApplicationContext缓存下来如果多个测试类使用了完全相同的配置后续测试直接复用同一个上下文不需要重新加载。只要配置差异一多缓存命中率就会下降启动时间成倍上升。另一个很隐蔽的坑是端口冲突。SpringBootTest默认的WebEnvironment是MOCK不会真的启动Tomcat所以一般不占端口。但有人为了让测试里可以发起真实的HTTP调用会设置成RANDOM_PORT随机分配端口这个问题相对好一些如果设置成DEFINED_PORT写死了一个端口那和本地正在跑的应用撞车只是时间问题。真要在RANDOM_PORT下获取端口号用LocalServerPort注入即可。还有一种情况测试里同时引入了Redis、消息队列之类的中间件SpringBootTest会因为连接不上这些外部服务而直接失败。我的建议是这种场景一律Mock掉中间件的客户端Bean或者使用Testcontainers在Docker里起一个临时容器测试结束自动销毁。Testcontainers看起来香但也要求开发机器上有Docker环境CI里也得有对应的配置自己权衡一下团队条件再上。4.3 测试命名与团队协作里的细节单元测试写到后面难的不是技术而是可维护性。我见过太多测试方法的命名是test1、test2这种半年之后重跑测试连作者本人都说不清楚这个测试到底在验证什么。建议用英文或者中文描述都行前提是必须说清楚“测试什么行为、输入是什么、期望什么结果”。团队协作的时候我一般建议约定这几条核心业务方法必须有对应测试修过的Bug必须补一条回归测试定时任务和异步方法尽量抽纯逻辑再测。把压力放在“行为覆盖”而不是“行覆盖”因为行覆盖率高不代表逻辑覆盖好有些行重复执行一百次也不会暴露分支错误。还有一个小印象不要把多个断言塞满一个Test断言之间如果有关联放在一起还行如果只是顺手凑的“顺便验证一下”分开写会更清晰。测试失败以后你第一时间要看的是“哪一行断言失败”而不是在一大堆断言里逐行排查。4.4 边界条件与异常分支的测试补充很多项目里测试覆盖率看起来不低但测的全是“正确路径”异常分支一测就露馅。单元测试真正值钱的地方恰恰在于能把这些边界情况一个个钉死。比如刚才那个UserService除开用存在的用户ID来测还得思考几个问题用户不存在时抛不抛异常用户level字段是null时会不会NPEID传0或负数会怎样给一段异常分支的测试示例Test DisplayName(用户不存在时抛出异常) void shouldThrowExceptionWhenUserNotFound() { when(userRepository.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() - userService.getUserDetailById(99L)) .isInstanceOf(RuntimeException.class) .hasMessageContaining(用户不存在); }隐患往往藏在那些“没写代码的分支”里。接手老项目时与其盯着正常功能翻来覆去不如先补异常分支的测试性价比要高得多。等这些测试全部亮绿再去调优化心里就有底了。5. 写在后面我的一点实操体会单元测试这件事刚开头的时候总是最难的。你可能觉得“业务都忙不过来了还写测试”真正做了之后才知道测试不是给老板看的指标是给自己兜底的。我个人的习惯是拿到一个SpringBoot需求先花十分钟想清楚核心业务有哪几个行为分支然后把每个分支对应到测试方法名上再动手写实现代码。等代码写完测试也跟着绿了那种踏实感是纯写业务代码给不了的。实测下来另外一个值得养成的习惯是每次修复线上Bug后第一反应补一条测试。要是没有对应测试这个Bug的下一次出现只是时间问题。测试名字可以不讲究但这条测试记录一定得留下它会成为你后来重构和升级依赖时最有力的保障。最后再分享一个小技巧给测试类加上DisplayName之后测试报告看起来一目了然团队周报里放上去也是加分项。