创建日期: 2026-01-12 Epic: Epic 9 - 残疾人管理完整 E2E 测试覆盖 目的: 为后续 Epic 提供可复用的测试模式和最佳实践
目的: 确保每个测试使用唯一数据,避免并发测试时的数据冲突
实现方式:
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)确保数据唯一性目的: 跟踪测试创建的数据,方便在 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 清理数组中的所有数据目的: 使用更大的随机数范围避免并发冲突
错误方式(Epic 9 早期):
// ❌ 6 位随机数范围太小,并发时可能冲突
const randomId = Math.floor(Math.random() * 1000000);
正确方式(Epic 9 改进):
// ✅ 使用 Number.MAX_SAFE_INTEGER 确保唯一性
const uniqueId = Number.MAX_SAFE_INTEGER - TEST_TIMESTAMP;
目的: 避免所有测试使用相同的省市,提高测试覆盖真实性
问题: 所有测试使用"湖北省/武汉市",虽然稳定但覆盖不全面
改进方案:
const PROVINCE_CITY_MAP = [
{ province: '北京市', city: '北京市' },
{ province: '上海市', city: '上海市' },
{ province: '广东省', city: '广州市' },
{ province: '湖北省', city: '武汉市' },
{ province: '四川省', city: '成都市' },
];
// 基于测试索引选择不同的省市
const location = PROVINCE_CITY_MAP[testIndex % PROVINCE_CITY_MAP.length];
注意: 需要确保并发时省市选择不会冲突(可以使用时间戳的最后一位作为索引)
问题: test.describe.serial 会阻止并行执行,违背 Playwright 设计初衷
修改前(Epic 9 早期):
// ❌ 串行执行,所有测试必须顺序执行
test.describe.serial('残疾人管理 - 照片上传功能', () => {
// 测试...
});
修改后(Epic 9 改进):
// ✅ 并行执行,多个测试可以同时运行
test.describe('残疾人管理 - 照片上传功能', () => {
// 测试...
});
默认配置(推荐):
// 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
});
# 并行运行所有测试(默认)
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
Epic 9 实测数据:
| 配置 | 执行时间 | 提升 |
|---|---|---|
| 1 worker (串行) | 3.3 分钟 | 基准 |
| 2 workers | 2.2 分钟 | 1.5x |
| 4 workers | 1.1 分钟 | 3x |
命名格式: 动词+名词,camelCase
| 方法类型 | 命名示例 |
|---|---|
| 创建 | createPerson(), addBankCard(), addNote() |
| 读取 | getPerson(), getBankCardList(), getNoteCount() |
| 更新 | updatePerson(), editBankCard(), editNote() |
| 删除 | deletePerson(), deleteBankCard(), removeNote() |
| 检查 | personExists(), isAddButtonDisabled() |
| 等待 | waitForPersonExists(), waitForPersonNotExists() |
创建类方法模板:
/**
* 添加银行卡
* @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;
}
优先级顺序:
data-testid - 最稳定,推荐aria-label + role - 语义化选择器:text-is() - 精确文本匹配: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('添加'); // 可能匹配多个元素
定义统一超时常量:
// 在 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); // ❌ 硬编码
| 问题类型 | 检查方法 | 修复方式 |
|---|---|---|
| Page Object 方法虚假完成 | 检查每个方法是否有实际实现 | 实现缺失的方法体 |
| 路径遍历漏洞 | 检查文件路径拼接 | 使用 path.join() |
| 数据隔离问题 | 检查全局变量 | 使用 describe 级别变量 |
| 表单验证错误 | 检查验证逻辑完整性 | 添加缺失的验证 |
| 问题类型 | 检查方法 | 修复方式 |
|---|---|---|
| console.log 使用 | 搜索 console.log |
改为 console.debug |
| 魔法数字 | 搜索数字字面量 | 使用 TIMEOUTS 常量 |
| 测试覆盖不完整 | 检查验收标准 | 添加缺失的测试 |
| 注释过时 | 检查注释与代码一致性 | 更新注释 |
开发者自查(提交前):
console.log 已改为 console.debugtest.describe.serial(除非确实需要串行)any 类型path.join() 或常量脚本模板:
#!/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"
问题: 表单提交后立即查询可能找不到数据
解决方案:
/**
* 等待人员记录出现(带重试机制)
*/
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;
}
Epic 9 经验: 4 轮修复将通过率从 77.4% 提升到 90.3%
| 轮次 | 通过率 | 主要修复 |
|---|---|---|
| 第 1 次 | 77.4% | 初始运行,识别问题 |
| 第 2 次 | 85% | 增加表单提交等待到 3 秒 |
| 第 3 次 | 91.2% | 数据持久化重试机制 |
| 第 4 次 | 90.3% | 银行卡类型名称修复 |
经验总结:
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 配置
| 文件类型 | 命名格式 | 示例 |
|---|---|---|
| 测试用例 | {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 模式
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 应该参考这些模式,确保测试的一致性和可维护性。
文档维护: 当发现新的模式或改进时,请更新本文档。