Files
aurak/docs/tests/playwright-test-template.md
T
Developer 92e0f56fe5 test: 题库管理全按钮UI测试(28项) + Playwright测试模板
题库管理测试(28项全部通过):
A. 列表页8项 — 标题/创建按钮/表单/筛选Tab/搜索/卡片/统计/重试
B. 详情页14项 — 返回/渲染/状态/统计/AI生成弹窗/全选/批量/添加/列表/操作按钮
C. 状态转换3项 — 创建题库→提交审核→发布 全流程UI
D. 单题操作3项 — 审批/删除 + API批量验证

新增 docs/tests/playwright-test-template.md:
- 页面UI测试架构模板
- 按钮覆盖Checklist(30+按钮类型)
- 三Agent使用流程
- 常见问题/定位器指南
- 案例对照

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:36:13 +08:00

245 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Playwright UI 测试模板 — 全按钮/全交互测试指南
> 用于对任何系统页面编写"零遗漏"的 UI 测试。
> 适用:Playwright Test + TypeScript + 三 AgentGenerator / 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);
}
```