forked from hangshuo652/aurak
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>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
# 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);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user