关于 playwright-test
playwright-test 是面向 Playwright 测试套件编写与运行的技能,包含测试可靠性、网络模拟、认证复用及 CI 配置方面的实践。它还说明如何分片执行测试并合并报告。
主要功能
- 01配置 Playwright 测试项目
- 02使用自动重试断言减少不稳定
- 03模拟 API 并录制或回放 HAR
- 04复用认证状态
- 05在 CI 中分片运行并合并报告
适用场景
- 01编写端到端浏览器测试
- 02排查 CI 中的不稳定测试
- 03为测试模拟 API 响应
- 04使用多个浏览器和分片并行测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mizchi/ skills --skill playwright-test安装后在 Claude Code 中输入/playwright-test确认已加载。
附带文件README.mdSKILL-ja.md
详细介绍
Playwright Test
配置模板
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // Forbid .only on CI
retries: process.env.CI ? 2 : 0, // Retry only on CI
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['html'], ['github']] // CI: HTML + GitHub annotations
: [['html']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Record trace only on retry
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
GitHub Actions
Linux 字体(CI 必需)
Ubuntu/Debian 默认不附带日语/CJK 字体。这会导致截图出现乱码和布局错乱:
- name: Install fonts
run: |
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji
--with-deps 选项会让 Playwright 安装所需的系统依赖,但不包含字体。
基本配置(不分片)
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji
- run: npx playwright test
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
分片执行(并行拆分)
将测试拆分到多个任务中,以加快执行速度:
name: E2E Tests (Sharded)
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: blob-report-${{ strategy.job-index }}
path: blob-report/
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: e2e
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm ci
- uses: actions/download-artifact@v8
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
retention-days: 14
将 blob reporter 添加到配置中,以支持分片:
reporter: process.env.CI
? [['blob'], ['github']] // For shards: emit blob
: [['html']],
分片 × 浏览器矩阵(多个浏览器并行)
要同时运行多个浏览器 × 多个分片,请使用两个 matrix 维度:
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm ci
- run: npx playwright install ${{ matrix.browser }} --with-deps
- run: sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji
- run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: blob-${{ matrix.browser }}-${{ strategy.job-index }}
path: blob-report/
retention-days: 1
合并任务会将所有 blob 汇总为一个 HTML。由于产物名称因浏览器 × 分片而异(blob-chromium-0 / blob-firefox-1 ...),请使用 blob-* 作为模式:
merge-reports:
if: ${{ !cancelled() }}
needs: e2e
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm ci
- uses: actions/download-artifact@v8
with:
path: all-blob-reports
pattern: blob-* # Collect across browsers
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
retention-days: 14
任务数将变为 browsers x shards(3 x 4 = 12 个任务),因此要注意组合数量急剧膨胀的问题。fail-fast: false 可在一个任务失败时让其他任务继续运行。workers: 1 控制分片内部的并行度(分片已经并行,因此在 CI 上设置为 1 即可;本地开发时可使用 undefined 自动设置)。
重试策略
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Up to 2 retries on CI
use: {
trace: 'on-first-retry', // Capture trace on retry
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
});
单个测试的重试:
// Change retry count for a specific test only
test('flaky external API test', async ({ page }) => {
test.info().annotations.push({ type: 'retry', description: 'External dependency' });
// ...
});
// Per describe
test.describe('payment flow', () => {
test.describe.configure({ retries: 3 });
// ...
});
选择 Trace / 截图 / 视频设置
| 设置 | 捕获时机 | 用途 / 大小 |
|---|---|---|
'on' | 每次测试 | 仅用于调试。不建议在 CI 上使用(产物会急剧膨胀) |
'on-first-retry' | 仅在重试时 | CI 推荐默认值。足以调查偶发失败,且占用空间极小 |
'retain-on-failure' | 失败时保留 | 如果要捕获首次运行就失败的情况。禁用重试时很有用 |
'off' | 从不捕获 | 为大型测试套件节省产物空间 |
选择标准:如果设置了 retries >= 1,请使用 on-first-retry(首次失败 -> 重试时生成 trace,占用空间减半)。没有重试,或需要立即检查单次失败时,请使用 retain-on-failure。需求表述为“仅在失败时”时,通常指 retain-on-failure。
按浏览器有条件地运行测试
按浏览器跳过测试或处理差异:
import { test } from '@playwright/test';
// Skip one test based on browser condition
test('webkit only feature', async ({ page, browserName }) => {
test.skip(browserName !== 'webkit', 'Safari-specific behavior');
// ...
});
// Skip a describe block
test.describe('chromium-only suite', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'Uses CDP');
test('uses cdp api', async ({ page }) => { /* ... */ });
});
// Change retry / mode per describe
test.describe('payment flow', () => {
test.describe.configure({ retries: 3, mode: 'serial' });
test('step 1', async ({ page }) => { /* ... */ });
test('step 2', async ({ page }) => { /* inherits state from step 1 */ });
});
browserName 有三个可能的值:chromium / firefox / webkit。结合基于标签的 CLI 筛选(--grep @chromium-only),可让 CI 配置更加灵活。
偶发失败检测工作流
要在 CI 上检测偶发失败,并在 PR 上发表评论 / 汇总统计数据,请添加 JSON reporter:
reporter: process.env.CI
? [['blob'], ['github'], ['json', { outputFile: 'test-results/results.json' }]]
: [['html']],
results.json 包含每项测试的 status / retries / duration。在后续步骤中提取:
# retry > 0 and final pass = flaky candidate
jq '.suites[].specs[] | select(.tests[].results | length > 1 and .[-1].status == "passed")' \
test-results/results.json
通常的做法是在 GitHub Actions 上将其上传为产物,并在单独的任务中汇总 -> 跟踪过去 N 次运行的趋势。@playwright/test 1.40+ 还提供了 expect.configure({ flaky: true }) 等内置支持。
规则:不要使用固定等待
规则:不要使用固定等待
Playwright 会自动等待,直到元素可操作。禁止使用 waitForTimeout()。
// BAD
await page.waitForTimeout(3000);
await page.click('#submit');
// GOOD: automatic waiting
await page.getByRole('button', { name: 'Submit' }).click();
// GOOD: web-first assertion (auto-retries)
await expect(page.getByText('Success')).toBeVisible();
// BAD: no retry
expect(await page.getByText('Success').isVisible()).toBe(true);
单次读取 API 不会自动重试:
| 形式 | 行为 |
|---|---|
expect(locator).toBeVisible() / toHaveText(...) 等 | 自动重试(默认 5s)。请使用这些 API |
await locator.isVisible() / innerText() / count() / textContent() | 单次读取,不重试。这是偶发失败的温床 |
如果测试偶发失败,很可能可以将单次读取 API 替换为 web-first 断言:
// BAD
const n = await page.locator('.row').count();
expect(n).toBeGreaterThan(0);
// GOOD
await expect(page.locator('.row')).not.toHaveCount(0);
需要显式等待的情况:
await page.waitForURL('**/dashboard'); // After navigation
await page.waitForLoadState('networkidle'); // Heavy initial load
await page.waitForResponse('**/api/data'); // Wait for API response
网络触发器
在执行操作之前设置 Promise:
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
// Conditional matching
const responsePromise = page.waitForResponse(
resp => resp.url().includes('/api/users') && resp.request().method() === 'POST'
);
注意:waitForResponse 可能会一直等待:如果目标 API 从未被调用(例如,SPA 的所有数据都在初始包中、缓存命中时跳过调用等),它会一直阻塞到超时。回退优先级:
- 首先判断是否有必要使用
waitForResponse(当 API 调用是副作用发生时机的明确信号时才需要) - 如果没有调用 API,仅使用 Web 优先断言就足够了(
await expect(page.getByTestId('result')).toBeVisible()) - 如需限制超时时间,请传入
{ timeout: 5_000 } - 如需统计任意响应,请将
page.on('response', ...)用作监听器(聚合事件,而不是使用 waitFor)
API 模拟
在 page.goto() 之前注册 page.route():
await page.route('**/api/items', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [{ id: 1, name: 'Test' }] }),
}));
// Modify the response
await page.route('**/api/data', async route => {
const response = await route.fetch();
const json = await response.json();
json.debug = true;
await route.fulfill({ response, json });
});
// Block resources (speed up)
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
通过 HAR 记录/重放网络请求
记录真实的 API 响应,并在测试期间原样重放:
// Record: generate a HAR file during test execution
test('record HAR', async ({ page }) => {
await page.routeFromHAR('tests/fixtures/api.har', {
url: '**/api/**',
update: true, // true = record mode, false = replay mode
});
await page.goto('/');
// ...interactions save API responses to the HAR
});
// Replay: return responses from the recorded HAR (no network needed)
test('replay from HAR', async ({ page }) => {
await page.routeFromHAR('tests/fixtures/api.har', {
url: '**/api/**',
update: false, // replay mode
});
await page.goto('/');
await expect(page.getByText('data from API')).toBeVisible();
});
从 CLI 记录 HAR:
npx playwright open --save-har=tests/fixtures/api.har https://example.com
请求/响应断言
// Validate the request body
const requestPromise = page.waitForRequest('**/api/submit');
await page.getByRole('button', { name: 'Submit' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');
expect(JSON.parse(request.postData()!)).toEqual({ name: 'test' });
// Validate the response body
const responsePromise = page.waitForResponse('**/api/submit');
await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise;
const body = await response.json();
expect(body.id).toBeDefined();
上下文级路由
如需对每个页面应用通用模拟,请使用 context.route():
test('context-level mock', async ({ context, page }) => {
// Applies to every page in the context
await context.route('**/api/config', route => route.fulfill({
status: 200,
json: { featureFlag: true },
}));
await page.goto('/');
const popup = await page.waitForEvent('popup'); // Also applies to new tabs
await expect(popup.getByText('Feature enabled')).toBeVisible();
});
拖放
简单情况
await page.locator('#source').dragTo(page.locator('#target'));
DnD 库(react-dnd、dnd-kit、SortableJS)
基于指针事件的库通常无法与 dragTo 配合使用:
async function dragAndDrop(page: Page, source: Locator, target: Locator) {
const srcBox = (await source.boundingBox())!;
const tgtBox = (await target.boundingBox())!;
await page.mouse.move(srcBox.x + srcBox.width / 2, srcBox.y + srcBox.height / 2);
await page.mouse.down();
await page.mouse.move(tgtBox.x + tgtBox.width / 2, tgtBox.y + tgtBox.height / 2, { steps: 10 });
await page.mouse.up();
}
{ steps: 10 }会生成中间的pointermove/dragover事件- 使用
DataTransfer的库可能需要通过page.evaluate()合成事件 - 断言最终状态(元素顺序/位置),而不是动画
定位器
优先级顺序(优先级从高到低):
page.getByRole('button', { name: 'Submit' }); // 1. Role-based
page.getByLabel('Email'); // 2. Label
page.getByText('Welcome'); // 2. Text
page.getByTestId('nav-menu'); // 3. Test ID
page.locator('button.btn-primary'); // 4. CSS (avoid)
链式调用和筛选:
const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
await product.getByRole('button', { name: 'Add to cart' }).click();
处理模态框/对话框
使用 getByRole('dialog') 限定模态框范围,并在其中查询。使用 toBeHidden() 检查是否已关闭:
// Open
await page.getByRole('button', { name: 'New Project' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Interact within scope
await dialog.getByLabel('Name').fill('My Project');
await dialog.getByRole('button', { name: 'Save' }).click();
// Confirm closure (during fade-out animation, toBeHidden auto-retries)
await expect(dialog).toBeHidden();
// Verify the result outside
await expect(
page.getByRole('list', { name: 'projects' }).getByRole('listitem').filter({ hasText: 'My Project' })
).toBeVisible();
警告对话框(例如删除确认)使用 role="alertdialog",并通过 getByRole('alertdialog') 定位。
断言
Web 优先断言会自动重试:
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByTestId('status')).toHaveText('Done');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle(/Home/);
// Soft assertion (test continues even on failure)
await expect.soft(page.getByTestId('count')).toHaveText('5');
复用身份验证
// tests/auth.setup.ts
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
不需要身份验证的测试:test.use({ storageState: { cookies: [], origins: [] } })
文件操作
// Upload
await page.getByLabel('Upload').setInputFiles('myfile.pdf');
// From a buffer (no file needed)
await page.getByLabel('Upload').setInputFiles({
name: 'file.txt', mimeType: 'text/plain', buffer: Buffer.from('content'),
});
// Download
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download').click();
const download = await downloadPromise;
await download.saveAs('/tmp/file.pdf');
页面对象模型
保持简单。将断言放在测试侧:
class LoginPage {
constructor(private page: Page) {}
readonly email = this.page.getByLabel('Email');
readonly password = this.page.getByLabel('Password');
readonly submit = this.page.getByRole('button', { name: 'Sign in' });
async login(email: string, pass: string) {
await this.email.fill(email);
await this.password.fill(pass);
await this.submit.click();
}
}
调试
npx playwright test --debug # Launch Inspector
npx playwright test --ui # UI mode (time-travel)
npx playwright test --trace on # Generate trace
npx playwright show-report # Show report
在代码中:await page.pause() 会在测试中途打开 Inspector。