项目:ERP-CRM | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本篇回答四个问题:测试现状长什么样、代表性用例是怎么写的(含 AbstractIntegrationTest 的线程模型与 sweepOnce() 这类"直接调方法不等定时器"的 fixture 手法)、质量门禁由哪些命令与流水线构成、缺口在哪以及怎么补。《06-部署运行》第 6 节已给过门禁清单,本篇只做深读与评估,命令细节不再重复。
后端 13 个测试类、约 47 个用例(AGENTS.md 口径,2026-09-28 本机与 CI 双绿基线),全部位于 erp-crm/src/test/java/com/ec/。按被测对象分四组:
| 分组 | 测试类 | 行数 | 测试形态 | 覆盖点 |
|---|---|---|---|---|
| 认证 | auth/LoginAuthChainIntegrationTest | 90 | RANDOM_PORT + TestRestTemplate 真发 HTTP | 登录成功签发 token、合法令牌访问 /getInfo、伪造租户头被拒且响应不泄露用户信息、无令牌返回 body 401 语义、错误密码被拒 |
| 业务流程 | crm/CrmApprovalFlowIntegrationTest | 174 | 继承基类(服务层直调) | 审批提交/通过/驳回 + 引擎失败补偿回退 |
| 业务流程 | crm/CrmCustomerOwnerGuardIntegrationTest | 158 | 继承基类 | 非管理员越权转移/入池/删除被守卫 |
| 业务流程 | erp/ErpStockInIntegrationTest | 122 | 继承基类 | 采购入库 CAS 认领、重复入库拒绝 |
| 业务流程 | erp/ErpTransferExecuteIntegrationTest | 133 | 继承基类 | 调拨双流水+库存推导、重复执行拦截、库存不足回滚 |
| SaaS | saas/TenantRegisterIntegrationTest | 83 | 继承基类 | 注册真实建库与主库登记、失败清理 |
| SaaS | saas/job/TenantExpirationJobIntegrationTest | 103 | 继承基类 + 直调 sweepOnce() | 到期停用/临期提醒/三类不受影响租户 + 两个指标接线断言 |
| SaaS | saas/security/TenantDbPasswordCipherTest | 98 | 纯单元测试(无 Spring 上下文) | 加密格式/幂等/解密失败抛错/未配钥直通告警路径 |
| SaaS | saas/service/MasterTenantPasswordMigratorTest | 115 | 单元为主 | 明文→密文启动迁移 |
| SaaS | saas/service/MasterTenantServiceImplTest | 159 | 单元为主 | 主库服务读写路径的加解密封装 |
| 配额 | web/service/TenantQuotaIntegrationTest | 124 | 继承基类 | 套餐 maxUsers 拦截新增/导入 |
| 配置 | common/config/EcProdConfigVerifierTest | 137 | 单元 | prod 密钥缺失 fail-fast 各分支 |
| 基座 | AbstractIntegrationTest | 73 | 抽象基类 | 见第 2 节 |
前端仅两个 vitest 文件:src/utils/erpcrm.test.js(工具函数:tansParams 参数序列化、blobValidate 等)与 src/composables/useDict.test.js(字典缓存:同类型只请求一次、失败不缓存)。没有组件测试、没有 E2E。
三个可直接观察到的取向:测试写得很"集成"——13 个类里 9 个要起完整 Spring 上下文并连真实 MySQL/Redis;命名即文档——用例方法名直接用中文长句描述行为(如 无验证码登录_令牌访问通过_伪造租户头被拒_无令牌401语义_错误密码被拒),失败信息里带响应体;锚定高危逻辑——被测的几乎全是多租户路由、跨库补偿、并发认领、越权守卫这类"写错就出事故"的路径,而不是 CRUD 覆盖率。
AbstractIntegrationTest 的线程模型基类(73 行)解决的核心问题是:业务 Service 依赖 TenantInterceptor 在 HTTP 线程上预置的 ThreadLocal 租户键,而测试线程没有这个前置。它在 @BeforeEach 做三件事(类注释自称"线程模型模拟生产"):
LoginUser(tenant1, userId=1, deptId=100, admin, 空 permissions) 放进 SecurityContextHolder——服务层 SecurityUtils.getLoginUser()/isAdmin() 因此可用;需要模拟普通用户时,子类(如 OwnerGuard 测试的 setSecurityContext)可以覆盖。TenantInterceptor 的注册逻辑(查 master_tenant → addDataSource),绕过 HTTP 也能路由到 ryt_nelub7xr。DynamicDataSourceContextHolder.setDataSourceKey("tenant1"),并建好 JdbcTemplate(注意它绑定的是动态路由数据源,随 ThreadLocal 键切换落库)。@AfterEach 清理键与安全上下文。由此产生两条使用纪律(多处测试注释反复出现):fixture 写租户库直接用 jdbc(键已是 tenant1);fixture 要写主库必须先 setDataSourceKey("default_db") 并在 finally 清理——TenantExpirationJobIntegrationTest.prepareFixture 第 33 行就是标准写法,且注释解释了原因:"基类已把线程键预置为 tenant1"。
sweepOnce() 直调模式TenantExpirationJobIntegrationTest 不等待 cron,而是直接注入 TenantExpirationJob 调 sweepOnce()——这依赖被测类的一个刻意设计:TenantExpirationJob 把定时入口 sweepBySchedule() 与执行体 sweepOnce() 拆成两个方法,javadoc 明写"fixture/测试请调用 sweepOnce(),定时入口为 sweepBySchedule()"。fixture 在主库灌五类租户行(过期/临期(3 天)/远期(90 天)/已停用/无到期日),断言只看 junit-exp-* 前缀行而不做全局断言——注释给出的理由很有工程味道:"CI 种子里就有 9 个过期历史租户,会被任务按设计停用,全局计数不可断言"。此外它还断言了 Micrometer 指标接线(erp.tenant.expiring.total gauge ≥1、erp.tenant.expired.disabled.total counter ≥1),把可观测性也纳入测试契约。
LoginAuthChainIntegrationTest这是唯一一个 RANDOM_PORT 真起 Servlet 容器的用例:TestRestTemplate 真发 HTTP,验证的是整条过滤器链(JWT filter → Security 规则 → TenantInterceptor)而非单个方法。最值得注意的是第 3 步——伪造租户头:令牌属于 tenant1 而请求头声称 forged-tenant,断言必须被拒且"拒绝响应不得泄露用户信息"(doesNotContain("userName"))。这实际锚定了"JWT 与租户头一致性"这条安全不变量(机制见《08-安全与权限设计》第 3 节)。类注释同时记录了产品决策:"图形验证码已按产品决策移除,登录防护依赖 IP 限流与密码错误锁定"——这句注释与 AGENTS.md 的验证码描述相矛盾,以测试注释与源码为准(另见 13 篇)。
ErpTransferExecuteIntegrationTest两个用例分别锚定"正常路径产生正确事实"与"异常路径不产生半事实":
derivedStock 用 SQL 重新按"Σ入−Σ出"口径算,独立于被测代码验证)。随后重复执行同一单,断言抛 ServiceException 且流水数不增——CAS 认领的回归测试。@AfterAll 还有一份带数据源重锚的兜底清理,防止用例失败时残留 fixture 污染下一轮。CrmApprovalFlowIntegrationTest用例围绕"引擎失败时租户库必须回退"构造:fixture 直接向租户库插合同行(insertContract(id, prevStatus))+ 清理时连 Flowable 历史也删(deleteHistoryQuietly,第 167 行,静默处理引擎历史不存在的情形)。这是全项目唯一与 Flowable 引擎表互动的测试,它的存在使"补偿式一致性"(《05-核心流程》流程三)不只是注释里的设计承诺。
| 端 | 命令 | 内容 | 红线 |
|---|---|---|---|
| 后端 | mvn test | 47 例集成+单元 | 任一失败即失败;依赖本机 MySQL/Redis 与 .env(跑前先确认环境,见《06-部署运行》2.1) |
| 前端 | pnpm lint | eslint --max-warnings 0(带缓存)+ prettier | 0 警告容忍度 |
| 前端 | pnpm typecheck | vue-tsc --noEmit --skipLibCheck | 全绿基线;但 tsconfig exclude **/*.js,业务 JS 不在检查范围(AGENTS.md 明示,故约定"新工具逻辑必须配套 vitest 用例") |
| 前端 | pnpm test | vitest run | 2 个测试文件 |
| 前端 | pnpm build | vite build(8G 内存上限)+ generate-version-file | 构建期无单测,靠 chunk 体积告警阈值 1000KB 做回归提示 |
erp-crm/.github/workflows/backend.yml:push/PR 触及 *.java/*.xml/*.yml/*.sql/pom 时起 MySQL 8.4 + Redis 7 服务容器 → 建双库、灌同两份 SQL、修正 tenant1 登记、补种 admin(与 compose 的 db-init 同源逻辑)→ mvn -B -ntp test,密钥全部走工作流 env: 段注入。erpcrm-ui/.github/workflows/frontend.yml:Node 22 + corepack 钉 pnpm 10.34.5 → pnpm install --frozen-lockfile → lint → typecheck → test → build。值得注意:CI 的数据库准备逻辑(.github/workflows/backend.yml 第 70-81 行)事实上是租户模板 SQL 的第二份消费者——它每次都从零验证"模板能建出可用租户库",等于把《11-扩展开发指南》里"改模板必须自测"的纪律自动化了一半。
按风险×成本排序,供排期参考:
| # | 缺口 | 现状证据 | 建议 |
|---|---|---|---|
| 1 | 生成器四模块(商机/合同/回款/CRM 订单)零测试 | 13 个测试类无一覆盖 CrmOpportunityServiceImpl 等 | 至少补一个"列表 join 列回填"集成测试(customerName/contractNo 的 resultMap 映射是生成代码里最容易随表结构漂移的点) |
| 2 | 审批列表聚合无测试 | CrmApprovalFlowIntegrationTest 只测提交/审批,selectApprovalList 的待办/已办分页与 useMaster/useTenant 切换无锚 | 补 1 例:插入合同→提交→分别断言 status=0/1 列表返回与租户过滤(另一租户的流程不可见) |
| 3 | ERP 零售/盘点入库无测试 | 仅采购入库与调拨有 | 复用高位 id fixture 模板各补 1 例(SN 状态翻转、盘亏负库存拒绝) |
| 4 | Controller 层无 MockMvc/Web 层测试 | 仅认证链有 HTTP 测试 | 优先给 CrmApprovalController.getInfo 的 {id} 语义补契约测试,防 taskId/主键混用回归(《07-模块详解》2.2 的坑) |
| 5 | 前端 useTable 无测试 | 仅 useDict/erpcrm 工具有 | resetQuery 保留分页、handleSelectionChange 的 ids 回退取键是两个易碎点 |
| 6 | 无 E2E/冒烟 | 登录→列表→提交审批全靠手工 | 可先用 vitest + 请求层 mock 覆盖 401 重登流转;E2E 待容器链路稳定后引入 |
| 7 | 测试数据管理靠约定 | fixture 全靠高位 id + 手工 delete | 若用例继续增多,可引入统一的 fixture 清理注册器(按前缀批量清) |
| 8 | mvn test 无法离线跑 | 依赖本机 MySQL/Redis/.env | CI 已覆盖;本机可在 README/文档强调"无 Redis 时必挂"的报错特征(见 12 篇 FAQ) |
补测时请沿用三个既有惯例:中文行为化命名、失败消息携带响应体、fixture 高位 id + @AfterEach/@AfterAll 双重清理——保持风格统一比追求覆盖率数字更重要。