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 关键部分)
<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. 待测试的工具类
// src/main/java/com/example/demo/util/PriceCalculator.javapackagecom.example.demo.util;/** * 金额计算工具类 */publicclassPriceCalculator{/** * 计算折扣后金额 * @param originalPrice 原价 * @param discountRate 折扣率(0.8 表示 8 折) * @return 折扣后金额(保留 2 位小数) */publicstaticdoublecalculateDiscountPrice(doubleoriginalPrice,doublediscountRate){// 边界校验:原价和折扣率不能为负if(originalPrice<0||discountRate<0){thrownewIllegalArgumentException("原价和折扣率不能为负数");}// 计算并保留 2 位小数doubleresult=originalPrice*discountRate;returnMath.round(result*100)/100.0;}}2. JUnit 5 测试用例
测试类放在src/test/java下,包结构和主类一致:
// src/test/java/com/example/demo/util/PriceCalculatorTest.javapackagecom.example.demo.util;importorg.junit.jupiter.api.Test;importstaticorg.junit.jupiter.api.Assertions.*;/** * 金额计算工具类测试 */// JUnit 5 无需类级注解,直接写测试方法publicclassPriceCalculatorTest{// 测试正常场景:100 元打 8 折,预期 80.0@TestvoidtestCalculateDiscountPrice_Normal(){doubleresult=PriceCalculator.calculateDiscountPrice(100,0.8);// 断言:实际结果等于预期结果(允许 0.001 误差)assertEquals(80.0,result,0.001);}// 测试边界场景:原价为 0@TestvoidtestCalculateDiscountPrice_ZeroPrice(){doubleresult=PriceCalculator.calculateDiscountPrice(0,0.9);assertEquals(0.0,result);}// 测试异常场景:折扣率为负,预期抛出 IllegalArgumentException@TestvoidtestCalculateDiscountPrice_NegativeDiscount(){// 断言方法会抛出指定异常IllegalArgumentExceptionexception=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. 实体类
// src/main/java/com/example/demo/entity/User.javapackagecom.example.demo.entity;publicclassUser{privateLongid;privateStringname;privateIntegerage;// 构造器、getter/setter、toStringpublicUser(){}publicUser(Longid,Stringname,Integerage){this.id=id;this.name=name;this.age=age;}// getter/setter 省略(实际开发中用 Lombok 的 @Data 更方便)publicLonggetId(){returnid;}publicvoidsetId(Longid){this.id=id;}publicStringgetName(){returnname;}publicvoidsetName(Stringname){this.name=name;}publicIntegergetAge(){returnage;}publicvoidsetAge(Integerage){this.age=age;}@OverridepublicStringtoString(){return"User{"+"id="+id+", name='"+name+'\''+", age="+age+'}';}}2. Repository 层(模拟)
// src/main/java/com/example/demo/repository/UserRepository.javapackagecom.example.demo.repository;importcom.example.demo.entity.User;importorg.springframework.stereotype.Repository;importjava.util.HashMap;importjava.util.Map;importjava.util.Optional;@RepositorypublicclassUserRepository{// 模拟数据库privatestaticfinalMap<Long,User>USER_DB=newHashMap<>();static{// 初始化测试数据USER_DB.put(1L,newUser(1L,"张三",20));USER_DB.put(2L,newUser(2L,"李四",25));}// 根据 ID 查询用户publicOptional<User>findById(Longid){returnOptional.ofNullable(USER_DB.get(id));}// 新增用户publicUsersave(Useruser){LongnewId=USER_DB.keySet().stream().max(Long::compare).orElse(0L)+1;user.setId(newId);USER_DB.put(newId,user);returnuser;}}3. Service 层
// src/main/java/com/example/demo/service/UserService.javapackagecom.example.demo.service;importcom.example.demo.entity.User;importcom.example.demo.repository.UserRepository;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Service;importjava.util.Optional;@ServicepublicclassUserService{@AutowiredprivateUserRepositoryuserRepository;/** * 根据 ID 查询用户 * @param id 用户 ID * @return 用户信息(若不存在则抛出异常) */publicUsergetUserById(Longid){returnuserRepository.findById(id).orElseThrow(()->newRuntimeException("用户不存在,ID:"+id));}/** * 新增用户(年龄校验:必须大于 0) * @param user 用户信息 * @return 新增后的用户(带 ID) */publicUsercreateUser(Useruser){if(user.getAge()==null||user.getAge()<=0){thrownewIllegalArgumentException("年龄必须大于 0");}returnuserRepository.save(user);}}4. Service 层测试用例
// src/test/java/com/example/demo/service/UserServiceTest.javapackagecom.example.demo.service;importcom.example.demo.entity.User;importcom.example.demo.repository.UserRepository;importorg.junit.jupiter.api.Test;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.context.SpringBootTest;importstaticorg.junit.jupiter.api.Assertions.*;/** * UserService 测试(启动 Spring 容器) */// 启动 Spring Boot 上下文,自动扫描 Bean@SpringBootTestpublicclassUserServiceTest{// 自动注入 Spring 容器中的 UserService@AutowiredprivateUserServiceuserService;// 自动注入 Repository(可选:用于验证数据)@AutowiredprivateUserRepositoryuserRepository;// 测试正常查询用户@TestvoidtestGetUserById_Success(){Useruser=userService.getUserById(1L);// 断言用户信息正确assertEquals("张三",user.getName());assertEquals(20,user.getAge());}// 测试查询不存在的用户(预期抛异常)@TestvoidtestGetUserById_NotFound(){RuntimeExceptionexception=assertThrows(RuntimeException.class,()->userService.getUserById(999L));assertEquals("用户不存在,ID:999",exception.getMessage());}// 测试新增用户(正常场景)@TestvoidtestCreateUser_Success(){// 准备测试数据UsernewUser=newUser();newUser.setName("王五");newUser.setAge(30);// 执行新增方法UsersavedUser=userService.createUser(newUser);// 断言结果assertNotNull(savedUser.getId());// ID 不为空assertEquals("王五",savedUser.getName());assertEquals(30,savedUser.getAge());// 验证 Repository 中确实存在该用户UserfoundUser=userRepository.findById(savedUser.getId()).orElse(null);assertNotNull(foundUser);}// 测试新增用户(年龄为负,预期抛异常)@TestvoidtestCreateUser_InvalidAge(){UserinvalidUser=newUser();invalidUser.setName("赵六");invalidUser.setAge(-5);IllegalArgumentExceptionexception=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 层
// src/main/java/com/example/demo/controller/UserController.javapackagecom.example.demo.controller;importcom.example.demo.entity.User;importcom.example.demo.service.UserService;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.http.HttpStatus;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.*;@RestController@RequestMapping("/api/users")publicclassUserController{@AutowiredprivateUserServiceuserService;/** * 根据 ID 查询用户 * @param id 用户 ID * @return 用户信息 */@GetMapping("/{id}")publicResponseEntity<User>getUserById(@PathVariableLongid){Useruser=userService.getUserById(id);returnResponseEntity.ok(user);}/** * 新增用户 * @param user 用户信息 * @return 新增后的用户 */@PostMappingpublicResponseEntity<User>createUser(@RequestBodyUseruser){UsersavedUser=userService.createUser(user);returnResponseEntity.status(HttpStatus.CREATED).body(savedUser);}}2. Controller 层测试用例
// src/test/java/com/example/demo/controller/UserControllerTest.javapackagecom.example.demo.controller;importcom.example.demo.entity.User;importcom.example.demo.service.UserService;importcom.fasterxml.jackson.databind.ObjectMapper;importorg.junit.jupiter.api.Test;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;importorg.springframework.boot.test.mock.mockito.MockBean;importorg.springframework.http.MediaType;importorg.springframework.test.web.servlet.MockMvc;importstaticorg.mockito.ArgumentMatchers.any;importstaticorg.mockito.Mockito.when;importstaticorg.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;importstaticorg.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;importstaticorg.springframework.test.web.servlet.result.MockMvcResultMatchers.*;/** * UserController 测试(仅启动 Web 层,模拟 HTTP 请求) */// 仅加载 Web 相关 Bean(Controller、HandlerMapping 等),不加载 Service/Repository@WebMvcTest(UserController.class)publicclassUserControllerTest{// 模拟 HTTP 请求的核心工具@AutowiredprivateMockMvcmockMvc;// 序列化/反序列化 JSON(用于请求体转换)@AutowiredprivateObjectMapperobjectMapper;// 模拟 UserService(避免依赖真实 Service,解耦测试)@MockBeanprivateUserServiceuserService;// 测试查询用户接口(成功场景)@TestvoidtestGetUserById_Success()throwsException{// 1. 模拟 Service 返回数据UsermockUser=newUser(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}// 测试查询用户接口(失败场景)@TestvoidtestGetUserById_NotFound()throwsException{// 1. 模拟 Service 抛异常when(userService.getUserById(999L)).thenThrow(newRuntimeException("用户不存在,ID:999"));// 2. 模拟 GET 请求,验证响应mockMvc.perform(get("/api/users/999").contentType(MediaType.APPLICATION_JSON)).andExpect(status().is5xxServerError())// 响应状态码 500.andExpect(content().string(containsString("用户不存在,ID:999")));// 响应内容包含异常信息}// 测试新增用户接口(成功场景)@TestvoidtestCreateUser_Success()throwsException{// 1. 准备测试数据UserrequestUser=newUser();requestUser.setName("王五");requestUser.setAge(30);UserresponseUser=newUser(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注解:
@Test@Disabled("暂时跳过,待修复 XXX 问题")voidtestTempSkip(){// ...}4. 测试生命周期
| 注解 | 作用 |
|---|---|
@BeforeEach | 每个测试方法执行前执行(比如初始化测试数据) |
@AfterEach | 每个测试方法执行后执行(比如清理数据) |
@BeforeAll | 所有测试方法执行前执行一次(静态方法) |
@AfterAll | 所有测试方法执行后执行一次(静态方法) |
示例:
@BeforeEachvoidsetUp(){// 每个测试方法执行前初始化数据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 层测试,加深理解。