关于 playwright-testing
面向 SPA 的 Claude Code 技能,指导编写可靠、快速且易维护的 Playwright 测试,重点关注用户可见行为和语义定位器。
主要功能
- 01以用户为中心的定位器指引
- 02基于条件的等待与重试断言
- 03测试结构、夹具与页面对象
- 04性能和并行执行指引
- 05调试与 CI 检查清单
适用场景
- 01围绕用户操作和结果编写测试
- 02减少任意等待导致的测试波动
- 03使用可复用设置组织测试套件
- 04排查本地或 CI 测试失败
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mhagrelius/ dotfiles --skill playwright-testing安装后在 Claude Code 中输入/playwright-testing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
reference/ci.mddebugging.mdperformance.mdstructure.md详细介绍
Playwright 测试
为 SPA 编写可靠、快速、易维护的 Playwright 测试。
目录
- 核心概念(定位器、等待、断言)
- 测试结构 → 请参阅 reference/structure.md
- 性能 → 请参阅 reference/performance.md
- 调试 → 请参阅 reference/debugging.md
- CI 配置 → 请参阅 reference/ci.md
核心原则: 测试用户看到和执行的内容。如果用户无法按角色或文本找到某个元素,你的测试也不应该这样做。
质量层次: 可靠(无随机失败)→ 快速(并行、尽量少等待)→ 易维护(重构后仍然有效)
理念: 默认使用以用户为中心的定位器。实现细节(test-id、CSS 选择器)是备选方案,而不是首选。
流程
- 确定: 我们要测试什么用户行为?
- 定位: 像用户一样查找元素(角色、标签、文本)
- 操作: 执行用户操作(点击、填写、导航)
- 断言: 验证可见结果,而不是内部状态
- 稳定: 处理异步操作,添加适当的等待
危险信号 - 停止
page.locator('.btn-primary')- CSS 类选择器page.waitForTimeout(1000)- 任意等待page.locator('[data-testid="x"]')作为首选- 测试组件内部实现,而不是用户可见结果
- 测试文件很长,却没有页面对象或 fixture
定位器优先级
digraph locator_priority {
rankdir=TB;
node [shape=box];
"Need to find element" [shape=diamond];
"Has accessible role?" [shape=diamond];
"Has label/placeholder?" [shape=diamond];
"Has visible text?" [shape=diamond];
"getByRole" [style=filled fillcolor=lightgreen];
"getByLabel/getByPlaceholder" [style=filled fillcolor=lightgreen];
"getByText" [style=filled fillcolor=lightgreen];
"getByTestId" [style=filled fillcolor=lightyellow];
"Need to find element" -> "Has accessible role?";
"Has accessible role?" -> "getByRole" [label="yes"];
"Has accessible role?" -> "Has label/placeholder?" [label="no"];
"Has label/placeholder?" -> "getByLabel/getByPlaceholder" [label="yes"];
"Has label/placeholder?" -> "Has visible text?" [label="no"];
"Has visible text?" -> "getByText" [label="yes"];
"Has visible text?" -> "getByTestId" [label="no (last resort)"];
}
| 优先级 | 定位器 | 使用场景 | 示例 |
|---|---|---|---|
| 第 1 优先 | getByRole | 按钮、链接、输入框、标题、列表 | getByRole('button', { name: 'Submit' }) |
| 第 2 优先 | getByLabel | 带标签的表单输入框 | getByLabel('Email address') |
| 第 3 优先 | getByPlaceholder | 带占位文本的输入框 | getByPlaceholder('Search...') |
| 第 4 优先 | getByText | 静态文本内容、段落 | getByText('Welcome back') |
| 第 5 优先 | getByAltText | 图片 | getByAltText('Company logo') |
| 最后 | getByTestId | 动态内容、没有语义含义的元素 | getByTestId('total-price') |
角色示例
// Buttons
page.getByRole('button', { name: 'Save changes' })
page.getByRole('button', { name: /submit/i }) // regex for flexibility
// Links
page.getByRole('link', { name: 'View profile' })
// Form inputs
page.getByRole('textbox', { name: 'Username' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('combobox', { name: 'Country' })
// Structure
page.getByRole('heading', { name: 'Dashboard', level: 1 })
page.getByRole('list').getByRole('listitem')
page.getByRole('dialog', { name: 'Confirm deletion' })
// Tables
page.getByRole('table').getByRole('row', { name: /john/i })
区分多个匹配项
// Specify which match you want
await page.getByRole('button', { name: 'Delete' }).first().click();
await page.getByRole('listitem').last().click();
await page.getByRole('row').nth(2).click(); // 0-indexed
// Filter by content or child elements
await page.getByRole('listitem').filter({ hasText: 'John' }).click();
await page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'Edit' })
}).click();
// Chain locators to scope
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
定位器反模式
| 不佳做法 | 原因 | 推荐做法 |
|---|---|---|
.locator('.submit-btn') | 类名重命名后会失效 | getByRole('button', { name: 'Submit' }) |
.locator('#email-input') | 与实现细节耦合 | getByLabel('Email') |
.locator('div > span:nth-child(2)') | 极其脆弱 | getByText('...') 或添加 test-id |
到处使用 getByTestId | 无法发现无障碍问题 | 优先使用语义定位器 |
| 未限定范围且匹配多个项的定位器 | 不稳定,可能会点击错误的元素 | 使用 .first()、.filter(),或限定在父元素范围内 |
等待与异步操作
Playwright 会对大多数操作自动等待。 除非有特定原因,否则不要添加手动等待。
digraph waiting {
rankdir=TB;
node [shape=box];
"What are you waiting for?" [shape=diamond];
"Element to appear" [shape=diamond];
"Auto-wait (built-in)" [style=filled fillcolor=lightgreen];
"expect + toBeVisible" [style=filled fillcolor=lightgreen];
"waitForResponse" [style=filled fillcolor=lightyellow];
"waitForURL" [style=filled fillcolor=lightyellow];
"NEVER waitForTimeout" [style=filled fillcolor=lightpink];
"What are you waiting for?" -> "Auto-wait (built-in)" [label="clicking/filling"];
"What are you waiting for?" -> "Element to appear" [label="element"];
"What are you waiting for?" -> "waitForResponse" [label="API call"];
"What are you waiting for?" -> "waitForURL" [label="navigation"];
"Element to appear" -> "expect + toBeVisible" [label="use assertion"];
"Element to appear" -> "NEVER waitForTimeout" [label="don't guess"];
}
内置自动等待
以下操作会自动等待,无需手动等待:
// All of these wait automatically for element to be actionable
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('checkbox').check();
await page.getByRole('combobox').selectOption('US');
显式等待(必要时)
// Wait for element state (prefer assertions)
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('list')).not.toBeEmpty();
// Wait for navigation
await page.waitForURL('**/dashboard');
await page.waitForURL(url => url.searchParams.has('token'));
// Wait for API response (useful for loading states)
await page.getByRole('button', { name: 'Load data' }).click();
await page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
);
// Wait for network idle (use sparingly - can be slow)
await page.waitForLoadState('networkidle');
// Wait for specific request to complete before asserting
const responsePromise = page.waitForResponse('/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
await responsePromise;
await expect(page.getByRole('list')).toContainText('John');
// Promise.all pattern - click and wait simultaneously
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/data')),
page.getByRole('button', { name: 'Submit' }).click()
]);
等待反模式
| 不佳做法 | 原因 | 推荐做法 |
|---|---|---|
waitForTimeout(2000) | 任意等待、速度慢,仍不稳定 | 等待特定条件 |
在循环中使用 waitForTimeout(100) | 手动轮询 | 使用支持自动重试的 expect |
到处使用 waitForLoadState('networkidle') | 速度慢,轮询时不可靠 | 等待特定响应 |
| 不等待就立即断言 | 竞态条件 | expect 会自动重试断言 |
断言自动重试
Playwright 断言会自动重试,直到超时。请用它代替手动等待:
// BAD: manual wait then check
await page.waitForTimeout(1000);
const text = await page.getByTestId('status').textContent();
expect(text).toBe('Complete');
// GOOD: auto-retrying assertion
await expect(page.getByText('Complete')).toBeVisible();
软断言
使用 expect.soft(),在断言失败后继续测试(收集多个失败项):
test('validates all form fields', async ({ page }) => {
await page.goto('/profile');
// Soft assertions don't stop the test - useful for checking multiple things
await expect.soft(page.getByLabel('Name')).toHaveValue('John');
await expect.soft(page.getByLabel('Email')).toHaveValue('[email protected]');
await expect.soft(page.getByLabel('Phone')).toHaveValue('555-1234');
// Test continues even if some assertions fail
// All failures reported at end
});
处理遮罩层和弹出窗口
当遮罩层可能干扰测试操作时,使用 addLocatorHandler:
// Setup handler for cookie consent popup
await page.addLocatorHandler(
page.getByRole('dialog', { name: 'Cookie consent' }),
async () => {
await page.getByRole('button', { name: 'Accept' }).click();
}
);
// Now write test normally - handler auto-dismisses popup if it appears
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Settings' }).click();
测试结构
有关页面对象、fixture 和测试组织,请参阅 reference/structure.md。
快速提示:
- 页面对象封装交互,而不是断言
- Fixture 处理常见的设置和清理
- 每个测试只测试一种行为
- 使用
test.describe对相关测试进行分组
性能
有关并行执行、身份验证优化和 API 快捷方式,请参阅 reference/performance.md。
快速提示:
- 通过
storageState复用身份验证状态 - 通过 API 创建测试数据,而不是通过 UI
- 使用
fullyParallel: true - 避免使用
waitForLoadState('networkidle')
调试
有关调试模式、跟踪记录和常见场景,请参阅 reference/debugging.md。
快速提示:
- 使用
npx playwright test --debug进行本地调试 - 查看 CI 失败的跟踪记录:
npx playwright show-trace trace.zip - 使用
await page.pause()暂停并检查
CI 配置
有关 GitHub Actions、分片和 CI 专属配置,请参阅 reference/ci.md。
快速提示:
forbidOnly: !!process.env.CI可防止在 CI 中使用 test.only- 在 CI 中设置
retries: 2,以应对暂时性失败 - 上传构建产物以便调试失败
质量检查清单
在完成测试前,为每项适用的检查创建 TodoWrite 项目。
第 1 层:可靠(无不稳定因素)
- 不调用
waitForTimeout(),而是使用基于条件的等待 - 断言使用带自动重试的
expect(),而不是手动检查 - 测试彼此独立,测试之间没有共享状态
- 等待特定的 API 响应,而不是
networkidle - 定位器足够具体(只匹配单个元素)
- 测试结果稳定(提交前在本地运行 5 次)
第 2 层:快速
- 通过
storageState复用身份验证 - 通过 API 创建测试数据,而不是通过 UI
- 并行运行测试(
fullyParallel: true) - 不使用不必要的
waitForLoadState('networkidle') - 在 fixture 中进行繁重的设置,而不是在每个测试中重复执行
- 为大型测试套件配置分片
第 3 层:易于维护
- 定位器使用角色/标签,而不是 CSS 选择器
- 页面对象封装交互
- 测试名称描述用户操作和预期结果
- 每个测试只测试一种行为,不测试多项内容
- Fixture 处理常见的设置和清理
- 不使用魔法字符串,重复值应使用常量
常见模式参考
| 需求 | 模式 |
|---|---|
| 已通过身份验证的用户 | storageState fixture |
| 测试数据设置 | 在 beforeEach 或 fixture 中调用 API |
| 等待数据加载 | waitForResponse('/api/...') |
| 点击并等待响应 | Promise.all([waitForResponse(...), click()]) |
| 多个相似测试 | test.describe + 参数化数据 |
| 耗时操作 | 为特定测试增加超时时间 |
| 模态框/对话框 | 使用 getByRole('dialog'),然后限定查找范围 |
| 下拉选择 | getByRole('combobox').selectOption() |
| 文件上传 | 在文件输入框上调用 setInputFiles() |
| 悬停菜单 | 调用 locator.hover(),然后点击显示的项目 |
| 拖放 | locator.dragTo(target) |
| iframe | 使用 frameLocator(),然后限定查找范围 |
| 新标签页/窗口 | page.waitForEvent('popup') |
| 多个匹配项 | .first()、.last()、.nth(n)、.filter() |
| 检查多项内容 | 对非阻塞断言使用 expect.soft() |
| 关闭弹窗/覆盖层 | addLocatorHandler() |
何时添加 data-testid
在以下情况下,将 data-testid 用作备用方案:
- 元素没有语义角色(装饰性容器)
- 动态内容没有稳定文本(生成的 ID、价格)
- 存在多个相同元素,且需要依赖位置
- 第三方组件没有无障碍标记
// Acceptable: price that changes dynamically
<span data-testid="cart-total">{formatCurrency(total)}</span>
page.getByTestId('cart-total')
// Still prefer scoping with semantic locators
page.getByRole('region', { name: 'Cart' }).getByTestId('total')
命令速查
# Run all tests
npx playwright test
# Run specific file
npx playwright test login.spec.ts
# Run tests matching name
npx playwright test -g "login"
# Run in headed mode
npx playwright test --headed
# Debug mode with inspector
npx playwright test --debug
# Update snapshots
npx playwright test --update-snapshots
# Generate test from recording
npx playwright codegen localhost:3000
# Show last HTML report
npx playwright show-report