# Epic 9 模式总结：E2E 测试最佳实践

**创建日期:** 2026-01-12
**Epic:** Epic 9 - 残疾人管理完整 E2E 测试覆盖
**目的:** 为后续 Epic 提供可复用的测试模式和最佳实践

## 目录

1. [测试数据隔离模式](#1-测试数据隔离模式)
2. [并行执行配置](#2-并行执行配置)
3. [Page Object 方法设计模式](#3-page-object-方法设计模式)
4. [代码审查常见问题检查清单](#4-代码审查常见问题检查清单)
5. [稳定性测试模式](#5-稳定性测试模式)
6. [测试文件组织结构](#6-测试文件组织结构)

---

## 1. 测试数据隔离模式

### 1.1 时间戳前缀模式（标准模式）

**目的:** 确保每个测试使用唯一数据，避免并发测试时的数据冲突

**实现方式:**

```typescript
test.describe('残疾人管理 - 功能测试', () => {
  // 在 test.describe 级别声明时间戳
  const TEST_TIMESTAMP = Date.now();
  const TEST_PREFIX = `test_${TEST_TIMESTAMP}`;

  test('应该成功添加数据', async ({ page }) => {
    const uniqueName = `${TEST_PREFIX}_数据A`;
    // 使用 uniqueName 创建数据
  });

  test('应该成功编辑数据', async ({ page }) => {
    const uniqueName = `${TEST_PREFIX}_数据B`;
    // 使用 uniqueName 创建数据
  });
});
```

**关键点:**
- 时间戳在 `test.describe` 级别声明，避免全局污染
- 每个测试使用不同的后缀（`_数据A`, `_数据B`）确保数据唯一性
- 所有测试可以并行运行，不会发生数据冲突

### 1.2 唯一数据追踪数组

**目的:** 跟踪测试创建的数据，方便在 `afterEach` 中清理

**实现方式:**

```typescript
test.describe('残疾人管理 - 功能测试', () => {
  const TEST_TIMESTAMP = Date.now();
  const TEST_PREFIX = `test_${TEST_TIMESTAMP}`;
  // 在 describe 级别声明数组，避免全局污染
  const createdTestData: Array<{ name: string }> = [];

  test('应该成功添加数据', async ({ page }) => {
    const testData = { name: `${TEST_PREFIX}_数据A` };
    await pageObject.create(testData);
    // 记录创建的数据
    createdTestData.push(testData);
  });

  test.afterEach(async ({ disabilityPersonPage, page }) => {
    // 清理本次测试创建的所有数据
    for (const data of createdTestData) {
      try {
        await disabilityPersonPage.goto();
        await disabilityPersonPage.searchByName(data.name);
        const deleteButton = page.getByRole('button', { name: '删除' }).first();
        if (await deleteButton.count({ timeout: 2000 }) > 0) {
          await deleteButton.click({ timeout: 5000 });
          await page.getByRole('button', { name: '确认' }).click({ timeout: 5000 }).catch(() => {});
          await page.waitForTimeout(500);
        }
      } catch (error) {
        console.debug(`  ⚠ 清理数据失败: ${data.name}`, error);
      }
    }
  });
});
```

**关键点:**
- `createdTestData` 数组在 `test.describe` 级别声明
- 每个测试完成后添加到数组
- `afterEach` 清理数组中的所有数据
- 使用 try-catch 确保单个清理失败不影响其他清理

### 1.3 随机数范围优化

**目的:** 使用更大的随机数范围避免并发冲突

**错误方式（Epic 9 早期）:**
```typescript
// ❌ 6 位随机数范围太小，并发时可能冲突
const randomId = Math.floor(Math.random() * 1000000);
```

**正确方式（Epic 9 改进）:**
```typescript
// ✅ 使用 Number.MAX_SAFE_INTEGER 确保唯一性
const uniqueId = Number.MAX_SAFE_INTEGER - TEST_TIMESTAMP;
```

### 1.4 省市选择多样化（可选）

**目的:** 避免所有测试使用相同的省市，提高测试覆盖真实性

**问题:** 所有测试使用"湖北省/武汉市"，虽然稳定但覆盖不全面

**改进方案:**
```typescript
const PROVINCE_CITY_MAP = [
  { province: '北京市', city: '北京市' },
  { province: '上海市', city: '上海市' },
  { province: '广东省', city: '广州市' },
  { province: '湖北省', city: '武汉市' },
  { province: '四川省', city: '成都市' },
];

// 基于测试索引选择不同的省市
const location = PROVINCE_CITY_MAP[testIndex % PROVINCE_CITY_MAP.length];
```

**注意:** 需要确保并发时省市选择不会冲突（可以使用时间戳的最后一位作为索引）

---

## 2. 并行执行配置

### 2.1 移除 test.describe.serial

**问题:** `test.describe.serial` 会阻止并行执行，违背 Playwright 设计初衷

**修改前（Epic 9 早期）:**
```typescript
// ❌ 串行执行，所有测试必须顺序执行
test.describe.serial('残疾人管理 - 照片上传功能', () => {
  // 测试...
});
```

**修改后（Epic 9 改进）:**
```typescript
// ✅ 并行执行，多个测试可以同时运行
test.describe('残疾人管理 - 照片上传功能', () => {
  // 测试...
});
```

### 2.2 Playwright 配置

**默认配置（推荐）:**
```typescript
// playwright.config.ts
export default defineConfig({
  fullyParallel: true,      // 启用并行
  workers: process.env.CI ? 1 : undefined,  // CI 环境单 worker，本地环境自动检测
});
```

**指定 worker 数量:**
```typescript
export default defineConfig({
  fullyParallel: true,
  workers: 4,  // 使用 4 个并行 worker
});
```

### 2.3 并行执行命令

```bash
# 并行运行所有测试（默认）
cd web
pnpm test:e2e:chromium

# 指定 4 个 worker
pnpm test:e2e:chromium --workers=4

# 指定单个文件并行运行
pnpm test:e2e:chromium disability-person-photo.spec.ts --workers=2
```

### 2.4 性能提升数据

Epic 9 实测数据：

| 配置 | 执行时间 | 提升 |
|------|---------|------|
| 1 worker (串行) | 3.3 分钟 | 基准 |
| 2 workers | 2.2 分钟 | 1.5x |
| 4 workers | 1.1 分钟 | **3x** |

---

## 3. Page Object 方法设计模式

### 3.1 方法命名规范

**命名格式:** `动词+名词`，camelCase

| 方法类型 | 命名示例 |
|---------|---------|
| 创建 | `createPerson()`, `addBankCard()`, `addNote()` |
| 读取 | `getPerson()`, `getBankCardList()`, `getNoteCount()` |
| 更新 | `updatePerson()`, `editBankCard()`, `editNote()` |
| 删除 | `deletePerson()`, `deleteBankCard()`, `removeNote()` |
| 检查 | `personExists()`, `isAddButtonDisabled()` |
| 等待 | `waitForPersonExists()`, `waitForPersonNotExists()` |

### 3.2 方法设计模板

**创建类方法模板:**
```typescript
/**
 * 添加银行卡
 * @param bankCardData 银行卡数据
 * @returns 添加的银行卡索引
 */
async addBankCard(bankCardData: {
  bankName: string;
  cardNumber: string;
  cardHolder: string;
  isDefault?: boolean;
}): Promise<number> {
  // 1. 点击"添加银行卡"按钮
  const addButton = this.page.locator('[data-testid="add-bank-card-button"]');
  await addButton.click();
  await this.page.waitForTimeout(TIMEOUTS.SHORT);

  // 2. 填写银行卡信息
  // ...

  // 3. 返回新添加的银行卡索引
  const cardCount = await this.getBankCardCount();
  return cardCount - 1;
}
```

**读取类方法模板:**
```typescript
/**
 * 获取银行卡列表
 * @returns 银行卡信息数组
 */
async getBankCardList(): Promise<Array<{
  bankName: string;
  cardNumber: string;
  cardHolder: string;
}>> {
  const cards: Array<{ bankName: string; cardNumber: string; cardHolder: string }> = [];
  const cardElements = this.page.locator('[data-testid^="bankcard-item-"]');

  const count = await cardElements.count();
  for (let i = 0; i < count; i++) {
    const bankName = await cardElements.nth(i).locator('[data-testid="bank-name"]').textContent();
    const cardNumber = await cardElements.nth(i).locator('[data-testid="card-number"]').textContent();
    const cardHolder = await cardElements.nth(i).locator('[data-testid="card-holder"]').textContent();
    cards.push({ bankName: bankName || '', cardNumber: cardNumber || '', cardHolder: cardHolder || '' });
  }

  return cards;
}
```

### 3.3 选择器策略优先级

**优先级顺序:**
1. `data-testid` - 最稳定，推荐
2. `aria-label` + `role` - 语义化选择器
3. `:text-is()` - 精确文本匹配
4. `:has-text()` - 模糊文本匹配（尽量避免）

**示例:**
```typescript
// ✅ 优先使用 data-testid
const button = this.page.locator('[data-testid="add-bank-card-button"]');

// ✅ 其次使用 aria-label + role
const select = this.page.getByRole('combobox', { name: '银行类型' });

// ⚠️ 尽量避免模糊文本匹配
const button = this.page.getByText('添加');  // 可能匹配多个元素
```

### 3.4 超时常量定义

**定义统一超时常量:**
```typescript
// 在 Page Object 文件顶部定义
const TIMEOUTS = {
  IMMEDIATE: 0,
  SHORT: 100,      // 100ms - UI 动画
  MEDIUM: 500,     // 500ms - 表单提交
  LONG: 1000,      // 1s - 数据刷新
  EXTRA_LONG: 3000, // 3s - 复杂数据持久化
  NETWORK: 10000,  // 10s - 网络请求
} as const;
```

**使用方式:**
```typescript
await this.page.waitForTimeout(TIMEOUTS.SHORT);  // ✅ 使用常量
await this.page.waitForTimeout(500);            // ❌ 硬编码
```

---

## 4. 代码审查常见问题检查清单

### 4.1 HIGH 优先级问题

| 问题类型 | 检查方法 | 修复方式 |
|---------|---------|---------|
| Page Object 方法虚假完成 | 检查每个方法是否有实际实现 | 实现缺失的方法体 |
| 路径遍历漏洞 | 检查文件路径拼接 | 使用 `path.join()` |
| 数据隔离问题 | 检查全局变量 | 使用 describe 级别变量 |
| 表单验证错误 | 检查验证逻辑完整性 | 添加缺失的验证 |

### 4.2 MEDIUM 优先级问题

| 问题类型 | 检查方法 | 修复方式 |
|---------|---------|---------|
| console.log 使用 | 搜索 `console.log` | 改为 `console.debug` |
| 魔法数字 | 搜索数字字面量 | 使用 TIMEOUTS 常量 |
| 测试覆盖不完整 | 检查验收标准 | 添加缺失的测试 |
| 注释过时 | 检查注释与代码一致性 | 更新注释 |

### 4.3 代码审查前自查清单

**开发者自查（提交前）:**
- [ ] 所有 `console.log` 已改为 `console.debug`
- [ ] 所有超时值使用 TIMEOUTS 常量
- [ ] Page Object 方法完整实现（非虚假完成）
- [ ] 数据使用 TEST_TIMESTAMP 前缀
- [ ] 移除 `test.describe.serial`（除非确实需要串行）
- [ ] 测试文件无 `any` 类型
- [ ] 文件路径使用 `path.join()` 或常量

---

## 5. 稳定性测试模式

### 5.1 稳定性测试脚本

**脚本模板:**
```bash
#!/bin/bash
# Epic 稳定性测试脚本

RUNS=10
PASSED=0
FAILED=0
TIMES=()

for i in $(seq 1 ${RUNS}); do
  echo "=== 运行 #${i}/${RUNS} ==="
  START=$(date +%s)

  if pnpm test:e2e:chromium --workers=4; then
    PASSED=$((PASSED + 1))
    echo "✅ 运行 #${i} 通过"
  else
    FAILED=$((FAILED + 1))
    echo "❌ 运行 #${i} 失败"
  fi

  END=$(date +%s)
  DURATION=$((END - START))
  TIMES+=($DURATION)
  echo "⏱️  耗时: ${DURATION}s"
  echo ""
done

# 计算统计数据
TOTAL=$((PASSED + FAILED))
PASS_RATE=$((PASSED * 100 / TOTAL))
AVG_TIME=$(awk '{sum+=$1} END {print sum/NR}' <<< "${TIMES[@]}")

echo "========================================="
echo "稳定性测试结果"
echo "========================================="
echo "通过: ${PASSED}/${TOTAL} (${PASS_RATE}%)"
echo "平均时间: ${AVG_TIME}s"
```

### 5.2 数据持久化重试机制

**问题:** 表单提交后立即查询可能找不到数据

**解决方案:**
```typescript
/**
 * 等待人员记录出现（带重试机制）
 */
async waitForPersonExists(name: string, options?: { timeout?: number }): Promise<boolean> {
  const timeout = options?.timeout ?? 10000;
  const startTime = Date.now();

  while (Date.now() - startTime < timeout) {
    await this.searchByName(name);
    await this.page.waitForTimeout(1000);

    const exists = await this.personExists(name);
    if (exists) {
      console.debug(`  ✓ 记录已出现: ${name}`);
      return true;
    }
  }

  console.debug(`  ✗ 记录未出现: ${name} (超时 ${timeout}ms)`);
  return false;
}
```

### 5.3 稳定性测试迭代修复模式

**Epic 9 经验:** 4 轮修复将通过率从 77.4% 提升到 90.3%

| 轮次 | 通过率 | 主要修复 |
|------|--------|---------|
| 第 1 次 | 77.4% | 初始运行，识别问题 |
| 第 2 次 | 85% | 增加表单提交等待到 3 秒 |
| 第 3 次 | 91.2% | 数据持久化重试机制 |
| 第 4 次 | 90.3% | 银行卡类型名称修复 |

**经验总结:**
- 稳定性问题通常需要多轮修复
- 每轮修复应专注于一个主要问题类型
- 100% 稳定性可能需要很长时间，核心功能稳定即可

---

## 6. 测试文件组织结构

### 6.1 标准目录结构

```
web/tests/e2e/
├── fixtures/              # 测试文件（图片、文档）
│   └── images/
│       ├── id-card-front.jpg
│       ├── id-card-back.jpg
│       └── disability-card.jpg
├── pages/                # Page Object
│   └── admin/
│       ├── disability-person.page.ts
│       ├── region-management.page.ts
│       └── order-management.page.ts
├── specs/                # 测试用例
│   └── admin/
│       ├── disability-person-photo.spec.ts
│       ├── disability-person-bankcard.spec.ts
│       ├── disability-person-note.spec.ts
│       ├── disability-person-visit.spec.ts
│       └── disability-person-crud.spec.ts
├── scripts/              # 测试脚本
│   └── run-stability-test.sh
└── playwright.config.ts  # Playwright 配置
```

### 6.2 测试文件命名规范

| 文件类型 | 命名格式 | 示例 |
|---------|---------|------|
| 测试用例 | `{feature}-{action}.spec.ts` | `disability-person-crud.spec.ts` |
| Page Object | `{entity}.page.ts` | `disability-person.page.ts` |
| 测试脚本 | `run-{test-type}.sh` | `run-stability-test.sh` |

---

## 应用示例：Epic 10 订单管理测试

### 应用 Epic 9 模式

```typescript
// Epic 10: 订单管理测试 - 应用 Epic 9 模式

test.describe('订单管理 - 创建订单', () => {
  // 模式 1: 时间戳前缀
  const TEST_TIMESTAMP = Date.now();
  const TEST_PREFIX = `test_order_${TEST_TIMESTAMP}`;
  const createdOrders: Array<{ orderNo: string }> = [];

  test('应该成功创建订单', async ({ orderManagementPage }) => {
    const orderData = {
      orderNo: `${TEST_PREFIX}_${Math.random().toString(36).substr(2, 9)}`,
      // ...
    };
    await orderManagementPage.createOrder(orderData);
    createdOrders.push(orderData);

    // 模式 2: 数据持久化重试
    const exists = await orderManagementPage.waitForOrderExists(orderData.orderNo, { timeout: 15000 });
    expect(exists).toBe(true);
  });

  test.afterEach(async ({ orderManagementPage }) => {
    // 模式 3: 清理钩子
    for (const order of createdOrders) {
      try {
        await orderManagementPage.deleteOrder(order.orderNo);
      } catch (error) {
        console.debug(`  ⚠ 清理订单失败: ${order.orderNo}`, error);
      }
    }
  });
});
```

---

## 总结

Epic 9 建立的可复用模式：

| 模式 | 关键要点 | 受益者 |
|------|---------|--------|
| 数据隔离 | TEST_TIMESTAMP + TEST_PREFIX | 所有 Epic |
| 并行执行 | 移除 serial，使用 workers | 所有 Epic |
| Page Object | 动词+名词命名，完整实现 | 所有 Epic |
| 超时常量 | TIMEOUTS 常量，避免魔法数字 | 所有 Epic |
| 稳定性测试 | 多轮迭代，渐进式修复 | 所有 Epic |

**Epic 9 是测试模式的黄金标准。** 后续 Epic 应该参考这些模式，确保测试的一致性和可维护性。

---

**文档维护:** 当发现新的模式或改进时，请更新本文档。
