mcpField
P
playwright-testing

playwright-testing

github.com/mhagrelius测试与质量36 次安装
代码仓库

关于 playwright-testing

面向 SPA 的 Claude Code 技能,指导编写可靠、快速且易维护的 Playwright 测试,重点关注用户可见行为和语义定位器。

主要功能

  1. 01以用户为中心的定位器指引
  2. 02基于条件的等待与重试断言
  3. 03测试结构、夹具与页面对象
  4. 04性能和并行执行指引
  5. 05调试与 CI 检查清单

适用场景

  1. 01围绕用户操作和结果编写测试
  2. 02减少任意等待导致的测试波动
  3. 03使用可复用设置组织测试套件
  4. 04排查本地或 CI 测试失败

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mhagrelius/dotfiles --skill playwright-testing

安装后在 Claude Code 中输入/playwright-testing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

reference/
ci.md
debugging.md
performance.md
structure.md

详细介绍

Playwright 测试

为 SPA 编写可靠、快速、易维护的 Playwright 测试。

目录

核心原则: 测试用户看到和执行的内容。如果用户无法按角色或文本找到某个元素,你的测试也不应该这样做。

质量层次: 可靠(无随机失败)→ 快速(并行、尽量少等待)→ 易维护(重构后仍然有效)

理念: 默认使用以用户为中心的定位器。实现细节(test-id、CSS 选择器)是备选方案,而不是首选。

流程

  1. 确定: 我们要测试什么用户行为?
  2. 定位: 像用户一样查找元素(角色、标签、文本)
  3. 操作: 执行用户操作(点击、填写、导航)
  4. 断言: 验证可见结果,而不是内部状态
  5. 稳定: 处理异步操作,添加适当的等待

危险信号 - 停止

  • 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

评论

支持 Markdown