单元测试编写规范总结
单元测试编写规范适用版本Spring Boot 2.2.13JUnit 5.6.3Mockito 3.6.28Java 8作用域全模块 Java 单元测试编写标准本文参考业内大佬总结1.黑白盒测试—2.Java 全链路测试体系1. 总则本规范适用于常见 java 项目模块的纯单元测试编写目标是统一风格所有模块的测试代码结构一致任何开发者都能快速读懂。高可维护性测试即文档用例名称本身就是测试场景的描述。高可信度通过严格的隔离策略和异常覆盖确保测试真正验证了业务逻辑。1.1 技术栈约束组件版本说明JUnit Jupiter5.6.3测试框架Java 8 兼容的最后稳定版Mockito3.6.28Mock 框架Java 8 兼容的最后稳定版AssertJ3.18.1流式断言库可选推荐Spring Boot Test2.2.13.RELEASE仅集成测试使用单元测试禁止启动 Spring为什么锁定 Java 8 兼容版本本项目基于 JDK 1.8Mockito 5.x 和 JUnit 5.11 均要求 Java 11强行使用会导致编译/运行时报错。2. 测试分类与 Tag 规范2.1 自定义 Tag 注解强制所有测试类必须使用项目统一定义的 Tag 注解禁止裸写Tag(xxx)。可在公共依赖模块的 xxxx.test 包下预定义注解// 纯单元测试仅使用 Mockito无任何外部中间件依赖Target(ElementType.TYPE)Retention(RetentionPolicy.RUNTIME)Tag(unit)publicinterfaceUnitTest{}// 集成测试依赖 DB / Redis / MQ / HTTP 接口等外部中间件Target(ElementType.TYPE)Retention(RetentionPolicy.RUNTIME)Tag(integration)publicinterfaceIntegrationTest{}2.2 分类判定标准场景分类判定依据工具类AESUtil、StringUtilsUnitTest纯计算无任何外部依赖Service 方法Mock 掉 MapperUnitTest仅依赖 MapperMock 后无真实 DB 交互Service → Mapper 真实读写IntegrationTest需要连接数据库MinIO / Redis / MQTT 交互IntegrationTest需要连接外部中间件Controller HTTP 契约验证IntegrationTest需要启动 Spring 上下文2.3 使用示例importcommon.test.UnitTest;// ✅ 正确使用自定义注解UnitTestExtendWith(MockitoExtension.class)DisplayName(AES 工具类单元测试)classAesTest{...}// ❌ 错误裸写 TagTag(unit)ExtendWith(MockitoExtension.class)classAesTest{...}3. 命名规范3.1 测试类命名类型命名格式示例单元测试被测类名 TestSysUserServiceImplTest、AESUtilTest集成测试被测类名 ITSysConfigServiceImplIT接口测试被测Controller ApiTestSysUserControllerApiTest3.2 测试方法命名强制格式被测方法名_输入条件_预期结果采用驼峰命名三段之间用下划线_分隔。方法名含义getUserById_NullUserId_ThrowsIllegalArgumentException传入 null 用户ID预期抛出非法参数异常setUserPassword_EmptyPassword_ThrowsServiceException密码为空预期抛出业务异常selectConfigByKey_ValidKey_ReturnsTypedValue合法 key预期返回类型正确的值encrypt_ValidInput_ReturnsBase64Cipher合法输入加密预期返回 Base64 密文list_NullParam_ReturnsEmptyList参数为 null预期返回空列表反面示例禁止// ❌ 含义不明Testvoidtest1(){...}// ❌ 无法看出输入和预期TestvoidtestGetUser(){...}// ❌ 缺少输入条件段TestvoidgetUserById_ReturnsUser(){...}3.3 DisplayName强制每个测试方法必须添加DisplayName用中文自然语言描述测试场景TestDisplayName(用户ID为null时抛出IllegalArgumentException)voidgetUserById_NullUserId_ThrowsIllegalArgumentException(){...}4. AAA 模式强制每个测试方法必须严格遵循Arrange → Act → Assert三段式结构三段之间用空行分隔。4.1 结构说明阶段职责包含内容Arrange准备构造测试数据、配置 Mock 行为when(...).thenReturn(...)、对象构建Act执行调用被测方法一行核心调用Assert断言验证结果assertXxx、verify4.2 完整示例UnitTestExtendWith(MockitoExtension.class)DisplayName(用户服务单元测试)classSysUserServiceImplTest{MockprivateSysUserMapperuserMapper;InjectMocksprivateSysUserServiceImpluserService;TestDisplayName(根据合法ID查询用户返回对应用户信息)voidgetById_ValidUserId_ReturnsUser(){// Arrange SysUserexpectedUsernewSysUser();expectedUser.setUserId(1L);expectedUser.setUserName(zhangsan);when(userMapper.selectById(1L)).thenReturn(expectedUser);// Act SysUseractualUseruserService.getById(1L);// Assert assertThat(actualUser).isNotNull();assertThat(actualUser.getUserId()).isEqualTo(1L);assertThat(actualUser.getUserName()).isEqualTo(zhangsan);verify(userMapper).selectById(1L);}}4.3 AAA 各段禁忌禁忌说明Arrange 中调用被测方法准备阶段不应触发被测逻辑Act 段包含断言执行和验证必须分离Assert 段构造新数据断言阶段只验证不准备多个 Act 调用一个用例只测一个行为路径5. Mock 规范5.1 只 Mock 直接依赖核心原则Mock 的边界是被测类的直接外部依赖不要穿透到依赖的依赖。// ✅ 正确UserService 直接依赖 UserMapper只 Mock MapperMockprivateSysUserMapperuserMapper;InjectMocksprivateSysUserServiceImpluserService;// ❌ 错误穿透 Mock 了 Mapper 依赖的 DataSource / SqlSessionMockprivateDataSourcedataSource;MockprivateSqlSessionsqlSession;判断标准如果被测类通过Autowired直接注入了某个 Bean就 Mock 它如果是间接依赖被注入的 Bean 内部使用的不要 Mock。5.2 Mock 行为配置规范// ✅ 精确匹配when(userMapper.selectById(1L)).thenReturn(expectedUser);// ✅ 参数匹配器需要灵活匹配时when(userMapper.selectById(anyLong())).thenReturn(expectedUser);// ✅ Mock 异常行为when(userMapper.selectById(anyLong())).thenThrow(newRuntimeException(DB error));// ❌ 禁止在 when 中执行真实业务逻辑when(userMapper.selectById(anyLong())).thenCallRealMethod();// 单元测试中禁止5.3 verify 使用规范// ✅ 验证交互次数verify(userMapper,times(1)).selectById(1L);// ✅ 验证从未调用verify(userMapper,never()).deleteById(anyLong());// ✅ 验证无更多交互verifyNoMoreInteractions(userMapper);原则verify用于验证行为发生了assert用于验证结果正确了。两者互补不要混用。6. 断言规范6.1 优先使用 AssertJ 流式断言AssertJ 提供可读性更强的链式断言推荐在单元测试中使用// ✅ 推荐AssertJ 流式断言assertThat(user).isNotNull();assertThat(user.getUserName()).isEqualTo(zhangsan);assertThat(userList).hasSize(3).extracting(SysUser::getUserName).contains(zhangsan);// ✅ 可接受JUnit 5 原生断言assertNotNull(user);assertEquals(zhangsan,user.getUserName());// ❌ 禁止JUnit 4 断言org.junit.AssertAssert.assertNotNull(user);// 必须迁移到 JUnit 56.2 断言必须明确// ✅ 明确断言assertThat(result.getCode()).isEqualTo(200);assertThat(result.getData()).isNotNull();assertThat(result.getData().getUserId()).isEqualTo(1L);// ❌ 模糊断言太弱无法发现问题assertNotNull(result);6.3 常用断言速查// 空值判断assertThat(result).isNull();assertThat(result).isNotNull();// 集合判断assertThat(list).isEmpty();assertThat(list).hasSize(3);assertThat(list).containsExactly(item1,item2,item3);// 字符串判断assertThat(str).startsWith(prefix);assertThat(str).contains(keyword);// 数值判断assertThat(count).isGreaterThan(0);assertThat(score).isBetween(0.0,100.0);// 异常断言assertThatThrownBy(()-service.doSomething(null)).isInstanceOf(ServiceException.class).hasMessageContaining(不能为空);7. 异常场景覆盖强制所有公开方法的异常路径必须有用例覆盖。这是 bug 的高发区。7.1 必须覆盖的异常类型异常类型说明示例参数校验异常入参为 null、空字符串、非法值setUserPassword(null)→ServiceException业务异常违反业务规则删除已有角色的用户 →ServiceException边界条件空集合、最大长度、零值、负数selectById(-1L)外部依赖异常Mock 的依赖抛出异常Mapper 抛RuntimeException7.2 异常测试示例TestDisplayName(密码为空时抛出ServiceException)voidsetUserPassword_EmptyPassword_ThrowsServiceException(){// Arrange SysUserusernewSysUser();user.setPassword();// Act Assert assertThatThrownBy(()-userService.setUserPassword(user)).isInstanceOf(ServiceException.class).hasMessageContaining(密码不能为空);}TestDisplayName(用户对象为null时抛出ServiceException)voidsetUserPassword_NullUser_ThrowsServiceException(){// Act Assert assertThatThrownBy(()-userService.setUserPassword(null)).isInstanceOf(ServiceException.class).hasMessageContaining(密码不能为空);}TestDisplayName(Mapper查询异常时异常向上传播)voidgetById_MapperThrowsException_PropagatesException(){// Arrange when(userMapper.selectById(anyLong())).thenThrow(newRuntimeException(数据库连接失败));// Act Assert assertThatThrownBy(()-userService.getById(1L)).isInstanceOf(RuntimeException.class).hasMessageContaining(数据库连接失败);}8. 不测试私有方法8.1 原则私有方法是类的内部实现细节不应直接测试。如果某个私有方法复杂到需要独立测试说明该类职责过大应进行重构将私有方法提取到一个新的工具类/服务类中将其改为public方法为新类编写独立的单元测试8.2 重构示例// ❌ 重构前私有方法难以测试publicclassDeviceService{publicDeviceStatusparseStatus(StringrawData){StringnormalizednormalizeData(rawData);// 私有方法returnconvertToStatus(normalized);// 私有方法}privateStringnormalizeData(Stringraw){...}// 逻辑复杂需要测试privateDeviceStatusconvertToStatus(Stringdata){...}}// ✅ 重构后拆分为独立类publicclassDeviceService{privatefinalDeviceDataParserdataParser;publicDeviceService(DeviceDataParserdataParser){this.dataParserdataParser;}publicDeviceStatusparseStatus(StringrawData){StringnormalizeddataParser.normalizeData(rawData);returndataParser.convertToStatus(normalized);}}// 新类可独立测试publicclassDeviceDataParser{publicStringnormalizeData(Stringraw){...}publicDeviceStatusconvertToStatus(Stringdata){...}}9. 测试隔离与生命周期9.1 测试独立性每个测试方法必须独立运行不依赖其他测试的执行顺序或共享状态。// ✅ 正确每个测试方法独立构造数据TestvoidmethodA_ValidInput_ReturnsResult(){SysUserusernewSysUser();// 独立构造user.setUserId(1L);// ...}TestvoidmethodB_ValidInput_ReturnsResult(){SysUserusernewSysUser();// 独立构造不复用 methodA 的数据user.setUserId(2L);// ...}9.2 BeforeEach / AfterEach 使用仅在所有测试方法共享相同初始化/清理逻辑时使用BeforeEachvoidsetUp(){// 所有测试方法共享的 Mock 配置when(userMapper.selectById(anyLong())).thenReturn(buildDefaultUser());}AfterEachvoidtearDown(){// 清理资源如果有}9.3 禁止事项禁止行为原因测试方法之间共享可变状态执行顺序不确定导致结果不稳定在测试中访问文件系统/网络破坏隔离性使用 Mock 替代在测试中 sleep 等待使用Awaitility或重构为可测试的设计硬编码环境相关配置使用ActiveProfiles(test)或 Mock10. 落地检查清单每个测试类提交前对照以下清单自检类级别标注UnitTest或IntegrationTest禁止裸写Tag类级别标注DisplayName(中文描述)每个方法命名格式为方法名_条件_预期结果每个方法标注DisplayName(中文描述)遵循 AAA 模式三段之间有空行分隔只 Mock 了被测类的直接依赖正常路径、参数校验异常、业务异常、边界条件均有覆盖未测试私有方法如需测试则重构拆分断言明确不使用assertNotNull一笔带过无硬编码环境配置IP、端口、密码等11. CI 执行命令# 只跑单元测试mvntest-Dgroupsunit# 只跑集成测试mvn verify-Dgroupsintegration# 跑全部测试mvntest# 跳过测试快速打包mvn package-DskipTests