单元测试
这篇解决什么
给 Service 写可重复跑的单测:本模块走内存 H2,跨模块依赖用 Mockito。读完能选基类、补 application-unit-test.yaml 和 H2 SQL,并照 SmsChannelServiceTest 补增删改查。
测试跑在模块 src/test,不启 48080,也不打开发库。栈是 JUnit 5 + Mockito + yudao-spring-boot-starter-test。
示意图:本模块 Bean 进 Spring 测试容器;别人模块的 Service 用 Mock。
测试组件
Starter 在 ruoyi-office/yudao-framework/yudao-spring-boot-starter-test/。业务 *-server 用 test 作用域引入,例如 yudao-module-system-server/pom.xml。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
BaseMockitoUnitTest | 只开 Mockito,不起库 | .../test/core/ut/BaseMockitoUnitTest.java |
BaseDbUnitTest | 内存 H2 + MyBatis,测完跑 clean.sql | .../test/core/ut/BaseDbUnitTest.java |
BaseRedisUnitTest | 内存 Redis(jedis-mock) | .../test/core/ut/BaseRedisUnitTest.java |
BaseDbAndRedisUnitTest | H2 和 Redis 一起起 | .../test/core/ut/BaseDbAndRedisUnitTest.java |
RedisTestConfiguration | 按配置端口拉起 RedisServer | .../test/config/RedisTestConfiguration.java |
SqlInitializationTestConfiguration | 懒加载下仍执行建表脚本 | .../test/config/SqlInitializationTestConfiguration.java |
带库的基类是 @SpringBootTest(webEnvironment = NONE),profile 固定 unit-test。只装数据源、事务、MyBatis、SpringUtil,不扫 Web。
本模块真实、外模块 Mock
BaseDbUnitTest 对自己模块的 Mapper 走 H2,对别的模块 Service 用 @MockitoBean。Mapper 也算外部依赖,但内存库比全程 Mock 更好写、更好查。
内存 Redis 默认 127.0.0.1:16379,避开本机 6379。一次起多个测试容器时,端口占用会被吞掉,后起的复用已有进程。
测试工具
| 名称 | 说明 | 仓库路径 |
|---|---|---|
randomPojo | Podam 填 Bean,可再 Consumer 改字段 | .../test/core/util/RandomUtils.java |
randomPojoList | 随机列表,长度 1~4 | 同上 |
assertPojoEquals | 按字段比对,缺字段和合成字段会跳过 | .../test/core/util/AssertUtils.java |
assertServiceException | 断言 ServiceException 的码和文案 | 同上 |
status 只随机 CommonStatusEnum 的 0/1。以 type、status、category、scope、result 结尾的 Integer 落在 tinyint。deleted 固定 false。LocalDateTime 的纳秒清零,避免 H2 存不下。BigDecimal 压在 DECIMAL(10,2) 内。
示例邮箱是 xxx@qq.com,示例站是 https://ruoyioffice.com。
业务错误码用 assertServiceException(..., SMS_CHANNEL_NOT_EXISTS),不要手写 assertThrows 再拆 code。错误码定义见 异常处理(错误码)。
内存库怎么配
system、infra、bpm 等 *-server 已经加了 Starter。新模块补这段:
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>配置放在模块 src/test/resources/application-unit-test.yaml。@ActiveProfiles("unit-test") 只读这一份,不会去连 application-local.yaml 的 MySQL。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 数据源 | jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value | yudao-module-system-server/src/test/resources/application-unit-test.yaml |
| 建表 | spring.sql.init.schema-locations=classpath:/sql/create_tables.sql | 同上 |
| 主键 | mybatis-plus.global-config.db-config.id-type=AUTO | 同上 |
| Redis | 16379,只给 BaseRedis* 用 | 同上 |
| 加速 | spring.main.lazy-initialization=true,Druid initial-size=1 | 同上 |
MODE=MYSQL 让 SQL 尽量按 MySQL 语义跑。DATABASE_TO_UPPER=false 保持表名、列名小写。value 是 H2 关键字,要写进 NON_KEYWORDS。
建表和清理是两份脚本:
| 名称 | 说明 | 仓库路径 |
|---|---|---|
create_tables.sql | 类启动时建表,IF NOT EXISTS | yudao-module-system-server/src/test/resources/sql/create_tables.sql |
clean.sql | 每个 @Test 结束后删数据,不删表 | .../sql/clean.sql,基类 @Sql(AFTER_TEST_METHOD) |
短信渠道表在 H2 里要写成 IDENTITY,不能照抄 MySQL 的 AUTO_INCREMENT。对应 DO 是 @TableName("system_sms_channel")。
CREATE TABLE IF NOT EXISTS "system_sms_channel" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"signature" varchar(10) NOT NULL,
"code" varchar(63) NOT NULL,
"status" tinyint NOT NULL,
"remark" varchar(255) DEFAULT NULL,
"api_key" varchar(63) NOT NULL,
"api_secret" varchar(63) DEFAULT NULL,
"callback_url" varchar(255) DEFAULT NULL,
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id")
) COMMENT '短信渠道';clean.sql 里对应一行:DELETE FROM "system_sms_channel";。只测渠道时,模板、日志表也要建好并清理,避免外键或后续用例踩脏数据。
不要把单测指到开发库
url 必须是 jdbc:h2:mem:...。指到移动云 33061 会污染业务表,也失去「每方法清理」的隔离。
H2 和 MySQL 的类型、引号、自增写法不同,要手改。基础设施 → 代码生成可以把「生成单元测试」打开(CodegenProperties.unitTestEnable),顺带出 H2 DDL 和测试类骨架。
Mapper、逻辑删除、自动填充见 MyBatis 数据库。
带库的 Service 单测
以短信渠道为准。实现是 SmsChannelServiceImpl,测试是 SmsChannelServiceTest,继承 BaseDbUnitTest。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 被测类 | 本模块 Service,@Import 进容器 | yudao-module-system-server/.../sms/SmsChannelServiceImpl.java |
| 测试类 | 继承 BaseDbUnitTest | .../src/test/java/.../sms/SmsChannelServiceTest.java |
| Mapper | 真实 Bean,读写 H2 | SmsChannelMapper |
| 外模块 | @MockitoBean | SmsClientFactory、SmsTemplateService |
@Import(SmsChannelServiceImpl.class)
public class SmsChannelServiceTest extends BaseDbUnitTest {
@Resource
private SmsChannelServiceImpl smsChannelService;
@Resource
private SmsChannelMapper smsChannelMapper;
@MockitoBean
private SmsClientFactory smsClientFactory;
@MockitoBean
private SmsTemplateService smsTemplateService;
}Spring Boot 3.4 起用 org.springframework.test.context.bean.override.mockito.MockitoBean,不要再用已移除的 @MockBean。
示意图:成功路径查库;失败路径对错误码。方法结束必清数据。
新增
randomPojo 可能带上 id,创建前要清掉,让 H2 自增。
@Test
public void testCreateSmsChannel_success() {
SmsChannelSaveReqVO reqVO = randomPojo(SmsChannelSaveReqVO.class,
o -> o.setStatus(randomCommonStatus())).setId(null);
Long smsChannelId = smsChannelService.createSmsChannel(reqVO);
assertNotNull(smsChannelId);
SmsChannelDO smsChannel = smsChannelMapper.selectById(smsChannelId);
assertPojoEquals(reqVO, smsChannel, "id");
}修改
先 insert 一条,ReqVO 带上同一 id。不存在的 id 用 assertServiceException(..., SMS_CHANNEL_NOT_EXISTS)。
@Test
public void testUpdateSmsChannel_success() {
SmsChannelDO dbSmsChannel = randomPojo(SmsChannelDO.class);
smsChannelMapper.insert(dbSmsChannel);
SmsChannelSaveReqVO reqVO = randomPojo(SmsChannelSaveReqVO.class, o -> {
o.setId(dbSmsChannel.getId());
o.setStatus(randomCommonStatus());
o.setCallbackUrl(randomString());
});
smsChannelService.updateSmsChannel(reqVO);
assertPojoEquals(reqVO, smsChannelMapper.selectById(reqVO.getId()));
}删除
成功:selectById 为 null。记录不存在:SMS_CHANNEL_NOT_EXISTS。渠道下还有模板:Mock getSmsTemplateCountByChannelId 返回大于 0,断言 SMS_CHANNEL_HAS_CHILDREN。
@Test
public void testDeleteSmsChannel_hasChildren() {
SmsChannelDO dbSmsChannel = randomPojo(SmsChannelDO.class);
smsChannelMapper.insert(dbSmsChannel);
when(smsTemplateService.getSmsTemplateCountByChannelId(eq(dbSmsChannel.getId())))
.thenReturn(10L);
assertServiceException(() -> smsChannelService.deleteSmsChannel(dbSmsChannel.getId()),
SMS_CHANNEL_HAS_CHILDREN);
}查询与分页
单条、列表用 assertPojoEquals。分页要插「能查到」的一条,再各插一条拆掉每个条件。
@Test
public void testGetSmsChannelPage() {
SmsChannelDO dbSmsChannel = randomPojo(SmsChannelDO.class, o -> {
o.setSignature("宇擎源码");
o.setStatus(CommonStatusEnum.ENABLE.getStatus());
o.setCreateTime(buildTime(2020, 12, 12));
});
smsChannelMapper.insert(dbSmsChannel);
smsChannelMapper.insert(cloneIgnoreId(dbSmsChannel, o -> o.setSignature("源码")));
smsChannelMapper.insert(cloneIgnoreId(dbSmsChannel, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
smsChannelMapper.insert(cloneIgnoreId(dbSmsChannel, o -> o.setCreateTime(buildTime(2020, 11, 11))));
SmsChannelPageReqVO reqVO = new SmsChannelPageReqVO();
reqVO.setSignature("宇擎");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
reqVO.setCreateTime(buildBetweenTime(2020, 12, 1, 2020, 12, 24));
PageResult<SmsChannelDO> pageResult = smsChannelService.getSmsChannelPage(reqVO);
assertEquals(1, pageResult.getTotal());
assertPojoEquals(dbSmsChannel, pageResult.getList().get(0));
}cloneIgnoreId 会去掉主键再改一个字段,用来造「差一点就不匹配」的行。
只测一条时:
cd ruoyi-office
mvn -pl yudao-module-system/yudao-module-system-server -Dtest=SmsChannelServiceTest test纯 Mockito 单测
不碰库、不碰 Redis 时继承 BaseMockitoUnitTest。基类只有 @ExtendWith(MockitoExtension.class)。被测对象 @InjectMocks,依赖 @Mock。
例子是发短信:SmsSendServiceImplTest 不启 H2,把用户、模板、渠道、日志、MQ 全部 Mock,再用 verify 看有没有把消息丢给 SmsProducer。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 测试类 | 继承 BaseMockitoUnitTest | .../sms/SmsSendServiceImplTest.java |
| 被测对象 | @InjectMocks SmsSendServiceImpl | 同上 |
| 依赖 | @Mock 的 Service / Producer | AdminUserService、SmsProducer 等 |
public class SmsSendServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private SmsSendServiceImpl smsSendService;
@Mock
private AdminUserService adminUserService;
@Mock
private SmsProducer smsProducer;
@Test
public void testSendSingleSmsToAdmin() {
Long userId = randomLongId();
String templateCode = randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder()
.put("code", "1234").put("op", "login").build();
AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setMobile("15601691300"));
when(adminUserService.getUser(eq(userId))).thenReturn(user);
// …mock 模板、渠道、日志后调用
Long resultSmsLogId = smsSendService.sendSingleSmsToAdmin(null, userId, templateCode, templateParams);
assertEquals(smsLogId, resultSmsLogId);
verify(smsProducer).sendSmsSendMessage(eq(smsLogId), eq(user.getMobile()),
eq(template.getChannelId()), eq(template.getApiTemplateId()),
eq(Lists.newArrayList(new KeyValue<>("code", "1234"), new KeyValue<>("op", "login"))));
}
}准备、when、调用、assert / verify 的顺序和带库用例一样。差在数据来自 Mock 返回值,不 insert。
有 Mapper 就不要用这套
@InjectMocks 不会给 Mapper 真实数据源。要查表、验分页,改继承 BaseDbUnitTest 并 @Import 实现类。
模块怎么拆、测试类跟 Service 同包,见 项目结构。
配置与操作
没有「单元测试」菜单。生成测试类看 yudao.codegen.unit-test-enable(默认 false)。开发在模块里跑 mvn -pl yudao-module-xxx-server -am test。
改这个开关只影响下次代码生成,不会给已有模块补测试。开启见 基建功能设计。
