epic-9-patterns-summary.md 15 KB

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

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

目录

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

1. 测试数据隔离模式

1.1 时间戳前缀模式(标准模式)

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

实现方式:

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 中清理

实现方式:

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 早期):

// ❌ 6 位随机数范围太小,并发时可能冲突
const randomId = Math.floor(Math.random() * 1000000);

正确方式(Epic 9 改进):

// ✅ 使用 Number.MAX_SAFE_INTEGER 确保唯一性
const uniqueId = Number.MAX_SAFE_INTEGER - TEST_TIMESTAMP;

1.4 省市选择多样化(可选)

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

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

改进方案:

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 早期):

// ❌ 串行执行,所有测试必须顺序执行
test.describe.serial('残疾人管理 - 照片上传功能', () => {
  // 测试...
});

修改后(Epic 9 改进):

// ✅ 并行执行,多个测试可以同时运行
test.describe('残疾人管理 - 照片上传功能', () => {
  // 测试...
});

2.2 Playwright 配置

默认配置(推荐):

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,      // 启用并行
  workers: process.env.CI ? 1 : undefined,  // CI 环境单 worker,本地环境自动检测
});

指定 worker 数量:

export default defineConfig({
  fullyParallel: true,
  workers: 4,  // 使用 4 个并行 worker
});

2.3 并行执行命令

# 并行运行所有测试(默认)
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 方法设计模板

创建类方法模板:

/**
 * 添加银行卡
 * @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;
}

读取类方法模板:

/**
 * 获取银行卡列表
 * @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() - 模糊文本匹配(尽量避免)

示例:

// ✅ 优先使用 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 超时常量定义

定义统一超时常量:

// 在 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;

使用方式:

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 稳定性测试脚本

脚本模板:

#!/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 数据持久化重试机制

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

解决方案:

/**
 * 等待人员记录出现(带重试机制)
 */
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 模式

// 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 应该参考这些模式,确保测试的一致性和可维护性。


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