SpringBoot单元测试实战:用Mockito 5分钟搞定Service层测试
1. 项目概述:为什么Service层单元测试总让人“傻傻等”?
做后端开发的朋友,尤其是用SpringBoot的,估计都经历过这种场景:产品经理催着要新功能,你吭哧吭哧把Service层的业务逻辑写完了,信心满满地准备自测。结果一运行,发现依赖的Mapper/Repository还没写,或者依赖的某个第三方服务接口还没开发好,又或者数据库里根本没有测试数据。于是,你不得不停下来,要么去催别人,要么自己先造一堆假数据,甚至去手动启动一个完整的应用上下文。这一等,可能就是十几分钟甚至更久,开发节奏被彻底打乱,效率低得让人抓狂。
这就是典型的“傻傻等接口”困境。单元测试的本意是快速验证一小块代码的逻辑正确性,但在SpringBoot这种重度依赖IoC容器和外部资源的框架里,传统的测试方法很容易就变成了“集成测试”甚至“端到端测试”,失去了其快速反馈的核心价值。我见过太多团队,因为觉得单元测试麻烦、启动慢、依赖多,而选择跳过或者只做粗粒度的接口测试,这为代码质量埋下了巨大的隐患。
今天要聊的,就是如何用SpringBoot 2.6 + Mockito这套组合拳,在5分钟内,为你的Service层构建起一套高效、隔离、可靠的单元测试防护网。我们不再需要等待数据库、等待网络接口、等待任何外部服务。核心思路就一句话: 把Service层依赖的所有“不确定因素”,都用Mockito模拟成“确定的行为” 。这样,测试的焦点就完全落在了你写的业务逻辑本身上。
这篇文章适合所有正在或即将使用SpringBoot进行开发的工程师,无论你是刚接触单元测试的新手,还是想优化现有测试套件的老鸟。接下来,我会从一个简单的用户查询场景出发,手把手带你走通整个流程,并附上可直接运行的完整代码。你会发现,写好Service层单元测试,真的没那么难。
2. 核心思路与工具选型:为什么是Mockito?
在深入代码之前,我们得先搞清楚两个关键问题:什么是真正的单元测试?以及为什么Mockito是解决SpringBoot Service层测试难题的“银弹”?
2.1 单元测试的本质:隔离与速度
很多人对单元测试有误解,认为在SpringBoot里用 @SpringBootTest 启动整个应用,然后测Service,就是单元测试。这其实是一种“集成测试”。真正的单元测试(Unit Test)有三大铁律:
- 快速 :单个测试用例应该在毫秒级完成,整个测试套件能在几分钟内跑完。
- 隔离 :只测试当前单元(如一个Service方法)的逻辑,其所有依赖(如数据库访问层、外部API、文件系统)都应该被隔离(模拟或打桩)。
- 可重复 :在任何环境、任何时间运行,结果都应该一致,不依赖外部状态。
基于这三点再看“傻傻等接口”的问题,根源就在于测试没有做到“隔离”。你的测试在等待真实的数据库响应、真实的网络请求,这必然导致速度慢、不稳定。
2.2 Mockito:制造“演员”的导演
Mockito是一个流行的Java模拟测试框架。你可以把它想象成电影导演。当你要测试一段剧情(Service方法)时,并不需要真实的爆炸场面(数据库操作)或昂贵的明星出场(第三方服务调用)。导演(Mockito)可以安排替身演员(Mock对象)来按照剧本(你设定的行为)表演。
在SpringBoot Service测试中,我们最常Mock的对象就是:
- Mapper接口(MyBatis) 或 Repository接口(JPA) :模拟数据库的增删改查。
- 其他Service :模拟内部服务调用。
- 第三方SDK或RestTemplate客户端 :模拟外部HTTP API调用。
通过Mockito,我们可以告诉这些Mock对象:“当调用你的A方法时,就返回B结果”,或者“验证你是否被以C参数调用了D次”。这样,Service方法就在一个完全受控的“沙箱”环境中运行,测试变得极其快速和稳定。
2.3 版本选择:SpringBoot 2.6与JUnit 5
选择SpringBoot 2.6是因为它是一个长期支持版本,稳定且生态成熟。它默认集成了JUnit 5和Mockito,开箱即用。JUnit 5相比JUnit 4有更清晰的扩展模型和更强大的功能(如 @RepeatedTest , @ParameterizedTest )。
关键依赖 :在你的 pom.xml 中,确保有以下依赖。 spring-boot-starter-test 已经包含了JUnit 5, Mockito, AssertJ等我们需要的一切。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<!-- 排除可选的JUnit 4遗留引擎,确保使用JUnit 5 -->
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
注意 :很多老项目或教程可能还在用
@RunWith(SpringRunner.class),这是JUnit 4的写法。在JUnit 5 + SpringBoot 2.6中,我们使用@ExtendWith(SpringExtension.class),但通常@SpringBootTest等注解已经默认包含了这个扩展,所以大多数情况下你不需要显式写@ExtendWith。
3. 实战:5分钟搭建可测试的Service与测试类
光说不练假把式。我们用一个经典的“用户服务”场景来演示。假设有一个 UserService ,里面有一个方法 getUserById ,它会调用 UserMapper 从数据库查询用户。
3.1 第一步:创建被测试的Service及其依赖
首先,我们得有被测试的代码。这是一个非常简单的结构:
1. 实体类 User.java
@Data // 使用Lombok简化getter/setter
public class User {
private Long id;
private String username;
private String email;
// 省略构造方法和其他字段
}
2. Mapper接口 UserMapper.java
@Mapper // MyBatis注解
public interface UserMapper {
User selectById(Long id);
}
3. Service接口 UserService.java
public interface UserService {
User getUserById(Long id);
}
4. Service实现类 UserServiceImpl.java
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper; // 依赖的Mapper
@Override
public User getUserById(Long id) {
// 这里是我们的核心业务逻辑
if (id == null || id <= 0) {
throw new IllegalArgumentException("用户ID不合法");
}
User user = userMapper.selectById(id);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 这里理论上可能还有其他的业务逻辑,比如权限检查、信息脱敏等
return user;
}
}
业务逻辑很清晰:校验参数 -> 调用Mapper查询 -> 处理结果。现在,我们要在不启动Spring、不连接数据库的情况下测试这个方法。
3.2 第二步:编写单元测试类(核心)
这是最关键的一步。我们不使用 @SpringBootTest 来启动整个应用,而是使用 @ExtendWith(MockitoExtension.class) ,这是Mockito为JUnit 5提供的扩展,它负责初始化Mock对象和处理注解。
UserServiceImplTest.java
import org.junit.jupiter.api.Test; // JUnit 5
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // 启用Mockito支持
public class UserServiceImplTest {
// 1. 模拟(Mock)依赖对象
@Mock
private UserMapper userMapper;
// 2. 将被测Service实例化,并将上面的Mock依赖注入进去
@InjectMocks
private UserServiceImpl userService;
@Test
public void testGetUserById_Success() {
// 3. 准备测试数据
Long userId = 1L;
User expectedUser = new User();
expectedUser.setId(userId);
expectedUser.setUsername("张三");
// 4. 定义Mock对象的行为(打桩 Stubbing)
// 含义:当userMapper.selectById(1L)被调用时,返回我们准备好的expectedUser对象
when(userMapper.selectById(userId)).thenReturn(expectedUser);
// 5. 执行被测试的方法
User actualUser = userService.getUserById(userId);
// 6. 验证结果(断言)
// 6.1 验证返回对象不为空
assertNotNull(actualUser);
// 6.2 验证返回的用户ID正确
assertEquals(userId, actualUser.getId());
// 6.3 验证返回的用户名正确
assertEquals("张三", actualUser.getUsername());
// 7. 验证交互行为(可选但推荐)
// 验证userMapper.selectById方法是否被调用了一次,并且参数是userId
verify(userMapper, times(1)).selectById(userId);
// 还可以验证其他方法没有被意外调用
verify(userMapper, never()).selectById(anyOther(Long.class));
}
}
逐行解析:
@ExtendWith(MockitoExtension.class):这是测试类的基石,它初始化了Mockito环境。@Mock:告诉Mockito,“请创建一个UserMapper的Mock对象”。这个对象看起来像UserMapper,但所有方法默认都是“空实现”,需要我们告诉它该如何行为。@InjectMocks:告诉Mockito,“请创建一个UserServiceImpl的真实对象,并把上面那些用@Mock标记的对象,想办法(通过构造器、setter或字段反射)注入进去”。这样我们就得到了一个依赖了Mock Mapper的真实Service。when(...).thenReturn(...):这是Mockito的核心语法,称为“打桩”。它定义了当Mock对象的某个方法以特定参数被调用时,应该返回什么值。 这是隔离外部依赖的关键 。assert...:JUnit的断言,用于验证结果是否符合预期。verify(...):Mockito的验证器,用于验证Mock对象的方法是否被按预期调用了。这能确保你的业务逻辑正确地触发了依赖。
3.3 第三步:扩展测试用例,覆盖边界与异常
一个好的测试套件不仅要测“阳光大道”,更要测“悬崖峭壁”。我们继续为 getUserById 方法补充测试。
@Test
public void testGetUserById_WithInvalidId_ThrowsException() {
// 测试场景:传入非法ID(null或<=0)
Long invalidId = -1L;
// 断言:执行方法应该抛出IllegalArgumentException异常
// 注意:这里使用了JUnit 5的assertThrows,它接收异常类型和一个可执行代码块
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.getUserById(invalidId) // 执行会抛出异常的代码
);
// 可选:进一步断言异常信息
assertTrue(exception.getMessage().contains("不合法"));
// 验证:因为参数不合法,在方法开头就抛异常了,所以mapper方法绝对不应该被调用
verify(userMapper, never()).selectById(any());
}
@Test
public void testGetUserById_UserNotFound_ThrowsException() {
// 测试场景:ID合法,但数据库查无此人
Long userId = 999L;
// 定义Mock行为:当查询这个ID时,返回null(模拟数据库中不存在)
when(userMapper.selectById(userId)).thenReturn(null);
// 断言:执行方法应该抛出“用户不存在”的运行时异常
RuntimeException exception = assertThrows(
RuntimeException.class,
() -> userService.getUserById(userId)
);
assertTrue(exception.getMessage().contains("不存在"));
// 验证:mapper方法确实被调用了一次,且参数正确
verify(userMapper, times(1)).selectById(userId);
}
实操心得:
- 测试异常是重中之重 。业务逻辑中最容易出bug的往往是各种边界条件和异常处理分支。用
assertThrows可以清晰地对异常行为进行断言。 verify(...)在异常测试中尤其有用。例如在testGetUserById_WithInvalidId_ThrowsException中,我们验证了userMapper.selectById从未被调用。这确保了我们的参数校验逻辑是生效的,避免了不必要的数据库查询,这也是业务逻辑正确性的一部分。
4. Mockito核心技巧进阶:让你的测试更强大
掌握了基础用法,我们来看看Mockito如何应对更复杂的场景。
4.1 处理void方法: doNothing() 与 doThrow()
Service层经常有更新或删除操作,对应Mapper的void方法。如何测试?
假设 UserService 有一个 updateUsername 方法:
@Override
public void updateUsername(Long id, String newName) {
if (id == null || newName == null || newName.trim().isEmpty()) {
throw new IllegalArgumentException("参数无效");
}
User user = userMapper.selectById(id);
if (user == null) {
throw new RuntimeException("用户不存在");
}
user.setUsername(newName);
userMapper.updateById(user); // 这是一个void方法
}
测试这个方法时,我们需要模拟 userMapper.updateById 这个void方法。
@Test
public void testUpdateUsername_Success() {
Long userId = 1L;
String newName = "李四";
User mockUser = new User();
mockUser.setId(userId);
mockUser.setUsername("旧名字");
// 1. 模拟selectById返回一个用户
when(userMapper.selectById(userId)).thenReturn(mockUser);
// 2. 模拟void方法:当updateById被调用时,什么都不做(默认行为,这行其实可省略)
doNothing().when(userMapper).updateById(any(User.class));
// 执行
userService.updateUsername(userId, newName);
// 验证:1. selectById被调用 2. updateById被调用,且传入的User对象的username已被更新
verify(userMapper).selectById(userId);
verify(userMapper).updateById(argThat(user -> newName.equals(user.getUsername())));
}
@Test
public void testUpdateUsername_UpdateFails_ThrowsException() {
Long userId = 1L;
String newName = "李四";
User mockUser = new User();
when(userMapper.selectById(userId)).thenReturn(mockUser);
// 模拟void方法抛出异常
doThrow(new RuntimeException("数据库更新失败")).when(userMapper).updateById(any(User.class));
// 断言执行会抛出包含特定信息的异常
RuntimeException exception = assertThrows(RuntimeException.class,
() -> userService.updateUsername(userId, newName));
assertTrue(exception.getMessage().contains("更新失败"));
}
doNothing().when(mock).voidMethod()是默认行为,显式写出更清晰。doThrow().when(mock).voidMethod()用于模拟void方法执行失败。argThat()是一个强大的参数匹配器,用于验证传入的参数是否符合更复杂的条件。
4.2 参数匹配器:让行为定义更灵活
when(mock.someMethod(any())) 中的 any() 就是参数匹配器。它表示“任何参数”。常用的还有:
eq(value):匹配特定值(默认行为)。anyInt(),anyString(),any(Class<T>):匹配对应类型的任何值。isNull(),isNotNull():匹配空或非空。argThat(Matcher):自定义匹配逻辑。
示例 :测试一个根据用户名模糊查询的服务。
when(userMapper.selectByUsernameLike(anyString())).thenReturn(someList); // 匹配任何字符串
when(userMapper.selectByUsernameLike(startsWith("张"))).thenReturn(zhangList); // 仅匹配以“张”开头的
4.3 验证方法调用次数与顺序
verify 的威力不止于验证是否调用。
// 验证至少调用2次
verify(mock, atLeast(2)).someMethod();
// 验证最多调用5次
verify(mock, atMost(5)).someMethod();
// 验证从未调用
verify(mock, never()).someMethod();
// 验证调用顺序
InOrder inOrder = inOrder(mock1, mock2);
inOrder.verify(mock1).firstMethod();
inOrder.verify(mock2).secondMethod();
在复杂的业务流程中,验证调用顺序能确保逻辑执行路径正确。
5. 集成Spring容器测试:何时使用 @SpringBootTest
我们一直在强调用Mockito做“纯单元测试”,但有些场景确实需要Spring容器,比如:
- 测试
@Transactional注解的声明式事务是否生效。 - 测试
@Cacheable等基于AOP的注解。 - 测试复杂的Bean装配或配置文件。
- 进行切片测试(如
@WebMvcTest,@DataJpaTest)。
这时, @SpringBootTest 就派上用场了。但即使是集成测试,我们也要尽量轻量。
@SpringBootTest // 这会启动一个轻量级的应用上下文
class UserServiceIntegrationTest {
@Autowired
private UserService userService; // 注入真实的Service
@MockBean // SpringBoot提供的注解,用于在Spring上下文中注入一个Mock Bean
private UserMapper userMapper;
@Test
void testGetUserByIdWithSpringContext() {
// 用法和之前完全一样!
when(userMapper.selectById(1L)).thenReturn(new User(1L, "test"));
User user = userService.getUserById(1L);
assertNotNull(user);
assertEquals("test", user.getUsername());
}
}
@MockBean 是SpringBoot测试框架提供的魔法,它能在Spring应用上下文中,用你Mock的对象替换掉真实的Bean。这样你既享受了容器提供的依赖注入、AOP支持,又隔离了特定的外部依赖。
重要建议 :建立清晰的测试分层。绝大多数(>80%)的Service层测试应该是使用
MockitoExtension的快速单元测试。只有少数需要容器特性的测试才用@SpringBootTest。避免滥用@SpringBootTest导致测试套件运行缓慢。
6. 常见问题与排查技巧实录
在实际操作中,你肯定会遇到一些坑。这里记录几个最常见的问题和解决方法。
6.1 问题:NPE(NullPointerException)在 @InjectMocks 对象内部
场景 :你正确地用了 @Mock 和 @InjectMocks ,但运行测试时,Service里调用Mock对象的方法却抛出了NPE。
原因与排查 :
- 检查测试类是否使用了
@ExtendWith(MockitoExtension.class)。没有这个注解,Mockito的注解不会生效。 - 检查
@InjectMocks标注的类是否可以被实例化 。如果Service类只有带参数的构造函数,Mockito可能无法自动注入。此时需要改用构造函数注入或配合@BeforeEach手动初始化。// 如果UserServiceImpl的构造器是 UserServiceImpl(UserMapper mapper) @BeforeEach void setUp() { userService = new UserServiceImpl(userMapper); // 手动构造 } - 确保被Mock的依赖项在Service中是通过
@Autowired(字段注入)或标准的setter方法注入的 。如果依赖是通过一些奇特的方式(如工厂方法)获取的,Mockito可能无法注入。
6.2 问题:Stubbing异常:“UnnecessaryStubbingException”
场景 :测试通过,但控制台有警告日志,提示某个Stubbing是多余的。
原因与解决 : Mockito默认是严格的(strictness),它会检查那些被定义了行为( when(...).thenReturn(...) )但从未在测试中被实际调用的Mock方法。这通常意味着你的测试用例设计有冗余,或者测试路径没有覆盖到你预想的方法调用。
- 方案A(推荐) :检查你的测试逻辑。这个Stubbing是不是真的不需要?也许你的测试用例应该触发它但没有触发?修正你的测试逻辑或Mock行为。
- 方案B :如果这个Stubbing是为多个测试方法准备的(比如在
@BeforeEach中设置的公共行为),可以忽略此警告,或者修改Mockito的严格级别(不推荐新手这么做)。
6.3 问题:如何测试静态方法或私有方法?
核心原则 :单元测试应专注于测试公共API的行为。过度测试内部细节(私有方法)会导致测试变得脆弱(内部实现一改,测试就崩)。
- 私有方法 :如果一段逻辑复杂到需要单独测试,考虑将它提取到另一个类(遵循单一职责原则),或者通过测试其所在的公有方法来间接覆盖。 不要为了测试而破坏封装 。
- 静态方法 :Mockito本身不能Mock静态方法(直到3.4.0版本才有限支持)。对于自己写的静态工具方法,如果它们是无状态的、确定性的(如
StringUtils.isEmpty),直接调用测试即可。如果它们依赖外部资源(如读取文件、调用网络),这就是一个设计上的“代码坏味道”,考虑将其重构为可以被注入的非静态服务,以便于Mock。
6.4 问题:测试Spring的 @Transactional 或 @Cacheable
场景 :在纯Mockito测试中, @Transactional 等基于Spring AOP的注解是无效的,因为Spring容器没有启动。
解决 :
- 如果只想测试事务回滚逻辑 :你需要使用
@SpringBootTest,并配合@Transactional在测试方法上,这样测试结束后数据会自动回滚。 - 如果只想测试缓存逻辑 :同样需要
@SpringBootTest,并可能需要在测试配置中配置一个内存缓存(如Caffeine)来验证@Cacheable是否工作。 - 最佳实践 :将“事务管理”、“缓存”等视为 架构组件 ,其正确性应由框架保证。你的单元测试应更多关注 业务逻辑 在给定输入下是否产生正确输出。对于组件集成,可以编写少量的、聚焦的集成测试来验证。
6.5 测试代码结构优化建议
- 命名规范 :测试类名
被测试类名+Test,方法名用被测试方法名_测试场景_预期结果格式,如getUserById_WithNullId_ThrowsException,一目了然。 - Given-When-Then模式 :在测试方法内用注释分隔“准备数据”、“执行操作”、“验证断言”三个阶段,让测试逻辑清晰。
@Test void testSomething() { // Given: 准备数据和Mock行为 Long id = 1L; when(repo.find(id)).thenReturn(new Entity()); // When: 执行被测方法 Result result = service.doSomething(id); // Then: 验证结果和行为 assertNotNull(result); verify(repo).find(id); } -
@BeforeEach初始化 :将测试类中多个测试方法共用的Mock行为或对象初始化放在@BeforeEach方法中,避免重复代码。 - 断言库选择 :除了JUnit的
assert*,可以尝试使用AssertJ或Hamcrest,它们提供更流畅、更强大的断言语法,错误信息也更友好。
从“傻傻等接口”到“5分钟搞定”,关键在于思维的转变。单元测试不是集成测试的简化版,它是一种完全不同的、以“隔离”和“快速反馈”为核心的设计活动。Mockito给了我们制造“可控替身”的能力,让我们能在一个纯净的环境中,精准地验证每一行业务代码。
我个人最深的体会是, 编写可测试的代码,往往会倒逼出更好的代码设计 。当你发现一个Service方法难以用Mockito测试时(比如依赖了太多静态方法、内部new了对象、逻辑过于冗长),这通常就是一个代码需要重构的信号。拥抱单元测试,不仅仅是提升代码质量,更是一个持续改进设计的过程。
最后分享一个小技巧:在IDEA中,你可以把测试类放在与主类相同的包下(但位于 src/test/java 目录),这样你可以利用包级私有(package-private)的可见性来设计一些测试辅助方法,而无需将内部细节暴露给生产代码。这比为了测试而将方法改为 public 要优雅得多。
更多推荐


所有评论(0)