# Playwright UI 测试模板 — 全按钮/全交互测试指南 > 用于对任何系统页面编写"零遗漏"的 UI 测试。 > 适用:Playwright Test + TypeScript + 三 Agent(Generator / Planner / Healer) --- ## 一、测试架构模板 ```typescript /** * [页面名称] — 全按钮/全交互 UI 测试 * * 覆盖: 页面可见元素 × 按钮点击 × 弹窗交互 × 表单操作 × 状态转换 × API 逻辑 * * Agent 使用: * Generator — codegen 录制基础操作定位器 * Planner — test.describe.serial 分模块编排测试 * Healer — trace + retries 自动修复 flaky 点击 */ import { test, expect } from '@playwright/test'; const API = 'http://localhost:3001'; const BASE = 'http://localhost:13001'; /* ── 辅助函数 ── */ async function api(token, method, path, body?) { const opts = { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }; if (body) opts.body = JSON.stringify(body); const r = await fetch(`${API}/api${path}`, opts); return { status: r.status, data: await r.json().catch(() => null) }; } async function loginApi(u, p) { const r = await fetch(`${API}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) }); return r.ok ? (await r.json()).access_token : null; } async function waitStable(page) { await page.waitForTimeout(2000); await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {}); await page.waitForTimeout(500); } test.describe.serial('[页面名称] — 全按钮测试', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE + '/login'); await page.waitForTimeout(500); await page.locator('input[type="text"]').first().fill('admin'); await page.locator('input[type="password"]').first().fill('admin123'); await page.locator('button[type="submit"]').click(); await page.waitForURL('**/'); await page.goto(BASE + '/[page-path]'); await waitStable(page); }); ``` --- ## 二、测试用例编写指南 ### 2.1 按钮测试模板 每个按钮覆盖 4 个维度: ```typescript test('[编号] — 按钮描述', async ({ page }) => { const btn = page.locator('[定位器]'); // 1. 按钮是否存在且可见 await expect(btn).toBeVisible({ timeout: 5000 }); // 2. 按钮是否可点击 await expect(btn).toBeEnabled({ timeout: 3000 }); // 3. 点击后 UI 是否正确变化 await btn.click(); await page.waitForTimeout(1000); const expectedElement = page.locator('[期望出现的元素]'); const visible = await expectedElement.isVisible().catch(() => false); expect(visible).toBeTruthy(); // 4. 截图留存 await page.screenshot({ path: 'test-results/[name].png' }).catch(() => {}); }); ``` ### 2.2 按钮定位器指南 | 按钮类型 | 推荐定位器 | 降级方案 | |---------|-----------|---------| | 文字按钮 | `page.locator('button').filter({ hasText: '创建题库' })` | `.locator('text=创建题库').first()` | | 图标按钮 | `page.locator('button[title="delete"]')` | `.locator('[class*="Trash2"]').first()` | | 下拉 | `page.locator('select').first()` | `.locator('[class*="select"]')` | | 输入框 | `page.locator('input[type="text"]').nth(0)` | `.locator('input').first()` | | 弹窗 | `page.locator('[class*="fixed"][class*="inset-0"]')` | — | | 表单提交 | `page.locator('button[type="submit"]')` | `.filter({ hasText: '保存' })` | ### 2.3 测试结构模板 ``` test.describe.serial('A. 列表页 — 全部按钮') ├── A01 — 页面标题渲染 expect(body).toContain('xxx') ├── A02 — 创建按钮 点击 → 确认抽屉/弹窗出现 ├── A03 — 表单交互 输入 → 选择 → 提交 ├── A04 — Tab/筛选按钮 逐个点击验证激活态 ├── A05 — 搜索框 fill → clear ├── A06 — 列表项点击 点击 → 确认 URL 变化 ├── A07 — 统计卡片 └── A08 — 空/错误状态按钮 test.describe.serial('B. 详情页 — 全部按钮') ├── B01 — 返回按钮 点击 → 返回列表页 ├── B02 — 标题/描述渲染 ├── B03 — 状态标签 ├── B04 — 统计卡片 ├── B05 — 操作按钮(提交/AI生成等) ├── B06 — 弹窗/抽屉交互 ├── B07 — 全选/批量操作 ├── B08 — 添加/编辑按钮 └── B09 — 单行操作按钮(hover后出现) test.describe.serial('C. 状态转换流程') ├── 状态1 → 状态2 → 状态3 完整流程 └── 每步截图确认 test.describe.serial('D. API 补充验证') └── 按钮调用的后端 API 逻辑正确性 ``` --- ## 三、三 Agent 使用流程 ``` Step 1: Generator 启动 $ npx playwright codegen http://localhost:13001 → 操作目标页面所有按钮,记录生成的定位器 → 把有用的 locator 复制出来 Step 2: Planner 编排 → 把定位器填入上面模板的 test() 中 → 按 A/B/C/D 模块分 test.describe.serial → 每个按钮 4 步:可见 → 可点击 → 点击 → 断言 Step 3: Healer 验证 $ npx playwright test [文件] --trace on → Healer 自动重试 flaky 点击 → 失败时保留 trace.zip → 查看 Trace: npx playwright show-trace test-results/**/trace.zip ``` --- ## 四、按钮覆盖检查清单 ### 列表页 Checklist - [ ] 创建/新增按钮 - [ ] 搜索框 - [ ] 筛选 Tab 按钮(全部 × 各状态) - [ ] 列表项卡片(点击进入详情) - [ ] 列表项操作按钮(编辑/删除) - [ ] 批量操作按钮(全选/通过/驳回) - [ ] 分页按钮 - [ ] 排序按钮 - [ ] 导出/导入按钮 - [ ] 刷新/重试按钮 - [ ] 弹窗关闭按钮(× / 取消 / 遮罩点击) - [ ] 表单保存/提交按钮 - [ ] 表单取消按钮 ### 详情页 Checklist - [ ] 返回按钮 - [ ] 状态操作按钮(提交审核/发布/下架) - [ ] AI/批量生成按钮 - [ ] 添加子项按钮 - [ ] 子项编辑/删除/审批按钮(含 hover 才出现的) - [ ] 全选/取消全选 - [ ] 批量通过/驳回 - [ ] 每项的展开/折叠 - [ ] 弹窗表单保存/取消 - [ ] 截图留存 ### 状态转换 Checklist - [ ] 草稿 → 提交审核 - [ ] 待审核 → 发布 / 驳回 - [ ] 发布 → 下架 - [ ] 驳回 → 重新提交 --- ## 五、实际案例对照 参见以下已完成的全按钮测试文件: | 页面 | 测试文件 | 按钮数 | 测试数 | 通过率 | |------|---------|:------:|:------:|:------:| | 题库管理列表页 | `tests/question-bank-ui-full.e2e.spec.ts` | ~18 个按钮 | 8 项 | ✅ | | 题库管理详情页 | `tests/question-bank-ui-full.e2e.spec.ts` | ~22 个按钮 | 14 项 | ✅ | | 状态转换流程 | `tests/question-bank-ui-full.e2e.spec.ts` | ~5 个状态按钮 | 3 项 | ✅ | | API补充验证 | `tests/question-bank-ui-full.e2e.spec.ts` | — | 1 项 | ✅ | --- ## 六、常用通用方法 ```typescript // 通用按钮点击 async function clickButton(page, locator) { await expect(locator).toBeVisible({ timeout: 5000 }); await expect(locator).toBeEnabled({ timeout: 3000 }); await locator.click(); } // 等待 Spinner 消失 async function waitIdle(page) { await page.waitForTimeout(2000); await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {}); await page.waitForTimeout(500); } // 关闭弹窗(通用) async function dismissModal(page) { const closeBtn = page.locator('button').filter({ hasText: '取消' }).first() .or(page.locator('[class*="XCircle"],[class*="X"]').first()); if (await closeBtn.isVisible().catch(() => false)) await closeBtn.click(); else await page.keyboard.press('Escape'); await page.waitForTimeout(500); } // 文本输入(React 受控组件) async function fillReact(page, value) { await page.evaluate((text) => { const el = document.activeElement; if (!el) return; const tag = el.tagName.toLowerCase(); const proto = tag === 'textarea' ? HTMLTextAreaElement : HTMLInputElement; Object.getOwnPropertyDescriptor(proto.prototype, 'value')?.set?.call(el, text); el.dispatchEvent(new Event('input', { bubbles: true })); }, value); } ```