SpringBoot与Mockito单元测试实战:从基础到高级技巧 | JUnit断言 | Mockito | 单元测试
1. 为什么你的SpringBoot单元测试总是不给力?
我见过太多开发者,一提到写单元测试就头疼。要么是觉得太麻烦,要么是写出来的测试用例脆弱不堪,稍微改点代码,测试就全挂了。尤其是在SpringBoot项目里,各种依赖注入、数据库连接、外部服务调用,让单元测试变得异常复杂。你是不是也经常遇到这种情况:想测试一个Service方法,但它依赖了好几个Dao和外部接口,为了跑通一个测试,得先把整个Spring容器启动起来,数据库连上,外部服务调通,结果跑一个测试要等半分钟,这还叫“单元”测试吗?
其实,问题的核心在于,我们没有把单元测试的“单元”二字理解透彻。真正的单元测试,应该只测试当前类内部的逻辑,所有外部依赖都应该被“隔离”和“模拟”。这就是Mockito这类模拟框架存在的意义。我刚开始用SpringBoot的时候,也是每次测试都加上@SpringBootTest,让Spring加载整个应用上下文,结果测试慢得像蜗牛。后来才发现,大部分情况下,我们根本不需要启动Spring容器,用Mockito就能搞定90%的单元测试场景。
SpringBoot和Mockito的结合,就像是给单元测试装上了涡轮增压。SpringBoot提供了便捷的测试脚手架,而Mockito则让你能轻松地模拟任何复杂的依赖。想象一下,你要测试一个用户注册服务,这个服务需要调用用户DAO保存数据,同时还要调用邮件服务发送验证邮件。如果没有Mockito,你就得准备一个真实的数据库和一个能发邮件的SMTP服务器。但有了Mockito,你可以轻松地“假装”用户DAO已经成功保存了数据,邮件服务也“假装”发送了邮件,然后专心测试你注册服务里的业务逻辑是否正确。这种测试方式又快又稳,而且完全可控。
那么,到底什么是Mock对象呢?你可以把它理解成一个“演员”,在测试这场戏里,它扮演某个真实的依赖对象。你可以提前给这个“演员”设计好台词(方法调用时返回什么值)和动作(抛出异常还是正常执行)。当被测试的代码调用这个依赖时,实际上调用的是这个“演员”,它会按照你预设的剧本表演,从而让你能精准地测试各种边界情况和异常流程。比如,你可以轻松测试“当数据库连接失败时,服务是否进行了恰当的降级处理”,而不用真的去把数据库搞挂。
2. 快速搭建你的第一个Mock测试环境
2.1 依赖引入:别小看这一行配置
万事开头难,但SpringBoot让开头变得异常简单。要使用Mockito,你只需要在项目的pom.xml文件里加入一个依赖。对,就一个,SpringBoot已经帮你把需要的所有测试组件都打包好了。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
这行配置看起来平平无奇,但它背后包含了JUnit Jupiter(测试框架)、Mockito(模拟框架)、AssertJ(断言库)、Hamcrest(匹配器)等一系列强大的工具。scope设置为test意味着这些库只在运行测试时生效,不会打包进最终的生产环境jar包,非常干净。
我建议你创建一个专门的测试目录结构,比如src/test/java/com/yourproject/service,和你的主代码结构保持一致。这样不仅清晰,而且像Maven或Gradle这样的构建工具会自动识别并运行这些测试。
2.2 两种测试模式:纯Mock vs. Spring集成
这是很多新手容易混淆的地方。Mockito测试其实有两种“打开方式”,适用于不同的场景。
第一种,纯Mock环境(轻量级,推荐)。当你只想测试某个类本身的逻辑,不关心Spring的依赖注入、事务管理等特性时,就用这种模式。它的特点是快。因为完全不加载Spring容器,测试启动几乎是瞬间完成的。
在这种模式下,核心是三个注解:@Mock, @Spy, @InjectMocks。它们都是由Mockito提供的,和Spring没关系。
@Mock:创建一个“完全虚假”的对象。这个对象所有的方法默认都不会执行真实逻辑,调用任何方法,如果没有特别设置,都会返回null、0、false这样的默认值。@Spy:创建一个“部分真实”的对象。它会包装一个真实的对象,默认情况下,调用它的方法会执行真实逻辑。只有当你显式地为某个方法设置了模拟行为时,它才会按你的设定来。@InjectMocks:这是“导演”。它会创建一个真实类的实例(就是你要测试的那个类),然后自动把前面用@Mock和@Spy标记的“演员”(依赖对象)注入到这个实例中。
这里有个巨坑,我踩过好几次:你必须手动初始化这些注解! 在测试类的@BeforeEach方法里,一定要加上MockitoAnnotations.openMocks(this);这一行。没有它,@Mock和@InjectMocks就全是null,测试必然失败。
第二种,Spring集成环境。当你需要测试的代码用到了Spring的特性,比如@Transactional事务、@Cacheable缓存,或者你想测试整个Spring Bean的装配是否正确时,才需要用到这种模式。这时你需要加上@SpringBootTest注解来启动Spring容器。
在这种模式下,你用的注解换成了SpringBoot测试提供的@MockBean和@SpyBean。它们的作用和@Mock、@Spy类似,但多了一个关键能力:它们会把模拟对象注册到Spring的测试应用上下文中,替换掉真正的Bean。这样,其他被@Autowired自动注入的Bean,拿到手的就已经是你模拟好的对象了。好处是,你不再需要@InjectMocks和MockitoAnnotations.openMocks(this);了,Spring会自动帮你完成注入和初始化。
简单来说,能不用@SpringBootTest就不用。99%的Service层、工具类的单元测试,用纯Mock模式就足够了,速度优势巨大。
2.3 第一个测试用例:从“Hello Mockito”开始
光说不练假把式,我们来看一个最简单的例子。假设我们有一个UserService,它依赖一个UserDao来获取用户。
// 首先,这是我们要测试的类
@Service
public class UserService {
private UserDao userDao; // 依赖
// 构造器注入(推荐)
public UserService(UserDao userDao) {
this.userDao = userDao;
}
public String getUserName(int id) {
User user = userDao.findById(id);
return user != null ? user.getName() : "Unknown";
}
}
// 对应的DAO接口
public interface UserDao {
User findById(int id);
}
// 测试类
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest { // 注意,这里没有 @SpringBootTest!
@Mock
private UserDao userDao; // 模拟依赖
@InjectMocks
private UserService userService; // 被测试对象,Mockito会自动注入userDao
@BeforeEach
void setUp() {
// 关键!初始化Mockito注解
MockitoAnnotations.openMocks(this);
}
@Test
void testGetUserName_UserExists() {
// 1. 安排(Arrange):定义模拟行为
User mockUser = new User(1, "张三");
when(userDao.findById(1)).thenReturn(mockUser); // 当调用findById(1)时,返回mockUser
// 2. 执行(Act):调用被测方法
String result = userService.getUserName(1);
// 3. 断言(Assert):验证结果
assertEquals("张三", result);
// 4. (可选)验证行为:验证findById方法确实被调用了一次,且参数是1
verify(userDao, times(1)).findById(1);
}
@Test
void testGetUserName_UserNotExists() {
// 模拟找不到用户的情况
when(userDao.findById(999)).thenReturn(null);
String result = userService.getUserName(999);
assertEquals("Unknown", result);
verify(userDao, times(1)).findById(999);
}
}
运行这两个测试,你会发现它们飞快地执行完了。这就是纯Mock测试的魅力。你不需要真正的UserDao实现,也不需要数据库。你完全掌控了userDao.findById()这个方法的行为,从而可以轻松测试UserService在不同情况下的逻辑是否正确。
3. Mockito核心技巧:让你的测试稳如泰山
3.1 模拟返回值:when...thenReturn 与 doReturn...when 的玄机
给Mock对象设置行为,最常用的就是when(...).thenReturn(...)。它的意思很直白:“当调用某个方法时,就返回某个值”。但这里有个细节很多人没注意:when()方法实际上会先调用一次你传入的方法。
听起来有点绕?看个例子。假设我们有一个Calculator类,里面有个complexCompute()方法,执行起来很耗时,或者有副作用(比如写日志)。
@Spy // 注意,这里用的是@Spy,部分模拟
private Calculator calculator;
@Test
void testWhenThenReturnTrap() {
// 错误示范:如果你这样写...
when(calculator.complexCompute(anyInt())).thenReturn(100);
// ...Mockito在后台实际上执行了:calculator.complexCompute(anyInt())
// 如果complexCompute方法会打印日志或者抛异常,这里就会出问题!
}
对于@Mock创建的完全模拟对象,这通常没问题,因为它的方法体是空的。但对于@Spy创建的部分模拟对象,或者某些特殊情况(比如模拟final方法或static方法的老版本Mockito),这可能就会触发真实调用,导致测试出现预期外的行为。
这时,就该doReturn(...).when(...)出场了。它的执行顺序是反的:先指定返回值(doReturn(100)),再指定对哪个对象哪个方法生效(when(calculator).complexCompute(anyInt()))。它不会去调用真实方法。
所以,记住这个简单的选择原则:
- 绝大多数情况,用
when(...).thenReturn(...)。直观,易读。 - 当你使用
@Spy,并且不希望触发真实方法调用时,用doReturn(...).when(...)。 - 模拟
void方法(无返回值方法)时,必须用doNothing().when(...)或doThrow(...).when(...),因为when()需要方法返回值,而void没有。
@Test
void testDoReturnWhen() {
// 使用doReturn,确保不会调用真实方法
doReturn(100).when(calculator).complexCompute(anyInt());
// 模拟void方法
doNothing().when(someService).logMessage(anyString()); // 什么都不做
doThrow(new RuntimeException("日志写入失败")).when(someService).logMessage("error"); // 模拟抛出异常
}
3.2 模拟异常:如何优雅地测试错误处理?
一个健壮的系统,不仅要测试正常流程,更要测试异常流程。Mockito让你能轻松模拟任何异常。
@Mock
private PaymentGateway paymentGateway;
@Test
void testPaymentFailure() {
// 模拟支付网关调用时抛出网络超时异常
when(paymentGateway.process(any(PaymentRequest.class)))
.thenThrow(new RuntimeException("网络连接超时"));
// 使用JUnit 5的assertThrows来断言异常
RuntimeException thrown = assertThrows(RuntimeException.class, () -> {
billingService.chargeUser(user, amount);
});
// 还可以进一步断言异常信息
assertEquals("网络连接超时", thrown.getMessage());
// 验证支付网关确实被调用了一次
verify(paymentGateway, times(1)).process(any(PaymentRequest.class));
}
thenThrow可以链式调用,用来模拟多次调用抛出不同的异常,这在测试重试逻辑时非常有用。
@Test
void testRetryLogic() {
// 第一次调用抛异常,第二次调用成功
when(remoteService.call())
.thenThrow(new RuntimeException("第一次失败"))
.thenReturn("成功响应");
// 你的业务代码里应该有重试逻辑
String result = myService.callWithRetry();
assertEquals("成功响应", result);
// 验证被调用了两次
verify(remoteService, times(2)).call();
}
3.3 参数匹配器:让模拟更灵活
你不可能为每一个可能的参数都写一个when语句。Mockito提供了一系列强大的参数匹配器(Argument Matchers)。
any():匹配任何对象(包括null)。any(Class<T> type):匹配任何属于该类型的对象,如any(String.class)。eq(value):匹配等于特定值的参数。当其他匹配器混合使用时,所有参数都必须用匹配器,此时eq()用于匹配具体值。isNull()/isNotNull():匹配null或非null参数。contains("substring"):匹配包含特定子串的字符串。
@Test
void testWithArgumentMatchers() {
// 匹配任何User对象
when(userDao.save(any(User.class))).thenReturn(1001);
// 匹配任何整数ID
when(userDao.findById(anyInt())).thenReturn(new User(1, "Test"));
// 混合使用匹配器:第一个参数是任何字符串,第二个参数必须是"admin"
when(roleService.checkPermission(anyString(), eq("admin"))).thenReturn(true);
// 注意:如果有一个参数用了匹配器,那么所有参数都必须用匹配器!
// 下面这个是错的:
// when(service.method(anyInt(), "fixed")).thenReturn(...); // 编译可能通过,但运行会报错
// 应该写成:
when(service.method(anyInt(), eq("fixed"))).thenReturn(...);
}
3.4 验证交互:你的代码真的按预期执行了吗?
模拟行为是“设定剧本”,验证交互则是“检查演员是否按剧本演了”。这是单元测试中确保代码行为正确的关键一步。
最基本的验证是verify(mockObject).methodName(arguments)。但Mockito的验证功能远不止于此。
@Test
void testVerification() {
OrderService orderService = new OrderService(inventoryService, notificationService);
orderService.placeOrder(order);
// 1. 验证方法被调用
verify(inventoryService).checkStock(order.getItemId());
// 2. 验证调用次数
verify(notificationService, times(1)).sendEmail(any(Order.class)); // 恰好一次
verify(notificationService, atLeastOnce()).sendEmail(any(Order.class)); // 至少一次
verify(notificationService, atMost(3)).sendEmail(any(Order.class)); // 最多三次
verify(notificationService, never()).sendSMS(anyString()); // 从未调用
// 3. 验证调用顺序
InOrder inOrder = inOrder(inventoryService, notificationService);
inOrder.verify(inventoryService).checkStock(order.getItemId()); // 必须先检查库存
inOrder.verify(notificationService).sendEmail(any(Order.class)); // 然后发送邮件
// 4. 验证没有其他意想不到的交互
verifyNoMoreInteractions(notificationService); // 除了上面验证过的,没有其他调用
// 或者验证整个对象没有任何交互
verifyNoInteractions(auditService); // auditService不应该被调用
}
什么时候该用验证? 我的经验是:验证状态(即方法的返回值)优先于验证交互。如果通过断言返回值就能确定逻辑正确,那就没必要再验证方法调用。验证交互更适合用于测试“副作用”,比如一个方法是否调用了发送邮件、记录日志等命令式操作。
4. 高级场景与实战避坑指南
4.1 处理静态方法、final方法与构造方法
老版本的Mockito(3.x之前)对静态方法、final类、final方法、私有方法和构造方法的模拟支持很弱,需要配合PowerMock这样的“重型武器”。但这带来了依赖复杂性和测试速度下降的问题。
好消息是,从Mockito 5.0开始,通过开启内联Mock生成器,已经可以模拟静态方法、final方法甚至构造方法了。在SpringBoot 3.x中,默认引入的Mockito版本通常已经支持。
启用方法:在src/test/resources目录下创建mockito-extensions文件夹,在里面放一个名为org.mockito.plugins.MockMaker的文件,文件内容写一行:mock-maker-inline。
启用后,你就可以像模拟普通方法一样模拟它们了:
@Test
void testStaticMethodMock() {
try (MockedStatic<UtilityClass> mockedStatic = mockStatic(UtilityClass.class)) {
// 模拟静态方法
mockedStatic.when(() -> UtilityClass.generateId()).thenReturn("fixed-id-123");
String id = UtilityClass.generateId();
assertEquals("fixed-id-123", id);
// 验证静态方法调用
mockedStatic.verify(() -> UtilityClass.generateId());
} // try-with-resources 块结束,模拟自动关闭
}
但是!我强烈建议你,除非万不得已(比如测试遗留代码),否则不要轻易模拟静态方法。 这通常是代码设计需要改进的信号(比如可以用依赖注入替代静态调用)。优先考虑重构你的代码,使其更容易测试。
4.2 使用@Spy进行部分模拟:何时用,怎么用?
@Spy是一个强大的工具,但也容易被滥用。它的典型使用场景是:一个类里大部分方法你都想用真实的,只有少数一两个方法需要模拟。
比如,你有一个FileProcessor类,它有一个readFile方法(从磁盘读文件,你想用真实的)和一个sendToNetwork方法(依赖外部网络,你想模拟)。
public class FileProcessor {
public String readFile(String path) throws IOException {
// 真实的文件读取逻辑...
return Files.readString(Paths.get(path));
}
public boolean sendToNetwork(String data) {
// 依赖外部网络服务...
return networkClient.send(data);
}
public String process(String path) throws IOException {
String content = readFile(path);
boolean sent = sendToNetwork(content);
return sent ? "Success" : "Failed";
}
}
// 测试类
class FileProcessorTest {
@Spy // 部分模拟FileProcessor本身
private FileProcessor fileProcessor;
@Test
void testProcess_Success() throws IOException {
// 模拟网络发送成功,但文件读取用真实逻辑(需要准备一个测试文件)
doReturn(true).when(fileProcessor).sendToNetwork(anyString());
// 假设我们有一个测试文件 test.txt
String result = fileProcessor.process("src/test/resources/test.txt");
assertEquals("Success", result);
// 验证sendToNetwork被调用了一次,且参数是文件内容
verify(fileProcessor).sendToNetwork("Hello Test File");
}
}
使用@Spy的注意事项:
- 初始化:和
@Mock一样,需要MockitoAnnotations.openMocks(this)或在类上使用@ExtendWith(MockitoExtension.class)。 - 小心真实逻辑:如果你用
when(spy.method()).thenReturn(...),Mockito会先去调用一次真实方法。如果这个方法有副作用(比如修改状态、抛异常),测试就可能出错。所以对@Spy对象,更推荐使用doReturn(...).when(spy).method()的语法,它不会调用真实方法。 - 不要滥用:如果一个类需要被
@Spy,并且很多方法都需要模拟,那可能意味着这个类职责过重,需要考虑拆分了。
4.3 利用Answer实现动态行为模拟
thenAnswer是Mockito提供的“大招”,它允许你根据方法的调用参数、调用次数甚至外部状态,动态地决定返回什么值或执行什么动作。这在你需要模拟一些复杂逻辑时非常有用。
场景一:根据参数动态返回值。
@Test
void testDynamicReturnByParam() {
when(userDao.findById(anyInt())).thenAnswer(invocation -> {
Integer id = invocation.getArgument(0); // 获取第一个参数
if (id < 0) {
return null;
} else if (id == 999) {
throw new RuntimeException("特殊ID异常");
} else {
return new User(id, "User_" + id);
}
});
assertNull(userDao.findById(-1));
assertEquals("User_123", userDao.findById(123).getName());
assertThrows(RuntimeException.class, () -> userDao.findById(999));
}
场景二:模拟多次调用返回不同值(替代链式thenReturn)。
@Test
void testMultipleCalls() {
final int[] callCount = {0}; // 用数组模拟一个可修改的计数器
when(randomIdGenerator.nextId()).thenAnswer(invocation -> {
callCount[0]++;
return "ID_" + callCount[0];
});
assertEquals("ID_1", randomIdGenerator.nextId());
assertEquals("ID_2", randomIdGenerator.nextId());
assertEquals("ID_3", randomIdGenerator.nextId());
}
场景三:模拟有副作用的调用。
@Test
void testMethodWithSideEffect() {
List<String> auditLog = new ArrayList<>();
when(auditService.log(anyString())).thenAnswer(invocation -> {
String message = invocation.getArgument(0);
auditLog.add("AUDIT: " + message); // 模拟记录日志的副作用
return null; // log方法可能是void,这里返回null
});
userService.deleteUser(1);
assertTrue(auditLog.get(0).contains("delete user 1"));
}
thenAnswer非常灵活,但也要慎用。因为它本质上是在测试代码里写了一段模拟逻辑,如果这段逻辑本身很复杂或者有bug,反而会让测试变得难以理解和维护。优先使用简单的thenReturn和thenThrow,只有在简单方式无法表达时才考虑thenAnswer。
4.4 测试私有方法?你可能想错了
经常有人问:怎么用Mockito测试一个类的私有方法?我的回答通常是:不要直接测试私有方法。
私有方法是类的内部实现细节,应该通过对公有方法的测试来间接覆盖。如果你觉得私有方法逻辑复杂到必须单独测试,那这往往是一个信号:这个私有方法应该被提取到另一个类中,变成一个公有方法,以便更好地进行测试和复用。
这就是“测试驱动设计”带来的好处之一——难以测试的代码,通常也是设计上可以改进的代码。通过重构,让代码变得更清晰、更可测,比强行用反射去测试一个私有方法要好得多。
如果真的遇到无法重构的遗留代码,迫不得已要测私有方法,可以使用Java反射,但这应该是最后的手段,并且要在测试中清楚地注释为什么这么做。
@Test
void testPrivateMethodAsLastResort() throws Exception {
MyClass myClass = new MyClass();
Method privateMethod = MyClass.class.getDeclaredMethod("hiddenLogic", String.class);
privateMethod.setAccessible(true); // 暴力反射!
String result = (String) privateMethod.invoke(myClass, "input");
assertEquals("expected", result);
}
5. 断言的艺术:不止是assertEquals
写测试,断言是灵魂。JUnit 5提供了丰富而强大的断言库,远不止assertEquals和assertTrue。
5.1 基础断言:确保结果符合预期
除了最常用的,还有一些基础断言能让你意图更明确:
assertNotEquals:断言不相等。assertSame/assertNotSame:断言是否是同一个对象(引用相等)。assertArrayEquals:断言数组内容相等。assertIterableEquals:断言可迭代对象(如List、Set)内容相等。assertLinesMatch:非常强大,用于断言字符串列表,支持正则表达式匹配。
@Test
void testBasicAssertions() {
// 对象相等(equals方法)
User user1 = new User(1, "Alice");
User user2 = new User(1, "Alice");
assertEquals(user1, user2); // 假设User重写了equals
// 对象引用相同
User sameRef = user1;
assertSame(user1, sameRef);
assertNotSame(user1, user2);
// 集合断言
List<String> expectedList = Arrays.asList("a", "b", "c");
List<String> actualList = new ArrayList<>(expectedList);
assertIterableEquals(expectedList, actualList);
// 行匹配(支持正则)
List<String> expectedLines = Arrays.asList(".*start.*", ">> data >>", "end");
List<String> actualLines = Arrays.asList("Processing start...", ">> data >>", "end");
assertLinesMatch(expectedLines, actualLines); // 第一行用正则匹配
}
5.2 异常与超时断言:测试非快乐路径
assertThrows:这是测试异常情况的利器。它接受一个异常类型和一个可执行代码块,如果代码块抛出了指定类型(或其子类)的异常,断言就通过。它还能返回抛出的异常对象,让你可以进一步检查异常信息、原因等。
@Test
void testException() {
// 旧方式(JUnit 4风格):用@Test(expected=...),但无法检查异常详情
// 新方式(JUnit 5):
IllegalArgumentException thrown = assertThrows(
IllegalArgumentException.class,
() -> validator.validateAge(-5), // 执行会抛异常的代码
"传入负数年龄应该抛出IllegalArgumentException" // 可选的失败信息
);
// 进一步断言异常的具体信息
assertTrue(thrown.getMessage().contains("年龄不能为负数"));
assertEquals("AgeValidator", thrown.getStackTrace()[0].getClassName());
}
assertTimeout和assertTimeoutPreemptively:用于测试性能或防止死锁。assertTimeout:在同一个线程中执行代码,如果超时则测试失败。assertTimeoutPreemptively:在另一个线程中执行代码,超时后会直接中断它。适用于测试可能无限循环的代码。
@Test
void testPerformance() {
// 断言任务在1秒内完成
assertTimeout(Duration.ofSeconds(1), () -> {
processor.expensiveCalculation();
});
// 如果任务可能卡死,用Preemptively
assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
externalService.callWithPotentialHang();
});
}
5.3 分组断言:一次验证多个条件
JUnit 5的assertAll是一个游戏规则改变者。在它出现之前,如果在一个测试方法里有多个断言,第一个断言失败后,后面的就不会执行了,你一次只能看到一个错误。assertAll允许你分组多个断言,它会执行所有断言,然后一次性报告所有失败。
@Test
void testUserCreation() {
User user = userService.createUser("john.doe@example.com", "John Doe", 30);
assertAll("用户属性校验",
() -> assertNotNull(user.getId(), "ID不应为空"),
() -> assertEquals("john.doe@example.com", user.getEmail(), "邮箱不匹配"),
() -> assertEquals("John Doe", user.getFullName(), "姓名不匹配"),
() -> assertTrue(user.getAge() >= 18, "用户应成年"),
() -> assertNotNull(user.getCreatedAt(), "创建时间不应为空")
);
}
运行这个测试,如果email和age都错了,你会同时看到两条失败信息,而不是只知道email错了,修复后运行才发现age也错了。这大大提升了调试效率。
5.4 第三方断言库:让断言更优雅
虽然JUnit的断言够用,但像AssertJ和Hamcrest这样的库提供了更流畅、更易读的DSL(领域特定语言)。
AssertJ示例:
import static org.assertj.core.api.Assertions.*;
@Test
void testWithAssertJ() {
List<User> users = userService.findActiveUsers();
assertThat(users)
.isNotEmpty()
.hasSize(5)
.extracting(User::getName) // 提取属性
.contains("Alice", "Bob")
.doesNotContain("Charlie")
.allMatch(name -> name.length() > 3); // 所有名字长度大于3
User user = users.get(0);
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("email", "alice@example.com")
.extracting(User::getAge, User::isActive)
.containsExactly(25, true); // 同时断言多个属性
}
AssertJ的链式调用读起来就像自然语言,而且错误信息非常详细。它对于集合、字符串、日期、异常等的断言支持尤其强大。很多SpringBoot项目默认就引入了AssertJ,不妨在项目中尝试一下,你会爱上这种写断言的方式。
6. 集成测试:当Mock不够用时
尽管我们推崇纯Mock的单元测试,但有些场景你确实需要启动Spring容器,进行集成测试。
6.1 @SpringBootTest:启动真正的容器
使用@SpringBootTest注解,SpringBoot会为你启动一个几乎完整的应用上下文(默认是WEB环境)。你可以通过webEnvironment属性来控制:
WebEnvironment.MOCK:启动一个模拟的Servlet环境(默认)。WebEnvironment.RANDOM_PORT:启动一个真实的嵌入式Servlet容器,并监听一个随机端口。WebEnvironment.DEFINED_PORT:监听定义的端口(如server.port配置的端口)。WebEnvironment.NONE:不提供任何Servlet环境。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate; // 用于发起HTTP请求
@Test
void testGetUserApi() {
ResponseEntity<User> response = restTemplate.getForEntity("/api/users/1", User.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getName()).isEqualTo("张三");
}
}
6.2 @MockBean与@SpyBean:容器内的模拟
在集成测试中,如果你想模拟某个Bean,就用@MockBean或@SpyBean。SpringBoot会把这个模拟对象放到容器里,替换掉原来的真实Bean。
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService; // 注入真实的Service
@MockBean
private PaymentGateway paymentGateway; // 模拟外部支付网关,替换容器中的Bean
@Test
void testPlaceOrderWithMockedGateway() {
// 模拟支付成功
when(paymentGateway.pay(any(BigDecimal.class))).thenReturn(new PaymentResult(true, "success"));
Order order = orderService.placeOrder(new OrderRequest(...));
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
verify(paymentGateway).pay(any(BigDecimal.class));
}
}
注意:@MockBean和@SpyBean是SpringBoot测试特有的,它们确保了模拟Bean的生命周期由Spring管理。在这种测试里,你不需要MockitoAnnotations.openMocks(this)。
6.3 测试切片:更精准的集成测试
启动整个应用上下文很重。SpringBoot提供了“测试切片”注解,只加载你关心的那部分配置,大大加快测试速度。
@WebMvcTest:专注于测试Spring MVC控制器。它会自动配置MockMvc,并只加载@Controller,@ControllerAdvice,@JsonComponent等相关的Bean,不会加载完整的服务层和仓库层。@DataJpaTest:专注于测试JPA仓库。它会配置一个内存数据库(如H2),并只加载@Entity和@Repository相关的Bean。@JsonTest:专注于测试JSON序列化/反序列化。@RestClientTest:专注于测试REST客户端。
@WebMvcTest(UserController.class) // 只加载UserController相关的配置
class UserControllerSliceTest {
@Autowired
private MockMvc mockMvc; // 注入MockMvc,用于模拟HTTP请求
@MockBean // 因为服务层没被加载,所以需要模拟
private UserService userService;
@Test
void getUserShouldReturnUser() throws Exception {
when(userService.getUser(1)).thenReturn(new User(1, "张三"));
mockMvc.perform(get("/api/users/1")) // 发起GET请求
.andExpect(status().isOk()) // 断言状态码200
.andExpect(jsonPath("$.name").value("张三")); // 断言JSON响应体
}
}
使用测试切片,你可以在需要测试控制器逻辑时,避免启动整个应用,测试速度接近单元测试,同时又利用了Spring的部分基础设施。
7. 让测试代码更优雅:最佳实践与模式
7.1 测试命名:见名知意
好的测试名应该清晰地表达三个要素:被测试的方法、测试的场景和预期的结果。我常用的几种格式:
methodName_Scenario_ExpectedResult:例如getUser_WithInvalidId_ThrowsNotFoundExceptionshould_ExpectedBehavior_when_StateUnderTest:例如should_ReturnEmptyList_when_NoUsersExistgiven_Precondition_when_MethodIsCalled_then_Outcome:例如given_UserIsAdmin_when_DeleteResource_then_Succeeds
团队内部统一一种风格即可,关键是清晰。
7.2 Given-When-Then模式:结构化你的测试
这是一个源自行为驱动开发(BDD)的模式,能让测试逻辑一目了然。
@Test
void givenUserExists_whenGetUserById_thenReturnUser() {
// Given: 准备测试数据(前置条件)
int userId = 1;
User expectedUser = new User(userId, "Alice");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// When: 执行被测操作
User actualUser = userService.getUserById(userId);
// Then: 验证结果和行为
assertThat(actualUser).isEqualTo(expectedUser);
verify(userRepository).findById(userId);
}
用空行把这三个部分隔开,测试的可读性会大大提高。
7.3 测试数据构建:使用Builder或工厂
在测试中硬编码对象创建,会导致测试代码冗长且难以维护。考虑使用建造者模式(Builder Pattern)或者像ObjectMother、TestDataFactory这样的模式来创建测试数据。
// 使用Lombok的@Builder
User testUser = User.builder()
.id(1)
.name("Test User")
.email("test@example.com")
.age(25)
.active(true)
.build();
// 或者使用一个专门的工厂类
public class TestUserFactory {
public static User createActiveUser(int id) {
return User.builder()
.id(id)
.name("User " + id)
.active(true)
.build();
}
public static User createInactiveUser(int id) {
return createActiveUser(id).toBuilder().active(false).build();
}
}
// 在测试中使用
User activeUser = TestUserFactory.createActiveUser(1);
7.4 保持测试独立与幂等
每个测试方法都应该是独立的,不依赖其他测试的运行结果,也不依赖外部状态(如数据库、文件系统)。使用@BeforeEach来初始化每个测试需要的新鲜数据,用@AfterEach来清理。
对于集成测试中需要数据库的操作,可以考虑使用@Transactional注解,这样每个测试方法都会在一个事务中运行,测试结束后自动回滚,数据库状态保持不变。或者使用像@DataJpaTest这样的切片测试,它默认会使用一个事务性的测试数据库。
7.5 测试覆盖率:工具与解读
使用JaCoCo这样的代码覆盖率工具来生成报告是很好的实践,但不要盲目追求高覆盖率数字。100%的覆盖率并不代表代码没bug,它只代表所有行都被执行过。更重要的是测试用例的质量,是否覆盖了主要的业务逻辑、边界条件和异常路径。
我通常关注:
- 行覆盖率:一个基本指标,确保没有“死代码”。
- 分支覆盖率:更重要,确保
if-else、switch的所有分支都被测试到。 - 重点覆盖核心业务逻辑和复杂算法,对于简单的Getter/Setter或自动生成的代码,不必强求。
把覆盖率报告作为一个发现未测试代码的辅助工具,而不是一个必须达成的目标。
更多推荐
所有评论(0)