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 Web、Spring 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 的使用遵循「由浅入深」的逻辑:
- 纯 Java 方法:直接用 JUnit 核心断言,无需 Spring;
- Spring Bean:用
@SpringBootTest启动容器,注入 Bean 测试; - Web 层:用
@WebMvcTest+MockMvc模拟 HTTP 请求,解耦测试。
实际开发中,写测试用例不是「额外工作」,而是「提效手段」------能提前发现 bug,减少手动测试成本,尤其是在迭代升级时,修改代码后跑一遍测试,就能快速验证是否影响原有功能。
所有代码均可直接复制到 Spring Boot 3 项目中运行,建议先跑通基础案例,再逐步尝试 Service 和 Controller 层测试,加深理解。