分为上篇:基础入门(纯案例,零基础上手)、下篇:结合现有项目实操(对应当前模板服务测试代码)
上篇 基础入门:JUnit5 + Mockito 零基础教程
一、核心原理
- 什么是单元测试?
只测单个 Java 方法/类,不启动整个网站、不连接数据库、不调用第三方接口,只验证代码逻辑写得对不对。
类比:单独测试洗衣机的「脱水功能」,不用接水管、不用通电整台机器运行。
- 什么是 Mockito?
模拟工具,专门用来造「假对象」。
项目里 Service 会依赖数据库、OSS、其他接口,真实环境启动麻烦;
用 Mockito 可以伪造依赖,告诉它「你查询就返回这个数据」「上传文件不用真传」,做到只测自己写的业务代码。
- 组合关系
- JUnit5:单元测试运行框架,提供 @Test、断言(判断结果对错)
- Mockito:模拟依赖框架,提供 @Mock、when、verify
- 两者搭配:Java 后端标准单元测试方案。
二、环境准备(Maven 项目)
你的项目 pom.xml 已经自带依赖,无需额外添加:
<!-- 核心测试包:包含 JUnit5 + Mockito -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<!-- 禁用旧版 JUnit4,只使用 JUnit5 -->
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
三、第一步:编写待测试业务代码
- 模拟依赖(仓库接口,代表数据库)
路径:src/main/java/com/demo/repo/UserRepo.java
package com.demo.repo;
// 模拟数据库查询接口
public interface UserRepo {
// 根据ID查用户名
String getUserName(Long userId);
// 根据ID删除用户
boolean deleteUser(Long userId);
}
- 编写业务 Service(真正要测试的代码)
路径:src/main/java/com/demo/service/UserService.java
package com.demo.service;
import com.demo.repo.UserRepo;
public class UserService {
// 依赖数据库接口
private final UserRepo userRepo;
// 构造函数注入依赖
public UserService(UserRepo userRepo) {
this.userRepo = userRepo;
}
// 业务1:获取用户名
public String getName(Long id) {
return userRepo.getUserName(id);
}
// 业务2:删除用户并返回提示
public String removeUser(Long id) {
boolean isSuccess = userRepo.deleteUser(id);
return isSuccess ? "删除成功" : "删除失败";
}
}
四、第二步:编写单元测试代码
测试代码统一放在:src/test/java/ 目录下
路径:src/test/java/com/demo/service/UserServiceTest.java
完整测试代码
package com.demo.service;
import com.demo.repo.UserRepo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// 固定写法:开启 Mockito 支持(JUnit5 必须加)
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
// 1. @Mock:伪造一个 假的 UserRepo 对象(不连真实数据库)
@Mock
private UserRepo userRepo;
// 被测试的业务类
private UserService userService;
// 单个测试方法
@Test
void testUserServiceLogic() {
// ========== 步骤1:初始化被测试对象,传入假依赖 ==========
userService = new UserService(userRepo);
// ========== 步骤2:伪造依赖的返回值 when(对象.方法).thenReturn(结果) ==========
// 规则:调用 getUserName(1L),固定返回 "张三"
when(userRepo.getUserName(1L)).thenReturn("张三");
// 规则:调用 getUserName(2L),固定返回 "李四"
when(userRepo.getUserName(2L)).thenReturn("李四");
// deleteUser(1L) 第一次调用返回true,第二次返回false
when(userRepo.deleteUser(1L))
.thenReturn(true)
.thenReturn(false);
// ========== 步骤3:执行业务代码 + 断言(判断结果是否正确) ==========
String name1 = userService.getName(1L);
assertEquals("张三", name1); // 预期结果=张三,正确则测试通过
String name2 = userService.getName(2L);
assertEquals("李四", name2);
assertEquals("删除成功", userService.removeUser(1L));
assertEquals("删除失败", userService.removeUser(1L));
// ========== 步骤4:校验依赖是否被正常调用 verify ==========
// 校验:deleteUser(1L) 一共调用了 2 次
verify(userRepo, times(2)).deleteUser(1L);
// 校验:getUserName(999L) 从未被调用
verify(userRepo, never()).getUserName(999L);
}
}
五、第三步:运行测试 & 常用命令
方式1:IDE 直接运行(IDEA/Eclipse)
右键测试类 → Run UserServiceTest
- 绿色对勾:测试通过
- 红色报错:代码逻辑/测试规则写错
六、核心语法速记
- @Mock:创建假对象,模拟数据库、OSS、接口等外部依赖
- when(对象.方法(参数)).thenReturn(返回值):给假对象设定「固定返回数据」
- assertEquals(预期值, 实际值):断言,判断代码运行结果是否正确
- verify(对象, times(n)).方法():校验方法调用次数
- verify(对象, never()).方法():校验方法从未调用
下篇 进阶实操:结合现有模板项目(TemplateDesignerService)
一、项目背景回顾
- 被测目标
TemplateDesignerService 模板设计器核心服务,三大核心功能:
-
替换 JSON 中图片路径
-
根据模板类型生成 rules.json/flow.json/manifest.json 运行配置
-
草稿发布:打包ZIP、上传OSS、保存版本记录
-
项目特点
- 依赖多:数据库仓库、OSS存储、导出服务、路由服务等
- 全部使用 @Mock 伪造依赖,不连真实数据库、不上传真实文件
- 测试覆盖:正常流程、边界场景(空预览图、文件丢失、非法类型)
二、整体原理
- 用 @Mock 伪造所有外部依赖(Repository、ObjectStorageService 等)
- @BeforeEach:每个测试执行前,统一初始化被测试 Service、注册模板策略
- when():伪造数据库查询、OSS下载/上传的返回数据
- 调用 Service 业务方法,执行逻辑
- assertEquals/assertThrows:判断生成文件、返回结果是否正确
- verify:判断 OSS 上传、数据库保存等操作是否正常执行
三、代码结构拆解
-
类头部注解 & 依赖伪造
// JUnit5 + Mockito 固定开启注解
@ExtendWith(MockitoExtension.class)
class TemplateDesignerServiceTest {
// 简单测试:JSON 图片路径替换 @Test void rewriteDesignerAssetPathsInJson_replacesDraftIdInUrl() { String in = "{\"styleImage\":\"/storage/designer/assets/5/foo.png\"}"; // 调用工具方法:把草稿ID 5 改成 99 String out = TemplateDesignerService.rewriteDesignerAssetPathsInJson(in, 5L, 99L); // 断言:路径成功替换 assertTrue(out.contains("/storage/designer/assets/99/")); assertFalse(out.contains("/storage/designer/assets/5/")); } // ========== 所有外部依赖:全部 @Mock 伪造(不连真实库/存储) ========== @Mock private TemplateDraftRepository draftRepository; @Mock private TemplateBuildRepository buildRepository; @Mock private ObjectStorageService objectStorageService; // 假OSS // ... 其他仓库、服务
理解:这里所有带 @Mock 的对象,都是「假的」,不会操作真实资源。
- 前置初始化 @BeforeEach
每个 @Test 方法执行前都会运行,统一初始化环境:
@BeforeEach
void setUp() {
// 1. 注册模板类型策略(证件照、风格人像、新版设计器)
TemplateTypeRegistry registry = new TemplateTypeRegistry();
registry.register("ID_PHOTO_V1", new IdPhotoTemplateTypeStrategy());
registry.register("STYLE_PORTRAIT_V1", new StylePortraitTemplateTypeStrategy());
// 2. 伪造路由规则:新版设计器 复用 风格人像逻辑
TemplateTypeRoutingService routing = routingMockMatchingDictionary();
// 3. 创建导出服务、被测试主服务
exportService = new TemplateDesignerExportService(...);
designerService = new TemplateDesignerService(...);
// 4. 反射赋值:设置文件存储路径
ReflectionTestUtils.setField(designerService, "storageBasePath", "./storage/designer-test");
}
作用:统一准备测试环境,避免每个测试方法重复写初始化代码。
- 工具方法(复用测试数据)
项目底部几个私有方法,是测试专用工具,简化代码:
- buildDraft():快速创建假的「模板草稿」实体类
- buildValidTemplateZip():快速生成合法ZIP压缩包字节数据
- readZipEntryBytes():读取ZIP包里的文件,用来校验内容
作用:不用每次手动构造复杂对象、文件,提高测试代码复用性。
- 第一类测试:生成运行配置文件(5个用例)
以证件照模板为例:
@Test
void testGenerateRuntimeFilesForDraft_IdPhotoV1() throws Exception {
// 1. 构造假草稿数据
TemplateDraft draft = buildDraft("ID_PHOTO_V1", "{\"formState\":...}");
// 2. 创建临时文件夹
Path tempDir = Files.createTempDirectory("designer_runtime_id_");
try {
// 3. 反射调用 Service 私有方法(测试内部逻辑)
ReflectionTestUtils.invokeMethod(designerService, "generateRuntimeFilesForDraft", draft, tempDir, "1.0.0");
// 4. 读取生成的 rules.json,断言内容是否符合证件照规则
JsonNode rules = objectMapper.readTree(Files.readString(tempDir.resolve("rules.json")));
assertEquals("C_CUTOUT_COMPOSE", rules.get("pipelineMode").asText());
} finally {
// 5. 收尾:删除临时文件
Files.deleteIfExists(tempDir.resolve("rules.json"));
}
}
场景覆盖:
- 证件照、风格人像、新版设计器:验证规则复用
- 非法模板类型:assertThrows 断言抛出异常(拦截非法数据)
- 第二类测试:草稿发布(4个核心用例)
以「正常发布+OSS上传」为例,核心流程:
@Test
void testPublishDraft_UploadsToOssAndWritesPackageObjectKey() throws Exception {
// 1. 构造假草稿、旧版本数据
TemplateDraft draft = buildDraft(...);
TemplateVersion baseVersion = new TemplateVersion();
// 2. 伪造依赖返回值:when + thenReturn
when(draftRepository.findById(10L)).thenReturn(Optional.of(draft));
when(objectStorageService.getObject(...)).thenReturn(baseZip); // 假OSS返回旧ZIP包
when(exportServiceMock.exportZip(...)).thenReturn(outZip); // 假打包服务返回新ZIP
// 3. 执行业务:发布草稿
TemplateDesignerService.PublishResponse resp = publishService.publishDraft(10L, req);
// 4. verify 校验:OSS上传、数据库保存 必须被执行
verify(objectStorageService).putObject(...);
verify(buildRepository).save(any(TemplateBuild.class));
// 5. 断言:返回结果、ID、URL 是否正确
assertEquals(101L, resp.getBuildId());
}
场景覆盖(边界测试):
- 正常发布:上传ZIP、保存版本、更新封面
- 预览图为空:不修改原有封面(verify(..., never()) 校验不执行保存)
- 本地预览图存在:替换ZIP内的预览图片
- 预览图文件丢失:保留原有默认图片(兜底逻辑)
四、项目专属 Maven 运行命令
-
运行最简单的入门测试
mvn test -Dtest=TemplateDesignerServiceTest#rewriteDesignerAssetPathsInJson_replacesDraftIdInUrl
-
运行「生成配置文件」某一个测试
mvn test -Dtest=TemplateDesignerServiceTest#testGenerateRuntimeFilesForDraft_IdPhotoV1
-
运行整个测试类(所有9个测试方法)
mvn test -Dtest=TemplateDesignerServiceTest
五、项目测试核心规则总结
- 所有外部依赖一律 Mock
数据库仓库、OSS、第三方服务,全部用 @Mock 伪造,不碰真实环境。
- when 造数据,assertEquals 判结果
先规定假对象返回什么数据,再运行代码,最后判断输出对不对。
- verify 校验「动作是否执行」
上传文件、保存数据库这类操作,必须用 verify 检查有没有被调用。
- 边界场景一定要测
空数据、文件丢失、非法参数、异常类型,保证代码有容错能力。
- 工具方法复用
复杂对象、文件、ZIP包,抽取成通用方法,减少重复代码。
六、常见问题排错
- 测试报空指针
大概率:忘记加 @ExtendWith(MockitoExtension.class),@Mock 对象没生效。
- 断言失败(结果不对)
检查 when() 设定的返回值、业务逻辑是否和预期一致。
- 临时文件残留
代码里 finally 块会自动删除临时文件,不用手动清理。
- 私有方法无法直接调用
使用 ReflectionTestUtils.invokeMethod 反射调用,专门用来测试类内部私有逻辑。
整体学习路线建议
- 先跑上篇基础案例,弄懂 @Mock / when / assertEquals / verify 四大基础用法
- 再跑项目里最简单的路径替换测试,熟悉项目代码结构
- 逐步学习「生成配置文件」测试,理解模板类型分支逻辑
- 最后学习「草稿发布」测试,掌握复杂链路、ZIP文件、OSS模拟、边界场景测试