# 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
残疾类型
视力残疾
听力残疾
肢体残疾
``` ### 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 提取测试标准 |