Parcourir la source

📝 docs(standards): 添加 Radix UI E2E 测试标准文档

从 PRD 文档提取测试模式,创建团队测试标准:
- 静态枚举型 Select 测试方法
- 异步加载型 Select 测试方法
- 文件上传测试模式
- 多步骤表单测试模式
- 动态列表测试模式
- 对话框测试模式
- 完整的残疾人管理测试示例

包含常见问题解答、性能标准和最佳实践。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname il y a 1 semaine
Parent
commit
bcb0d072b6
1 fichiers modifiés avec 533 ajouts et 0 suppressions
  1. 533 0
      docs/standards/e2e-radix-testing.md

+ 533 - 0
docs/standards/e2e-radix-testing.md

@@ -0,0 +1,533 @@
+# Radix UI E2E 测试标准
+
+## 版本信息
+| 版本 | 日期 | 描述 | 作者 |
+|------|------|------|------|
+| 1.0 | 2026-01-07 | 从 PRD 提取,定义 Radix UI 组件 E2E 测试标准 | Claude Code |
+
+## 概述
+
+本文档定义了 Radix UI 组件的 E2E 测试标准,提供可复用的测试模式和最佳实践。
+
+**适用范围:**
+- Playwright E2E 测试中对 Radix UI 组件的测试
+- 所有管理功能的表单测试(用户管理、残疾人管理等)
+- 任何使用 Radix UI Select、Dialog、Tabs 等组件的功能测试
+
+**核心价值:**
+- 统一的测试模式,减少重复代码
+- 自动处理 Radix UI 的复杂 DOM 结构和时序问题
+- 清晰的错误提示,便于调试
+- 提高测试稳定性,减少 flaky 测试
+
+## 测试工具包
+
+### 安装
+
+```bash
+# 在 web/ 目录下
+pnpm add -D @d8d/e2e-test-utils@workspace:*
+```
+
+### 导入
+
+```typescript
+import {
+  selectRadixOption,
+  selectRadixOptionAsync,
+  uploadFileToField,
+  fillMultiStepForm,
+  addDynamicListItem,
+  deleteDynamicListItem,
+  handleDialog,
+  waitForDialogClosed,
+  cancelDialog
+} from '@d8d/e2e-test-utils';
+```
+
+## Radix UI Select 测试
+
+### Select 组件类型
+
+Radix UI Select 分为两种类型,测试方式不同:
+
+| 类型 | 特征 | 测试方法 |
+|------|------|----------|
+| **静态枚举型** | 选项固定在代码中,无需异步加载 | `selectRadixOption()` |
+| **异步加载型** | 选项通过 API 动态加载 | `selectRadixOptionAsync()` |
+
+### 1. 静态枚举型 Select
+
+**使用场景:** 残疾类型、残疾等级、性别等固定枚举值
+
+```typescript
+import { selectRadixOption } from '@d8d/e2e-test-utils';
+
+// 基础用法
+await selectRadixOption(page, '残疾类型', '视力残疾');
+
+// 选择性别
+await selectRadixOption(page, '性别', '男');
+
+// 选择残疾等级
+await selectRadixOption(page, '残疾等级', '一级');
+```
+
+**DOM 结构示例:**
+```html
+<!-- Radix UI Select 的典型 DOM 结构 -->
+<div data-radix-select-trigger="">
+  <span>残疾类型</span>
+  <svg><!-- 下箭头 --></svg>
+</div>
+
+<!-- 点击后展开的选项列表 -->
+<div role="listbox">
+  <div role="option" data-value="blind">视力残疾</div>
+  <div role="option" data-value="hearing">听力残疾</div>
+  <div role="option" data-value="physical">肢体残疾</div>
+</div>
+```
+
+### 2. 异步加载型 Select
+
+**使用场景:** 省份、城市、通过 API 搜索的选项
+
+```typescript
+import { selectRadixOptionAsync } from '@d8d/e2e-test-utils';
+
+// 基础用法
+await selectRadixOptionAsync(page, '省份', '广东省');
+
+// 带自定义超时
+await selectRadixOptionAsync(page, '城市', '深圳市', {
+  timeout: 10000,
+  waitForOption: true
+});
+
+// 选择异步加载的区县
+await selectRadixOptionAsync(page, '区县', '南山区');
+```
+
+**等待策略:**
+- 默认等待 5 秒(`timeout: 5000`)
+- 使用 `waitForLoadState('networkidle')` 确保数据加载完成
+- 等待选项出现在 DOM 中后再点击
+
+### 3. 错误处理
+
+工具函数提供清晰的错误提示:
+
+```typescript
+// 如果选项不存在
+Error: Radix Select 选项 "xxx" 未找到
+  标签: 残疾类型
+  期望值: xxx
+  可用选项: 视力残疾, 听力残疾, 肢体残疾, 智力残疾
+
+// 如果超时
+Error: Radix Select 等待超时
+  标签: 省份
+  期望值: 广东省
+  超时时间: 5000ms
+  可能原因: 网络请求过慢或选项未加载
+```
+
+### 4. 选择器策略
+
+工具函数使用稳定的选择器:
+
+```typescript
+// 触发器选择器
+const triggerSelector = [
+  `[data-testid="${label}-trigger"]`,
+  `text="${label}"`,
+  `[role="combobox"]`
+].join(', ');
+
+// 选项选择器
+const optionSelector = [
+  `[role="option"][data-value="${value}"]`,
+  `[role="option"]:has-text("${value}")`
+].join(', ');
+```
+
+## 文件上传测试
+
+### 单文件上传
+
+```typescript
+import { uploadFileToField } from '@d8d/e2e-test-utils';
+
+// 上传身份证照片
+await uploadFileToField(
+  page,
+  '[data-testid="id-card-photo-input"]',
+  'sample-id-card.jpg'
+);
+
+// 上传残疾证照片
+await uploadFileToField(
+  page,
+  '[data-testid="disability-card-input"]',
+  'disability-cert.pdf'
+);
+```
+
+### Fixtures 目录结构
+
+```
+web/tests/
+└── fixtures/
+    ├── images/
+    │   ├── sample-id-card.jpg
+    │   ├── passport.jpg
+    │   └── profile.png
+    └── documents/
+        ├── disability-cert.pdf
+        └── bank-statement.pdf
+```
+
+### 多文件上传
+
+```typescript
+// 多个文件输入框
+await uploadFileToField(page, '#photo1', 'photo1.jpg');
+await uploadFileToField(page, '#photo2', 'photo2.jpg');
+await uploadFileToField(page, '#photo3', 'photo3.jpg');
+```
+
+## 表单测试模式
+
+### 多步骤表单
+
+```typescript
+import { fillMultiStepForm, scrollToSection } from '@d8d/e2e-test-utils';
+
+// 填写多步骤表单
+await fillMultiStepForm(page, [
+  // 步骤 1: 基本信息
+  {
+    section: '基本信息',
+    fields: [
+      { selector: '#name', value: '张三' },
+      { selector: '#idCard', value: '123456789012345678' }
+    ]
+  },
+  // 步骤 2: 联系方式
+  {
+    section: '联系方式',
+    fields: [
+      { selector: '#phone', value: '13800138000' },
+      { selector: '#address', value: '北京市朝阳区' }
+    ]
+  }
+]);
+
+// 滚动到特定区域
+await scrollToSection(page, '银行卡信息');
+```
+
+### 表单验证错误
+
+```typescript
+// 测试必填字段验证
+await page.click('button[type="submit"]');
+
+// 检查错误提示
+await expect(page.locator('text=请输入姓名')).toBeVisible();
+
+// 填写后重试
+await page.fill('#name', '张三');
+await page.click('button[type="submit"]');
+
+// 验证成功
+await expect(page.locator('text=保存成功')).toBeVisible();
+```
+
+## 动态列表测试
+
+### 添加列表项
+
+```typescript
+import { addDynamicListItem } from '@d8d/e2e-test-utils';
+
+// 添加银行卡
+await addDynamicListItem(page, '银行卡', {
+  bankName: '中国工商银行',
+  accountNumber: '6222021234567890',
+  accountHolder: '张三'
+});
+
+// 添加备注
+await addDynamicListItem(page, '备注', {
+  content: '这是测试备注',
+  createdAt: new Date().toISOString()
+});
+```
+
+### 删除列表项
+
+```typescript
+import { deleteDynamicListItem } from '@d8d/e2e-test-utils';
+
+// 删除第一个银行卡
+await deleteDynamicListItem(page, '银行卡', 0);
+
+// 删除指定备注
+await deleteDynamicListItem(page, '备注', 1);
+```
+
+### 验证列表状态
+
+```typescript
+// 验证银行卡数量
+const bankCards = page.locator('[data-testid="bank-card-item"]');
+await expect(bankCards).toHaveCount(2);
+
+// 验证银行卡内容
+await expect(page.locator('text=中国工商银行')).toBeVisible();
+await expect(page.locator('text=6222021234567890')).toBeVisible();
+```
+
+## 对话框测试
+
+### 打开对话框
+
+```typescript
+import { handleDialog } from '@d8d/e2e-test-utils';
+
+// 点击按钮打开对话框
+await page.click('button:has-text("添加银行卡")');
+
+// 等待对话框打开
+await expect(page.locator('[role="dialog"]')).toBeVisible();
+```
+
+### 填写并提交对话框
+
+```typescript
+// 填写对话框表单
+await page.fill('[data-testid="bank-name"]', '中国建设银行');
+await page.fill('[data-testid="account-number"]', '6217001234567890');
+
+// 点击确认
+await handleDialog(page, 'confirm');
+
+// 或使用通用操作
+await page.click('button:has-text("确认")');
+```
+
+### 取消对话框
+
+```typescript
+import { cancelDialog } from '@d8d/e2e-test-utils';
+
+// 方法 1: 使用取消函数
+await cancelDialog(page);
+
+// 方法 2: 点击取消按钮
+await page.click('button:has-text("取消")');
+
+// 方法 3: 按 ESC 键
+await page.keyboard.press('Escape');
+```
+
+### 等待对话框关闭
+
+```typescript
+import { waitForDialogClosed } from '@d8d/e2e-test-utils';
+
+// 等待对话框关闭
+await waitForDialogClosed(page);
+
+// 验证对话框已关闭
+await expect(page.locator('[role="dialog"]')).not.toBeVisible();
+```
+
+## 完整测试示例
+
+### 残疾人管理 E2E 测试
+
+```typescript
+import { test, expect } from '@playwright/test';
+import { DisabilityPersonPage } from '../pages/admin/disability-person.page';
+import {
+  selectRadixOption,
+  selectRadixOptionAsync,
+  uploadFileToField
+} from '@d8d/e2e-test-utils';
+
+test.describe('残疾人管理 E2E 测试', () => {
+  let pageObject: DisabilityPersonPage;
+
+  test.beforeEach(async ({ page }) => {
+    pageObject = new DisabilityPersonPage(page);
+    await pageObject.goto();
+    await pageObject.clickAddButton();
+  });
+
+  test('完整流程:新增残疾人', async ({ page }) => {
+    // 基本信息
+    await page.fill('#name', '张三');
+    await page.fill('#idCard', '123456789012345678');
+
+    // 选择静态枚举型 Select
+    await selectRadixOption(page, '残疾类型', '视力残疾');
+    await selectRadixOption(page, '残疾等级', '一级');
+
+    // 选择异步加载型 Select
+    await selectRadixOptionAsync(page, '省份', '广东省');
+    await selectRadixOptionAsync(page, '城市', '深圳市');
+    await selectRadixOptionAsync(page, '区县', '南山区');
+
+    // 上传照片
+    await uploadFileToField(page, '[data-testid="id-card-input"]', 'id-card.jpg');
+    await uploadFileToField(page, '[data-testid="disability-cert-input"]', 'cert.pdf');
+
+    // 添加银行卡
+    await pageObject.addBankCard({
+      bankName: '中国工商银行',
+      accountNumber: '6222021234567890'
+    });
+
+    // 添加备注
+    await pageObject.addRemark('这是测试备注');
+
+    // 提交表单
+    await page.click('button[type="submit"]');
+
+    // 验证成功
+    await expect(page.locator('text=保存成功')).toBeVisible();
+  });
+
+  test('编辑残疾人信息', async ({ page }) => {
+    // 点击编辑按钮
+    await pageObject.clickEdit('张三');
+
+    // 修改残疾等级
+    await selectRadixOption(page, '残疾等级', '二级');
+
+    // 提交
+    await page.click('button[type="submit"]');
+
+    // 验证
+    await expect(page.locator('text=残疾等级: 二级')).toBeVisible();
+  });
+});
+```
+
+## 测试稳定性最佳实践
+
+### 1. 等待策略
+
+```typescript
+// ✅ 推荐:使用工具函数的自动等待
+await selectRadixOption(page, '残疾类型', '视力残疾');
+
+// ❌ 不推荐:手动等待
+await page.click(`text=残疾类型`);
+await page.waitForTimeout(1000); // 不可靠
+await page.click(`text=视力残疾`);
+```
+
+### 2. 选择器策略
+
+```typescript
+// ✅ 推荐:使用 data-testid
+await page.click('[data-testid="submit-button"]');
+
+// ⚠️ 谨慎:使用文本选择器
+await page.click('text=提交'); // 可能匹配多个元素
+
+// ❌ 不推荐:使用不稳定的 CSS 类
+await page.click('.btn-primary.ant-btn'); // 类名可能变化
+```
+
+### 3. 错误断言
+
+```typescript
+// ✅ 推荐:明确的错误消息
+await expect(page.locator('[data-testid="error-message"]'))
+  .toHaveText('请输入姓名');
+
+// ❌ 不推荐:模糊的错误检查
+await expect(page.locator('.error')).toBeVisible();
+```
+
+### 4. 测试隔离
+
+```typescript
+test.beforeEach(async ({ page }) => {
+  // 每个测试前清理数据
+  await cleanupTestData(page);
+});
+
+test.afterEach(async ({ page }) => {
+  // 每个测试后截图(失败时)
+  if (test.info().status !== 'passed') {
+    await page.screenshot({ path: `failure-${test.info().title}.png` });
+  }
+});
+```
+
+## 常见问题
+
+### Q: Select 组件点击后选项列表没有展开?
+
+A: 可能是时序问题,工具函数已处理。如果仍有问题,检查:
+- 页面是否有遮挡层(如 loading 遮罩)
+- Select 组件是否禁用
+- 网络请求是否完成
+
+### Q: 异步 Select 选项加载很慢?
+
+A: 可以增加超时时间:
+
+```typescript
+await selectRadixOptionAsync(page, '省份', '新疆维吾尔自治区', {
+  timeout: 10000 // 10 秒
+});
+```
+
+### Q: 文件上传失败?
+
+A: 检查:
+- 文件路径是否正确(相对于 fixtures 目录)
+- 文件是否存在
+- 输入框是否被禁用
+
+### Q: 对话框操作不稳定?
+
+A: 使用工具函数的等待机制:
+
+```typescript
+// 等待对话框完全关闭
+await waitForDialogClosed(page);
+
+// 然后再继续操作
+await page.click('[data-testid="next-button"]');
+```
+
+## 性能标准
+
+| 操作 | 目标时间 | 最大可接受时间 |
+|------|---------|---------------|
+| 静态 Select 选择 | < 1s | 2s |
+| 异步 Select 选择 | < 3s | 5s |
+| 文件上传 | < 2s | 5s |
+| 表单提交 | < 2s | 5s |
+
+## 参考资料
+
+- **PRD 文档**: `_bmad-output/planning-artifacts/prd.md`
+- **测试工具包**: `packages/e2e-test-utils/`
+- **示例测试**: `web/tests/e2e/specs/admin/disability-person-complete.spec.ts`
+- **Radix UI 文档**: https://www.radix-ui.com/
+- **Playwright 文档**: https://playwright.dev/
+
+## 更新日志
+
+| 日期 | 版本 | 变更内容 |
+|------|------|---------|
+| 2026-01-07 | 1.0 | 初始版本,从 PRD 提取测试标准 |