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

11 KiB
Raw Blame History

Playwright UI 测试模板 — 全按钮/全交互测试指南

用于对任何系统页面编写"零遗漏"的 UI 测试。 适用:Playwright Test + TypeScript + 三 AgentGenerator / Planner / Healer


一、测试架构模板

/**
 * [页面名称] — 全按钮/全交互 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 个维度:

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):

// ❌ 错误的写法:只检查了按钮可见就点取消了
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 测试,又不遗漏后端验证
// ✅ 正确的做法: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 必须补上

七、常用通用方法

// 通用按钮点击
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);
}