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 层测试,加深理解。

相关推荐
小坏讲微服务2 小时前
Spring Boot 4.0 + MyBatis-Plus 实战响应式编程的能力实战
java·spring boot·后端·mybatis
李慕婉学姐2 小时前
Springboot遇见宠物生活馆系统设计与实现n6ea5118(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·宠物
BAStriver2 小时前
关于Flowable的使用小结
java·spring boot·spring·flowable
Dolphin_Home3 小时前
Java Stream 实战:订单商品ID过滤技巧(由浅入深)
java·开发语言·spring boot
白宇横流学长5 小时前
基于SpringBoot实现的垃圾分类管理系统
java·spring boot·后端
汝生淮南吾在北12 小时前
SpringBoot+Vue养老院管理系统
vue.js·spring boot·后端·毕业设计·毕设
李慕婉学姐13 小时前
【开题答辩过程】以《基于springboot的地铁综合服务管理系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·spring boot·后端
期待のcode13 小时前
Springboot配置属性绑定
java·spring boot·后端
JosieBook14 小时前
【Spring Boot】Spring Boot调用 WebService 接口的两种方式:动态调用 vs 静态调用 亲测有效
java·spring boot·后端