mcpField
P
playwright-test

playwright-test

github.com/mizchi测试与质量41691 次安装2 周前更新
代码仓库

关于 playwright-test

playwright-test 是面向 Playwright 测试套件编写与运行的技能,包含测试可靠性、网络模拟、认证复用及 CI 配置方面的实践。它还说明如何分片执行测试并合并报告。

主要功能

  1. 01配置 Playwright 测试项目
  2. 02使用自动重试断言减少不稳定
  3. 03模拟 API 并录制或回放 HAR
  4. 04复用认证状态
  5. 05在 CI 中分片运行并合并报告

适用场景

  1. 01编写端到端浏览器测试
  2. 02排查 CI 中的不稳定测试
  3. 03为测试模拟 API 响应
  4. 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 的所有数据都在初始包中、缓存命中时跳过调用等),它会一直阻塞到超时。回退优先级:

  1. 首先判断是否有必要使用 waitForResponse(当 API 调用是副作用发生时机的明确信号时才需要)
  2. 如果没有调用 API,仅使用 Web 优先断言就足够了(await expect(page.getByTestId('result')).toBeVisible())
  3. 如需限制超时时间,请传入 { timeout: 5_000 }
  4. 如需统计任意响应,请将 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。

评论

支持 Markdown