Files
aurak/docs/tests/playwright-test-template.md
T
Developer 214d8a4cb0 docs: 补充测试模板实战教训章节(从AI生成按钮bug中总结)
新增第六章「实战教训」:
- 「看见不等于测了」陷阱(B07案例分析)
- 弹窗测试的「红按钮」规则
- 「测试时绕过=上线时爆炸」警示
- 自我检查Checklist(3个问题)

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

321 lines
11 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 项 | ✅ |
---
## 六、实战教训 —— 从 AI 生成按钮 bug 中学到的
### 6.1 「看见不等于测了」—— 最常见的测试陷阱
**案例**:题库管理的 AI 生成弹窗测试(B07):
```typescript
// ❌ 错误的写法:只检查了按钮可见就点取消了
test('AI生成弹窗交互', async ({ page }) => {
const genBtn = page.locator('button').filter({ hasText: /生成/ }).first();
await expect(genBtn).toBeVisible({ timeout: 3000 }); // ← 看见了
const cancelBtn = page.locator('button').filter({ hasText: /取消/ }).first();
await cancelBtn.click(); // ← 没点生成直接关闭
// 结果:没发现 knowledgeBaseContent 传了空字符串导致后端 400
});
```
**规则:每个「确认/提交/保存/生成」按钮必须至少被实际点击一次。**
### 6.2 弹窗/抽屉测试的「红按钮」规则
| 弹窗中的按钮 | 必须测? | 为什么 |
|:------------|:--------:|--------|
| **红按钮(提交/保存/生成/删除)** | **✅ 必须点** | 不点发现不了传参错误、校验失败、后端 500 |
| 灰按钮(取消/关闭/X) | 可选 | 主要测 UI 渲染 |
| 输入框/下拉 | ✅ 必须填 | 不填不知道表单绑定是否正确 |
### 6.3 「测试时绕过」= 「上线时爆炸」
我们当初故意绕过 AI 生成的点击,理由是「怕 AI 调用太慢」。结果是:
```
测试绕过 → 前端传空字符串 → 后端 400 → 用户报错
↑ 如果测试点了「生成」按钮,立即就能发现
```
**解决方案**
- UI 测试只验证**弹窗打开 + 参数已填充 + 按钮可点击**(不等待 AI 返回)
- API 测试单独覆盖**实际后端调用是否正确**(短 timeout)
- 两者结合既不卡 UI 测试,又不遗漏后端验证
```typescript
// ✅ 正确的做法:UI 测弹窗交互,API 测后端逻辑
// UI 测试
test('弹窗交互', async ({ page }) => {
await aiBtn.click();
await expect(genBtn).toBeVisible();
// 验证内容已填充(不是空的)
const countInput = page.locator('input[type="number"]');
await expect(countInput).toBeVisible();
// 点取消关闭,不等待 AI 返回
await cancelBtn.click();
});
// API 测试(单独)
test('API 调用验证', async () => {
const gen = await fetch(`/api/xxx/generate`, { body: JSON.stringify({ count: 1, content }) });
expect(gen.status).toBe(201);
});
```
### 6.4 Checklist 自我检查
写完测试后问自己三个问题:
```
□ 每个「提交/保存/生成/删除」按钮都被实际点击过吗?
→ 不只是检查 visible/enabled,是真正调用了 click()
□ 弹窗/抽屉关闭前,表单里的关键输入框都被填充验证过吗?
→ 不只是打开看了一眼
□ 被测试绕过的「太慢/太难测」路径,有没有 API 测试兜底?
→ UI 跳过的地方,API 必须补上
```
---
## 七、常用通用方法
```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);
}
```