SpringBoot单元测试实战:JUnit5与Mockito高效测试业务逻辑
1. 项目概述:为什么SpringBoot项目必须认真对待单元测试?
干了这么多年后端开发,我见过太多项目在初期为了赶进度,把单元测试当成“可选项”,甚至直接忽略。结果呢?项目规模稍微一大,每次改点东西都提心吊胆,生怕把哪个隐蔽的功能点给搞崩了。单元测试,尤其是结合了SpringBoot、Mockito和JUnit的这套组合拳,绝对不是锦上添花,而是保障代码质量、提升开发效率的“基础设施”。它就像你代码的“安全气囊”和“自动化质检员”。
简单来说,这个教程要解决的核心问题就是:在一个典型的SpringBoot项目中,如何高效、可靠地对业务逻辑层(Service)、数据访问层(Repository/Mapper)甚至部分控制器(Controller)进行隔离测试。我们不会去启动整个Web容器,连接真实的数据库或调用第三方接口,那样太慢、太不稳定。我们要做的是“单元”测试,即把待测的类(比如一个UserService)从复杂的依赖网络(如UserMapper, EmailClient, RedisTemplate)中剥离出来,用“模拟对象”(Mock)替换掉这些依赖,然后精准地验证这个类本身的逻辑是否正确。这就是Mockito的用武之地,而JUnit提供了测试的运行框架和丰富的断言(Assertions)来验证结果。
这套组合适合所有使用SpringBoot的Java开发者,无论你是刚入门的新手,还是想优化现有项目测试体系的老鸟。它能帮你快速定位bug、安全重构、并形成可重复运行的“活文档”。下面,我就结合最常见的场景,从环境搭建到高级技巧,带你完整走一遍。
2. 环境准备与项目基础搭建
在开始写测试之前,得先把战场布置好。这里我假设你使用Maven作为构建工具,IDE用IntelliJ IDEA或Eclipse都可以,原理相通。
2.1 核心依赖引入
在你的 pom.xml 文件中,除了基本的 spring-boot-starter ,单元测试相关的依赖通常集中在 <dependencies> 里。Spring Boot已经为我们准备好了 spring-boot-starter-test ,这是一个“全家桶”式的起步依赖,它囊括了JUnit Jupiter(JUnit 5)、Mockito、AssertJ、Hamcrest等一整套测试库。对于绝大多数情况,引入这一个就够了。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
注意 <scope>test</scope> ,这意味这些依赖只在运行测试时生效,不会打包进最终的生产环境Jar包。用IDEA的话,引入依赖后记得点击Maven面板的刷新按钮。
注意 :Spring Boot 2.2.x 及以上版本默认使用 JUnit 5 (JUnit Jupiter)。如果你从老项目迁移过来,发现用的是 JUnit 4,需要调整注解和导入。本教程基于 JUnit 5。
2.2 测试代码结构规划
保持清晰的结构能让测试代码和业务代码一样易于维护。Maven和Gradle的标准约定是: src/main/java 放业务代码, src/test/java 放测试代码,两者的包结构应该一一对应。
例如,你的业务服务类在 com.example.demo.service.UserService ,那么对应的测试类就应该放在 src/test/java/com/example/demo/service/UserServiceTest.java 。这种镜像结构让寻找测试变得非常直观。测试类的命名通常是在被测试类名后加上 Test 或 IT (集成测试)。
3. 核心框架深度解析:JUnit 5与Mockito如何协同工作
在动手写案例前,有必要把几个核心角色的职责和关系理清楚。很多人写测试时注解乱用,就是因为没明白谁该干什么。
3.1 JUnit 5:测试的组织者与执行引擎
JUnit 5是测试框架本身,它负责发现测试、组织测试生命周期(如 @BeforeEach )、运行测试并报告结果。它的核心是 断言 (Assertions),用来验证实际结果是否符合预期。
关键注解:
@Test:标记一个方法是一个测试用例。@BeforeEach/@AfterEach:在每个@Test方法 之前/之后 运行。常用于初始化测试数据或清理资源。比如,在每个测试前重置Mock对象的状态。@BeforeAll/@AfterAll:在所有@Test方法 之前/之后 运行一次。方法必须是static的。适合做重量级且全局一次的初始化,比如初始化嵌入式数据库。@DisplayName:为测试类或方法设置一个更易读的显示名称。@Nested:用于创建嵌套的测试类,更好地组织具有共同上下文的测试。
断言(Assertions) :这是验证逻辑的核心。JUnit 5的 Assertions 类提供了大量静态方法。
import static org.junit.jupiter.api.Assertions.*;
// 判断相等
assertEquals(expected, actual);
// 判断是否为真
assertTrue(condition);
// 判断是否为空
assertNull(object);
// 判断是否抛出特定异常
assertThrows(ExpectedException.class, () -> { /* 执行代码 */ });
3.2 Mockito:依赖隔离与行为模拟的大师
Mockito是一个模拟框架。它的核心作用是 创建“假”对象(Mock/Spy) ,并 定义这些假对象在测试中的行为 。
- Mock对象 :一个完全虚拟的对象,其所有方法默认返回
null、空集合或0等“空”值,除非你明确指定它的行为。 - Spy对象 :一个部分真实的对象。它会调用真实对象的方法,但你可以选择性地对某些方法进行“监视”和“篡改”。
关键概念与API:
Mockito.mock(Class<T> classToMock):创建一个Mock对象。@Mock注解:配合@ExtendWith(MockitoExtension.class)使用,可以自动注入Mock对象,更简洁。@InjectMocks注解:自动创建被测试类的实例,并将由@Mock(或@Spy)创建的模拟对象注入进去。这是最常用的方式。when(...).thenReturn(...):桩(Stubbing)操作。定义当模拟对象的某个方法以特定参数被调用时,应该返回什么值。verify(mockObject, times(n)).methodCall(...):验证操作。检查在测试过程中,模拟对象的某个方法是否被调用,以及调用的次数、参数是否符合预期。
它们如何协作? 想象一下,你要测试一个 OrderService 的 placeOrder 方法,这个方法内部调用了 InventoryService.checkStock() 和 PaymentService.process() 。在单元测试中,你不会真的去查数据库或调用支付网关。你会:
- 用Mockito创建
InventoryService和PaymentService的Mock对象。 - 用
@InjectMocks创建 真实的OrderService实例,并将上面的Mock对象注入给它。 - 用
when().thenReturn()告诉Mock对象 :当checkStock被调用时,返回“有库存”;当process被调用时,返回“支付成功”。 - 用JUnit的
@Test执行orderService.placeOrder(...)。 - 最后,用JUnit的断言 验证
placeOrder的返回值(比如订单ID), 并用Mockito的verify验证checkStock和process方法是否按预期被调用了。
4. 实战演练:从零编写一个用户服务单元测试
光说不练假把式。我们假设一个非常经典的场景:一个用户服务 UserService ,它依赖一个用户仓库 UserRepository (可以是JPA Repository或MyBatis Mapper)来操作数据库。
4.1 定义业务类与接口
首先,我们有两个简单的类:
// src/main/java/com/example/demo/model/User.java
@Data // Lombok注解,生成getter/setter等
public class User {
private Long id;
private String username;
private String email;
}
// src/main/java/com/example/demo/repository/UserRepository.java (JPA接口示例)
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
}
// src/main/java/com/example/demo/service/UserService.java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(String username, String email) {
// 业务逻辑1:检查邮箱是否已存在
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("邮箱已被注册");
}
// 业务逻辑2:创建用户
User user = new User();
user.setUsername(username);
user.setEmail(email);
// 业务逻辑3:保存用户
return userRepository.save(user);
}
public User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户不存在"));
}
}
4.2 编写对应的单元测试类
现在,我们在 src/test/java 的对应位置创建测试类 UserServiceTest 。
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
// 关键:启用Mockito扩展,这样才能让@Mock和@InjectMocks注解生效
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock // 创建一个UserRepository的Mock对象
private UserRepository userRepository;
@InjectMocks // 创建UserService实例,并将上面Mock的userRepository注入进去
private UserService userService;
private User testUser;
@BeforeEach
void setUp() {
// 在每个测试方法执行前,初始化一个测试用的用户对象
testUser = new User();
testUser.setId(1L);
testUser.setUsername("testUser");
testUser.setEmail("test@example.com");
}
@Test
@DisplayName("创建用户 - 成功场景")
void createUser_Success() {
// 1. 准备阶段 (Given/Arrange)
// 定义Mock对象的行为:当检查邮箱是否存在时,返回false(表示邮箱可用)
when(userRepository.existsByEmail(anyString())).thenReturn(false);
// 定义Mock对象的行为:当调用save方法时,返回我们预设的testUser对象
when(userRepository.save(any(User.class))).thenReturn(testUser);
// 2. 执行阶段 (When/Act)
User createdUser = userService.createUser("testUser", "test@example.com");
// 3. 断言阶段 (Then/Assert)
// 验证返回的用户不为空
assertNotNull(createdUser);
// 验证返回用户的ID与预期相符
assertEquals(1L, createdUser.getId());
assertEquals("testUser", createdUser.getUsername());
// 4. 验证交互 (Interaction Verification)
// 验证userRepository.existsByEmail被调用了一次,参数是任意字符串
verify(userRepository, times(1)).existsByEmail(anyString());
// 验证userRepository.save被调用了一次,参数是任意User对象
verify(userRepository, times(1)).save(any(User.class));
}
@Test
@DisplayName("创建用户 - 邮箱已存在,应抛出异常")
void createUser_WhenEmailExists_ShouldThrowException() {
// 准备阶段:模拟邮箱已存在的情况
when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
// 执行与断言:验证调用服务方法时,确实抛出了IllegalArgumentException
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser("newUser", "existing@example.com")
);
// 可选:进一步断言异常信息
assertTrue(exception.getMessage().contains("邮箱已被注册"));
// 验证交互:save方法绝对不应该被调用
verify(userRepository, never()).save(any(User.class));
// 验证existsByEmail被调用了一次,且参数是特定的邮箱
verify(userRepository, times(1)).existsByEmail("existing@example.com");
}
@Test
@DisplayName("根据用户名获取用户 - 用户存在")
void getUserByUsername_WhenUserExists() {
// 准备阶段:模拟findByUsername返回一个包含用户的Optional
when(userRepository.findByUsername("testUser")).thenReturn(Optional.of(testUser));
// 执行阶段
User foundUser = userService.getUserByUsername("testUser");
// 断言阶段
assertNotNull(foundUser);
assertEquals(testUser.getId(), foundUser.getId());
// 验证交互
verify(userRepository, times(1)).findByUsername("testUser");
}
@Test
@DisplayName("根据用户名获取用户 - 用户不存在,应抛出异常")
void getUserByUsername_WhenUserNotExists_ShouldThrowException() {
// 准备阶段:模拟findByUsername返回一个空的Optional
when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty());
// 执行与断言:验证抛出了RuntimeException
RuntimeException exception = assertThrows(
RuntimeException.class,
() -> userService.getUserByUsername("unknown")
);
assertTrue(exception.getMessage().contains("用户不存在"));
// 验证交互
verify(userRepository, times(1)).findByUsername("unknown");
}
}
这个测试类几乎涵盖了单元测试的所有核心要素:模拟依赖、定义行为、执行方法、断言结果、验证交互。每个测试方法都遵循“准备-执行-断言”的模式,结构清晰。
5. Mockito高级技巧与常见陷阱
掌握了基础之后,我们来看看一些能让你测试写得更加得心应手的高级功能,以及那些我踩过坑的地方。
5.1 参数匹配器(Argument Matchers)
在上面的例子中,我们用了 anyString() 和 any(User.class) 。这些就是参数匹配器。它们非常有用,因为你有时并不关心调用方法时传入的具体参数是什么,只关心方法被调用了。
any():匹配任何对象(包括null)。any(Class<T> type):匹配任何属于该类型的对象。anyString(),anyInt(),anyList()等:匹配对应类型的任意值。eq(value):匹配一个具体的值。当你想混合使用具体值和匹配器时,所有参数都必须使用匹配器,这时eq()就派上用场了。例如:when(repo.find(eq(1), anyString())).thenReturn(...)。isNull(),isNotNull():匹配空或非空参数。
重要规则 :如果一个方法有多个参数,当你对其中任何一个参数使用了匹配器(如
any()),那么 所有参数都必须使用匹配器 ,不能混用具体值和匹配器(除了eq())。
5.2 验证方法调用(Verification)
verify 是Mockito的另一个强大工具,用于确保你的代码按你期望的方式与依赖进行了交互。
- 验证调用次数 :
verify(mock, times(n)).method():被调用了n次。verify(mock, atLeast(n)).method():至少被调用了n次。verify(mock, atMost(n)).method():至多被调用了n次。verify(mock, never()).method():从未被调用。verify(mock, atLeastOnce()).method():至少一次(等同于times(1))。
- 验证调用顺序 :使用
InOrder对象。InOrder inOrder = inOrder(mockA, mockB); inOrder.verify(mockA).method1(); inOrder.verify(mockB).method2(); // 这确保了method1在method2之前被调用。 - 验证参数捕获 :
ArgumentCaptor可以捕获方法调用时传递的实际参数,用于更复杂的断言。ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); verify(userRepository).save(userCaptor.capture()); User capturedUser = userCaptor.getValue(); assertEquals("expectedUsername", capturedUser.getUsername());
5.3 模拟void方法和异常抛出
有些依赖方法没有返回值(void),或者我们需要测试当依赖抛出异常时,主逻辑是否正确。
- 模拟void方法 :使用
doNothing(),doThrow(),doAnswer()等doXxx()系列方法。// 模拟一个void方法什么都不做 doNothing().when(emailService).sendWelcomeEmail(any(User.class)); // 模拟一个void方法抛出异常 doThrow(new RuntimeException("网络错误")).when(emailService).sendWelcomeEmail(any(User.class)); - 模拟方法抛出异常 :对于有返回值的方法,也可以在
thenReturn的位置用thenThrow。when(userRepository.findById(999L)).thenThrow(new RuntimeException("数据库连接失败"));
5.4 Spy与Mock的区别及使用场景
这是容易混淆的点。简单记住:
- Mock :对象是“空壳”,所有方法默认返回“空”值,除非你显式定义行为(stub)。适用于 绝大多数 需要隔离的依赖。
- Spy :对象是“真实对象的克隆”,它会调用真实方法,除非你显式地“部分模拟”它。适用于你 只想模拟某个大对象的其中一两个方法,而其他方法保持真实逻辑 的场景。使用需谨慎,因为真实方法可能带来副作用(如连接数据库)。
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList); // 创建Spy
// 默认调用真实方法
spyList.add("real");
assertEquals(1, spyList.size());
// 模拟(篡改)某个特定方法的行为
doReturn(false).when(spyList).isEmpty();
assertFalse(spyList.isEmpty()); // 此时isEmpty()返回我们设定的false,而不是真实的false(因为列表非空,其实也是false,这里只是演示)
6. SpringBoot测试上下文与集成测试浅析
我们上面做的都是“纯单元测试”,完全隔离了Spring容器。但有时候,你需要测试一些与Spring强绑定的组件,比如:
- 测试
@Configuration配置类。 - 测试使用了
@Autowired,@Value注入的组件。 - 进行轻量级的集成测试,比如测试Controller层(但不想启动整个服务器),或者测试MyBatis Mapper/Spring Data JPA Repository与嵌入式数据库的交互。
这时就需要用到Spring Boot的测试支持。它通过 @SpringBootTest 注解来启动一个测试专用的Spring应用上下文。
6.1 使用@SpringBootTest进行切片测试
Spring Boot Test提供了“切片测试”注解,只加载你关心的那部分上下文,速度比加载整个应用快很多。
@WebMvcTest:专门用于测试Spring MVC控制器(Controller)。它会自动配置MockMvc,让你能模拟HTTP请求,而无需启动完整的Servlet容器(如Tomcat)。 它会自动注入相关的Controller,但Service等Bean需要你自己用@MockBean来模拟 。@WebMvcTest(UserController.class) // 只加载UserController相关的Web层配置 class UserControllerTest { @Autowired private MockMvc mockMvc; // 注入MockMvc用于模拟请求 @MockBean // 注意这里是@MockBean,不是@Mock。它会将Mock对象注册到Spring测试上下文中。 private UserService userService; @Test void getUserTest() throws Exception { when(userService.getUserById(1L)).thenReturn(new User(...)); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("testUser")); } }@DataJpaTest:专门用于测试JPA Repository。它会配置一个嵌入式数据库(如H2),并自动扫描@Entity类和Repository。 它不会加载Service、Controller等其他Bean 。@DataJpaTest class UserRepositoryTest { @Autowired private TestEntityManager entityManager; // 用于操作测试数据库 @Autowired private UserRepository userRepository; @Test void whenFindByUsername_thenReturnUser() { // 使用entityManager持久化一个测试实体 User user = new User(...); entityManager.persist(user); entityManager.flush(); // 调用repository方法 User found = userRepository.findByUsername(user.getUsername()).orElse(null); assertThat(found).isNotNull(); assertThat(found.getEmail()).isEqualTo(user.getEmail()); } }@JsonTest:专门用于测试JSON序列化/反序列化。@RestClientTest:专门用于测试REST客户端。
6.2 @MockBean与@Mock的区别
这是另一个关键点:
@Mock:是 Mockito框架 的注解。在纯单元测试(使用@ExtendWith(MockitoExtension.class))中,它用于创建模拟对象,这些对象与Spring容器无关。@MockBean:是 Spring Boot Test 提供的注解。当你的测试需要Spring测试上下文(如使用了@SpringBootTest,@WebMvcTest)时,用它来向Spring的测试应用上下文中添加或替换一个Bean为Mock对象。这样,在测试中@Autowired进来的依赖,就会是这个Mock版本。
简单决策树 :
- 如果你测试的类不依赖Spring容器(如一个纯Java工具类,或使用
@InjectMocks注入Mock的Service),用@ExtendWith(MockitoExtension.class)+@Mock+@InjectMocks。 - 如果你要测试Spring管理的组件(如Controller, 或需要真实Spring上下文的配置),用
@WebMvcTest等切片测试注解 +@MockBean。
7. 测试中的常见问题与调试技巧
即使框架用得再熟,写测试时还是会遇到各种奇怪的问题。这里记录几个我印象深刻的“坑”和解决方法。
7.1 “Unfinished stubbing”或“Misplaced argument matcher”错误
这通常是使用Mockito时参数匹配器使用不当导致的。 最常见的原因 是 when() 方法中调用的模拟方法,其参数列表混用了具体的值和匹配器(如 any() )。
错误示例 :
// 错误!第一个参数是具体值,第二个是匹配器,混用了。
when(someService.doSomething("concreteValue", anyInt())).thenReturn(...);
正确做法 :所有参数都必须使用匹配器,对于需要匹配具体值的地方,使用 eq() 。
// 正确
when(someService.doSomething(eq("concreteValue"), anyInt())).thenReturn(...);
7.2 测试方法执行顺序问题
JUnit 5默认不保证测试方法的执行顺序。这是设计如此,目的是让每个测试都是独立的。如果你的测试之间有依赖(比如一个测试创建数据,另一个测试读取),那说明你的测试设计有问题。
解决方案 :
- 重构测试 :确保每个
@Test方法都能独立运行。使用@BeforeEach来准备数据,@AfterEach来清理数据。 - 如果必须排序 (极少数情况,如性能测试),可以使用
@TestMethodOrder和@Order注解,但强烈不推荐在单元测试中这样做。
7.3 如何测试静态方法或私有方法?
这是一个有争议的话题。好的单元测试实践通常强调 只测试公共接口 。如果你觉得需要测试私有方法,往往意味着这个类的职责过重,可以考虑将私有方法提取到一个新的公共工具类或辅助类中,然后测试那个新类。
对于遗留代码中的静态方法(比如一些工具类),可以使用PowerMock或Mockito 3.4+的内联MockMaker(需要额外配置)来模拟静态方法。但引入这些工具会增加测试的复杂性, 更好的策略是重构代码,减少对静态方法的直接依赖,或者将这些静态方法包装在一个非静态的实例方法中,然后模拟这个实例 。
7.4 测试覆盖率与持续集成
写测试不是目的,保障代码质量才是。我习惯在IDEA中安装像 JaCoCo 这样的插件,它可以直观地显示代码的测试覆盖率(哪些行被测试执行到了)。在Maven中集成JaCoCo也很方便:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version> <!-- 使用最新版本 -->
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
运行 mvn clean test 后,会在 target/site/jacoco 目录下生成覆盖率报告。在CI/CD流水线中,可以设置覆盖率门槛(比如行覆盖率不低于80%),低于这个门槛则构建失败,从而保证测试的严肃性。
最后,单元测试应该是开发流程中自然而然的一部分,就像写代码要编译一样。刚开始可能会觉得慢,但当你习惯了测试驱动开发(TDD)或者至少是测试伴随开发,你会发现它带来的信心和效率提升,远超过编写它所花费的时间。尤其是在团队协作和代码重构时,一套好的单元测试就是最坚实的后盾。
更多推荐



所有评论(0)