Skip to content

单元测试 ​

这篇解决什么 ​

给 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
BaseDbAndRedisUnitTestH2 和 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。一次起多个测试容器时,端口占用会被吞掉,后起的复用已有进程。

测试工具 ​

名称说明仓库路径
randomPojoPodam 填 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。新模块补这段:

xml
<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=valueyudao-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同上
Redis16379,只给 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 EXISTSyudao-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")。

sql
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,读写 H2SmsChannelMapper
外模块@MockitoBeanSmsClientFactory、SmsTemplateService
java
@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 自增。

java
@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)。

java
@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。

java
@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。分页要插「能查到」的一条,再各插一条拆掉每个条件。

java
@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 会去掉主键再改一个字段,用来造「差一点就不匹配」的行。

只测一条时:

powershell
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 / ProducerAdminUserService、SmsProducer 等
java
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。

改这个开关只影响下次代码生成,不会给已有模块补测试。开启见 基建功能设计。

相关篇 ​

联系我们

获取报价、演示和二开方案

微信咨询二维码

微信咨询

17156169080

添加时备注「RuoYi Office」

在线体验商业版