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() 。在单元测试中,你不会真的去查数据库或调用支付网关。你会:

  1. 用Mockito创建 InventoryService PaymentService 的Mock对象。
  2. @InjectMocks 创建 真实的 OrderService 实例,并将上面的Mock对象注入给它。
  3. when().thenReturn() 告诉Mock对象 :当 checkStock 被调用时,返回“有库存”;当 process 被调用时,返回“支付成功”。
  4. 用JUnit的 @Test 执行 orderService.placeOrder(...)
  5. 最后,用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版本。

简单决策树

  1. 如果你测试的类不依赖Spring容器(如一个纯Java工具类,或使用 @InjectMocks 注入Mock的Service),用 @ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks
  2. 如果你要测试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默认不保证测试方法的执行顺序。这是设计如此,目的是让每个测试都是独立的。如果你的测试之间有依赖(比如一个测试创建数据,另一个测试读取),那说明你的测试设计有问题。

解决方案

  1. 重构测试 :确保每个 @Test 方法都能独立运行。使用 @BeforeEach 来准备数据, @AfterEach 来清理数据。
  2. 如果必须排序 (极少数情况,如性能测试),可以使用 @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)或者至少是测试伴随开发,你会发现它带来的信心和效率提升,远超过编写它所花费的时间。尤其是在团队协作和代码重构时,一套好的单元测试就是最坚实的后盾。

更多推荐