Playwright自动化模式技能Skill playwright-patterns

这个技能提供了Playwright自动化代码的最佳实践,用于编写稳健的浏览器自动化脚本、端到端测试和网络爬虫。它包括选择器策略、等待模式和数据提取技术,以最小化脆弱性和最大化可维护性。关键词:Playwright、自动化、测试、选择器、等待、Web 自动化、端到端测试、网络爬虫、JavaScript、浏览器自动化。

测试 0 次安装 1 次浏览 更新于 3/8/2026

name: playwright-patterns description: 用于编写Playwright自动化代码、构建网络爬虫或创建端到端测试时使用 - 提供选择器策略、等待模式和稳健自动化的最佳实践,以最小化脆弱性 user-invocable: false

Playwright 自动化模式

概述

可靠的浏览器自动化需要战略性的选择器选择、适当的等待和防御性编码。这个技能提供了最小化测试脆弱性和最大化可维护性的模式。

何时使用

  • 编写新的Playwright脚本或测试
  • 调试脆弱的自动化
  • 重构不可靠的选择器
  • 构建需要处理动态内容的网络爬虫
  • 创建必须可维护的端到端测试

何时不使用:

  • 简单的一次性浏览器任务
  • 当需要Playwright API文档时(使用context7 MCP)

选择器策略

优先级顺序

首先使用面向用户的定位器(最弹性),然后是测试ID,最后作为最后手段使用CSS/XPath:

  1. 基于角色的定位器(最佳 - 用户中心)

    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
    
  2. 其他面向用户的定位器

    await page.getByLabel('Password').fill('secret');
    await page.getByPlaceholder('Search...').fill('query');
    await page.getByText('Submit Order').click();
    
  3. 测试ID属性(显式契约)

    // 默认使用data-testid
    await page.getByTestId('submit-button').click();
    
    // 可以在playwright.config.ts中自定义:
    // use: { testIdAttribute: 'data-pw' }
    
  4. CSS/ID选择器(脆弱,尽可能避免)

    await page.locator('#submit-btn').click();
    await page.locator('.btn.btn-primary.submit').click();
    

严格性和特异性

定位器默认严格 - 如果多个元素匹配,操作会抛出错误:

// 如果存在2个以上按钮,会出错
await page.getByRole('button').click();

// 解决方案:
// 1. 使定位器更具体
await page.getByRole('button', { name: 'Submit' }).click();

// 2. 过滤以缩小范围
await page.getByRole('button')
  .filter({ hasText: 'Submit' })
  .click();

// 3. 链式定位器以限定范围
await page.locator('.product-card')
  .getByRole('button', { name: 'Add to cart' })
  .click();

// 避免:使用first()会使测试脆弱
await page.getByRole('button').first().click(); // 不要这样做

定位器过滤和链式

// 按文本内容过滤
await page.getByRole('listitem')
  .filter({ hasText: 'Product 2' })
  .getByRole('button')
  .click();

// 按子元素过滤
await page.getByRole('listitem')
  .filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
  .getByRole('button', { name: 'Buy' })
  .click();

// 按没有文本过滤
await expect(
  page.getByRole('listitem')
    .filter({ hasNot: page.getByText('Out of stock') })
).toHaveCount(5);

// 处理“要么/或”场景
const loginOrWelcome = await page.getByRole('button', { name: 'Login' })
  .or(page.getByText('Welcome back'))
  .first();
await expect(loginOrWelcome).toBeVisible();

要避免的反模式

脆弱的CSS路径

// 坏:当HTML结构改变时中断
await page.click('div.container > div:nth-child(2) > button.submit');

稳定的语义选择器

// 好:在结构变化中存活
await page.getByRole('button', { name: 'Submit' }).click();

带位置的XPath

// 坏:脆弱
await page.locator('xpath=//div[3]/button[1]').click();

带内容的XPath

// 更好:更稳定
await page.locator('xpath=//button[contains(text(), "Submit")]').click();

等待模式

内置自动等待

Playwright在大多数操作前自动等待。信任它。

// 自动等待元素可见、启用和稳定
await page.click('button');
await page.fill('input[name="email"]', 'test@example.com');

自动等待检查什么:

  • 元素附加到DOM
  • 元素可见
  • 元素稳定(不动画)
  • 元素启用
  • 元素接收事件(不被遮挡)
// 绕过检查(谨慎使用)
await page.click('button', { force: true });

// 测试而不执行(试运行)
await page.click('button', { trial: true });

以Web为先的断言

使用以Web为先的断言 - 它们重试直到条件满足:

// 错误 - 不重试,立即检查
expect(await page.getByText('welcome').isVisible()).toBe(true);

// 正确 - 自动重试直到超时
await expect(page.getByText('welcome')).toBeVisible();
await expect(page.getByText('Status')).toHaveText('Complete');
await expect(page.getByRole('listitem')).toHaveCount(5);

// 软断言 - 即使在失败时也继续测试
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await page.getByRole('link', { name: 'next' }).click();
// 测试继续,失败在最后报告

动态内容的显式等待

// 等待特定元素(现代 - 使用以Web为先的断言)
await expect(page.locator('.results-loaded')).toBeVisible();

// 等待网络空闲
await page.waitForLoadState('networkidle');

// 等待自定义条件
await page.waitForFunction(() =>
  document.querySelectorAll('.item').length > 10
);

处理异步更新

// 已知数量 - 断言精确数量
await expect(page.locator('.item')).toHaveCount(5);

// 未知数量 - 等待容器,然后提取
await expect(page.locator('.search-results')).toBeVisible();
const items = await page.locator('.item').all();

// 加载旋转器 - 等待不存在然后存在
await expect(page.locator('.loading-spinner')).not.toBeVisible();
await expect(page.locator('.results')).toBeVisible();

// 等待文本内容出现
await expect(page.locator('.status')).toHaveText('Complete');

// 至少一个结果(拒绝零结果)
await expect(page.locator('.item').first()).toBeVisible();

数据提取模式

单个元素

// textContent() - 获取所有文本,包括隐藏元素
const title = await page.locator('h1').textContent();

// innerText() - 仅获取可见文本(尊重CSS显示)
const price = await page.locator('.price').innerText();

// getAttribute() - 获取属性值
const href = await page.locator('a.product').getAttribute('href');

// 对于断言,首选以Web为先的断言
await expect(page.locator('.price')).toHaveText('$99');

多个元素

// 重要:locator.all()不等待元素
// 如果列表仍在加载,这可能脆弱

// 已知数量 - 先断言,然后提取
await expect(page.locator('.item')).toHaveCount(5);
const items = await page.locator('.item').all();
const data = await Promise.all(
  items.map(async item => ({
    title: await item.locator('.title').textContent(),
    price: await item.locator('.price').textContent(),
  }))
);

// 未知数量 - 等待容器,然后提取
await expect(page.locator('.results-container')).toBeVisible();
const data = await page.locator('.item').evaluateAll(items =>
  items.map(el => ({
    title: el.querySelector('.title')?.textContent?.trim(),
    price: el.querySelector('.price')?.textContent?.trim(),
  }))
);

// 最佳:使用evaluateAll进行批量提取(单次往返)
// 使用场景:从定位器限定范围的元素提取(最常见)
const data = await page.locator('.item').evaluateAll(items =>
  items.map(el => ({
    title: el.querySelector('.title')?.textContent?.trim(),
    price: el.querySelector('.price')?.textContent?.trim(),
  }))
);

使用evaluate()的复杂提取

// 当需要全局页面上下文时使用evaluate()
// (例如,检查窗口变量、文档状态)
const data = await page.evaluate(() => {
  return {
    items: Array.from(document.querySelectorAll('.item')).map(el => ({
      title: el.querySelector('.title')?.textContent?.trim(),
      price: el.querySelector('.price')?.textContent?.trim(),
      url: el.querySelector('a')?.href,
      available: !el.classList.contains('out-of-stock')
    })),
    totalCount: window.productCount, // 访问全局变量
    filters: window.appliedFilters   // 页面级状态
  };
});

// 对于定位器限定范围的提取,首选evaluateAll(更聚焦)
const items = await page.locator('.item').evaluateAll(els =>
  els.map(el => ({ /* ... */ }))
);

错误处理

优雅降级

// 在交互前检查元素是否存在
const cookieBanner = page.locator('.cookie-banner');
if (await cookieBanner.isVisible()) {
  await cookieBanner.getByRole('button', { name: 'Accept' }).click();
}

重试逻辑

// Playwright自动重试,但可以自定义
await expect(async () => {
  const status = await page.locator('.status').textContent();
  expect(status).toBe('Complete');
}).toPass({ timeout: 10000, intervals: [1000] });

超时配置

// 为特定操作设置超时
await page.click('button', { timeout: 5000 });

// 为整个测试设置超时
test.setTimeout(60000);

// 为页面设置默认超时
page.setDefaultTimeout(10000);

导航模式

等待导航

// 现代模式 - 点击自动等待导航
await page.click('a.next-page');
await page.waitForLoadState('networkidle'); // 仅在需要时

// 使用现代定位器
await page.getByRole('link', { name: 'Next Page' }).click();

多页面工作流

// 打开新标签
const [newPage] = await Promise.all([
  context.waitForEvent('page'),
  page.click('a[target="_blank"]')
]);

await newPage.waitForLoadState();
// 处理newPage
await newPage.close();

表单交互模式

基本表单填充

// fill() - 推荐用于大多数输入(快速、原子操作)
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'secret123');

// type() - 用于按键敏感输入(较慢,触发每个按键事件)
await page.locator('input.search').type('Product', { delay: 100 });

// 现代方法,使用基于角色的定位器
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
await page.getByRole('checkbox', { name: 'I agree' }).check();
await page.getByRole('button', { name: 'Submit' }).click();

文件上传

await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');

// 多个文件
await page.setInputFiles('input[type="file"]', [
  '/path/to/file1.pdf',
  '/path/to/file2.pdf'
]);

自动完成/搜索输入

// 输入并等待建议(现代方法)
await page.getByPlaceholder('Search products').fill('Product Name');
await expect(page.locator('.suggestions')).toBeVisible();

// 点击特定建议,使用基于角色的定位器
await page.getByRole('option', { name: 'Product Name - Premium' }).click();

// 或过滤建议
await page.locator('.suggestions')
  .getByText('Product Name', { exact: false })
  .first()
  .click();

截图和调试

战略性截图

// 全页面截图
await page.screenshot({ path: 'screenshot.png', fullPage: true });

// 元素截图
await page.locator('.chart').screenshot({ path: 'chart.png' });

// 失败时截图(在测试中)
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) {
    await page.screenshot({
      path: `failure-${testInfo.title}.png`,
      fullPage: true
    });
  }
});

调试模式

// 暂停执行以调试
await page.pause();

// 减慢操作以观察
const browser = await chromium.launch({ slowMo: 1000 });

常见模式参考

任务 模式
点击按钮 await page.getByRole('button', { name: 'Text' }).click()
填充输入 await page.getByLabel('Field').fill('value')
选择选项 await page.getByRole('combobox').selectOption('value')
勾选复选框 await page.getByRole('checkbox', { name: 'Label' }).check()
等待元素 await expect(page.locator('.el')).toBeVisible()
断言文本 await expect(page.locator('.el')).toHaveText('text')
提取文本 const text = await page.locator('.el').textContent()
提取多个 await expect(locator).toHaveCount(5); const els = await locator.all()
批量提取 const data = await page.locator('.el').evaluateAll(els => ...)
在页面中运行JS await page.evaluate(() => /* JS代码 */)
截图 await page.screenshot({ path: 'shot.png' })
处理新标签 const newPage = await context.waitForEvent('page', () => page.click('a'))

反模式清单

避免这些常见错误:

  • ❌ 使用 page.waitForTimeout(5000) 而不是以Web为先的断言
  • ❌ 使用CSS类名或nth-child选择器而不是基于角色的定位器
  • ❌ 使用 expect(await locator.isVisible()).toBe(true) 而不是 await expect(locator).toBeVisible()
  • ❌ 使用已弃用的 waitForNavigation() - 点击现在自动等待
  • ❌ 使用 locator.all() 而没有先断言数量
  • ❌ 使用 first() 当定位器应该更具体时
  • ❌ 不处理弹窗或cookie横幅
  • ❌ 硬编码延迟而不是等待条件
  • ❌ 使用截图进行数据提取(使用evaluate代替)

记住

稳健自动化的优先顺序:

  1. 首先使用面向用户的定位器 - 角色、标签、占位符、文本(而非CSS)
  2. 以Web为先的断言 - await expect(locator).toBeVisible() 而非 expect(await ...)
  3. 信任自动等待 - 不要添加手动延迟或已弃用的模式
  4. 严格性是你的朋友 - 修复模糊的定位器,不要使用 first()
  5. 明智地批量提取 - 先断言数量再使用 all(),使用 evaluateAll() 提高效率

浏览器自动化本质上是异步和依赖于时间的。从一开始就构建弹性。