你好!作为一名在 Spring Boot 生态系统中摸爬滚打多年的开发者,我深知单元测试在保障代码质量方面的核心地位。你是否也曾因为担心修改一个功能破坏另一个功能而彻夜难眠?或者,你是否厌倦了每次验证逻辑都要启动整个沉重的 Spring 容器?在这篇文章中,我们将深入探讨如何利用 JUnit 5 和 Mockito 这两大神器,为你的 Spring Boot 项目构建快速、可靠且隔离的单元测试。我们将一起走过从项目搭建到编写复杂测试用例的全过程,分享我在实际开发中总结的实战技巧和避坑指南。
为什么选择 Mockito 和 JUnit?
在开始编写代码之前,让我们先统一一下认识。在 Spring Boot 应用中,我们的代码通常充满了各种依赖:数据库连接、外部 API 调用、以及其他 Service 层的逻辑。如果我们直接进行集成测试,不仅启动慢,而且一旦外部环境(如数据库)不稳定,测试就会失败。这并不是我们想要的“单元测试”。
- JUnit 5:这是 Java 领域事实上的测试标准,它提供了强大的生命周期管理和断言机制。
- Mockito:这是我们的“魔法棒”。它允许我们创建“伪装”的对象(Mock Objects),模拟真实依赖的行为,从而让我们能够将测试焦点集中在被测类本身的逻辑上,完全隔离外部干扰。
准备工作:项目搭建与依赖配置
让我们首先创建一个新的 Spring Boot 项目。你可以使用 IntelliJ IDEA 的初始化向导,或者直接访问 Spring Initializr 网站。在创建项目时,请务必勾选以下核心依赖,这将为我们后续的开发省去不少麻烦:
- Spring Web:构建 Web 应用的基础。
- Spring Data JPA:用于数据持久化,我们将主要测试这部分的逻辑。
- Lombok:通过注解减少样板代码,让实体类和测试类更整洁。
- MySQL Driver:虽然本次单元测试不连接真实数据库,但在业务代码中我们需要它。
- Spring Boot Starter Test:这是一个“全家桶”依赖,它自带了 JUnit 5, Mockito 以及 AssertJ 等测试库,是我们测试工作的核心。
为了确保大家的环境一致,我在下方列出了一个标准的 pom.xml 配置清单。你可以参考它来核对你的项目配置,特别是要注意 JUnit 和 Mockito 的版本通常由 Spring Boot Parent 版本管理,我们不需要手动指定版本号,以免冲突。
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.5.4
com.example
unit-testing-demo
0.0.1-SNAPSHOT
unit-testing-demo
Demo project for Spring Boot Unit Testing
11
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-data-jpa
mysql
mysql-connector-java
runtime
org.springframework.boot
spring-boot-starter-test
test
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-maven-plugin
构建基础:实体与数据访问层
在编写测试之前,我们需要先有一些“被测”的逻辑。让我们保持简单,模拟一个简单的用户管理系统。为了方便你理解,我们采用标准的分层架构:Controller -> Service -> Repository。
#### 1. 创建实体类
首先,我们需要一个 INLINECODEeee34b6f 实体。这里我使用了 Lombok 来简化 getter/setter 的书写,让代码看起来更清爽。INLINECODEda17064b 注解告诉 JPA 这是一个需要持久化的对象。
package com.demo.entities;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
@Data // Lombok 自动生成 Getter/Setter/toString
@NoArgsConstructor // JPA 通常需要无参构造器
@AllArgsConstructor // 方便我们在测试中快速构造对象
public class Person {
// 主键 ID
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer personId;
private String personName;
private String personCity;
}
#### 2. 创建 Repository 接口
接下来是数据访问层。虽然通常情况下我们不需要测试 Repository(因为那是 Spring Data JPA 的职责),但我们需要模拟它的行为来测试 Service 层。
package com.demo.repo;
import com.demo.entities.Person;
import org.springframework.data.jpa.repository.JpaRepository;
// 只要继承 JpaRepository,我们就拥有了 CRUD 功能
public interface PersonRepo extends JpaRepository {
// 我们可以定义一些自定义查询方法,JPA 会自动实现
// 例如:根据名字查找用户(虽然本例主要演示 ID 操作)
// Person findByPersonName(String name);
}
核心逻辑:Service 层的实现
这是单元测试的“主战场”。Service 层包含我们的业务逻辑。请看下面的 PersonService 类。我特意加入了一些常见的逻辑:保存用户、获取用户、以及根据 ID 判断用户是否存在。请注意看代码中的注释,这里有很多值得我们在测试中验证的细节。
package com.demo.service;
import com.demo.entities.Person;
import com.demo.repo.PersonRepo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class PersonService {
@Autowired
private PersonRepo personRepo;
// 业务场景 1:保存或更新用户
public Person addPerson(Person person) {
// 这里可以加入业务校验逻辑
// 比如:名字不能为空等
return personRepo.save(person);
}
// 业务场景 2:查询所有用户
public List getAllPersons() {
return personRepo.findAll();
}
// 业务场景 3:根据 ID 查询特定用户
// 这是一个测试 Optional 返回值的绝佳场景
public Optional getPersonById(Integer id) {
return personRepo.findById(id);
}
// 业务场景 4:删除用户
public void deletePerson(Integer id) {
// 通常我们会先检查是否存在,再删除
if(personRepo.existsById(id)) {
personRepo.deleteById(id);
} else {
throw new RuntimeException("Person not found with id: " + id);
}
}
}
重头戏:使用 Mockito 和 JUnit 编写测试
现在,让我们进入最激动人心的部分。我们将为 PersonService 编写测试。这里有两个关键点需要你掌握:
-
@ExtendWith(MockitoExtension.class):这是 JUnit 5 与 Mockito 集成的桥梁。它告诉 JUnit 在运行测试时启用 Mockito 的扩展功能。 - INLINECODE831266e2 和 INLINECODE3bb5f8c1:这是 Mockito 的核心注解。
* @Mock:创建一个假的对象。比如我们不需要连接数据库的 Repository,我们给它一个“傀儡”,并告诉它当被调用时该怎么反应。
* @InjectMocks:将上面创建的“傀儡”自动注入到被测对象中。
让我们创建 PersonServiceTest.java。为了让你看得更清楚,我会把代码拆分成几个场景来讲解。
package com.demo.service;
import com.demo.entities.Person;
import com.demo.repo.PersonRepo;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
// 使用 Mockito 扩展,这是 JUnit 5 的标准写法
@ExtendWith(MockitoExtension.class)
public class PersonServiceTest {
// 1. 创建模拟的 Repository
// 我们不需要真实的数据库连接,只需要控制它的返回值
@Mock
private PersonRepo personRepo;
// 2. 将模拟的 Repository 自动注入到 Service 中
@InjectMocks
private PersonService personService;
private Person person1;
// 在每个测试方法执行前初始化数据
@BeforeEach
public void setUp() {
// 初始化一个测试用的 Person 对象
person1 = new Person(1, "Alice", "New York");
}
// --- 测试场景 1:测试获取所有用户 ---
@Test
@DisplayName("测试获取所有用户列表")
public void testGetAllPersons() {
// Given: 设定模拟数据
// 当 personRepo.findAll() 被调用时,返回我们预设的列表
Person person2 = new Person(2, "Bob", "London");
List mockList = Arrays.asList(person1, person2);
// 这是 Mockito 的核心语法:当调用...thenReturn...
Mockito.when(personRepo.findAll()).thenReturn(mockList);
// When: 执行实际业务逻辑
List actualList = personService.getAllPersons();
// Then: 验证结果
// 验证返回的列表大小是否为 2
Assertions.assertEquals(2, actualList.size(), "应该返回 2 个用户");
// 验证列表中是否包含 Alice
Assertions.assertTrue(actualList.stream().anyMatch(p -> "Alice".equals(p.getPersonName())));
}
}
#### 深入理解:验证行为与异常处理
除了验证返回值,我们在实际开发中经常需要验证“某个方法是否被调用”,或者测试“当数据不存在时是否抛出异常”。让我们继续添加更多的测试方法。
// --- 测试场景 2:测试根据 ID 获取用户 ---
@Test
@DisplayName("测试根据 ID 成功获取用户")
public void testGetPersonById_Success() {
// Given: 模拟 Repository 返回一个 Optional 对象
Mockito.when(personRepo.findById(1)).thenReturn(Optional.of(person1));
// When: 调用 Service 方法
Optional result = personService.getPersonById(1);
// Then: 验证结果
Assertions.assertTrue(result.isPresent(), "用户应该存在");
Assertions.assertEquals("Alice", result.get().getPersonName());
}
@Test
@DisplayName("测试根据 ID 查询不存在的用户")
public void testGetPersonById_NotFound() {
// Given: 模拟 Repository 返回空的 Optional
Mockito.when(personRepo.findById(99)).thenReturn(Optional.empty());
// When: 调用 Service 方法
Optional result = personService.getPersonById(99);
// Then: 验证结果为空
Assertions.assertFalse(result.isPresent(), "用户不应该存在");
}
// --- 测试场景 3:测试保存用户 ---
@Test
@DisplayName("测试保存新用户")
public void testAddPerson() {
// Given: 模拟 save 方法返回传入的对象
Mockito.when(personRepo.save(person1)).thenReturn(person1);
// When: 执行保存操作
Person savedPerson = personService.addPerson(person1);
// Then: 验证返回的对象
Assertions.assertNotNull(savedPerson);
Assertions.assertEquals("Alice", savedPerson.getPersonName());
// --- 高级用法:验证交互 ---
// 我们不仅要验证结果,还要验证 personRepo.save() 确实被调用了一次
Mockito.verify(personRepo, Mockito.times(1)).save(person1);
}
// --- 测试场景 4:测试异常处理 ---
@Test
@DisplayName("测试删除不存在用户时抛出异常")
public void testDeletePerson_Exception() {
// Given: 模拟 ID 不存在
int invalidId = 999;
Mockito.when(personRepo.existsById(invalidId)).thenReturn(false);
// When & Then: 捕获异常并验证
// 我们期望这段代码抛出 RuntimeException
Exception exception = Assertions.assertThrows(RuntimeException.class, () -> {
personService.deletePerson(invalidId);
});
// 进一步验证异常信息是否符合预期
Assertions.assertTrue(exception.getMessage().contains("Person not found"));
}
进阶见解:匹配器的高级运用
在编写测试时,你可能会遇到参数不确定的情况。比如,我们只关心用户的名字,而不关心 ID 是多少。这时,Mockito 提供的参数匹配器就派上用场了。
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
// ... 在测试类中
@Test
@DisplayName("测试使用 Any 匹配器")
public void testGenericSave() {
Person anotherPerson = new Person();
anotherPerson.setPersonName("Charlie");
// 使用 any() 匹配任意 Person 对象
// 当 save 方法被传入任意 Person 参数时,都返回该对象
Mockito.when(personRepo.save(any(Person.class))).thenReturn(anotherPerson);
Person result = personService.addPerson(new Person()); // 即使传入空对象
Assertions.assertNotNull(result);
}
常见错误与最佳实践
在多年的开发经验中,我总结了一些新手容易踩的坑,希望能帮你节省宝贵的时间:
- 误用 INLINECODE1391d77f:这是最常见的性能杀手。如果你只是测试 Service 层的业务逻辑,千万不要使用 INLINECODEa533339a。它会启动整个 Spring 上下文,包括数据库连接池、Web 服务器等,这让单元测试变得像集成测试一样慢。请记住:单元测试应该是毫秒级的。
- Mock 的 Stubbing(存根)未生效:确保你在 INLINECODE7ff6cd8f 中使用的参数与实际调用时的参数在 INLINECODE9bcff746 意义上是完全一致的。如果不想严格匹配参数,请使用
any()匹配器。
- 忘记验证 Void 方法:对于没有返回值的方法(如 INLINECODE1ec2302e),你可以使用 INLINECODE5eba11bd 来确认它确实被执行了,而不是仅仅静默运行。
- 测试数据覆盖不全:不要只测试“成功路径”。一定要编写测试用例来覆盖“数据不存在”、“参数为空”、“权限不足”等异常场景。这些往往才是 Bug 藏身的地方。
总结
通过这篇文章,我们一起构建了一个完整的 Spring Boot 单元测试环境。我们并没有依赖笨重的数据库,而是巧妙地利用 Mockito 模拟了依赖行为,实现了真正的逻辑隔离测试。我们掌握了 INLINECODEc0d59950 和 INLINECODE7d6cb67d 的核心用法,学会了如何验证返回值、验证方法调用次数以及如何处理异常。
单元测试不仅仅是为了保证代码的正确性,更是一种设计驱动。当你开始编写测试时,你会发现你的代码结构变得更加模块化,耦合度更低。希望你能把这些技巧应用到你的下一个项目中,享受那种“即使重构代码也心有成竹”的自信!
如果你想继续提升,下一步可以尝试探索 MockMvc 来进行 Controller 层的切片测试,或者学习如何使用 TestContainers 进行轻量级的集成测试。祝编码愉快!