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的注意事项:

  1. 初始化:和@Mock一样,需要MockitoAnnotations.openMocks(this)或在类上使用@ExtendWith(MockitoExtension.class)。
  2. 小心真实逻辑:如果你用when(spy.method()).thenReturn(...),Mockito会先去调用一次真实方法。如果这个方法有副作用(比如修改状态、抛异常),测试就可能出错。所以对@Spy对象,更推荐使用doReturn(...).when(spy).method()的语法,它不会调用真实方法。
  3. 不要滥用:如果一个类需要被@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_ThrowsNotFoundException
  • should_ExpectedBehavior_when_StateUnderTest:例如 should_ReturnEmptyList_when_NoUsersExist
  • given_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,它只代表所有行都被执行过。更重要的是测试用例的质量,是否覆盖了主要的业务逻辑、边界条件和异常路径。

我通常关注:

  1. 行覆盖率:一个基本指标,确保没有“死代码”。
  2. 分支覆盖率:更重要,确保if-else、switch的所有分支都被测试到。
  3. 重点覆盖核心业务逻辑和复杂算法,对于简单的Getter/Setter或自动生成的代码,不必强求。

把覆盖率报告作为一个发现未测试代码的辅助工具,而不是一个必须达成的目标。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐