1. 项目概述与核心价值

在SpringBoot项目里写单元测试,这事儿听起来简单,但真动起手来,不少朋友都会卡在几个地方:Controller层的HTTP请求怎么测?Service层依赖的Dao或者第三方服务怎么隔离?测出来的结果到底靠不靠谱?我自己带团队做项目,最怕的就是代码上线后因为一个边界条件没测到,半夜被报警电话叫醒。所以,今天咱们不聊那些“单元测试很重要”的大道理,直接上干货,手把手带你用JUnit5、MockMvc和Mockito这套当前Java生态里最主流的组合拳,把SpringBoot项目的单元测试写得既扎实又高效。

这套组合的核心价值在于“各司其职,精准打击”。JUnit5是测试框架的骨架,提供了组织、运行测试的基础能力;MockMvc专门用来模拟HTTP请求,测试Controller层的行为,让你不用启动整个Web容器就能验证接口逻辑;而Mockito则是“造假”大师,能轻松创建和操控模拟对象(Mock),把那些难搞的外部依赖(比如数据库、消息队列、第三方API)隔离开,让你能专心测试当前代码的逻辑。把它们仨用好了,你的测试用例就会像手术刀一样精准,覆盖率高,运行速度快,而且维护成本低。接下来,我们就从环境搭建开始,一步步拆解每个工具的核心用法和那些容易踩坑的细节。

2. 环境准备与项目基础配置

2.1 依赖引入与版本选择

第一步,咱们得把“武器”配齐。在SpringBoot项目里,通常我们使用Maven或Gradle来管理依赖。我这里以Maven为例,在 pom.xml 中添加必要的依赖。SpringBoot的 spring-boot-starter-test 起步依赖已经为我们整合了JUnit5、Mockito、AssertJ、Hamcrest等一整套测试工具,是首选。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

这里有个关键点: 务必确认你的SpringBoot版本与 spring-boot-starter-test 中集成的JUnit5版本兼容 。Spring Boot 2.2.x及以上版本默认就集成了JUnit5。你可以通过查看依赖树( mvn dependency:tree )来确认。如果因为历史原因项目还在用JUnit4,迁移到JUnit5是值得投入的,因为JUnit5在参数化测试、扩展模型等方面强太多了。

注意 spring-boot-starter-test 默认会引入一个老版本的Mockito(如3.x)。如果你想使用Mockito 4或5的一些新特性(比如对静态方法、构造方法模拟的更好支持),可以显式地排除旧版本并引入新版本。但要注意,Mockito 5.x需要Java 11+。对于大多数项目,使用starter提供的版本已经足够稳定。

2.2 测试类结构与基础注解

在JUnit5中,测试类的结构非常清晰。一个标准的测试类大概长这样:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

// 关键注解:表明这是一个Spring Boot测试,会加载应用程序上下文。
// 如果只测某个切片(如Web层、数据层),可以用更轻量的注解,后面会讲。
@SpringBootTest
// JUnit5通过扩展机制集成Spring,这个注解是桥梁。
@ExtendWith(SpringExtension.class)
public class MyServiceTest {

    @Test
    void testSomething() {
        // 你的测试逻辑
    }
}

这里重点说下 @SpringBootTest 。这个注解功能强大,但代价是它会加载完整的应用上下文,启动所有Bean,速度比较慢。 它更适合做集成测试 。对于纯粹的、隔离的单元测试,我们其实有更轻量级的选择,这也是很多新手容易混淆的地方。比如,如果你只想测试Web层(Controller),可以用 @WebMvcTest ;只想测试数据层(Repository),可以用 @DataJpaTest 。它们只加载相关的上下文切片,速度飞快。我们今天的重点虽然是“单元测试”,但会覆盖从Controller到Service的测试场景,所以会根据情况选择不同的注解。

3. 核心组件深度解析与实战应用

3.1 JUnit5:现代测试框架的基石

JUnit5和JUnit4看起来像,但内核完全不同。它由三个子模块组成:JUnit Platform(在JVM上启动测试的基础服务)、JUnit Jupiter(新的编程模型和扩展模型)、JUnit Vintage(用于兼容运行JUnit4/3的测试)。我们日常编码主要和JUnit Jupiter打交道。

几个必知的核心注解:

  • @Test :标记一个方法是测试方法。和JUnit4的 @Test 不同,它来自 org.junit.jupiter.api.Test 包。
  • @BeforeEach / @AfterEach :在每个 @Test 方法 之前/之后 执行。用来做测试数据的准备和清理,替代JUnit4的 @Before / @After
  • @BeforeAll / @AfterAll :在所有 @Test 方法 之前/之后 执行 一次 。方法必须是 static 的。适合做耗时的全局初始化,比如建临时数据库。
  • @DisplayName :为测试类或方法设置一个易读的名称,会在测试报告中显示,非常有用。
  • @Disabled :临时禁用某个测试,相当于JUnit4的 @Ignore

一个容易被忽略但极其强大的功能:参数化测试。 它能用不同的输入数据多次运行同一个测试逻辑,完美测试边界条件。比如测试一个计算器除法方法,你需要测试正数、负数、除零等情况。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ParameterizedTestExample {

    @ParameterizedTest
    @ValueSource(ints = {1, 3, 5, -3, 15})
    void testIsOdd(int number) {
        assertTrue(number % 2 != 0);
    }
}

除了 @ValueSource ,还有 @CsvSource (用CSV格式提供多参数)、 @MethodSource (从一个工厂方法获取流式数据)等,功能非常灵活。

3.2 Mockito:依赖隔离的艺术

单元测试的核心原则之一是“隔离”。你的Service方法依赖了Dao,测试Service时,我们不应该真的去连数据库,因为那会引入不确定性(网络、数据状态)和慢速。Mockito的作用就是创建一个Dao的“替身”(Mock对象),并规定好这个替身在各种情况下的行为(当调用A方法时,返回B结果)。

基本使用三步曲:

  1. 创建Mock对象 MyDao daoMock = Mockito.mock(MyDao.class);
  2. 打桩(Stubbing) :定义Mock对象的行为。 when(daoMock.findById(1L)).thenReturn(new User(“张三”));
  3. 将Mock对象注入被测试类 :通常通过构造器或Setter注入。在Spring测试中,更常用 @MockBean 注解(后面会讲)。

进阶技巧:验证交互。 有时候,我们不仅关心方法返回什么,还关心方法是否被调用了、调用了多少次、以什么参数调用的。这就是Mockito的验证(Verification)功能。

@Test
void testUserUpdate() {
    UserService userService = new UserService(userDaoMock, emailServiceMock);
    User user = new User(1L, “李四”);

    userService.updateUserName(1L, “李四”);

    // 验证 userDaoMock 的 save 方法被调用了一次,并且参数是 user 对象
    verify(userDaoMock, times(1)).save(user);
    // 验证 emailServiceMock 的 sendEmail 方法从未被调用
    verify(emailServiceMock, never()).sendEmail(any());
}

实操心得:关于 @Mock @MockBean 的选择。

  • @Mock :是纯Mockito的注解,需要在测试类上添加 @ExtendWith(MockitoExtension.class) 才能生效。它创建一个普通的Mockito mock对象, Spring容器不知道它的存在
  • @MockBean :是Spring Boot Test提供的注解。它会在Spring的测试应用上下文中注册一个Mockito mock对象,并替换掉上下文中同类型的Bean。 当你测试的类是通过 @Autowired 从Spring容器注入依赖时,必须用 @MockBean

简单记:如果你用new手动创建被测试对象,用 @Mock + @ExtendWith(MockitoExtension.class) ,更轻量。如果你测试Spring管理的Bean(比如加了 @Service 的类),用 @MockBean + @SpringBootTest 或其它切片测试注解。

3.3 MockMvc:Web层的精准测试工具

Controller层测试的传统方式是启动一个内嵌的Servlet容器(如Tomcat),然后发真实的HTTP请求。这太慢了,而且是集成测试。MockMvc可以模拟Servlet容器,直接调用Controller的方法,并检查返回的模型(Model)、视图(View)或JSON响应。

如何初始化MockMvc? 有两种主流方式,推荐第一种:

  1. @WebMvcTest + @AutoConfigureMockMvc (推荐) @WebMvcTest 是一个切片测试注解,它只加载Web层相关的Bean(Controller, ControllerAdvice, Filter等),不会加载Service、Repository。速度极快。它会自动配置一个MockMvc实例。

    @WebMvcTest(UserController.class) // 只加载UserController这一个Controller
    @AutoConfigureMockMvc(addFilters = false) // 自动配置MockMvc,addFilters=false可禁用安全过滤器方便测试
    class UserControllerTest {
    
        @Autowired
        private MockMvc mockMvc;
    
        @MockBean // 因为Controller依赖UserService,我们需要Mock它
        private UserService userService;
    
        @Test
        void getUserById() throws Exception {
            // 打桩
            when(userService.getUserById(1L)).thenReturn(new User(1L, “测试用户”));
    
            // 执行请求并断言
            mockMvc.perform(get(“/api/users/1”) // 模拟GET请求
                    .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk()) // 断言HTTP状态码是200
                    .andExpect(jsonPath(“$.name”).value(“测试用户”)); // 断言JSON响应体中的name字段
        }
    }
    
  2. @SpringBootTest 中手动构建 :如果你用了 @SpringBootTest (加载了完整上下文),可以手动注入一个 WebApplicationContext 来构建MockMvc。这种方式更重,通常在你需要测试Web层与其它层(如安全)的集成时才用。

MockMvc的核心API:

  • mockMvc.perform() :构建一个请求。里面可以链式调用 .contentType() .content() (传请求体JSON)、 .param() (传表单参数)、 .header() 等方法。
  • .andExpect() :对结果进行断言。这是最常用的部分。
    • status().isOk() :状态码200。
    • jsonPath(“$.field”).value(“expected”) :使用JsonPath断言JSON响应。
    • content().string(“expected”) :断言响应内容字符串。
    • view().name(“home”) :断言视图名。
  • .andDo() :执行一些额外的操作,比如打印请求/响应详情到控制台( MockMvcResultHandlers.print() ),这在调试时非常有用。

4. 分层单元测试实战演练

光说不练假把式,我们用一个经典的“用户管理”模块来串联所有知识点。假设我们有 UserController UserService UserRepository 三层。

4.1 Service层单元测试:Mockito的主场

Service层包含核心业务逻辑,是单元测试的重点。我们的目标是隔离数据库(Repository)和任何外部服务。

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.Mockito.*;

// 使用Mockito扩展,这样@Mock和@InjectMocks才能生效
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepositoryMock; // 模拟Repository

    @Mock
    private EmailService emailServiceMock; // 模拟一个外部邮件服务

    @InjectMocks
    private UserService userService; // Mockito会自动将上面的mock注入到这里

    @Test
    void getUserById_Success() {
        // 1. 准备数据 & 打桩
        Long userId = 1L;
        User expectedUser = new User(userId, “张三”, “zhangsan@example.com”);
        when(userRepositoryMock.findById(userId)).thenReturn(Optional.of(expectedUser));

        // 2. 执行测试方法
        User actualUser = userService.getUserById(userId);

        // 3. 断言结果
        assertNotNull(actualUser);
        assertEquals(“张三”, actualUser.getName());
        assertEquals(userId, actualUser.getId());

        // 4. 验证交互(可选)
        verify(userRepositoryMock, times(1)).findById(userId);
        verify(emailServiceMock, never()).sendEmail(any()); // 这个方法不应该被调用
    }

    @Test
    void getUserById_NotFound() {
        Long userId = 999L;
        when(userRepositoryMock.findById(userId)).thenReturn(Optional.empty());

        // 断言会抛出特定的异常
        assertThrows(UserNotFoundException.class, () -> {
            userService.getUserById(userId);
        });
        verify(userRepositoryMock).findById(userId);
    }

    @Test
    void createUser_Success() {
        UserDto userDto = new UserDto(“李四”, “lisi@example.com”);
        User savedUser = new User(1L, userDto.getName(), userDto.getEmail());

        // 模拟保存操作,any(User.class)表示匹配任何User对象参数
        when(userRepositoryMock.save(any(User.class))).thenReturn(savedUser);
        // 模拟发送邮件成功
        doNothing().when(emailServiceMock).sendWelcomeEmail(anyString());

        User result = userService.createUser(userDto);

        assertNotNull(result.getId());
        assertEquals(userDto.getName(), result.getName());

        // 验证保存和发送邮件都被调用了
        verify(userRepositoryMock).save(any(User.class));
        verify(emailServiceMock).sendWelcomeEmail(userDto.getEmail());
    }
}

关键点解析:

  • @InjectMocks :它会尝试用构造器注入、setter注入或字段注入的方式,将本类中 @Mock (或 @Spy )注解创建的mock对象,注入到被标注的实例中。非常方便。
  • any() :这是一个参数匹配器(Argument Matcher),表示“任何此类型的参数”。当你不太关心调用时传入的具体对象,只关心方法被调用这个事实时,就用它。类似的还有 eq() (等于某个值)、 anyString() 等。
  • doNothing().when(mock).method() :用于对返回类型是 void 的方法进行打桩。 when(mock.method()).thenReturn(...) 的语法对void方法不适用。

4.2 Controller层单元测试:MockMvc的舞台

Controller层测试关注HTTP协议层面的输入输出是否正确,业务逻辑依赖的Service层我们用MockBean隔离。

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Arrays;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class) // 关键:只加载Web层和UserController
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper; // Spring Boot会自动配置,用于JSON序列化/反序列化

    @MockBean
    private UserService userService;

    @Test
    void getAllUsers_ShouldReturnUserList() throws Exception {
        // 准备模拟数据
        List<User> userList = Arrays.asList(
                new User(1L, “用户一”, “user1@test.com”),
                new User(2L, “用户二”, “user2@test.com”)
        );
        when(userService.getAllUsers()).thenReturn(userList);

        mockMvc.perform(get(“/api/users”)
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print()) // 打印请求响应详情,调试神器
                .andExpect(status().isOk())
                .andExpect(jsonPath(“$”, hasSize(2))) // 断言返回的JSON数组长度为2
                .andExpect(jsonPath(“$[0].name”).value(“用户一”))
                .andExpect(jsonPath(“$[1].email”).value(“user2@test.com”));
    }

    @Test
    void createUser_WithValidInput_ShouldReturnCreated() throws Exception {
        UserDto userDto = new UserDto(“新用户”, “new@test.com”);
        User createdUser = new User(10L, userDto.getName(), userDto.getEmail());
        when(userService.createUser(any(UserDto.class))).thenReturn(createdUser);

        // 将对象转换为JSON字符串作为请求体
        String requestBody = objectMapper.writeValueAsString(userDto);

        mockMvc.perform(post(“/api/users”)
                        .contentType(MediaType.APPLICATION_JSON) // 声明请求体是JSON
                        .content(requestBody)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated()) // 断言HTTP 201 Created
                .andExpect(header().string(“Location”, “/api/users/10”)) // 断言Location头
                .andExpect(jsonPath(“$.id”).value(10));
    }

    @Test
    void createUser_WithInvalidInput_ShouldReturnBadRequest() throws Exception {
        // 测试一个name为空的无效请求
        UserDto invalidUserDto = new UserDto(“”, “invalid-email”);
        String requestBody = objectMapper.writeValueAsString(invalidUserDto);

        mockMvc.perform(post(“/api/users”)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestBody))
                .andExpect(status().isBadRequest()); // 假设Controller用了@Valid,会返回400
        // 可以进一步断言返回的错误信息结构
    }
}

注意事项:

  • @WebMvcTest 默认会启用Spring Security。如果你的Controller有安全限制,测试时需要配置用户凭证,或者像上面例子一样在 @AutoConfigureMockMvc 里设置 addFilters = false 来禁用安全过滤器链(仅用于测试)。
  • ObjectMapper 是Jackson库的核心,用来处理JSON。Spring Boot自动配置了一个,在测试中直接 @Autowired 注入使用即可。
  • jsonPath 语法非常强大,类似于XPath for JSON。 $ 表示根节点, $[0] 表示数组第一个元素, $.field 表示字段。

4.3 Repository层测试:与真实数据库的轻量集成

严格来说,Repository(或Dao)层的测试属于集成测试范畴,因为它需要和真实的数据库交互。但Spring Boot提供了 @DataJpaTest 注解来优雅地处理这个问题。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import javax.persistence.EntityManager;
import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest // 关键:只加载JPA相关的配置和Bean,使用一个内存数据库(如H2)
class UserRepositoryTest {

    @Autowired
    private TestEntityManager testEntityManager; // 用于测试的EntityManager,方便操作

    @Autowired
    private UserRepository userRepository; // 这是我们要测试的真实Repository

    @Test
    void findByName_ShouldReturnUser() {
        // 使用TestEntityManager准备数据,不经过Repository
        User savedUser = testEntityManager.persistFlushFind(new User(null, “王五”, “wangwu@test.com”));

        // 调用真实的Repository方法
        User foundUser = userRepository.findByName(“王五”).orElse(null);

        // 断言
        assertThat(foundUser).isNotNull();
        assertThat(foundUser.getEmail()).isEqualTo(savedUser.getEmail());
        assertThat(foundUser.getId()).isEqualTo(savedUser.getId());
    }

    @Test
    void findByEmail_WhenNotExist_ShouldReturnEmpty() {
        Optional<User> found = userRepository.findByEmail(“nonexist@test.com”);
        assertThat(found).isEmpty(); // 使用AssertJ的流式断言,可读性更好
    }
}

核心要点:

  • @DataJpaTest 会自动配置一个内存数据库(默认是H2),并只初始化 @Entity 类和Spring Data JPA的Repository。它 不会 加载 @Service @Controller 等其他Bean,所以速度很快。
  • 它默认在每个测试方法后回滚事务,确保测试之间数据隔离。
  • TestEntityManager 是专门为测试设计的,提供了 persistFlushFind 这类便捷方法,能立即持久化并刷新,然后查找,确保数据已写入“数据库”(内存中)。
  • 对于Repository层的测试,我们主要验证自定义的查询方法(如 findByName )是否编写正确,以及JPA的映射关系是否正确。基础的 save findById 等方法由Spring Data JPA提供,通常不需要测试。

5. 高级技巧与最佳实践

5.1 测试代码的组织与命名

混乱的测试代码比没有测试更可怕。好的命名和组织能让测试意图一目了然。

命名约定: 一个流行的模式是 [MethodUnderTest]_[Scenario]_[ExpectedBehavior] 。例如:

  • getUserById_WithValidId_ReturnsUser
  • createUser_WithDuplicateEmail_ThrowsException
  • updateUser_WhenUserNotFound_ThrowsNotFoundException

测试类结构: 使用 @Nested 注解对测试进行逻辑分组,让测试报告更清晰。

@DisplayName(“UserService 测试套件”)
class UserServiceTest {

    @Nested
    @DisplayName(“当查询用户时”)
    class WhenGettingUser {
        @Test
        @DisplayName(“给定有效ID,应返回用户”)
        void withValidId_shouldReturnUser() { ... }

        @Test
        @DisplayName(“给定无效ID,应抛出异常”)
        void withInvalidId_shouldThrowException() { ... }
    }

    @Nested
    @DisplayName(“当创建用户时”)
    class WhenCreatingUser {
        // ... 相关测试
    }
}

5.2 静态方法、构造方法及final类的模拟

有时候,你不得不面对一些遗留代码,里面调用了静态工具类(如 DateUtils.format() )或第三方库的final类。早期的Mockito(3.x之前)对此无能为力。但现在,通过 mockito-inline 模块,可以模拟静态方法、构造方法甚至final类。

步骤:

  1. 引入依赖(如果starter里的Mockito版本够新,可能已包含):
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-inline</artifactId>
        <scope>test</scope>
    </dependency>
    
  2. 在测试类上使用 @ExtendWith(MockitoExtension.class)
  3. 使用 Mockito.mockStatic() 来模拟静态方法。
import org.mockito.MockedStatic;
import static org.mockito.Mockito.*;

@Test
void testWithStaticMethod() {
    // 模拟一个静态工具类
    try (MockedStatic<MyStaticUtils> mockedStatic = mockStatic(MyStaticUtils.class)) {
        // 打桩静态方法
        mockedStatic.when(() -> MyStaticUtils.generateId()).thenReturn(“fixed-id-123”);

        // 调用被测代码,其内部会调用MyStaticUtils.generateId()
        String result = myService.doSomething();

        assertEquals(“result-with-fixed-id-123”, result);
        // 可以验证静态方法是否被调用
        mockedStatic.verify(() -> MyStaticUtils.generateId());
    } // try-with-resources 块结束,静态模拟会自动关闭
}

重要提醒 :模拟静态方法或构造器是“终极武器”,意味着你的代码设计可能出现了问题(过度耦合、职责不清)。在编写新代码时,应优先考虑通过依赖注入来传递这些依赖,让代码更可测。这只在改造难以变动的旧代码时使用。

5.3 测试覆盖率与持续集成

写测试不是为了自我感动,而是为了保障质量。我们需要工具来量化测试效果。

Jacoco(Java Code Coverage) 是最流行的代码覆盖率工具之一。它可以和Maven/Gradle以及CI/CD工具(如Jenkins, GitLab CI)无缝集成。

Maven配置示例:

<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>
        <!-- 可选的:设置覆盖率检查规则,在CI中失败构建 -->
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum> <!-- 要求行覆盖率至少80% -->
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

运行 mvn clean test 后,Jacoco会生成报告,通常位于 target/site/jacoco/index.html ,用浏览器打开可以看到详细的覆盖率分析(行覆盖、分支覆盖等)。

最佳实践建议:

  • 不要盲目追求100%覆盖率 。覆盖率达到70%-90%通常已经很好。重点覆盖核心业务逻辑、复杂分支和边界条件。
  • 将测试作为CI/CD流水线的必过环节 。每次代码提交或合并请求,都必须通过所有测试,并且覆盖率不能低于预设阈值。
  • 区分单元测试和集成测试 。单元测试要快(秒级),可以频繁运行。集成测试、端到端测试可以放在单独的Maven profile或CI的不同阶段运行。

6. 常见问题排查与调试技巧

即使按照最佳实践来,写测试时还是会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。

问题1: @MockBean 注入失败,测试运行时提示 NoSuchBeanDefinitionException

  • 可能原因 :你用了 @WebMvcTest(SomeController.class) ,但你的Controller依赖了一个没有被 @MockBean 模拟的Service,而这个Service又依赖了其他Repository或Component。因为 @WebMvcTest 只加载Web层相关的Bean,这些下游依赖不会被加载,导致创建Controller Bean失败。
  • 解决方案 :确保Controller所有直接依赖的Bean都被 @MockBean 模拟了。或者,如果这个测试确实需要更完整的上下文,考虑使用 @SpringBootTest 配合 @AutoConfigureMockMvc ,但要做好速度变慢的心理准备。

问题2:Mockito打桩不生效,实际调用了真实方法。

  • 可能原因1 :你mock的对象并不是被测试对象实际使用的那个。在使用 @InjectMocks 时,确保被测试类是通过构造器或setter注入依赖的。如果被测试类内部用 new 关键字创建了依赖对象,Mockito是无法注入mock的。
  • 可能原因2 :你在对final方法、静态方法或private方法打桩。默认情况下Mockito不能mock这些。对于final类和final方法,需要添加 mockito-inline 依赖。
  • 排查技巧 :在测试中加一行 verify(mockObj, times(1)).someMethod(...); 。如果验证失败,说明mock对象根本没被调用,问题出在依赖注入或对象创建上。

问题3:使用 @DataJpaTest 时,找不到自定义的查询方法(如 findByName )。

  • 可能原因 :你的查询方法名可能不符合Spring Data JPA的命名约定,或者JPQL/HQL写错了。
  • 解决方案 :首先检查方法名是否遵循“findBy…”、“readBy…”、“queryBy…”、“countBy…”等前缀。如果是 @Query 注解的JPQL,在测试环境下运行一下,看控制台是否打印了SQL创建语句,检查生成的SQL是否正确。一个很实用的调试技巧是在 application-test.properties 中开启SQL日志:
    spring.jpa.show-sql=true
    spring.jpa.properties.hibernate.format_sql=true
    logging.level.org.hibernate.SQL=DEBUG
    logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
    

问题4:MockMvc测试时,请求体中的日期(LocalDateTime)序列化/反序列化出错。

  • 场景 :你的 UserDto 里有一个 LocalDateTime createTime 字段,测试时发现Jackson无法将JSON字符串反序列化成对象。
  • 解决方案 :确保你的项目中配置了Jackson对Java 8时间类型的支持。通常Spring Boot会自动配置,但如果出现问题,可以检查是否引入了 jackson-datatype-jsr310 依赖,并且在 ObjectMapper 中注册了 JavaTimeModule 。在测试中,你可以直接使用Spring自动注入的 ObjectMapper ,它通常是配置好的。

问题5:测试本身通过,但一运行就报 java.lang.OutOfMemoryError: Metaspace 或 PermGen space。

  • 可能原因 :每个 @SpringBootTest @WebMvcTest 注解的测试类,Spring都会为其创建一个独立的应用程序上下文(ApplicationContext)。如果测试类很多,且没有重用上下文,会导致元空间(Metaspace)被大量加载的类占满。
  • 解决方案 :Spring Test框架本身支持上下文缓存。确保你的测试类结构相似(使用相同的配置)。你可以通过 @DirtiesContext 注解来控制上下文是否需要清理后重建。对于大多数情况,Spring能智能地缓存和重用上下文。如果问题严重,可以考虑将一些重量级的集成测试拆分到单独的模块或使用 @TestPropertySource 来让测试共享更相似的配置。

写单元测试是一个需要持续练习和反思的技能。一开始可能会觉得繁琐,但当你发现它帮你提前拦截了数个线上bug,或者在重构代码时给你十足的信心,你就会觉得所有投入都是值得的。从今天开始,尝试为你正在开发的新功能先写上测试,你会发现你的代码设计也会在不自觉中变得更好——因为可测试的代码,往往是松耦合、职责清晰的代码。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐