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:
- First
waitForResponseDetermine whether is required (necessary if the API call is the timing that confirms a side effect) - If the API is not called, web-first assertion alone is sufficient (
await expect(page.getByTestId('result')).toBeVisible()) - If you want to limit the timeout to a short value,
{ timeout: 5_000 }pass - 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 intermediatepointermove/dragoverevent withDataTransferLibraries that use may require synthetic events withpage.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.