name: playwright-patterns description: 用于编写Playwright自动化代码、构建网络爬虫或创建端到端测试时使用 - 提供选择器策略、等待模式和稳健自动化的最佳实践,以最小化脆弱性 user-invocable: false
Playwright 自动化模式
概述
可靠的浏览器自动化需要战略性的选择器选择、适当的等待和防御性编码。这个技能提供了最小化测试脆弱性和最大化可维护性的模式。
何时使用
- 编写新的Playwright脚本或测试
- 调试脆弱的自动化
- 重构不可靠的选择器
- 构建需要处理动态内容的网络爬虫
- 创建必须可维护的端到端测试
何时不使用:
- 简单的一次性浏览器任务
- 当需要Playwright API文档时(使用context7 MCP)
选择器策略
优先级顺序
首先使用面向用户的定位器(最弹性),然后是测试ID,最后作为最后手段使用CSS/XPath:
-
基于角色的定位器(最佳 - 用户中心)
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com'); -
其他面向用户的定位器
await page.getByLabel('Password').fill('secret'); await page.getByPlaceholder('Search...').fill('query'); await page.getByText('Submit Order').click(); -
测试ID属性(显式契约)
// 默认使用data-testid await page.getByTestId('submit-button').click(); // 可以在playwright.config.ts中自定义: // use: { testIdAttribute: 'data-pw' } -
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代替)
记住
稳健自动化的优先顺序:
- 首先使用面向用户的定位器 - 角色、标签、占位符、文本(而非CSS)
- 以Web为先的断言 -
await expect(locator).toBeVisible()而非expect(await ...) - 信任自动等待 - 不要添加手动延迟或已弃用的模式
- 严格性是你的朋友 - 修复模糊的定位器,不要使用
first() - 明智地批量提取 - 先断言数量再使用
all(),使用evaluateAll()提高效率
浏览器自动化本质上是异步和依赖于时间的。从一开始就构建弹性。