Springboot3 | JUnit 5 使用详解

Spring Boot 3 中 JUnit 5 使用详解

我们从「能用」到「用好」逐步拆解 Spring Boot 3 中 JUnit 5 的使用,全程结合实际开发场景,所有代码可直接运行。

基础认知:为什么要在 Spring Boot 中用 JUnit?

实际开发中,我们写的 Controller、Service、工具类都需要验证逻辑是否正确------比如用户注册时的参数校验、订单计算的金额是否准确。手动测试(比如启动项目调接口)效率低,而 JUnit 能让我们写「自动化测试用例」,代码写完就能验证,还能在打包、部署前自动执行,避免低级错误。

Spring Boot 3 内置了 JUnit 5(替代了老版本的 JUnit 4),核心依赖是 spring-boot-starter-test,无需额外配置就能用。

第一步:环境准备

1. 创建 Spring Boot 3 项目

用 Spring Initializr 创建项目,选择:

  • Spring Boot 3.2+
  • 依赖:Spring WebSpring Boot Starter Test(自动包含 JUnit 5、AssertJ、Mockito 等)

2. 核心依赖(pom.xml 关键部分)

xml 复制代码
<dependencies>
    <!-- Spring Boot 测试核心依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- Web 依赖(用于 Controller 测试) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

第二步:入门案例------测试简单工具类(无 Spring 依赖)

先从「最基础的纯 Java 方法测试」入手,不依赖 Spring 容器,理解 JUnit 5 的核心注解。

场景:测试金额计算工具类

实际开发中,订单系统常需要计算折扣后金额,我们先写工具类,再写测试用例。

1. 待测试的工具类
java 复制代码
// src/main/java/com/example/demo/util/PriceCalculator.java
package com.example.demo.util;

/**
 * 金额计算工具类
 */
public class PriceCalculator {
    /**
     * 计算折扣后金额
     * @param originalPrice 原价
     * @param discountRate 折扣率(0.8 表示 8 折)
     * @return 折扣后金额(保留 2 位小数)
     */
    public static double calculateDiscountPrice(double originalPrice, double discountRate) {
        // 边界校验:原价和折扣率不能为负
        if (originalPrice < 0 || discountRate < 0) {
            throw new IllegalArgumentException("原价和折扣率不能为负数");
        }
        // 计算并保留 2 位小数
        double result = originalPrice * discountRate;
        return Math.round(result * 100) / 100.0;
    }
}
2. JUnit 5 测试用例

测试类放在 src/test/java 下,包结构和主类一致:

java 复制代码
// src/test/java/com/example/demo/util/PriceCalculatorTest.java
package com.example.demo.util;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

/**
 * 金额计算工具类测试
 */
// JUnit 5 无需类级注解,直接写测试方法
public class PriceCalculatorTest {

    // 测试正常场景:100 元打 8 折,预期 80.0
    @Test
    void testCalculateDiscountPrice_Normal() {
        double result = PriceCalculator.calculateDiscountPrice(100, 0.8);
        // 断言:实际结果等于预期结果(允许 0.001 误差)
        assertEquals(80.0, result, 0.001);
    }

    // 测试边界场景:原价为 0
    @Test
    void testCalculateDiscountPrice_ZeroPrice() {
        double result = PriceCalculator.calculateDiscountPrice(0, 0.9);
        assertEquals(0.0, result);
    }

    // 测试异常场景:折扣率为负,预期抛出 IllegalArgumentException
    @Test
    void testCalculateDiscountPrice_NegativeDiscount() {
        // 断言方法会抛出指定异常
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> PriceCalculator.calculateDiscountPrice(100, -0.5)
        );
        // 验证异常信息
        assertEquals("原价和折扣率不能为负数", exception.getMessage());
    }
}
运行测试
  • 在 IDEA 中,右键点击测试类 → Run PriceCalculatorTest
  • 控制台会显示测试结果:绿色对勾表示通过,红色叉号表示失败

核心知识点(入门级)

注解/方法 作用
@Test 标记测试方法,JUnit 会自动执行
assertEquals 断言实际值等于预期值(支持数值、字符串、对象等)
assertThrows 断言方法执行时会抛出指定类型的异常
assertTrue/assertFalse 断言布尔值为 true/false

第三步:进阶案例------测试 Spring Bean(Service 层)

实际开发中,Service 层依赖 Repository、其他 Service,需要启动 Spring 容器才能测试。Spring Boot 提供了 @SpringBootTest 注解,自动加载上下文。

场景:测试用户服务(UserService)

用户服务包含「根据 ID 查询用户」「新增用户」逻辑,依赖模拟的 Repository。

1. 实体类
java 复制代码
// src/main/java/com/example/demo/entity/User.java
package com.example.demo.entity;

public class User {
    private Long id;
    private String name;
    private Integer age;

    // 构造器、getter/setter、toString
    public User() {}
    public User(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    // getter/setter 省略(实际开发中用 Lombok 的 @Data 更方便)
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
2. Repository 层(模拟)
java 复制代码
// src/main/java/com/example/demo/repository/UserRepository.java
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Repository
public class UserRepository {
    // 模拟数据库
    private static final Map<Long, User> USER_DB = new HashMap<>();

    static {
        // 初始化测试数据
        USER_DB.put(1L, new User(1L, "张三", 20));
        USER_DB.put(2L, new User(2L, "李四", 25));
    }

    // 根据 ID 查询用户
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(USER_DB.get(id));
    }

    // 新增用户
    public User save(User user) {
        Long newId = USER_DB.keySet().stream().max(Long::compare).orElse(0L) + 1;
        user.setId(newId);
        USER_DB.put(newId, user);
        return user;
    }
}
3. Service 层
java 复制代码
// src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    /**
     * 根据 ID 查询用户
     * @param id 用户 ID
     * @return 用户信息(若不存在则抛出异常)
     */
    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("用户不存在,ID:" + id));
    }

    /**
     * 新增用户(年龄校验:必须大于 0)
     * @param user 用户信息
     * @return 新增后的用户(带 ID)
     */
    public User createUser(User user) {
        if (user.getAge() == null || user.getAge() <= 0) {
            throw new IllegalArgumentException("年龄必须大于 0");
        }
        return userRepository.save(user);
    }
}
4. Service 层测试用例
java 复制代码
// src/test/java/com/example/demo/service/UserServiceTest.java
package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

/**
 * UserService 测试(启动 Spring 容器)
 */
// 启动 Spring Boot 上下文,自动扫描 Bean
@SpringBootTest
public class UserServiceTest {

    // 自动注入 Spring 容器中的 UserService
    @Autowired
    private UserService userService;

    // 自动注入 Repository(可选:用于验证数据)
    @Autowired
    private UserRepository userRepository;

    // 测试正常查询用户
    @Test
    void testGetUserById_Success() {
        User user = userService.getUserById(1L);
        // 断言用户信息正确
        assertEquals("张三", user.getName());
        assertEquals(20, user.getAge());
    }

    // 测试查询不存在的用户(预期抛异常)
    @Test
    void testGetUserById_NotFound() {
        RuntimeException exception = assertThrows(
                RuntimeException.class,
                () -> userService.getUserById(999L)
        );
        assertEquals("用户不存在,ID:999", exception.getMessage());
    }

    // 测试新增用户(正常场景)
    @Test
    void testCreateUser_Success() {
        // 准备测试数据
        User newUser = new User();
        newUser.setName("王五");
        newUser.setAge(30);

        // 执行新增方法
        User savedUser = userService.createUser(newUser);

        // 断言结果
        assertNotNull(savedUser.getId()); // ID 不为空
        assertEquals("王五", savedUser.getName());
        assertEquals(30, savedUser.getAge());

        // 验证 Repository 中确实存在该用户
        User foundUser = userRepository.findById(savedUser.getId()).orElse(null);
        assertNotNull(foundUser);
    }

    // 测试新增用户(年龄为负,预期抛异常)
    @Test
    void testCreateUser_InvalidAge() {
        User invalidUser = new User();
        invalidUser.setName("赵六");
        invalidUser.setAge(-5);

        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> userService.createUser(invalidUser)
        );
        assertEquals("年龄必须大于 0", exception.getMessage());
    }
}

核心知识点(进阶级)

注解/特性 作用
@SpringBootTest 启动 Spring Boot 上下文,加载所有 Bean,模拟真实运行环境
@Autowired 在测试类中注入 Spring 容器中的 Bean
assertNotNull 断言对象不为 null(常用语验证返回的实体、ID 等)
测试隔离性 每次测试方法执行后,Spring 上下文默认复用,但数据会重置(保证测试独立)

第四步:高级案例------测试 Controller 层(模拟 HTTP 请求)

实际开发中,Controller 层接收 HTTP 请求,返回响应,需要模拟接口调用。Spring Boot 提供了 @WebMvcTest 注解,专门测试 Controller,无需启动完整 Spring 上下文,效率更高。

场景:测试用户接口(UserController)

1. Controller 层
java 复制代码
// src/main/java/com/example/demo/controller/UserController.java
package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 根据 ID 查询用户
     * @param id 用户 ID
     * @return 用户信息
     */
    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }

    /**
     * 新增用户
     * @param user 用户信息
     * @return 新增后的用户
     */
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User savedUser = userService.createUser(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedUser);
    }
}
2. Controller 层测试用例
java 复制代码
// src/test/java/com/example/demo/controller/UserControllerTest.java
package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.service.UserService;
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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * UserController 测试(仅启动 Web 层,模拟 HTTP 请求)
 */
// 仅加载 Web 相关 Bean(Controller、HandlerMapping 等),不加载 Service/Repository
@WebMvcTest(UserController.class)
public class UserControllerTest {

    // 模拟 HTTP 请求的核心工具
    @Autowired
    private MockMvc mockMvc;

    // 序列化/反序列化 JSON(用于请求体转换)
    @Autowired
    private ObjectMapper objectMapper;

    // 模拟 UserService(避免依赖真实 Service,解耦测试)
    @MockBean
    private UserService userService;

    // 测试查询用户接口(成功场景)
    @Test
    void testGetUserById_Success() throws Exception {
        // 1. 模拟 Service 返回数据
        User mockUser = new User(1L, "张三", 20);
        when(userService.getUserById(1L)).thenReturn(mockUser);

        // 2. 模拟 GET 请求,并验证响应
        mockMvc.perform(get("/api/users/1") // 请求路径
                        .contentType(MediaType.APPLICATION_JSON)) // 请求类型
                .andExpect(status().isOk()) // 响应状态码 200
                .andExpect(jsonPath("$.id").value(1)) // 响应 JSON 的 id 字段为 1
                .andExpect(jsonPath("$.name").value("张三")) // name 字段为 张三
                .andExpect(jsonPath("$.age").value(20)); // age 字段为 20
    }

    // 测试查询用户接口(失败场景)
    @Test
    void testGetUserById_NotFound() throws Exception {
        // 1. 模拟 Service 抛异常
        when(userService.getUserById(999L)).thenThrow(new RuntimeException("用户不存在,ID:999"));

        // 2. 模拟 GET 请求,验证响应
        mockMvc.perform(get("/api/users/999")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().is5xxServerError()) // 响应状态码 500
                .andExpect(content().string(containsString("用户不存在,ID:999"))); // 响应内容包含异常信息
    }

    // 测试新增用户接口(成功场景)
    @Test
    void testCreateUser_Success() throws Exception {
        // 1. 准备测试数据
        User requestUser = new User();
        requestUser.setName("王五");
        requestUser.setAge(30);

        User responseUser = new User(3L, "王五", 30);

        // 2. 模拟 Service 返回数据
        when(userService.createUser(any(User.class))).thenReturn(responseUser);

        // 3. 模拟 POST 请求,验证响应
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestUser))) // 请求体转 JSON
                .andExpect(status().isCreated()) // 响应状态码 201
                .andExpect(jsonPath("$.id").value(3))
                .andExpect(jsonPath("$.name").value("王五"));
    }
}

核心知识点(高级)

注解/工具 作用
@WebMvcTest 仅加载 Web 层 Bean,专注测试 Controller,启动速度比 @SpringBootTest
MockMvc 模拟 HTTP 请求(GET/POST/PUT/DELETE),无需启动服务器
@MockBean 模拟 Service/Repository,解耦测试(不依赖真实实现)
jsonPath 解析响应 JSON,验证字段值(如 $.name 表示 JSON 中的 name 字段)
ObjectMapper 将 Java 对象转为 JSON 字符串(用于构造请求体)

第五步:实战技巧(贴近真实开发)

1. 测试命名规范

测试方法名要清晰,一眼看出「测试场景 + 预期结果」,比如:

  • testGetUserById_Success(查询用户-成功)
  • testCreateUser_InvalidAge(新增用户-年龄无效)

2. 测试分层策略

层级 测试注解 核心目标
工具类 无(纯 JUnit) 验证逻辑正确性
Service @SpringBootTest 验证业务逻辑、依赖调用
Controller @WebMvcTest 验证请求映射、参数解析、响应

3. 跳过测试

个别测试暂时不想运行,用 @Disabled 注解:

java 复制代码
@Test
@Disabled("暂时跳过,待修复 XXX 问题")
void testTempSkip() {
    // ...
}

4. 测试生命周期

注解 作用
@BeforeEach 每个测试方法执行前执行(比如初始化测试数据)
@AfterEach 每个测试方法执行后执行(比如清理数据)
@BeforeAll 所有测试方法执行前执行一次(静态方法)
@AfterAll 所有测试方法执行后执行一次(静态方法)

示例:

java 复制代码
@BeforeEach
void setUp() {
    // 每个测试方法执行前初始化数据
    System.out.println("开始执行测试方法...");
}

总结

Spring Boot 3 中 JUnit 5 的使用遵循「由浅入深」的逻辑:

  1. 纯 Java 方法:直接用 JUnit 核心断言,无需 Spring;
  2. Spring Bean:用 @SpringBootTest 启动容器,注入 Bean 测试;
  3. Web 层:用 @WebMvcTest + MockMvc 模拟 HTTP 请求,解耦测试。

实际开发中,写测试用例不是「额外工作」,而是「提效手段」------能提前发现 bug,减少手动测试成本,尤其是在迭代升级时,修改代码后跑一遍测试,就能快速验证是否影响原有功能。

所有代码均可直接复制到 Spring Boot 3 项目中运行,建议先跑通基础案例,再逐步尝试 Service 和 Controller 层测试,加深理解。

相关推荐
rADu REME几秒前
SpringBoot + vue 管理系统
vue.js·spring boot·后端
你好潘先生24 分钟前
Next.js + Spring Boot 实现 AI 多模型并行对话系统(架构设计与关键实现)
spring boot·向量检索·next.js·pgvector·ai对话·多模型对比·sse流式输出
苍煜24 分钟前
SpringBoot单体应用到分布式下的数据库锁、事务、Redis事务、分布式锁、分布式事务协调
数据库·spring boot·分布式
Dylan的码园28 分钟前
springBoot与Web后端基础
前端·spring boot·后端
skiy44 分钟前
SpringBoot项目中读取resource目录下的文件(六种方法)
spring boot·python·pycharm
salipopl1 小时前
Spring Boot 整合 Druid 并开启监控
java·spring boot·后端
geNE GENT1 小时前
Spring Boot 实战篇(四):实现用户登录与注册功能
java·spring boot·后端
HackTorjan11 小时前
深度神经网络的反向传播与梯度优化原理
人工智能·spring boot·神经网络·机器学习·dnn
直奔標竿20 小时前
Java开发者AI转型第二十五课!Spring AI 个人知识库实战(四)——RAG来源追溯落地,拒绝AI幻觉
java·开发语言·人工智能·spring boot·后端·spring
敖正炀1 天前
WebFlux 深度:Reactor 线程模型、背压与错误处理
spring boot