# 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 { // 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> { 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 { 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 应该参考这些模式,确保测试的一致性和可维护性。 --- **文档维护:** 当发现新的模式或改进时,请更新本文档。