测试规范
适用端:后端 + Web | 类型:机制
后端 440 个测试类 / 5077 个测试方法,前端 59 个 spec / 1280 个用例(均为实测计数,2026-10-25;复现命令见下两节)。移动端没有单测,见移动端坑位。本页讲清两边的测试基类、跑法、资源文件布局,以及本项目特有的一类测试——源码扫描式守卫:不跑业务代码,而是读源码原文做断言,把「约定」变成「撞红」。
前置阅读:新增一个菜单功能端到端。
它解决什么问题
项目里实际存在四种形态的测试,选错形态是最常见的返工原因:
选型三句话:
- 要验证 SQL 真的能跑(分页条件、逻辑删除、
updateStrategy)→BaseDbUnitTest,别的模块 Service 用@MockBean打桩; - 只验证编排与分支 →
BaseMockitoUnitTest,启动快一个数量级; - 要守住一条「不许这么写」的约定 → 扫描守卫。用 ArchUnit 拦不住的那种(字面用法、注释内容、跨仓文件形状)只能靠读原文。
代码在哪
| 内容 | 路径 |
|---|---|
| 测试 starter | enlev-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} |
| 单测 profile | enlev-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 个有测试) | 23 | 150 | 混合(BaseMockitoUnitTest 6,17 个不继承基类) |
enlev-module-system | 85 | 1021 | BaseDbUnitTest 43、BaseMockitoUnitTest 28、BaseDbAndRedisUnitTest 1 |
enlev-module-infra | 123 | 1381 | BaseMockitoUnitTest 37、BaseDbUnitTest 12、BaseRedisUnitTest 1 |
enlev-module-bpm | 36 | 194 | BaseMockitoUnitTest 29、BaseDbUnitTest 4 |
enlev-module-ai | 13 | 107 | BaseMockitoUnitTest 3,其余不继承基类 |
enlev-module-agent | 160 | 2224 | BaseMockitoUnitTest 75,85 个不继承任何基类 |
| reactor 合计 | 440 | 5077 |
🔴 这张表曾经有一行 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/ 下):
# 每个模块的测试类与测试方法数
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
doneenlev-module-agent 的 85 个「裸 JUnit」类是刻意的:它们测的是 DSL 编译、prompt 装配、契约校验这类纯函数,起 Spring 容器纯属浪费。
enlev-server/src/test/java/ 下只有一个 ProjectReactor.java,它不是测试(不带 Test 后缀,surefire 收不到),是上游带来的一次性改名工具,见仓库结构与改名约定。
四个基类
BaseDbUnitTest 全文(starter-test 的 test/core/ut/):
@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 方法
四个基类的差异只有「装了什么」:
| 基类 | 容器 | DB | Redis | @Sql 清库 |
|---|---|---|---|---|
BaseMockitoUnitTest | 无(只有 @ExtendWith(MockitoExtension.class)) | 无 | 无 | 无 |
BaseDbUnitTest | Spring(WebEnvironment.NONE) | H2 内存 | 无 | 每个方法后跑 clean.sql |
BaseRedisUnitTest | Spring | 无 | jedismock 内嵌 | 无 |
BaseDbAndRedisUnitTest | Spring | H2 内存 | jedismock 内嵌 | 每个方法后跑 clean.sql |
BaseMockitoUnitTest 全文就三行有效代码:
@ExtendWith(MockitoExtension.class)
public class BaseMockitoUnitTest {
}H2 是怎么起来的
enlev-module-system/src/test/resources/application-unit-test.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 里复制了一份并显式关掉懒加载:
@Lazy(value = false) // 禁止延迟加载
@EnableConfigurationProperties(SqlInitializationProperties.class)
public class SqlInitializationTestConfiguration {
@Bean
public DataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(...) { ... }
}类注释原文:「不过呢,当前类的实现代码,基本是复制 DataSourceInitializationConfiguration 的哈!」——升级 Spring Boot 时要留意这个类是否还跟得上。
内嵌 Redis 由 RedisTestConfiguration 提供,用的是 com.github.fppt.jedismock.RedisServer:
@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)。骨架是固定套路:
@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」:
@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 全文:
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/ | 8 | DSL 门禁夹具、编辑历史、图编辑、节点 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/ | 2 | routerHelper、注释行号守卫 |
| 其余 8 个目录 | 各 1 | TableActions/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,而是读它的源码原文:
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,顺序无所谓(没有物理外键):
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 单测
- 在
enlev-module-<x>/src/test/java/下按被测类的包路径建XxxServiceImplTest.java; extends BaseDbUnitTest+ 类上@Import(XxxServiceImpl.class)(只装被测类,其余依赖用@MockBean);@Resource注入被测 Service 与它自己的 Mapper;- 用
randomPojo造入参、用 Mapper 直接insert造既有数据; - 断言用
assertPojoEquals/assertServiceException; - 表在
create_tables.sql里有对应 H2 建表语句、在clean.sql里有一行DELETE; - 跑
mvn -pl enlev-module-<x> test -Dtest=XxxServiceImplTest。
跑单个测试类时必须带 -Dsurefire.failIfNoSpecifiedTests=false:
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 单测
先补三样,一样都不能少:
enlev-module-agent/src/test/resources/application-unit-test.yaml(照system那份抄,改enlev.info.base-package);src/test/resources/sql/create_tables.sql(H2 方言,手写,不是从 MySQL DDL 直接拷);src/test/resources/sql/clean.sql。
agent 模块的测试方法里没有一个是 DB 单测(总数见 Agent 模块总览 的「规模与形状」表——那张表每次后端提交都会变,所以这里不抄数字),加第一个之前想清楚值不值:agent 的持久化大多经过 infra / system 的 api 层,直接测自己的 Mapper 覆盖面有限。
加一个前端 spec
- 在被测模块同级建
<name>.spec.ts(include: ['src/**/*.spec.ts'],放别处不会被收到); import { describe, expect, it } from 'vitest'——environment: 'node',没有全局注入,describe/it都要显式 import;- 只能用
@/别名(vitest.config.ts里只配了这一个),auto-import 一律不生效:ref/computed/useMessage/DICT_TYPE全都要手写 import; - 跑
pnpm test:unit(一次性)或pnpm test:unit:watch。
实测参考值:全量 59 文件 / 1280 用例,热缓存 4.14 s(2026-10-25);单个 spec 文件 1.7 s 左右。
写一条源码扫描守卫
照 service.spec.ts 的形状来,四件事缺一不可:
- 读原文:
readFileSync(fileURLToPath(new URL('./target.ts', import.meta.url)), 'utf-8'); - 锚点抓不到就
throw,附上「请同步本用例」的说明; - 正向自检:构造一个已知违规的字符串,断言扫描器能抓到它(否则「扫不出来」和「没有违规」不可区分);
- 反向自检 / 变异探针:断言被守的那段代码还在、处数没变。前端那条守卫甚至专门写了一个用例叫「扫描器自检(防止「把要守的东西删掉,禁令就更绿」)」,里面包含「本文件自己也在扫描范围内,故本文件的注释必须先过自己这一关」。
计数类断言要钉死字面量,不要用 >=:
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-framework15 个子模块合计 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 mstestTimeout而报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/里写注释时不行,改用类#成员。 - 守卫测试自己也在被扫范围内(前端那条),所以在守卫文件里举违规例子必须放进字符串字面量,不能写在注释里。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 新增一个菜单功能端到端:新功能的第 10 步就是补单测;
- 接口约定与 CRUD 骨架:被测的 Service / Mapper 长什么样;
- 数据库约定与表总览:H2 建表要对照的 MySQL 侧约定;
- 构建与部署:
mvn test在打包流程里的位置; - 坑位索引:全项目踩过的坑汇总。
