Junit到Springboot单元测试

3 阅读14分钟

第一部分 junit与springboot的前世今生

一、junit4与junit5及springboot中的使用

在现代软件开发中,单元测试是确保代码质量的重要环节。Spring Boot框架通过整合JUnit,为开发者提供了便捷的单元测试支持。

1.1 Spring Boot中JUnit版本的变化

在Spring Boot 2.0之前,框架默认使用JUnit 4作为测试平台。然而,从Spring Boot 2.0开始,JUnit 5成为默认的测试框架。以下是Spring Boot不同版本中JUnit版本的对比:

Spring Boot版本默认JUnit版本
1.xJUnit 4
2.xJUnit 5

例如,Spring Boot 2.2.0使用JUnit 5.5.2版本。开发者可以通过POM文件确认具体版本。


1.2 POM文件配置

在Spring Boot项目中,单元测试的依赖通过spring-boot-starter-test启动器引入。以下是POM文件的配置示例:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

1.3 排除JUnit Vintage Engine

junit-vintage-engine是JUnit 3和JUnit 4的运行支持平台。默认情况下,Spring Boot测试启动器会排除该依赖,以鼓励开发者使用JUnit 5。如果需要使用JUnit 4,可以移除<exclusions>标签。


二、JUnit 4与JUnit 5的对比

以下是JUnit 4和JUnit 5的主要差异:

特性JUnit 4JUnit 5
注解@RunWith@Test@SpringBootTest@Test
默认启动类支持需要手动指定启动类自动检测启动类
测试方法支持需要@Test注解需要@Test注解
扩展支持有限更强大的扩展机制

三、JUnit 4测试代码示例

以下是一个基于JUnit 4的测试代码示例:

import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.junit.Test;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = SpringBootExceptionAndJourneyApplication.class)
public class UserServiceTest {

    @Test
    public void testAddUser() {
        System.out.println("JUnit 4 测试方法运行成功!");
    }
}


四、JUnit 5测试代码示例

以下是一个基于JUnit 5的测试代码示例:

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

@SpringBootTest
public class UserServiceTest {

    @Test
    public void testAddUser() {
        System.out.println("JUnit 5 测试方法运行成功!");
    }
}

五、实际案例:持久层与业务层测试

假设我们有一个UserDAOUserService,以下是它们的实现代码:

5.1 持久层代码

public class UserDAOImpl {
    public void insert() {
        System.out.println("INSERT INTO USER VALUES(...)");
    }
}

5.2 业务层代码

import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl {
    private final UserDAOImpl userDAO;

    public UserServiceImpl(UserDAOImpl userDAO) {
        this.userDAO = userDAO;
    }

    public void addUser() {
        userDAO.insert();
    }
}

5.3 测试代码

import org.springframework.boot.test.context.SpringBootTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

@SpringBootTest
public class UserServiceTest {

    @Autowired
    private UserServiceImpl userService;

    @Test
    public void testAddUser() {
        userService.addUser();
    }
}


六、常见问题与解答

以下是关于Spring Boot整合JUnit单元测试的常见问题:

问题答案
1. Spring Boot默认使用哪个JUnit版本?Spring Boot 2.x默认使用JUnit 5。
2. 如何在Spring Boot中使用JUnit 4?需要移除POM文件中junit-vintage-engine的排除配置。
3. JUnit 5是否需要指定启动类?默认情况下,JUnit 5会自动检测启动类。如果项目中有多个启动类,需要通过@SpringBootTest(classes = ...)指定。
4. JUnit 5的测试方法是否需要@RunWith注解?不需要,JUnit 5使用@SpringBootTest注解即可。
5. 如何确认JUnit版本?通过POM文件中的依赖版本确认,例如junit-jupiter-api的版本号。

七、流程图:Spring Boot整合JUnit单元测试的步骤

创建Spring Boot项目添加spring-boot-starter-test依赖确认JUnit版本编写测试代码运行测试

image.png

第一部分总结

我们现在直接使用junit5在springboot项目中使用即可。使用方式为添加注解@SpringBootTest(会自己找启动类,有多个则通过classes = ...指定)就可以让容器启动来测试。

疑问1:不是说5的话boot项目需要ext注解吗?

AI解答: @ExtendWith 确实是 JUnit 5 的核心注解。

不过,在 Spring Boot 项目里,你通常不需要手动添加它

🤔 为什么会有这个误解?

你的理解是对的:JUnit 5 确实使用 @ExtendWith 来集成 Spring 的功能,它取代了 JUnit 4 的 @RunWith

在早期版本的 Spring Boot (2.1.x 之前),确实需要手动在测试类上写上 @ExtendWith(SpringExtension.class)

✅ 现在的实际情况是怎样的?

从 Spring Boot 2.1.x 版本开始,官方为了让开发更便捷,做了一个“贴心”的优化。

@SpringBootTest 这个我们最常用的注解,其内部源码已经自动包含了 @ExtendWith(SpringExtension.class)

你可以把它理解为:@SpringBootTest 是一个“大礼包”,里面已经帮你把 @ExtendWith 这个“工具”放进去了。所以,当你使用 @SpringBootTest 时,就无需再重复添加了。

代码对比
  • 早期写法 (已过时)

    // 在 Spring Boot 2.1 之前,需要这样写
    @ExtendWith(SpringExtension.class) // 手动启用 Spring 扩展
    @SpringBootTest
    public class OrderServiceTest {
        // ...
    }
    
  • 现在写法 (推荐) // 现在,这样写就足够了! @SpringBootTest public class OrderServiceTest { // ... }

💡 什么时候才需要手动写 @ExtendWith

只有在一些不使用 @SpringBootTest 的底层测试场景中,你才可能需要手动添加。

例如,如果你只用 @ContextConfiguration 来加载一个特定的 Spring 配置文件进行测试,这时就需要手动通过 @ExtendWith(SpringExtension.class) 来启用 Spring 的扩展支持。

但对于绝大多数 Spring Boot 的集成测试来说,放心地只使用 @SpringBootTest 吧!

疑问2:有必要每次启动整个容器吗?请看第二部分

第二部分 springboot项目怎么进行mock

很多开发者在刚开始写测试时,都会纠结是“启动整个 Spring 容器”还是“只测一个类”。

简单来说, “优雅”的测试 = 选对测试类型 + 用好 Mock 隔离

🤔 一、Mock 是啥?解决啥问题?

1. 通俗解释:什么是 Mock?

想象一下,你是一名汽车引擎设计师(你在测试 UserService),你需要测试引擎(UserService)的运转是否良好。

  • 如果不使用 Mock:你必须把引擎装到整辆车里,连上油箱、排气管、轮胎,甚至要把车开到路上(启动 Spring 容器、连接真实数据库、连接真实 Redis)。这非常慢,而且如果车打不着火,你不知道是引擎坏了,还是油箱漏了,还是轮胎没气。

  • 使用 Mock:你在实验室里,给引擎接上一个模拟油箱(Mock Repository)和一个模拟排气管(Mock EmailService)。

    • 你可以控制“模拟油箱”里有多少油(Stubbing:预设返回值)。
    • 你可以观察引擎是否真的向“模拟排气管”排气了(Verification:验证调用)。
    • 重点:你只测试引擎本身,不关心外面的世界。
2. Mock 解决了什么问题?
  • 速度极快:不需要启动 Spring 容器,不需要连接数据库(IO 操作最耗时)。单元测试通常是毫秒级的。
  • 隔离性强:如果测试失败了,肯定是你的 Service 逻辑写错了,而不是因为数据库连不上,或者网络波动。
  • 覆盖极端情况:你可以轻松模拟“数据库挂了”或者“查不到数据”的场景,而在真实环境中很难故意制造这些故障。

✨ 二、如何“优雅”地执行 Boot 项目单元测试?

在 Spring Boot 中,优雅的核心在于 “各司其职” 。不要把所有测试都写成 @SpringBootTest(启动全容器),那样太慢了。

我们需要区分两种测试策略:

1. 纯单元测试 (Unit Test) —— 推荐用于 Service 层

特点:完全不启动 Spring 容器,纯 Java 代码运行。
工具:JUnit 5 + Mockito (@Mock@InjectMocks)。

场景:测试 UserService 里的业务逻辑(比如计算价格、校验参数)。

代码示例

// 1. 不需要 @SpringBootTest,不需要启动容器!
// 使用 Mockito 的扩展来初始化 Mock 对象
@ExtendWith(MockitoExtension.class) 
class UserServiceTest {

    // 2. @Mock: 创建一个假的 UserRepository,它是空的,需要你喂数据
    @Mock
    private UserRepository userRepository;

    // 3. @InjectMocks: 创建 UserService 实例,并把上面的 userRepository 塞进去
    @InjectMocks
    private UserService userService;

    @Test
    void shouldFindUserById() {
        // --- Arrange (准备) ---
        User mockUser = new User(1L, "Alice");
        // 告诉 Mock 对象:当有人调用 findById(1L) 时,返回 mockUser
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        // --- Act (执行) ---
        User result = userService.findById(1L);

        // --- Assert (断言) ---
        assertThat(result.getName()).isEqualTo("Alice");
        
        // --- Verify (验证) ---
        // 验证 userRepository.findById 是否真的被调用了一次
        verify(userRepository, times(1)).findById(1L);
    }
}

优雅点:速度飞快,完全隔离。

2. 切片测试 / 集成测试 (Slice Test) —— 推荐用于 Controller 或 Repository

特点:只启动 Spring 容器的一部分(比如只启动 Web 层,或者只启动 JPA 层)。
工具@WebMvcTest (控制器), @DataJpaTest (数据库), @MockBean

场景:测试 UserController 的接口映射是否正确,或者测试 SQL 语句是否正确。

代码示例 (测试 Controller)

// 1. @WebMvcTest: 只启动 Web 层相关的 Bean (Controller, Converter 等),不启动 Service
@WebMvcTest(UserController.class) 
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc; // Spring 提供的模拟 HTTP 客户端

    // 2. @MockBean: 这是 Spring 的注解!
    // 它会去 Spring 容器里,把 UserService 替换成一个 Mock 对象
    @MockBean
    private UserService userService;

    @Test
    void shouldReturnUserJson() throws Exception {
        // --- Arrange ---
        // 模拟 Service 层返回数据
        when(userService.findById(1L)).thenReturn(new User(1L, "Alice"));

        // --- Act & Assert ---
        // 发送一个模拟的 GET 请求
        mockMvc.perform(get("/users/1"))
               .andExpect(status().isOk()) // 期望状态码 200
               .andExpect(jsonPath("$.name").value("Alice")); // 期望返回 JSON 中有 name: Alice
    }
}

优雅点:比 @SpringBootTest 快,但又能测试 Spring 的注解(如 @RestController@RequestMapping)是否生效。


📊 三、总结:Mock 注解对比表

这是最容易混淆的地方,请注意区分:

表格

特性@Mock(Mockito)@MockBean(Spring Boot)
所属库MockitoSpring Boot Test
是否启动 Spring (纯单元测试) (集成测试/切片测试)
作用范围仅在测试类内部有效会替换 Spring 容器中的 Bean
使用场景测试 Service 业务逻辑测试 Controller, 或者需要 Spring 注入的场景
性能极快 (毫秒级)较快 (秒级,取决于加载多少组件)

🚀 四、最佳实践建议

  1. Service 层:优先使用  @ExtendWith(MockitoExtension.class) + @Mock。不要动不动就 @SpringBootTest,那样太慢了。
  2. Controller 层:使用  @WebMvcTest + @MockBean
  3. Repository 层:使用  @DataJpaTest (它会自动配置内存数据库 H2)。
  4. 全链路测试:只有当你需要测试“整个应用能不能跑起来”或者“配置类是否正确”时,才使用  @SpringBootTest

这样分层测试,你的项目构建速度会非常快,而且逻辑清晰,维护起来也很优雅。

第二部分总结

使用mock可以最小化范围测试,而不是启动整个容器。一般测试的都是service层,直接使用@ExtendWith(MockitoExtension.class) + @Mock。不要动不动就 @SpringBootTest,那样太慢了


第三部分 Mock中的常见问题

一、mock原理

就是 “伪造” 依赖接口 / 对象 / 函数的返回结果,让程序在没有真实后端、真实服务时也能正常跑、正常测。原理为通过动态代理、字节码增强或请求拦截等方式,劫持目标方法 / 接口调用,跳过真实逻辑执行并直接返回预设伪造数据,从而实现依赖隔离与行为模拟。

✨ 疑问:final类怎么模拟呢?

在 Java 中,final 关键字的设计初衷就是为了防止继承(类)或重写(方法)。而 Mockito 的核心原理恰恰是生成子类(动态代理)来拦截方法调用。 所以,默认情况下,Mockito 无法 Mock final 类或 final 方法。如果你强行去 mock,通常会报 Cannot mock/spy class ... final class 的错误。

🚀 方案:使用 mockito-inline(推荐,现代做法)

这是目前最主流的做法。从 Mockito 2.x 后期版本开始,官方提供了一个扩展模块 mockito-inline,它利用 Java Instrumentation API 在运行时修改字节码,从而支持 Mock final 类。

适用场景:Spring Boot 2.x (较新版本) 或 Spring Boot 3.x,且你不想引入沉重的 PowerMock。

1. 添加依赖

虽然 Spring Boot 的 spring-boot-starter-test 已经包含了 mockito-core,但你需要额外引入 mockito-inline

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <version>5.x.x</version> <!-- 版本号通常与 mockito-core 保持一致 -->
    <scope>test</scope>
</dependency>
2. 开启配置(关键步骤)

仅仅加依赖是不够的,你必须告诉 Mockito 使用这个“内联”模式。
在 src/test/resources 目录下创建一个文件夹 mockito-extensions,并在其中创建一个文件 org.mockito.plugins.MockMaker

  • 文件路径src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

  • 文件内容

    mock-maker=inline
    

image.png

3. 编写测试

配置好后,你就可以像 Mock 普通类一样 Mock final 类了,代码完全不用变:

// 假设 FinalService 是一个 final 类
final class FinalService {
    public String sayHello() { return "Hello"; }
}

@ExtendWith(MockitoExtension.class)
class FinalServiceTest {

    @Mock
    private FinalService finalService; // 直接 @Mock,不会报错!

    @Test
    void testFinalClass() {
        when(finalService.sayHello()).thenReturn("Mocked Hello");
        assertEquals("Mocked Hello", finalService.sayHello());
    }
}

二、🤔 Spy 是啥?解决什么问题?

1. 核心概念:部分模拟

  • Mock(完全模拟) :创建一个空壳对象。所有方法默认都不执行真实代码,直接返回 null 或 0。你必须手动定义每一个方法的行为。

  • Spy(部分模拟) :包装一个真实的对象

    • 默认情况下,它会执行真实的代码
    • 只有当你明确告诉它“这个方法要拦截”时,它才会返回假数据。

2. 解决什么问题?

  • 场景一:遗留代码或复杂对象。当你有一个类,方法很多,你只想 Mock 其中一个很难测的方法(比如调用了外部 API),而其他方法逻辑很复杂你不想重写,这时用 Spy 最省事。
  • 场景二:验证真实调用。你想确保某个方法被调用了,同时还想验证它执行后的真实副作用。

🛠️ 怎么用?(核心语法)

在 Spring Boot 项目中,我们通常分两种情况使用 Spy:纯单元测试 和 Spring 容器集成测试

1. 纯单元测试(使用 @Spy

这是 Mockito 的原生用法,用于测试普通的 Java 类。

关键点:使用 Spy 时,存根语法(Stubbing)必须换

  • Mock 用:when(mock.method()).thenReturn(...)

  • Spy 用:doReturn(...).when(spy).method()

    • 为什么?因为 Spy 默认执行真实方法,如果用 when(spy.method()),真实方法会立即执行,可能导致空指针异常。
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    // 1. 必须初始化真实对象!不能写 @Spy private UserService userService; (这样会报空指针)
    @Spy
    private UserService userService = new UserService(); 

    @Mock
    private UserRepository userRepository;

    @Test
    void testSpyUsage() {
        // --- Arrange ---
        // 假设 UserService 有个方法 calculateTax() 很复杂,我们想 Mock 它
        // 注意语法:doReturn(...).when(spy).method()
        doReturn(100.0).when(userService).calculateTax();

        // --- Act ---
        // 调用其他未 Mock 的方法,会执行真实逻辑
        // 调用 calculateTax,会返回 100.0
        double tax = userService.calculateTax();

        // --- Assert ---
        assertEquals(100.0, tax);
        
        // --- Verify ---
        // 验证真实方法是否被调用
        verify(userService, times(1)).calculateTax();
    }
}
2. Spring 集成测试(使用 @SpyBean

当你使用 @SpringBootTest 时,普通的 @Spy 无法替换 Spring 容器里的 Bean。这时要用 Spring Boot 提供的  @SpyBean

作用:把 Spring 容器里原本的 Bean 替换成一个 Spy 对象。

@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService; // 真实的 Service

    // 1. @SpyBean:替换容器里的 UserService,但保留真实逻辑
    @SpyBean 
    private UserService userService;

    @Test
    void testOrderWithSpyBean() {
        // --- Arrange ---
        // 拦截 getUserLevel 方法,返回 "VIP"
        doReturn("VIP").when(userService).getUserLevel(anyLong());

        // --- Act ---
        // 调用 orderService,它会调用 userService.getUserLevel
        // 此时 getUserLevel 返回 "VIP",但 userService 的其他方法(如 saveUser)仍走真实数据库逻辑(如果配置了的话)
        orderService.createOrder(1L);

        // --- Verify ---
        // 验证 getUserLevel 确实被调用了
        verify(userService).getUserLevel(1L);
    }
}

⚖️ Mock vs Spy:怎么选?

为了让你更清晰地做决定,我整理了这个对比表:

表格

维度@Mock (完全模拟)@Spy / @SpyBean (部分模拟)
真实代码执行绝不执行默认执行 (除非被拦截)
初始化要求不需要实例化必须有真实实例 (new Object())
存根语法when(mock.method())...doReturn(...).when(spy)...
风险低(完全隔离)中(真实代码可能抛异常或依赖数据库)
适用场景依赖对象(Repository, Client)被测对象本身(想测部分逻辑)、遗留代码

⚖️ Spy vs InjectMocks

维度@Spy@InjectMocks
核心职责部分模拟。包装一个真实对象,保留真实逻辑,但允许拦截特定方法。依赖注入。创建被测对象,并自动把 @Mock@Spy 塞进去。
代码行为默认执行真实代码。负责初始化对象(通过构造函数或字段注入)。
语法陷阱必须手动初始化实例(= new UserService()),否则报错。不需要手动初始化,Mockito 会自动帮你 new 出来。
常用搭配用于被测对象本身(当你不想 Mock 所有方法时)。用于被测对象(当你想完全隔离,只测逻辑流转时)。
存根语法必须用 doReturn(...).when(spy)...(它本身不存根,它注入的对象如果是 Mock,则用 when...thenReturn)

💡 避坑指南

  1. 初始化陷阱:使用 @Spy 时,字段必须手动初始化(如 = new UserService()),否则 Mockito 无法创建 Spy 对象,会报 NullPointerException
  2. Final 方法:和 Mock 一样,Spy 也无法 Spy final 方法。调用 final 方法时,永远执行真实代码,无法拦截。
  3. 自调用问题:在 Spring 中,如果一个 Bean 的方法 A 调用了同一个类的方法 B(this.methodB()),即使你 Spy 了方法 B,A 调用 B 时走的也是真实逻辑,Spy 的拦截可能失效(因为 Spring AOP 代理机制)。

总结建议:在单元测试中,优先使用 @Mock,因为它更干净、更安全。只有当你真的需要保留真实逻辑,或者为了省事不想 Mock 所有依赖时,才使用  @Spy