Skip to content

测试规范 ​

适用端:后端 + Web | 类型:机制

后端 440 个测试类 / 5077 个测试方法,前端 59 个 spec / 1280 个用例(均为实测计数,2026-10-25;复现命令见下两节)。移动端没有单测,见移动端坑位。本页讲清两边的测试基类、跑法、资源文件布局,以及本项目特有的一类测试——源码扫描式守卫:不跑业务代码,而是读源码原文做断言,把「约定」变成「撞红」。

前置阅读:新增一个菜单功能端到端。

它解决什么问题 ​

项目里实际存在四种形态的测试,选错形态是最常见的返工原因:

选型三句话:

  1. 要验证 SQL 真的能跑(分页条件、逻辑删除、updateStrategy)→ BaseDbUnitTest,别的模块 Service 用 @MockBean 打桩;
  2. 只验证编排与分支 → BaseMockitoUnitTest,启动快一个数量级;
  3. 要守住一条「不许这么写」的约定 → 扫描守卫。用 ArchUnit 拦不住的那种(字面用法、注释内容、跨仓文件形状)只能靠读原文。

代码在哪 ​

内容路径
测试 starterenlev-framework/enlev-spring-boot-starter-test/
四个基类.../test/core/ut/{BaseDbUnitTest, BaseMockitoUnitTest, BaseRedisUnitTest, BaseDbAndRedisUnitTest}.java
随机数据工具.../test/core/util/RandomUtils.java
断言工具.../test/core/util/AssertUtils.java
SQL 初始化配置.../test/config/SqlInitializationTestConfiguration.java
内嵌 Redis 配置.../test/config/RedisTestConfiguration.java
H2 建表 / 清库脚本enlev-module-{system,infra,bpm,im}/src/test/resources/sql/{create_tables.sql, clean.sql}
单测 profileenlev-module-{system,infra,bpm,im}/src/test/resources/application-unit-test.yaml
DB 单测样本enlev-module-system/src/test/java/.../service/dept/PostServiceImplTest.java(15 个用例)
架构守卫enlev-module-agent/src/test/java/.../framework/AgentArchitectureTest.java(472 行)
注释行号守卫(后端)enlev-module-agent/src/test/java/.../framework/CommentLineRefBanTest.java(341 行)
vitest 配置ainb-ui-admin-vue3/vitest.config.ts
注释行号守卫(前端)ainb-ui-admin-vue3/src/utils/commentLineRefBan.spec.ts(577 行)
错误形状守卫ainb-ui-admin-vue3/src/config/axios/service.spec.ts(74 行)

核心机制 ​

后端的测试规模分布 ​

实测各模块(测试类 = 文件名以 Test 结尾的类;测试方法 = @Test + @ParameterizedTest 计数):

模块测试类测试方法用到的基类
enlev-framework(15 个子模块中 7 个有测试)23150混合(BaseMockitoUnitTest 6,17 个不继承基类)
enlev-module-system851021BaseDbUnitTest 43、BaseMockitoUnitTest 28、BaseDbAndRedisUnitTest 1
enlev-module-infra1231381BaseMockitoUnitTest 37、BaseDbUnitTest 12、BaseRedisUnitTest 1
enlev-module-bpm36194BaseMockitoUnitTest 29、BaseDbUnitTest 4
enlev-module-ai13107BaseMockitoUnitTest 3,其余不继承基类
enlev-module-agent1602224BaseMockitoUnitTest 75,85 个不继承任何基类
reactor 合计4405077

🔴 这张表曾经有一行 enlev-module-im(21 类 / 298 方法,注明「不在 reactor 里」)。那个目录已在 12347efcd1 refactor(系统):清除未用上的模块 里连文件一起删掉,ls -d enlev-module-* 只剩 5 个。所以「reactor 合计」与「逐行相加」现在是同一个数——之前不是,读旧版本时要小心。

⚠️ 口径刻意只数 *Test.java,把 *Tests.java(复数)排除在外:全仓有 30 个这种文件(ai 模块 29 个 + system 的 SmsClientTests),它们是「必须联网 + 必须有厂商密钥」的集成测试,30 个全都挂着 @Disabled,mvn test 收到但一条不跑。把它们算进「测试规模」会虚高一截。ai 模块那批的密钥口径见 ai 域开发 · 测试。

复现命令(Git Bash,在 ainb/ 下):

bash
# 每个模块的测试类与测试方法数
for m in enlev-framework enlev-module-{system,infra,bpm,ai,agent}; do
  echo "$m  $(find $m -path '*/src/test/*' -name '*Test.java' | wc -l)"
  grep -rhoE '@(Parameterized)?Test\b' --include='*Test.java' $m | wc -l
done

enlev-module-agent 的 85 个「裸 JUnit」类是刻意的:它们测的是 DSL 编译、prompt 装配、契约校验这类纯函数,起 Spring 容器纯属浪费。

enlev-server/src/test/java/ 下只有一个 ProjectReactor.java,它不是测试(不带 Test 后缀,surefire 收不到),是上游带来的一次性改名工具,见仓库结构与改名约定。

四个基类 ​

BaseDbUnitTest 全文(starter-test 的 test/core/ut/):

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbUnitTest.Application.class)
@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件
@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB
public class BaseDbUnitTest {

    @Import({
            // DB 配置类
            EnlevDataSourceAutoConfiguration.class, // 自己的 DB 配置类
            DataSourceAutoConfiguration.class, // Spring DB 自动配置类
            DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类
            DruidDataSourceAutoConfigure.class, // Druid 自动配置类
            SqlInitializationTestConfiguration.class, // SQL 初始化
            // MyBatis 配置类
            EnlevMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类
            MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类
            MybatisPlusJoinAutoConfiguration.class, // MyBatis 的Join配置类

            // 其它配置类
            SpringUtil.class
    })
    public static class Application {
    }
}

类注释里那句是理解整套设计的关键:

注意,Service 层同样适用。对于 Service 层的单元测试,我们针对自己模块的 Mapper 走的是 H2 内存数据库,针对别的模块的 Service 走的是 Mock 方法

四个基类的差异只有「装了什么」:

基类容器DBRedis@Sql 清库
BaseMockitoUnitTest无(只有 @ExtendWith(MockitoExtension.class))无无无
BaseDbUnitTestSpring(WebEnvironment.NONE)H2 内存无每个方法后跑 clean.sql
BaseRedisUnitTestSpring无jedismock 内嵌无
BaseDbAndRedisUnitTestSpringH2 内存jedismock 内嵌每个方法后跑 clean.sql

BaseMockitoUnitTest 全文就三行有效代码:

java
@ExtendWith(MockitoExtension.class)
public class BaseMockitoUnitTest {
}

H2 是怎么起来的 ​

enlev-module-system/src/test/resources/application-unit-test.yaml 的关键几段(原文):

yaml
spring:
  main:
    lazy-initialization: true # 开启懒加载,加快速度
    banner-mode: off # 单元测试,禁用 Banner
  datasource:
    name: ruoyi-vue-pro
    url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写
    driver-class-name: org.h2.Driver
    username: sa
    password:
    druid:
      async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度
      initial-size: 1 # 单元测试,配置为 1,提升启动速度
  sql:
    init:
      schema-locations: classpath:/sql/create_tables.sql
  data:
    redis:
      host: 127.0.0.1
      port: 16379 # 端口(单元测试,使用 16379 端口)
mybatis:
  lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试
enlev:
  info:
    base-package: cn.enlev.ainb.module

四个 URL 参数各有各的用处,删掉任何一个都会炸:MODE=MYSQL 让 H2 认 MySQL 方言、DATABASE_TO_UPPER=false 保住小写表名字段名、NON_KEYWORDS=value 放行叫 value 的列。

SqlInitializationTestConfiguration 存在的理由

Spring Boot 自带的 DataSourceInitializationConfiguration 在 spring.main.lazy-initialization=true 下不会初始化,建表脚本就不会执行。所以 starter-test 里复制了一份并显式关掉懒加载:

java
@Lazy(value = false) // 禁止延迟加载
@EnableConfigurationProperties(SqlInitializationProperties.class)
public class SqlInitializationTestConfiguration {
    @Bean
    public DataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(...) { ... }
}

类注释原文:「不过呢,当前类的实现代码,基本是复制 DataSourceInitializationConfiguration 的哈!」——升级 Spring Boot 时要留意这个类是否还跟得上。

内嵌 Redis 由 RedisTestConfiguration 提供,用的是 com.github.fppt.jedismock.RedisServer:

java
@Bean
public RedisServer redisServer(RedisProperties properties) throws IOException {
    RedisServer redisServer = new RedisServer(properties.getPort());
    // 一次执行多个单元测试时,貌似创建多个 spring 容器,导致不进行 stop。这样,就导致端口被占用,无法启动。。。
    try {
        redisServer.start();
    } catch (Exception ignore) {}
    return redisServer;
}

那个 catch (Exception ignore) {} 是必需的:多个测试类共用 16379 端口,第二个容器起来时端口已被占。别把它「修好」成抛异常。

一个真实的 DB 单测 ​

PostServiceImplTest(15 个用例,实测 Tests run: 15, Failures: 0, Errors: 0, Time elapsed: 7.193 s)。骨架是固定套路:

java
@Import(PostServiceImpl.class)          // 只把被测 Service 装进容器,其余靠 H2 + Mapper
public class PostServiceImplTest extends BaseDbUnitTest {

    @Resource
    private PostServiceImpl postService;

    @Resource
    private PostMapper postMapper;      // 直接注入 Mapper 造数据、验结果

    @Test
    public void testCreatePost_success() {
        // 准备参数
        PostSaveReqVO reqVO = randomPojo(PostSaveReqVO.class,
                o -> o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()))
                .setId(null); // 防止 id 被设置
        // 调用
        Long postId = postService.createPost(reqVO);
        // 断言
        assertNotNull(postId);
        PostDO post = postMapper.selectById(postId);
        assertPojoEquals(reqVO, post, "id");
    }

    @Test
    public void testValidatePost_notFoundForDelete() {
        Long id = randomLongId();
        // 调用, 并断言异常
        assertServiceException(() -> postService.deletePost(id), POST_NOT_FOUND);
    }

    @SafeVarargs
    private static PostDO randomPostDO(Consumer<PostDO>... consumers) {
        Consumer<PostDO> consumer = (o) -> {
            o.setStatus(randomCommonStatus()); // 保证 status 的范围
        };
        return randomPojo(PostDO.class, ArrayUtils.append(consumer, consumers));
    }
}

三段式注释(// 准备参数 / // 调用 / // 断言)是全仓统一写法,别省。

本项目加的两个定制用例最能说明「为什么要 DB 单测而不是 Mockito」:

java
@Test
public void testUpdatePost_clearDeptId() {
    // mock 数据:岗位原本挂在某个部门下
    PostDO postDO = randomPostDO(o -> o.setDeptId(100L));
    postMapper.insert(postDO);
    // 准备参数:把所属部门清空
    PostSaveReqVO reqVO = randomPojo(PostSaveReqVO.class, o -> {
        o.setId(postDO.getId());
        o.setName(postDO.getName()); // 避免撞名称/编码唯一性校验
        o.setCode(postDO.getCode());
        o.setStatus(randomEle(CommonStatusEnum.values()).getStatus());
        o.setDeptId(null);
    });

    // 调用
    postService.updatePost(reqVO);
    // 断言:dept_id 必须真的被写成 NULL。updateById 默认跳过 null 字段,
    // 全靠 PostDO#deptId 上的 FieldStrategy.ALWAYS —— 少了它,「编辑里清掉组织」会静默不生效
    assertNull(postMapper.selectById(reqVO.getId()).getDeptId());
}

FieldStrategy.ALWAYS 是 MyBatis-Plus 在生成 SQL 时的行为,Mockito 打桩根本碰不到;只有真跑一次 H2 才能证明 null 被写进去了。另一个 testGetPostPage_byDeptId 同理,验的是 eqIfPresent 的精确匹配语义(不按子部门级联、deptId 为 null 的岗位不被带出来)。

随机数据与断言工具 ​

RandomUtils(starter-test 的 test/core/util/):

方法说明
randomPojo(Class<T>, Consumer<T>...)主力。用 jodd/podam 风格填充整个对象,再按 consumer 覆盖个别字段
randomPojoList(Class<T>, [size,] Consumer<T>...)同上,返回 List
randomString() / randomLongId() / randomInteger() / randomShort()标量
randomDate() / randomLocalDateTime()时间
randomCommonStatus()只在 CommonStatusEnum 的取值里随机——避免生成非法状态
randomEmail() / randomMobile() / randomURL()带格式的串
randomSet(Class<T>)集合

AssertUtils:

方法说明
assertPojoEquals(expected, actual, ignoreFields...)逐字段比对,ignoreFields 常传 "id"
isPojoEquals(expected, actual, ignoreFields...)同上的布尔版
assertServiceException(Executable, ErrorCode, messageParams...)断言抛出 ServiceException 且 code 与 msg(含 {} 占位符渲染后)都对得上

assertServiceException 会连 msg 一起比,所以改错误码文案会让一批测试变红——那是有意的。

前端 vitest ​

ainb-ui-admin-vue3/vitest.config.ts 全文:

ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'node:path'

/**
 * Vitest 配置
 *
 * 只跑纯逻辑的单元测试:联动规则求值器、脚本沙箱这类不依赖浏览器与后端的部分。
 * 涉及界面与接口的仍走 Playwright(tests/e2e),两者职责不重叠。
 *
 * 为什么单独一个配置文件而不复用 vite.config.ts:后者带着一整套构建插件
 * (自动导入、图标、压缩……),跑单测时全是无谓开销,还会因为缺少浏览器环境而报错。
 */
export default defineConfig({
  plugins: [vue()],
  resolve: { alias: { '@': resolve(__dirname, 'src') } },
  test: {
    environment: 'node',
    include: ['src/**/*.spec.ts'],
    reporters: ['default']
  }
})

注释里提到的 Playwright 那半已经不存在了

配置注释写「涉及界面与接口的仍走 Playwright(tests/e2e)」,但 playwright.config.ts 和 tests/e2e/ 目录都不在仓库里,pnpm test:e2e 必然失败。别去补全它,也别按注释理解职责划分——现状是:只有纯逻辑单测,没有 E2E。

environment: 'node' 意味着没有 DOM、没有 window、没有 auto-import。所以 59 个 spec 全是纯函数/纯模块测试,一个 .vue 组件测试都没有。要测组件行为得先加 jsdom 与一整套 mock 基座,目前没有。

59 个 spec 的分布(实测,复现:find src -name '*.spec.ts' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn):

目录数量测什么
src/components/FormWidgets/21明细设计器的单元格合并、列设置、行分组、打印模板、步骤校验、内联编辑;plainText.spec.ts 守的是「查看明文」按钮不该出现时真的不出现(脱敏字段才挂、view-plain 权限门、权限收回后旧按钮要清掉、子表内部不挂)
src/views/agent/workflow/editor/8DSL 门禁夹具、编辑历史、图编辑、节点 IO 契约、沙箱模型、触发用例
src/views/agent/workflow/7编排模型、布局、节点配置 schema、发布门禁、回放模型、恢复路由
src/components/FormLogic/3联动规则求值器、非法字段引用、运行时
src/components/FormCreate/src/subtable/3子表增强、子表运行时、虚拟滚动
src/views/system/role/grant/3批量授权、生效树、资源树
src/components/FormScript/、FormFormula/各 2脚本沙箱、公式求值
src/utils/2routerHelper、注释行号守卫
其余 8 个目录各 1TableActions/actions、config/axios/service、FormValidation、FormInput、system/resource/constants、system/rule/components/ruleParams、bpm/processInstance/detail/manifestFallback、FormCreate/src/utils/remoteOptionRuntime

集中在表单引擎与智能体编排两块——都是本项目定制最重、回归代价最高的地方。

源码扫描式守卫(本项目特色) ​

src/config/axios/service.spec.ts 全文 74 行,是这类测试最干净的样本。它不 import service.ts,而是读它的源码原文:

ts
const src = readFileSync(fileURLToPath(new URL('./service.ts', import.meta.url)), 'utf-8')

/**
 * 抓 `else if (code !== 0 && code !== 200) { … }` 这一支的分支体。
 *
 * 🔴 抓不到就抛:锚点漂了说明这段被重构过,那时该改的是本用例的读法,
 * 而不是把断言放宽成「文件里没有 reject('error') 就算过」。
 */
const bizErrorBranch = (): string => {
  const start = src.indexOf('} else if (code !== 0 && code !== 200) {')
  if (start < 0) {
    throw new Error('service.ts 里找不到业务错误码那一支:被重构了?请同步本用例的起始锚点')
  }
  const end = src.indexOf('\n    } else {', start)
  if (end < 0) {
    throw new Error('业务错误码那一支的结束锚点(`} else { return data }`)找不到,请同步本用例')
  }
  return src.slice(start, end)
}

it('业务错误码那一支 reject 的是 Error(msg),不是字面量字符串', () => {
  const branch = bizErrorBranch()
  expect(branch).toContain('return Promise.reject(new Error(msg))')
  expect(branch).not.toContain("Promise.reject('error')")
})

类注释交代了为什么不真跑:service.ts 在模块顶层就 useI18n()(走 auto-import,没有显式 import 可以 mock)、读 import.meta.env、拉 element-plus 与 router——在 node 环境里 import 它必然炸。

这类守卫有两条铁律,写新的时候必须照办:

铁律一:抓不到锚点就抛,绝不回落默认值。 否则「代码被删了」和「代码是对的」在断言里长得一模一样。

铁律二:每条禁令都配一句反向自检。 AgentArchitectureTest 的类注释把这个失效方式说得很直白:

🔴 每条禁令都配一句「反向自检」。文本扫描有个共通的失效方式:把要守的代码删掉,禁令就更绿(判定没了,违规自然没了;目录挪走了,Files.walk 什么都不返回)。所以每条禁令旁边都钉一句「那段东西必须还在 / 那个目录必须扫得到」,让「静默失效」变成「撞红」。

AgentArchitectureTest(472 行)实测的 8 条用例:

@DisplayName守什么
🔴 禁止使用 TenantUtils.executeIgnore它把租户隔离整个关掉
🔴 禁止 @TenantIgnore与 executeIgnore 设的是同一个标志位,只是换了个写法
🔴 反向自检:白名单那一处必须真的还在、处数没变、且真的就是注解形式防禁令空转
🔴 禁止自定义系统表前缀常量名单只能有一份
🔴 跨模块只能走 api/ 层这是「行权限 fail-open 够不着 AI」的唯一支撑
扫描确实覆盖到了源码防止测试本身空跑
🔴 校验路径与 Planner prompt 装配路径禁用 getRisk() / ToolDefinition#isRequireConfirm()风险判定只能有一个来源
🔴 反向自检:那两条路径确实还在读 getDeclaredRisk()防止删掉判定让禁令变绿

它用源码扫描而不是 ArchUnit,理由也写在注释里:要拦的是字面用法而非类型依赖,扫文本更直接、也不必为此引入新依赖。

CommentLineRefBanTest(后端 341 行)与 commentLineRefBan.spec.ts(前端 577 行)是一对同源守卫,禁止在注释里写「标识符:行号」这种引用。后端那份的类注释给了实测依据:

E4 实测这种引用在后端 src/main 里有 100 处,且漂移已经真实发生:AgentAssistantServiceImpl:195 早已漂到约 900 行外的 #runToolLoop、WorkflowDslValidator:365-366 漂到 1689 行外的 #validateReferenceReachability,共 6 处原行号指向了完全错误的成员。

正确写法(两侧一致):

想指向写成
某个成员类#成员,如 AiChatMessageServiceImpl#buildPrompt
类级 javadoc 的某一段类注释「节名」那一节
界面元素data-testid,如 sandbox-drawer 那个抽屉
枚举 / 编码值用等号而不是冒号(冒号会被当成位置引用)
跨仓的前端目标File.vue 的 xxx computed

两侧的实现差异是刻意的

后端那条只扫 src/main/java、零白名单、宽正则;前端要扫 .vue,而 .vue 注释里合法地钉着大量与违规同形状的东西——CSS 属性、Playwright 配置键、示例对象字面量、dev: 5173 这种端口。前端的解法是收窄语法而不是加白名单:只有「带源码扩展名的文件名 / 点或井号分隔的成员路径 / PascalCase 类名 / camelCase 方法名 / 版本号式规则名」才判违规,全小写单个单词一律放行。

两份实现都跑真正的注释状态机而不是「行前缀粗判」,因为行尾注释(int x = 1; // Foo:123)不以注释符开头会漏掉,而字符串里的 "Foo:123" 又会被误报。状态机还必须识别正则字面量:前端 e2e 里有 path.replace(/\//g, ...),那两个连续斜杠不认就会被当成行注释起点。

数据模型 ​

测试资源文件的布局是硬约定,BaseDbUnitTest 直接按路径找:

enlev-module-<x>/src/test/
├── java/cn/enlev/ainb/module/<x>/…      # 测试类,包路径与被测类对齐
└── resources/
    ├── application-unit-test.yaml       # @ActiveProfiles("unit-test") 加载它
    └── sql/
        ├── create_tables.sql            # schema-locations 指向它,H2 方言
        └── clean.sql                    # @Sql AFTER_TEST_METHOD 逐个方法后执行

实测只有 system / infra / bpm / im 四个模块有这两份资源,行数分别是:system 的 create_tables.sql 764 行、clean.sql 41 行。

clean.sql 是一长串 DELETE,顺序无所谓(没有物理外键):

sql
DELETE FROM "system_dept";
DELETE FROM "system_dict_data";
...
DELETE FROM "system_post";
DELETE FROM "system_user_post";

新增表忘了补这两个文件,失败信息会指向别处

漏 create_tables.sql → Spring 启动阶段就报 schema 初始化失败,堆栈指向 schema-locations,看起来像配置问题; 漏 clean.sql → 更阴:单个测试类跑得过,整个模块一起跑才炸,因为上一个测试类留下的数据撞了唯一性校验。

enlev-module-ai 与 enlev-module-agent 都没有 application-unit-test.yaml。这不是遗漏:它们的测试全是 Mockito / 裸 JUnit,不需要容器。

怎么扩展 ​

加一个后端 DB 单测 ​

  1. 在 enlev-module-<x>/src/test/java/ 下按被测类的包路径建 XxxServiceImplTest.java;
  2. extends BaseDbUnitTest + 类上 @Import(XxxServiceImpl.class)(只装被测类,其余依赖用 @MockBean);
  3. @Resource 注入被测 Service 与它自己的 Mapper;
  4. 用 randomPojo 造入参、用 Mapper 直接 insert 造既有数据;
  5. 断言用 assertPojoEquals / assertServiceException;
  6. 表在 create_tables.sql 里有对应 H2 建表语句、在 clean.sql 里有一行 DELETE;
  7. 跑 mvn -pl enlev-module-<x> test -Dtest=XxxServiceImplTest。

跑单个测试类时必须带 -Dsurefire.failIfNoSpecifiedTests=false:

bash
cd ainb
mvn -pl enlev-module-system -am -Dtest=PostServiceImplTest \
    -Dsurefire.failIfNoSpecifiedTests=false test

-am 会连带构建上游模块(enlev-common、各 starter),那些模块里没有叫 PostServiceImplTest 的测试,surefire 3.5.3 默认把「指定了但一个没跑」当失败:

[ERROR] Failed to execute goal …maven-surefire-plugin:3.5.3:test (default-test)
        on project enlev-common: No tests matching pattern "PostServiceImplTest" were executed!

给 agent / ai 模块加 DB 单测 ​

先补三样,一样都不能少:

  1. enlev-module-agent/src/test/resources/application-unit-test.yaml(照 system 那份抄,改 enlev.info.base-package);
  2. src/test/resources/sql/create_tables.sql(H2 方言,手写,不是从 MySQL DDL 直接拷);
  3. src/test/resources/sql/clean.sql。

agent 模块的测试方法里没有一个是 DB 单测(总数见 Agent 模块总览 的「规模与形状」表——那张表每次后端提交都会变,所以这里不抄数字),加第一个之前想清楚值不值:agent 的持久化大多经过 infra / system 的 api 层,直接测自己的 Mapper 覆盖面有限。

加一个前端 spec ​

  1. 在被测模块同级建 <name>.spec.ts(include: ['src/**/*.spec.ts'],放别处不会被收到);
  2. import { describe, expect, it } from 'vitest'——environment: 'node',没有全局注入,describe / it 都要显式 import;
  3. 只能用 @/ 别名(vitest.config.ts 里只配了这一个),auto-import 一律不生效:ref / computed / useMessage / DICT_TYPE 全都要手写 import;
  4. 跑 pnpm test:unit(一次性)或 pnpm test:unit:watch。

实测参考值:全量 59 文件 / 1280 用例,热缓存 4.14 s(2026-10-25);单个 spec 文件 1.7 s 左右。

写一条源码扫描守卫 ​

照 service.spec.ts 的形状来,四件事缺一不可:

  1. 读原文:readFileSync(fileURLToPath(new URL('./target.ts', import.meta.url)), 'utf-8');
  2. 锚点抓不到就 throw,附上「请同步本用例」的说明;
  3. 正向自检:构造一个已知违规的字符串,断言扫描器能抓到它(否则「扫不出来」和「没有违规」不可区分);
  4. 反向自检 / 变异探针:断言被守的那段代码还在、处数没变。前端那条守卫甚至专门写了一个用例叫「扫描器自检(防止「把要守的东西删掉,禁令就更绿」)」,里面包含「本文件自己也在扫描范围内,故本文件的注释必须先过自己这一关」。

计数类断言要钉死字面量,不要用 >=:

ts
const count = (src.match(/return Promise\.reject\(new Error\(msg\)\)/g) || []).length
// 🔴 刻意钉死字面量 3:将来真要加第四支同形的 reject,本用例会红 ——
//    那时把 3 改成 4 并想清楚为什么是 4,比让「同形」这句话悄悄失效要好。
expect(count).toBe(3)

坑与注意 ​

  • mvn test 在根目录跑会跑满 5077 个方法,很慢。日常用 -pl <模块> -Dtest=<类>,别忘了 -Dsurefire.failIfNoSpecifiedTests=false。
  • enlev-module-im 的测试已经不存在了:它连同目录一起在 12347efcd1 里被删掉(旧版本这里写「21 个测试类 / 298 个方法是死的」,现在连死的都没有)。上游那些被清掉的模块同批消失,别按 yudao 的模块清单预期这里有什么。
  • -am 会连带跑上游模块的测试吗?不会——-Dtest= 过滤后配合 failIfNoSpecifiedTests=false,上游模块是「编译但零测试」。但如果你去掉 -Dtest=,-am 就会把 enlev-common 等 starter 的测试一起跑(enlev-framework 15 个子模块合计 150 个方法,见上面那张规模表)。
  • H2 不是 MySQL。MODE=MYSQL 只覆盖大部分方言差异;存储过程、ON DUPLICATE KEY UPDATE、部分函数、以及 bit(1) 与 tinyint(1) 的边界行为都可能不同。「H2 过了 MySQL 没过」是真会发生的,尤其是手写 SQL 的 Mapper XML。
  • clean.sql 漏一行的表现是「单独跑绿、一起跑红」,且报错是业务唯一性校验而不是 SQL 错误,很容易查错方向。
  • RedisTestConfiguration 里那个 catch (Exception ignore) {} 不能删:多容器共用 16379,第二个必然端口占用。
  • assertServiceException 连 msg 一起比,改错误码文案会红一片。这是有意的——文案是契约的一部分。
  • 前端 spec 里没有 auto-import、没有 DOM。想 mount 一个组件是不行的(environment: 'node',也没装 @vue/test-utils);想测 service.ts 这种顶层就有副作用的模块,走「读原文 + 正则断言」。
  • pnpm test:e2e 是坏的,playwright.config.ts 与 tests/e2e/ 都不存在。别运行、别补全。
  • 前端全量单测冷启动时可能有一条超时:commentLineRefBan.spec.ts 的全域扫描用例(扫 src + tests 全部 .ts/.vue)实测热跑 1.1–2.2 s,但冷启动、磁盘忙时会突破 vitest 默认的 5000 ms testTimeout 而报 Error: Test timed out in 5000ms。重跑一次即绿(实测第二次 59 文件 / 1280 用例全过,4.14 s)。要根治就给这一条单独传 timeout 参数。
  • 注释里别写 camelCase键:值:注释行号守卫 commentLineRefBan.spec.ts 会把它判成「标识符 + 冒号 + 行号」的位置引用,整套 pnpm test:unit 直接红。实测踩过:flexShrink:0 写在列宽注释里,9e1025c35 提交后一整套单测红了一天没人发现。出路是守卫报错信息里本来就给了的——描述枚举/编码值用等号,写 flexShrink=0。详见坑位索引 F6。
  • 后端注释里不许写「标识符:行号」(CommentLineRefBanTest 会红),前端同理。本手册是另一个仓库、不在扫描范围内,所以文档里可以用行号;但你往 ainb/ 或 ainb-ui-admin-vue3/src/ 里写注释时不行,改用 类#成员。
  • 守卫测试自己也在被扫范围内(前端那条),所以在守卫文件里举违规例子必须放进字符串字面量,不能写在注释里。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

相关页面 ​