# 测试规范（统一入口）

> **目标**: 通过分层测试体系实现理论 100% 覆盖，消除层间缝隙

---

## 1. 测试策略（三层全覆盖：契约 / 集成 / 端到端）

**测试金字塔三大层**：
- **契约层**：L0a / L0b / L0c — 字段对不对（前后端契约一致性）
- **集成层**：L1（含 L1a + L1b）/ L1c — 后端跑得对不对（API + 业务规则 + 数据质量）
- **端到端层**：L2 / L3 — 用户用起来对不对（浏览器交互 + 人工验收）

> L0a/L0b/L0c/L1a/L1b/L1c 是子项命名（保持向后兼容），不是金字塔意义上的"独立层"。规划范围用的"L0 页面清点"严格说不是测试，是测试范围定义。

### 1.0 设计原理

测试盲区的根源是**层间缝隙**——每一层测试都有其无法触及的区域：
- 集成测试直接调 API，绕过了前端代码 → 前端字段名错了测不出来
- MCP E2E 走浏览器操作，但只验 UI 状态 → 后端返回多余/缺少字段测不出来
- 集成测试自己造数据 → 种子数据 / 生产脱敏快照有问题测不出来
- 手工测试能发现所有问题，但不可重复、不可扩展

三层策略的设计目标：**每一层专门消除上一层的结构性盲区**。

### 1.1 三层定义

| 层 | 名称 | 测什么 | 怎么测 | 捕获的盲区类型 | 触发时机 |
|----|------|--------|--------|---------------|----------|
| L0a | 请求契约校验 | 前端请求参数 ↔ 后端 DTO 字段一致性 | 静态脚本对比 | 前端发 `managerScore`、后端只认 `score` → 400 | pre-commit / CI |
| L0b | 响应契约校验 | 后端 Controller 返回值 ↔ 前端 response interface 一致性 | 静态脚本对比 | Controller `.map()` 漏了 `relationship` → 前端显示 “?” | pre-commit / CI |
| L1a | 集成测试 - API 结构 | 端点存在性 + 鉴权 + DTO 校验 + 响应字段名/类型/形状 | HTTP 请求 → 真实数据库 | 端点缺失、鉴权失败状态码、响应字段缺失、`{items:[]}` 形状错误 | 每次后端提交 |
| L1b | 集成测试 - 业务规则 | 状态机、业务约束、计算结果、权限矩阵、组织隔离、跨域链路、级联影响 | HTTP 请求 → 真实数据库 | 状态跳步、权重和≠100 通过、加权分计算错、跨组织数据泄露 | 每次后端提交 |
| L1c | 数据质量校验 | 种子数据 / 生产脱敏快照结构完整性 | 数据校验脚本查 DB（**禁止直连生产**，仅可连脱敏快照或 staging snapshot） | 360 模板 `dimensions` 是 `[[], []]` → 页面只显示序号 | 部署后 / 种子数据变更后 |
| L2 | E2E 测试 | 前后端连通 + 交互逻辑 | Playwright MCP 走业务流程 | 条件渲染错误、loading 状态、空数据防御、mock ≠ 真实 | API 改动后 |
| L3 | 人工验收 | 视觉 / 体验 / 边界 | 用户手动操作 | CSS 布局、动效、文案、极端数据展示 | 前四层通过后 |

### 1.1.1 L1a 与 L1b 的边界规则

L1a 和 L1b 共享一个测试运行时（HTTP → 真实 DB），但断言关注点不同。容易模糊的几个场景按以下规则归类：

| 模糊场景 | 归 L1a 还是 L1b | 理由 |
|---------|----------------|------|
| 状态转换 API 必须返回完整资源对象 | **L1a** | 这是响应结构问题（缺字段），不是业务规则 |
| Employee 访问 admin 端点返回 403 | **L1a** | 这是 HTTP 状态码 + 鉴权流程，结构层 |
| Employee 角色缺少 `kpi:evaluate` 权限码导致 403 | **L1b** | 这是权限矩阵配置问题，业务规则层 |
| 列表/概览 API 无数据时返回 `{items: [], total: 0}` 而非 500 | **L1a** | 响应形状的健壮性 |
| KPI 权重之和 = 100 才能提交 | **L1b** | 业务约束规则 |
| 自评完成自动激活 360 | **L1b** | 跨域业务链路 |
| 修改已提交 KPI 触发整体提交撤回 | **L1b** | 级联影响业务规则 |

**简记**：**L1a 测"调用得通、返回得对"**（端点 + 鉴权 + 响应形状），**L1b 测"业务做得对"**（状态机 + 业务约束 + 权限矩阵 + 计算 + 链路 + 级联）。同一个测试用例可以同时断言 L1a 和 L1b（一次 HTTP 调用既验响应结构也验业务结果），但写场景时按"主要意图"归类放在对应 `describe` 下。

### 1.2 覆盖率保证机制

每层都有明确的**完备性定义**和**验证方式**：

```
L0a 完备性 = 前端所有 apiClient 调用的请求字段 ⊆ 后端 DTO 字段
  验证方式: npx ts-node testing/scripts/contract-check.ts 输出 0 个 ❌（请求部分）
  白名单机制: 确认为”设计差异”的条目记入 contract-whitelist.json，不再报错

L0b 完备性 = 前端所有 response interface 的字段 ⊆ 后端 Controller 实际返回字段
  验证方式: npx ts-node testing/scripts/contract-check.ts 输出 0 个 ❌（响应部分）
  重点检查: Controller 中有 .map() 手动映射的端点（最易丢字段）

L1 完备性 = 07-api.md 中所有 In-scope 端点都有对应测试用例
  验证方式: 测试报告中”未覆盖端点”列表为空
  断言要求: 每个端点至少验证 07-api.md 中定义的所有必填字段

L1c 完备性 = 所有 JSONB 字段的内部结构符合业务要求
  验证方式: npx ts-node testing/scripts/data-quality-check.ts 输出 0 个 ❌
  检查项: JSONB 字段非空数组、内部对象必须有 id/name、FK 引用目标存在

L2 完备性 = 10-e2e-test-spec.md 中所有业务流都执行通过
  验证方式: E2E 测试报告中所有流标记 ✅
  执行要求: 关闭 mock（ENABLE_PERFORMANCE_DEMO=false），用真实 API

L3 完备性 = 用户按页面级验收清单逐项确认
  验证方式: 验收清单全部打勾签字
```

### 1.3 层间缝隙消除矩阵

每个缝隙都有明确的归属层和消除方式，不允许存在”没人管”的盲区：

| 缝隙类型 | 具体表现 | 归属层 | 消除方式 |
|---------|---------|--------|---------|
| 前端请求字段名 ≠ 后端 DTO | 前端发 `{score, feedback}`，后端要 `{responses: [...]}` → 400 | **L0a** | 契约脚本自动检测请求字段差异 |
| 后端返回值缺字段 | Controller `.map()` 漏了 `relationship`，前端显示 “?” | **L0b** | 契约脚本对比 response interface ↔ Controller return |
| Controller 手动映射字段名不一致 | 后端返回 `targetId`，前端 interface 期望 `evaluateeId` | **L0b** | 契约脚本检测已知别名映射 |
| 业务逻辑错误 | 自评完成后 360 未自动激活 | **L1** | 跨域链路集成测试 |
| 权限配置遗漏 | Employee 角色缺少 `kpi:evaluate` 权限 → 403 | **L1** | 多角色权限矩阵测试 |
| 修改已有数据的级联影响 | 修改 KPI 后整体提交未撤回 | **L1** | 级联影响专项测试 |
| 种子数据结构损坏 | 360 模板 dimensions 是空数组 | **L1c** | 数据质量脚本校验 JSONB 结构 |
| FK 数据缺失 | 模板的 created_by 引用不存在的 user | **L1c** | 数据质量脚本校验 FK 完整性 |
| 集成测试造数据通过但真实数据不对 | 测试自建正确模板，但生产环境模板是空的 | **L1c** | 数据质量脚本查生产**脱敏快照**（禁止直连生产 DB） |
| mock ≠ 真实 API | ENABLE_PERFORMANCE_DEMO=true 返回假数据 | **L2** | E2E 关闭 mock 运行 |
| 前端条件渲染逻辑错误 | 自评完成后按钮仍可点击 | **L2** | MCP 验证操作后 UI 状态变化 |
| 前端空数据防御缺失 | `tasks.filter is not a function`（后端返回 `{items:[]}` 而非数组） | **L2** | MCP 在无数据状态下操作验证 |
| CSS / 布局问题 | 部门进度卡片挤在一列 | **L3** | 人工视觉验收 |
| 文案 / 翻译错误 | 按钮文字显示 key 而非翻译值 | **L3** | 人工验收 |

### 1.4 历史 bug 回溯验证

以下是项目实际发生的 bug 及其在三层体系中的归属（验证方法论完备性）：

| bug | 根因 | 归属层 | 当时为什么没测到 |
|-----|-----|--------|---------------|
| 主管评价 400 | 前端发 `managerScore`，DTO 只认 `score` | L0a | 没有契约脚本 |
| 360 提交 400 | 前端发 `{score, feedback}`，后端要 `{responses:[]}` | L0a | 没有契约脚本 |
| findMyTasks 返回显示 “?” | Controller `.map()` 缺少 `relationship` 字段 | L0b | 没有响应字段对比 |
| 360 维度只显示序号 | 模板 dimensions 是 `[[], []]` | L1c | 集成测试自建正确数据，跳过了种子数据 |
| 员工 403 | roles.seed.ts 权限码 `cycle:read` 应为 `cycle:view` | L1 | 集成测试只用 admin 角色 |
| `tasks.filter is not a function` | 后端返回 `{items:[]}` 而非数组 | L2 | 集成测试直接取 `.body.data.items` |
| 周期名显示 undefined | 状态转换 API 只返回 `{id, status}` | L1 | 测试没断言返回值的完整字段 |
| KPI 自评后 360 未激活 | selfEvaluate 缺少 tryActivate360 调用 | L1 | 集成测试没有跨域链路用例 |

18 个历史 bug 的归属分布：L0a 占 39%、L0b 占 17%、L1 占 22%、L1c 占 6%、L2 占 11%、L3 占 5%。

### 1.5 执行顺序与阻断规则

**严格按 L0a → L0b → L1 → L1c → L2 → L3 顺序执行，前一层未通过则后一层无意义。**

```
L0a ❌ → 停止，先修复前后端请求字段不一致
  ↓ ✅
L0b ❌ → 停止，先修复响应字段缺失
  ↓ ✅
L1  ❌ → 停止，先修复业务逻辑
  ↓ ✅
L1c ❌ → 停止，先修复种子或生产脱敏快照中的数据问题
  ↓ ✅
L2  ❌ → 停止，先修复前端交互问题
  ↓ ✅
L3 → 人工验收签字
```

### 1.6 脚本与工具清单

| 工具 | 路径 | 用途 | 运行命令 |
|------|------|------|---------|
| 契约校验（L0a+L0b） | `testing/scripts/contract-check.ts` | 前后端字段对比 | `npx ts-node --transpile-only testing/scripts/contract-check.ts` |
| 数据质量校验（L1c） | `testing/scripts/data-quality-check.ts` | JSONB 结构 + FK 完整性 | `npx ts-node --transpile-only testing/scripts/data-quality-check.ts` |
| 集成测试（L1） | `testing/backend/integration/` | 后端 API 测试 | `cd testing && npm run test:backend:integration` |
| E2E 测试（L2） | `docs/modules/{module}/10-e2e-test-spec.md` | MCP 业务流程 | AI + Playwright MCP 执行 |
| 验收清单（L3） | `testing/checklists/{module}-acceptance.md` | 页面级验收 | 人工逐项检查 |

### 1.65 第三轮测试经验（2026-03-17）

**发现 25 个问题，其中 21 个（84%）应在自动化层被发现但全部漏检。**

根因分布：
- A 类（前后端字段名不匹配）：8 个（32%）— 契约脚本只检查请求不检查响应
- B 类（后端缺少字段/数据）：5 个（20%）— 不检查嵌套结构
- E 类（业务逻辑缺失）：5 个（20%）— 集成测试缺少计算断言
- F 类（UI/交互）：4 个（16%）— L3 人工验收职责
- C 类（权限配置）：2 个（8%）— 不检查权限种子
- D 类（空数据防御）：1 个（4%）

**关键改进**：
1. 新增 L0c 层（响应快照对比），彻底解决 A+B 类问题
2. L1 集成测试增加计算结果断言和多组织隔离测试
3. L1c 增加权限矩阵校验
4. Warning 不再算通过

### 1.7 环境就绪矩阵

**测试执行前必须通过以下 6 项环境检查，任一项失败则阻断测试。**

| # | 检查项 | 验证方式 | 失败时处理 |
|---|--------|---------|-----------|
| 1 | 数据库可达 | `docker exec ffoa-dev-postgres pg_isready` 或对应测试库连接检查 | 启动 Docker 容器 |
| 2 | Schema 已同步 | `npx prisma migrate status` 无 pending 迁移 | 执行 `npx prisma migrate deploy` |
| 3 | 基础种子已加载 | 查询 IAM 角色表 + 组织表有记录 | 执行 `npx prisma db seed` |
| 4 | 模块种子已加载 | 查询模块核心表（如 360 模板、绩效周期模板）有记录 | 执行模块种子脚本 |
| 5 | 后端服务可达 | `curl -s http://localhost:3001/api/health` 返回 200 | 启动后端服务 |
| 6 | 前端服务可达（E2E 时） | `curl -s http://localhost:3000` 返回 200 | 启动前端服务 |

**来源**：03-19 测试报告中四轮迭代总结——v1/v2 因环境不就绪（锁文件、种子丢失）浪费大量时间，v4 引入环境就绪矩阵后效率显著提升。

**执行时机**：
- 手动测试前：按清单逐项检查
- CI 中：由 `quality-gates.yml` 的 setup 步骤自动覆盖
- 脚本化目标：`testing/scripts/` 中应有 `env-check.sh`，一键完成全部检查

### 1.8 契约差异处理规则

**禁止使用"设计差异"作为契约不匹配的终态标签。**

当 L0a/L0b 契约校验发现前后端字段不匹配时，必须按以下决策树处理：

```
发现契约不匹配
  ├─ 是代码错误（实现与文档不符）
  │   └─ 本次修复，修代码对齐文档
  ├─ 是文档过期（文档与最新设计不符）
  │   └─ 本次修复，先更新文档再对齐代码
  └─ 是版本差异（v1 兼容 v2 的过渡期）
      └─ 标记为"下一迭代修复"，必须包含：
          - 具体修复时间（绝对日期，如 2026-04-01）
          - 责任人
          - 写入 contract-whitelist.json 并附注释说明理由与期限
```

**白名单管理规则**：
- `contract-whitelist.json` 中的每个条目必须有 `reason`（理由）和 `deadline`（过期日期）
- 超过 deadline 未解决的白名单条目视为阻断级问题
- 每次全量测试时必须 review 白名单，清理已过期条目

**来源**：03-16 报告将 9 个契约不匹配标记为"设计差异不阻断"，03-17 全部修复。证明"设计差异"标签只是延迟修复的借口。

### 1.9 核心原则

- **集成测试为主**，不写后端单元测试（除非有复杂纯计算逻辑）。
- **现有后端单元测试按历史遗留资产管理**，不作为默认新增或默认维护要求；普通业务逻辑默认用 L1 集成测试覆盖。
- **集成测试必须使用独立测试数据库**，推荐由脚本自动拉起 Docker PostgreSQL，禁止复用开发数据库。
- **MCP 验连通**，不写前端组件测试（MCP 在真实环境验证更全面）。
- **契约校验先行**，任何 API 变更必须先过 L0 再跑 L1。
- 优先行为测试，避免依赖实现细节。
- 测试必须可重复、不可抖动。
- 代码行为发生变化时，应按风险补对应层级测试。
- E2E 必须通过 AI + MCP（Playwright MCP）执行，禁止编写新的 E2E 测试代码。

### 1.8 E2E 最小要求

- 至少 1 个”到达”断言。
- 至少 1 个”成功”信号或”稳定”断言。
- 使用 `expect()` 验证，不要只用 `console.log()`。
- 校验失败时，需要同时验证错误提示与表单状态。

### 1.9 E2E 执行约束

- 使用 `storageState` 做鉴权，禁止每次界面登录。
- 开发环境默认只跑 Chromium，CI 再扩展多浏览器。
- 导航使用 `domcontentloaded`，不要依赖 `networkidle`。
- 使用智能等待，不要硬编码 `waitForTimeout`。

---

### 1.95 cleanup helper 与 Schema 同步规则

**首选方案：前缀过滤 cleanup（schema 无关，零维护负担）**

新模块测试默认采用 `WHERE code/name LIKE 't_%'` 前缀过滤模式 cleanup——测试数据强制带 `t_{timestamp}_` 前缀，按前缀清就能保证只删测试数据、不碰种子。schema 增删表无需修改 cleanup helper。

调用 `cleanupByPrefix()`（`testing/backend/helpers/cleanup.helper.ts`）：

```ts
import { cleanupByPrefix, getTestTimestamp } from '../helpers/cleanup.helper';

afterEach(async () => {
  await cleanupByPrefix(prisma);  // 默认 prefix='t_'，扫所有已知 schema
});

// 测试 fixture 命名加 t_ 前缀
const ts = getTestTimestamp();
await prisma.organization.create({
  data: { code: `t_${ts}_org`, name: `t_${ts}_org`, ... },
});
```

工作机制：禁用 FK → 扫 information_schema 找含 code/name/username/email/slug 列的表 → DELETE WHERE LIKE prefix → 恢复 FK。详见函数 docstring（含已知边界：join 表 dangling 行处理、schema/columns 自定义参数）。

**次选方案：按表精确 DELETE（历史遗留模块仍在用）**

旧模块如果仍按表逐个 DELETE cleanup，则保留以下 schema 同步规则；新模块不再要求：

| 触发条件 | 必须执行的动作 |
|---------|--------------|
| 新增 Prisma 模型 | 在 `testing/backend/helpers/cleanup.helper.ts` 的清理列表中添加对应表，注意 FK 依赖顺序（子表在前，父表在后） |
| 删除 Prisma 模型 | 从 cleanup helper 中移除对应表的清理逻辑 |
| 修改表名/列名 | 同步修改 cleanup helper 中的引用 |
| 修改外键关系 | 检查清理顺序是否仍然正确（FK 约束不允许先删父表） |

**验证方式**：
- PR review 时检查：schema 变更 diff 中新增的模型是否在 cleanup helper 中有对应条目
- 自动化目标：`testing/scripts/` 中应有脚本对比 Prisma schema 模型列表与 cleanup helper 表清单，差异则报错

**来源**：cleanup helper 遗漏新表的问题在 03-15、03-16、03-17、03-20 四份报告中反复出现，每次都导致测试数据残留或清理报错。

### 1.96 测试数据生命周期规则

**种子数据是"基础设施"，只在 force-reset 时创建一次；测试数据是"临时资源"，每个测试自己创建、自己清理。**

| 规则 | 说明 |
|------|------|
| 种子数据全程不删 | 角色、权限、岗位等由 `seed.ts` 创建，cleanup 通过种子 code 列表保留 |
| 测试数据必须随机化 | 所有 code/name/username 必须带 `Date.now()` + 随机后缀，禁止硬编码固定名称 |
| 每个 beforeEach 从干净状态开始 | `setupIntegrationTest` 的 `cleanupBefore` 默认为 `true`，走全量 cleanup |
| cleanup 用精确 DELETE | 按种子 code 列表过滤保留种子，禁止 TRUNCATE（会删种子导致重建开销大） |
| **优先用前缀过滤而非按表清理** | 既然测试数据强制带 `t_{timestamp}_` 前缀，cleanup 优先按 `WHERE name LIKE 't_%' OR code LIKE 't_%'` 清理；这样 schema 加新表时不需要修 cleanup helper（按表清理是历史遗留方案，新模块优先用前缀方案） |
| raw SQL 禁止空 catch | `.catch()` 必须 `console.warn` 输出错误，否则表名错/列名错永远发现不了 |
| 写 raw SQL 查实际表名 | Prisma model 名（`userRole`）≠ PostgreSQL 表名（`user_role_rel`），用 `@@map` 定义 |
| 测试数据名称避开种子 | API 可能同时校验 code 和 name 唯一性，测试名称不能和种子名称相同 |

**force-reset 流程**：
```
DROP DATABASE → CREATE DATABASE → prisma db push → seed.ts
```

**每个 beforeEach 流程**：
```
cleanupAllData()       ← 精确 DELETE 测试数据，保留种子
  → 创建临时 admin    ← 随机用户名，分配种子 Administrator 角色
  → 登录拿 token
  → 创建测试组织/部门  ← 随机 code/name
```

**来源**：2026-04-08 测试基建修复。原 TRUNCATE 方案导致每次重建种子（30s/suite），改为精确 DELETE 后降至 ~7s/suite。详见 `.learnings/2026-04-08-testing-infra-debt.md`。

---

## 2. 统一入口（必须）

- **测试执行入口**：`testing/`
- **测试资产与配置**：`testing/config/`、`testing/setup-tests.ts`
- **文档入口**：`testing/README.md`
- **测试报告**：`testing/reports/`

## 2.1 测试报告要求（必须）
- 报告命名：`testing/reports/{module}-{YYYY-MM-DD}-{type}-report.md`
- 必填内容：测试范围、测试类型、环境信息、版本/分支、执行时间、执行命令
- 用例明细：用例ID、前置条件、输入、输出、断言点、结果
- 失败信息：失败原因、复现步骤、阻断说明（如有）
- 资产引用：截图/日志/trace/视频等路径（如有）

---

## 3. 决策边界

- 后端逻辑验证：优先补集成测试（直接调 API + 真实数据库）。
- 前后端对接验证：用 Playwright MCP 走业务流程（多角色、组织切换、空数据状态）。
- 纯视觉/交互体验：用户手动验收。
- 测试失败时，先核对文档、契约与场景期望，再判断是否修实现或修测试。
- 文档与实现冲突时，以文档为准，并停止继续扩展测试。

### 3.1 闭环交付（必须）

- 明确测试范围与类型选择说明（单元 / 集成 / E2E）。
- 提供验证命令与最小复现步骤。
- 输出测试报告到 `testing/reports/`。
- 如存在偏离、阻断或无法复现的问题，必须说明原因、影响范围与后续动作。

### 3.2 决策门与停止条件

- 文档与实现冲突：立刻停止，说明差异并等待确认。
- 测试失败：先核对文档、契约、场景期望，确认实现问题后再修复。
- E2E 失败且无法稳定复现：记录阻断原因与环境信息，不得静默跳过。

### 3.3 测试失败处理

1. 不要立刻认为测试错了。
2. 先验证实现是否违反文档中的业务规则、API 契约或测试场景期望。
3. 若实现与文档冲突，以文档为准，优先修复实现。
4. 仅在文档明确允许或测试已被证明错误/过期时，才调整或删除测试。

### 3.4 覆盖率目标（指导）

- 关键业务流程跑通优先于覆盖率数字。
- 后端集成测试覆盖所有 in-scope API 端点。
- MCP 测试覆盖所有 P0 业务流程。

### 3.5 反模式

- 提交 `.skip` 测试
- 测试互相依赖
- 直接调用真实外部服务
- 断言私有实现细节而非结果
- 用 `.catch(() => {})` 吞掉超时
- E2E 中不做显式断言

### 3.6 验证要求

- 每次 PR 或变更都应包含运行测试的命令。
- 每次 PR 或变更都应包含最小冒烟验证步骤。

### 3.7 测试报告与中断恢复

- 执行测试时应同步更新测试报告，避免结果丢失。
- 每完成 5 个测试用例应至少更新一次报告。
- 截图、日志、trace、视频等资产放在同级目录保存并在报告中引用。

---

## 4. 与技能保持一致

- **共享 skill 清单**：`.agents/skills/README.md`
- **后端测试 skill**：`.agents/skills/test-backend/SKILL.md`
- **前端 E2E skill**：`.agents/skills/test-frontend/SKILL.md`

---

## 5. 常用命令（示例）

```bash
cd testing

# 前端
npm run test:frontend

# 后端
npm run test:backend

# E2E（Playwright）
npm run test:e2e
```

---

## 5. L1 集成测试实战陷阱（2026-05 沉淀，16 条 learning）

> 以下是项目 2026-04 → 2026-05 期间在 L1 集成测试上反复踩的坑。新模块写 L1 测试前**先翻一遍**，可避开 80% 已知陷阱。

### 5.1 `res.body` 不是 controller 返回值——TransformInterceptor 包装

**现象**：

```ts
const res = await request(app.getHttpServer()).get('/api/v1/health/detailed');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');             // ← undefined
expect(res.body.services.database.status).toBe('up'); // ← TypeError
```

**根因**：`createTestApp()` 跟 `main.ts` 对齐装了全局 `TransformInterceptor`，**所有 controller 返回值被自动包装成 envelope**：

```json
{
  "success": true,
  "data": <controller 真实返回值>,
  "message": "success",
  "timestamp": "...",
  "path": "..."
}
```

**正确断言**：

```ts
expect(res.body.success).toBe(true);
expect(res.body.data.status).toBe('healthy');           // ← 多一层 .data
expect(res.body.data.services.database.status).toBe('up');
```

**异常路径**：`AllExceptionsFilter` 把 HttpException 改写成不同格式（`{code, message, statusCode, stack}`），不要假设异常 body 也走 envelope。

完整背景：[`nestjs-global-pitfalls.md`](../../backend-main/references/nestjs-global-pitfalls.md) §2。

### 5.2 ThrottlerGuard 5 req/30s 干扰 L1

**现象**：跑 31 个 L1 case，test 1+2 PASS，test 3+ **全部 401 Unauthorized**。单独跑 test 3 时 PASS——典型"测试间累积效应"。

**根因**：全局 `ThrottlerGuard` 默认 `5 req / 30s / IP`，supertest 所有请求都从 `127.0.0.1` 一个 IP 发。`beforeEach` 每次 2 次 login，30s 内累积 6+ login → throttler 429 → token undefined → `Bearer ${undefined}` → **401（不是 429！这是误导点）**。

**修法**：L1 测试 setup 时显式调大：

```ts
// testing/backend/helpers/app.helper.ts 或模块 _helpers.ts
process.env.AUTH_THROTTLER_LIMIT = '10000';
process.env.AUTH_THROTTLER_TTL_MS = '1000';
```

**排错口诀**："POST 同 token 工作 + DELETE 同 token 401" → JWT 验证逻辑路径相同不可能时好时坏，**先查 throttler**。

### 5.3 `req.user.currentOrganizationId` 不是 `organizationId`

测试发请求时必须**显式 set `X-Organization-Id` header**，否则 `req.user.currentOrganizationId` 是 undefined，下游 Prisma 查询会撞 §5.4 静默丢过滤：

```ts
await request(app.getHttpServer())
  .post('/some/endpoint')
  .set('Authorization', `Bearer ${token}`)
  .set('X-Organization-Id', orgId)   // ← 关键
  .send(payload);
```

完整 `req.user` shape 见 [`nestjs-global-pitfalls.md`](../../backend-main/references/nestjs-global-pitfalls.md) §6。

### 5.4 Prisma `where: { x: undefined }` 静默丢过滤（prod hazard）

itadmin 用户 `currentOrganizationId` 是 undefined 时，Prisma 标准 query 静默丢 org 过滤，**测试会跨 org 看到全量数据**（dashboard 825M tokens 事故）。

**修法**：raw SQL 用 `orgFilterSql` helper（参考 `backend/src/modules/ai-usage/services/dashboard.service.ts:82`）。完整说明见 [`database-standards.md`](../../database-main/references/database-standards.md) §"Prisma 实战陷阱" §1。

### 5.5 测试 admin factory 维护独立权限列表

`testing/backend/helpers/factories/user.factory.ts::ensureAdminRoleAndPermissions()` **硬编码一份独立权限列表**（30+ 个 `{resource, action}`），与生产端 `roles.seed.ts` 不互通。新模块加了 `@RequirePermissions('flow:create')` 后，必须**同步两处**：

```ts
// 1. roles.seed.ts —— 生产 admin 角色
// 2. testing/backend/helpers/factories/user.factory.ts —— 测试 admin
const permissions = [
  // ... 现有
  { resource: 'flow', action: 'create' },
  { resource: 'flow', action: 'read' },
  // ...
];
```

**症状**：`backend-integration` CI 失败 11 分钟，build / contract / migration 全绿。`PermissionsGuard` 直接 403 → 整套测试 fail。

**根治方向**（未实施）：测试 factory 直接读 `roles.seed.ts` 的 system admin 权限集，消除两份列表的双写。

### 5.6 `createTestApp` 默认 override `APP_INTERCEPTOR` 为空

`testing/backend/helpers/app.helper.ts:createTestApp()` 主动 override `APP_INTERCEPTOR` 为空拦截器：

```ts
.overrideProvider(APP_INTERCEPTOR)
.useValue({ intercept: (context, next) => next.handle() })
```

理由是"避免审计日志在测试中触发外键约束错误"。但意味着**任何依赖 APP_INTERCEPTOR 的横切模块**（审计、scope guard、tracing）在 `createTestApp()` 里都不会被验证。

**症状**：`expect(audit_log.count >= before+1)` 永远失败（审计拦截器没运行），但请求本身 200 成功——误以为是 timing 问题。

**解法**：在模块本地 `_helpers.ts` 写一份**不 override** 的 `createXxxTestApp` 变体：

```ts
import { Test } from '@nestjs/testing';
import { AppModule } from '@/app.module';

export async function createAuditTestApp() {
  const moduleFixture = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();  // 不 override APP_INTERCEPTOR

  const app = moduleFixture.createNestApplication();
  // 复制 createTestApp 的剩余配置（apiPrefix / pipes / filters）
  return app;
}
```

### 5.7 共享测试容器用 flock 串行（不依赖 `concurrency:`）

`quality-gates::backend-integration` 和 `deploy-uat::post-deploy-regression` 共用 `ffoa-test-postgres` / `ffoa-test-redis` 全局容器。两 job 都先 `docker rm -f` 清残留 → 后入 job 把先入 job 容器干掉 → `E57P01 admin_shutdown`。

`concurrency:` 在 Gitea 1.25 静默失效（1.26+ 才支持），即使升级也建议用 **flock 平台无关**机制：

```bash
exec 200>/tmp/ffoa-test-containers.lock
if ! flock -x -w 1800 200; then
  echo "❌ 1800s 内未取到锁，疑似前序 job 卡死"
  exit 1
fi
# ... 跑测试 ...
```

落地见 `testing/scripts/run-backend-integration.sh`。完整背景见 [`16-gitea-actions-platform-semantics.md`](../../../docs/standards/16-gitea-actions-platform-semantics.md) §3。

### 5.8 CI DB reset 必须 `ON_ERROR_STOP=1`

`testing/scripts/lib-test-db.sh::reset_test_db_schema` 历史用 `>/dev/null 2>&1` 吞 SQL 错误：

```bash
# ❌ 旧代码（吞错）
docker exec ... psql -c "SELECT pg_terminate_backend(...);" >/dev/null 2>&1 || true
docker exec ... psql -c "DROP DATABASE IF EXISTS ${TEST_DB_NAME};" >/dev/null 2>&1
docker exec ... psql -c "CREATE DATABASE ${TEST_DB_NAME};" >/dev/null 2>&1
```

CI runner **共享多 PR** 时旧 backend 孤儿连接导致 `DROP DATABASE` 失败 → 静默 → `prisma db push` 跑在**旧数据库**上 → schema 新但 seed 残留 → 大面积 403 / FK violation。

**修法**：

```bash
# ✅
docker exec ... psql -v ON_ERROR_STOP=1 -c "SELECT pg_terminate_backend(...);" >/dev/null
docker exec ... psql -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS ${TEST_DB_NAME};"
docker exec ... psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${TEST_DB_NAME};"
```

不再 redirect stderr——失败要响。

### 5.9 共享容器被并发杀掉时 fail-fast

`ffoa-test-postgres` 共享容器场景，并发任务 `pg_ctl stop` / `docker restart` 把容器干掉时，剩下 18+ suite 全部因 `Server has closed the connection` 失败，6+ 分钟全在白跑，真因 `E57P01 admin_shutdown` 被淹在 4000+ 行错误里。

**判定模式（命中其一即视为 infra 故障）**：

- `E57P01` (admin_shutdown)
- `administrator command`
- `Server has closed the connection`
- `Can't reach database server`
- `ECONNREFUSED.*543\d`
- `Connection terminated unexpectedly`

命中后 `process.exit(2)`（区别于 jest 默认 1，CI 一眼分辨"infra 挂"vs"代码 bug"）。

### 5.10 jest 集成测试 OOM → batch by module

`--runInBand` 把 36 个 integration suite 串行塞 1 个 Node 进程，heap 累积撞 V8 默认 ~2 GB 上限。已治根方案 3（脚本层 batch by module）：

`testing/scripts/run-backend-integration.sh`：

```bash
# 无参数 → batch by module：扫子目录，依次起 jest
# 传具体路径 → 单次 jest 透传（向后兼容）
if [[ $# -gt 0 ]]; then
  run_jest_once "$@"
  exit $?
fi
mapfile -t MODULES < <(find ... -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
for mod in "${MODULES[@]}"; do
  run_jest_once "${INTEGRATION_ROOT}/${mod}/"
done
```

**陷阱**：`deploy-uat.yml::post-deploy-regression` 早期写 `--runInBand` 在路径外传入 → batch 判定看 `$# -gt 0` 误以为是路径 → 走单次 jest 透传 → OOM 复发（PR #337 修）。

**规则**：`run-backend-integration.sh` 的"是否 batch"判定**必须区分"路径"和"选项"**——历史方案 PR #337 之后已修，新模块不要再传 `--runInBand` 到顶层。

### 5.11 测试数据生命周期与 cleanup

- 种子数据（角色 / 权限 / 岗位）由 `seed.ts` 在 `force-reset` 时创建**一次**，全程不删
- 测试数据由 `beforeEach` 创建，cleanup 时只删测试数据、保留种子
- **cleanup 优先用前缀过滤**（`WHERE code/name LIKE 't_%'`）——schema 增删表零维护负担
- 按表精确 DELETE 仅旧模块沿用，不推荐新模块
- raw SQL 的 `.catch()` 必须 `console.warn`，**禁止空 catch**
- 写 raw SQL 必须查实际表名（Prisma model 名 ≠ PostgreSQL 表名，用 `@@map` 定义）

cleanupByPrefix 在 FK-heavy schemas 下可能失败（Prisma 连接池让 `SET session_replication_role = replica` 不可靠）——**显式按 FK 依赖顺序删，用 `prisma.$transaction` 自动复用 connection**：

```ts
afterEach(async () => {
  const targetDefs = await prisma.formDefinition.findMany({
    where: { slug: { startsWith: 't_' } },
    select: { id: true },
  });
  const defIds = targetDefs.map((d) => d.id);
  await prisma.$transaction(async (tx) => {
    await tx.formInstance.deleteMany({ where: { businessKey: { startsWith: 't_' } } });
    await tx.formDefinition.deleteMany({ where: { id: { in: defIds } } });
  });
});
```

### 5.12 测试数据必须随机化

硬编码 `'Test Organization'` / `'WF_DIRECT_MANAGER'` / `'testuser'` 等固定值，只要测试间共享数据库就一定冲突（33 个 409 Conflict 集体爆）。

**规则**：所有测试数据的 `code` / `name` / `username` **必须带 `Date.now()` 随机后缀**。

### 5.13 新模块前置 L1 checklist

写新 controller 的 L1 测试前，照下面顺序自检：

- [ ] 断言用 `res.body.data.X` 不是 `res.body.X`（envelope 包装）
- [ ] setup 时 disable / 调大 ThrottlerGuard
- [ ] 请求带 `X-Organization-Id` header
- [ ] 测试 admin factory（`user.factory.ts`）+ `roles.seed.ts` 双处加权限
- [ ] 横切模块测试（审计 / scope / tracing）用 `createXxxTestApp` 变体不 override APP_INTERCEPTOR
- [ ] 共享容器场景用 flock 而非 `concurrency:`
- [ ] CI DB reset 命令带 `ON_ERROR_STOP=1`
- [ ] 测试数据 code/name/username 都带 `Date.now()` 随机后缀
- [ ] cleanup 用前缀过滤优先，FK-heavy 场景显式按依赖顺序删

---

## 6. 参考文档

- `testing/README.md`
- `testing/backend/docs/README.md`
- `testing/frontend/README.md`
- [`nestjs-global-pitfalls.md`](../../backend-main/references/nestjs-global-pitfalls.md)（NestJS 全局组件冲突，含 §2 envelope / §5 throttler / §6 req.user shape）
- [`database-standards.md`](../../database-main/references/database-standards.md) §"Prisma 实战陷阱"（含 `where: undefined` 静默丢过滤）
- [`16-gitea-actions-platform-semantics.md`](../../../docs/standards/16-gitea-actions-platform-semantics.md) §3（flock vs concurrency 完整背景）
