SuperAPI AI Directory

playwright-test

Testing & quality

View repository

Updated 10-15 22:39

About this skill

Playwright Test

Configuration Template

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, // Forbid .only in CI retries: process.env.CI ? 2 : 0, // Retry only in 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 フォントがない。スクリーンショットの文字化けやレイアウト崩れの原因になる:

```yaml
      - name: Install fonts
        run: |
          sudo apt-get update
          sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji

--with-deps Optionally installs the system dependencies required by Playwright, but does not include fonts.

Basic (no shard)

name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        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@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Shard Execution (parallel split)

Split tests across multiple jobs to speed up:

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@v4
      - uses: actions/setup-node@v4
        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@v4
        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@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: npm ci
      - uses: actions/download-artifact@v4
        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@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Add the blob reporter in config for sharding:

reporter: process.env.CI
  ? [['blob'], ['github']]    // shard 用: blob で出力
  : [['html']],

Shard × Browser Matrix (multiple browsers in parallel)

When running combinations of multiple browsers × multiple shards,matrix has 2 axes:

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@v4
      - uses: actions/setup-node@v4
        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@v4
        if: ${{ !cancelled() }}
        with:
          name: blob-${{ matrix.browser }}-${{ strategy.job-index }}
          path: blob-report/
          retention-days: 1

Merge all blobs into one HTML in the merge job. The artifact name varies by browser × shard as blob-chromium-0 / blob-firefox-1 ..., so the pattern is blob-* set to:

  merge-reports:
    if: ${{ !cancelled() }}
    needs: e2e
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: npm ci
      - uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-*             # ブラウザ横断で回収
          merge-multiple: true
      - run: npx playwright merge-reports --reporter html ./all-blob-reports
      - uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

The number of jobs becomes browsers × shards times, so be careful with the number of combinations (3 × 4 = 12 jobs).fail-fast: false prevents other jobs from stopping when 1 job fails.workers: 1 は Within a shard parallelism (shards are already parallel, so 1 is fine in CI; for local development undefined で auto)。

Retry Strategy

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,  // CI で最大 2 回リトライ

  use: {
    trace: 'on-first-retry',         // リトライ時にトレース取得
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
});

Retry per test:

// 特定のテストだけリトライ回数を変更
test('flaky external API test', async ({ page }) => {
  test.info().annotations.push({ type: 'retry', description: 'External dependency' });
  // ...
});

// describe 単位
test.describe('payment flow', () => {
  test.describe.configure({ retries: 3 });
  // ...
});

Criteria for Choosing Trace / Screenshot / Video

Setting Capture Timing Use / Size
'on' All tests Only when debugging. Not recommended in CI (artifacts balloon)
'on-first-retry' Only on retry Recommended CI default. Sufficient for investigating flaky causes, smallest size
'retain-on-failure' Retained on failure When you want to reliably catch failures on the first shot. Useful in environments without retry
'off' No capture Save artifact capacity in large suites

Selection criteria: If you set retry to 1 or more, on-first-retry(first failure → trace on retry, size ½).If no retry or you want to see a single failure immediately, retain-on-failure. If the requirement says "failure only", it usually means retain-on-failure .

Browser Other Conditional Tests

Handling skip / behavior differences only for specific browsers:

import { test } from '@playwright/test';

// ブラウザ条件で 1 テストを skip
test('webkit only feature', async ({ page, browserName }) => {
  test.skip(browserName !== 'webkit', 'Safari-specific behavior');
  // ...
});

// describe 単位で skip
test.describe('chromium-only suite', () => {
  test.skip(({ browserName }) => browserName !== 'chromium', 'Uses CDP');
  test('uses cdp api', async ({ page }) => { /* ... */ });
});

// describe 単位で retry / mode 変更
test.describe('payment flow', () => {
  test.describe.configure({ retries: 3, mode: 'serial' });
  test('step 1', async ({ page }) => { /* ... */ });
  test('step 2', async ({ page }) => { /* step 1 の状態を引き継ぐ */ });
});

browserName は chromium / firefox / webkit the three values of . Combined with CLI filtering using tags (--grep @chromium-only), it increases the flexibility of CI configuration.

Flaky Detection Operations

To detect flaky tests in CI and post PR comments / aggregate them, add a JSON reporter:

reporter: process.env.CI
  ? [['blob'], ['github'], ['json', { outputFile: 'test-results/results.json' }]]
  : [['html']],

results.json contains each test's status / retries / duration . Extract later:

# retry > 0 で最終 pass = flaky 候補
jq '.suites[].specs[] | select(.tests[].results | length > 1 and .[-1].status == "passed")' \
  test-results/results.json

Saving artifacts in GitHub Actions and aggregating in a separate job → tracking trends over the past N runs is a standard operational practice.@playwright/test if 1.40+ expect.configure({ flaky: true }) Built-in supports such as can also be used.

Golden Rule: Don't Use Fixed waits

Golden Rule: Don't Use Fixed waits

Playwright automatically waits until an element becomes actionable.waitForTimeout() is forbidden.

// BAD
await page.waitForTimeout(3000);
await page.click('#submit');

// GOOD: 自動待機
await page.getByRole('button', { name: 'Submit' }).click();

// GOOD: web-first assertion (自動リトライ)
await expect(page.getByText('Success')).toBeVisible();

// BAD: リトライなし
expect(await page.getByText('Success').isVisible()).toBe(true);

One-shot Read APIs Do Not Auto-retry:

Format Behavior
expect(locator).toBeVisible() / toHaveText(...) etc. with auto-retry(default 5s). Use this
await locator.isVisible() / innerText() / count() / textContent() One-shot read, no retry. A hotbed of flakiness

If flaky, there's a high probability you can replace a one-shot API with a web-first assertion:

// BAD
const n = await page.locator('.row').count();
expect(n).toBeGreaterThan(0);

// GOOD
await expect(page.locator('.row')).not.toHaveCount(0);

Cases requiring explicit waiting:

await page.waitForURL('**/dashboard');          // ナビゲーション後
await page.waitForLoadState('networkidle');      // 重い初期ロード
await page.waitForResponse('**/api/data');       // API レスポンス待ち

network trigger

Before the action Set up the Promise:

const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);

// 条件付きマッチ
const responsePromise = page.waitForResponse(
  resp => resp.url().includes('/api/users') && resp.request().method() === 'POST'
);

waitForResponse The trap of waiting forever: In cases where the target API is never called in the first place (SPA holding all data in the initial bundle, skipping due to cache hit, etc.), it stalls until timeout.Fallback order:

  1. First waitForResponse Determine whether is required (necessary if the API call is the timing that confirms a side effect)
  2. If the API is not called, web-first assertion alone is sufficient (await expect(page.getByTestId('result')).toBeVisible())
  3. If you want to limit the timeout to a short value, { timeout: 5_000 } pass
  4. If you want to count arbitrary responses, page.on('response', ...) convert to a listener (not waitFor but event aggregation)

API mock

page.route() は page.goto() のbeforeregister:

await page.route('**/api/items', route => route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({ items: [{ id: 1, name: 'Test' }] }),
}));

// レスポンスを改変
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 });
});

// リソースブロック(高速化)
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());

Network Recording and Replay with HAR

You can record actual API responses and replay them as-is during tests:

// 記録: テスト実行時に HAR ファイルを生成
test('record HAR', async ({ page }) => {
  await page.routeFromHAR('tests/fixtures/api.har', {
    url: '**/api/**',
    update: true,  // true で記録モード、false で再生モード
  });
  await page.goto('/');
  // ...操作すると API レスポンスが HAR に保存される
});

// 再生: 記録済み HAR からレスポンスを返す(ネットワーク不要)
test('replay from HAR', async ({ page }) => {
  await page.routeFromHAR('tests/fixtures/api.har', {
    url: '**/api/**',
    update: false,  // 再生モード
  });
  await page.goto('/');
  await expect(page.getByText('data from API')).toBeVisible();
});

How to record HAR via CLI:

npx playwright open --save-har=tests/fixtures/api.har https://example.com

Request / Response Verification

// リクエストボディを検証
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' });

// レスポンスボディを検証
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-level routing

If you want to apply a common mock to all pages, use context.route() :

test('context-level mock', async ({ context, page }) => {
  // context 内の全ページに適用
  await context.route('**/api/config', route => route.fulfill({
    status: 200,
    json: { featureFlag: true },
  }));
  await page.goto('/');
  const popup = await page.waitForEvent('popup');  // 新しいタブにも適用される
  await expect(popup.getByText('Feature enabled')).toBeVisible();
});

Drag and Drop

Simple case

await page.locator('#source').dragTo(page.locator('#target'));

DnD libraries (react-dnd, dnd-kit, SortableJS)

Pointer event-based libraries are dragTo often does not work:

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 } generate an intermediate pointermove/dragover event with
  • DataTransfer Libraries that use may require synthetic events with page.evaluate() in some cases
  • Assert the final state (element order / position) rather than the animation

Locator

Priority (higher is more recommended):

page.getByRole('button', { name: 'Submit' });  // 1. ロールベース
page.getByLabel('Email');                        // 2. ラベル
page.getByText('Welcome');                       // 2. テキスト
page.getByTestId('nav-menu');                    // 3. テスト ID
page.locator('button.btn-primary');              // 4. CSS (避ける)

Chains and filters:

const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
await product.getByRole('button', { name: 'Add to cart' }).click();

Handling Modals / Dialogs

Modals are getByRole('dialog') Scope with and query inner elements. To confirm closing, toBeHidden():

// 開く
await page.getByRole('button', { name: 'New Project' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();

// 内部操作はスコープ内で
await dialog.getByLabel('Name').fill('My Project');
await dialog.getByRole('button', { name: 'Save' }).click();

// 閉じ確認(fade out アニメーション中は toBeHidden が auto-retry で待つ)
await expect(dialog).toBeHidden();

// 反映結果を外側で確認
await expect(
  page.getByRole('list', { name: 'projects' }).getByRole('listitem').filter({ hasText: 'My Project' })
).toBeVisible();

role="alertdialog" uses for warning-type dialogs (such as delete confirmations). getByRole('alertdialog') uses .

Assertions

web-first assertions auto-retry:

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/);

// ソフトアサーション(失敗してもテスト続行)
await expect.soft(page.getByTestId('count')).toHaveText('5');

Reusing Authentication

// tests/auth.setup.ts
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@test.com');
  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' });
});

Tests that don't require authentication: test.use({ storageState: { cookies: [], origins: [] } })

File Operations

// アップロード
await page.getByLabel('Upload').setInputFiles('myfile.pdf');

// バッファから(ファイル不要)
await page.getByLabel('Upload').setInputFiles({
  name: 'file.txt', mimeType: 'text/plain', buffer: Buffer.from('content'),
});

// ダウンロード
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download').click();
const download = await downloadPromise;
await download.saveAs('/tmp/file.pdf');

Page Object Model

Keep it simple. Assertions belong on the test file side:

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();
  }
}

Debugging

npx playwright test --debug          # Inspector 起動
npx playwright test --ui             # UI モード (time-travel)
npx playwright test --trace on       # トレース生成
npx playwright show-report           # レポート表示

In code: await page.pause() to open Inspector during a test.