playwright-testing について
SPA 向けの Playwright テストを信頼性・速度・保守性を意識して作成する Claude Code スキルです。ユーザーに見える動作とセマンティックなロケーターを重視します。
主な機能
- 01ユーザー中心のロケーター指針
- 02条件ベースの待機と再試行アサーション
- 03テスト構成、フィクスチャ、ページオブジェクト
- 04パフォーマンスと並列実行の指針
- 05デバッグと CI のチェックリスト
活用シーン
- 01ユーザー操作と結果に沿ったテスト作成
- 02任意の待機によるテストの不安定さの低減
- 03再利用可能なセットアップによるテスト構成
- 04ローカルや CI でのテスト失敗の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mhagrelius/ dotfiles --skill playwright-testingインストール後、Claude Code で/playwright-testingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
reference/ci.mddebugging.mdperformance.mdstructure.md詳細
Playwright テスト
SPA 向けの信頼性が高く、高速で、保守しやすい Playwright テストを作成します。
目次
- 基本概念(ロケーター、待機、アサーション)
- テスト構造 → reference/structure.md を参照
- パフォーマンス → reference/performance.md を参照
- デバッグ → reference/debugging.md を参照
- CI 設定 → reference/ci.md を参照
基本原則: ユーザーが見て、操作するものをテストします。ユーザーがロールやテキストで要素を見つけられないなら、テストでも見つけられるべきではありません。
品質の段階: 信頼性(不安定なテストがない) → 高速(並列実行、待機を最小限に) → 保守性(リファクタリング後も動作する)
方針: デフォルトではユーザー中心のロケーターを使います。実装の詳細(test-id、CSS セレクター)は、最初の選択肢ではなく、代替手段として使います。
プロセス
- 特定: どのユーザー行動をテストしますか?
- 特定: ユーザーが見つける方法(ロール、ラベル、テキスト)で要素を探します
- 操作: ユーザーの操作を実行します(クリック、入力、移動)
- 検証: 内部状態ではなく、目に見える結果を確認します
- 安定化: 非同期処理に対応し、適切な待機を追加します
危険信号 - 中止してください
page.locator('.btn-primary')- CSS クラスセレクターpage.waitForTimeout(1000)- 根拠のない待機- 最初の選択肢としての
page.locator('[data-testid="x"]') - ユーザーが得る結果ではなく、コンポーネントの内部をテストしている
- ページオブジェクトやフィクスチャがない長いテストファイル
ロケーターの優先順位
digraph locator_priority {
rankdir=TB;
node [shape=box];
"Need to find element" [shape=diamond];
"Has accessible role?" [shape=diamond];
"Has label/placeholder?" [shape=diamond];
"Has visible text?" [shape=diamond];
"getByRole" [style=filled fillcolor=lightgreen];
"getByLabel/getByPlaceholder" [style=filled fillcolor=lightgreen];
"getByText" [style=filled fillcolor=lightgreen];
"getByTestId" [style=filled fillcolor=lightyellow];
"Need to find element" -> "Has accessible role?";
"Has accessible role?" -> "getByRole" [label="yes"];
"Has accessible role?" -> "Has label/placeholder?" [label="no"];
"Has label/placeholder?" -> "getByLabel/getByPlaceholder" [label="yes"];
"Has label/placeholder?" -> "Has visible text?" [label="no"];
"Has visible text?" -> "getByText" [label="yes"];
"Has visible text?" -> "getByTestId" [label="no (last resort)"];
}
| 優先順位 | ロケーター | 使用する場面 | 例 |
|---|---|---|---|
| 第1位 | getByRole | ボタン、リンク、入力欄、見出し、リスト | getByRole('button', { name: 'Submit' }) |
| 第2位 | getByLabel | ラベル付きのフォーム入力欄 | getByLabel('Email address') |
| 第3位 | getByPlaceholder | プレースホルダーテキスト付きの入力欄 | getByPlaceholder('Search...') |
| 第4位 | getByText | 静的なテキスト内容、段落 | getByText('Welcome back') |
| 第5位 | getByAltText | 画像 | getByAltText('Company logo') |
| 最後 | getByTestId | 動的なコンテンツ、意味的な役割がないもの | getByTestId('total-price') |
ロールの例
// Buttons
page.getByRole('button', { name: 'Save changes' })
page.getByRole('button', { name: /submit/i }) // regex for flexibility
// Links
page.getByRole('link', { name: 'View profile' })
// Form inputs
page.getByRole('textbox', { name: 'Username' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('combobox', { name: 'Country' })
// Structure
page.getByRole('heading', { name: 'Dashboard', level: 1 })
page.getByRole('list').getByRole('listitem')
page.getByRole('dialog', { name: 'Confirm deletion' })
// Tables
page.getByRole('table').getByRole('row', { name: /john/i })
複数の一致候補を区別する
// Specify which match you want
await page.getByRole('button', { name: 'Delete' }).first().click();
await page.getByRole('listitem').last().click();
await page.getByRole('row').nth(2).click(); // 0-indexed
// Filter by content or child elements
await page.getByRole('listitem').filter({ hasText: 'John' }).click();
await page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'Edit' })
}).click();
// Chain locators to scope
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
ロケーターのアンチパターン
| 悪い例 | 理由 | 良い例 |
|---|---|---|
.locator('.submit-btn') | クラス名の変更で壊れます | getByRole('button', { name: 'Submit' }) |
.locator('#email-input') | 実装に依存しています | getByLabel('Email') |
.locator('div > span:nth-child(2)') | 非常に壊れやすいです | getByText('...') を使うか、test-id を追加します |
あらゆる場所で getByTestId を使う | アクセシビリティの不具合を見逃します | まず意味的なロケーターを使います |
| 複数の一致候補がある、スコープ未指定のロケーター | 不安定で、誤った要素をクリックする可能性があります | .first()、.filter() を使うか、親要素でスコープを指定します |
待機と非同期処理
Playwright はほとんどの操作で自動的に待機します。 明確な理由がない限り、手動の待機を追加しないでください。
digraph waiting {
rankdir=TB;
node [shape=box];
"What are you waiting for?" [shape=diamond];
"Element to appear" [shape=diamond];
"Auto-wait (built-in)" [style=filled fillcolor=lightgreen];
"expect + toBeVisible" [style=filled fillcolor=lightgreen];
"waitForResponse" [style=filled fillcolor=lightyellow];
"waitForURL" [style=filled fillcolor=lightyellow];
"NEVER waitForTimeout" [style=filled fillcolor=lightpink];
"What are you waiting for?" -> "Auto-wait (built-in)" [label="clicking/filling"];
"What are you waiting for?" -> "Element to appear" [label="element"];
"What are you waiting for?" -> "waitForResponse" [label="API call"];
"What are you waiting for?" -> "waitForURL" [label="navigation"];
"Element to appear" -> "expect + toBeVisible" [label="use assertion"];
"Element to appear" -> "NEVER waitForTimeout" [label="don't guess"];
}
組み込みの自動待機
次の操作では自動的に待機するため、手動で待つ必要はありません。
// All of these wait automatically for element to be actionable
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('checkbox').check();
await page.getByRole('combobox').selectOption('US');
明示的な待機(必要な場合)
// Wait for element state (prefer assertions)
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('list')).not.toBeEmpty();
// Wait for navigation
await page.waitForURL('**/dashboard');
await page.waitForURL(url => url.searchParams.has('token'));
// Wait for API response (useful for loading states)
await page.getByRole('button', { name: 'Load data' }).click();
await page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
);
// Wait for network idle (use sparingly - can be slow)
await page.waitForLoadState('networkidle');
// Wait for specific request to complete before asserting
const responsePromise = page.waitForResponse('/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
await responsePromise;
await expect(page.getByRole('list')).toContainText('John');
// Promise.all pattern - click and wait simultaneously
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/data')),
page.getByRole('button', { name: 'Submit' }).click()
]);
待機のアンチパターン
| 悪い例 | 理由 | 良い例 |
|---|---|---|
waitForTimeout(2000) | 根拠がなく、遅い上に不安定です | 特定の条件を待ちます |
ループ内の waitForTimeout(100) | 手動でポーリングしています | 自動再試行される expect を使います |
あらゆる場所で waitForLoadState('networkidle') を使う | ポーリングがあると遅く、信頼性に欠けます | 特定のレスポンスを待ちます |
| 待機せずにすぐアサーションする | 競合状態が発生します | expect がアサーションを自動再試行します |
アサーションの自動再試行
Playwright のアサーションはタイムアウトまで自動再試行します。手動で待機する代わりに、こちらを使ってください。
// BAD: manual wait then check
await page.waitForTimeout(1000);
const text = await page.getByTestId('status').textContent();
expect(text).toBe('Complete');
// GOOD: auto-retrying assertion
await expect(page.getByText('Complete')).toBeVisible();
ソフトアサーション
アサーションに失敗した後もテストを続けるには、expect.soft() を使います(複数の失敗を収集します)。
test('validates all form fields', async ({ page }) => {
await page.goto('/profile');
// Soft assertions don't stop the test - useful for checking multiple things
await expect.soft(page.getByLabel('Name')).toHaveValue('John');
await expect.soft(page.getByLabel('Email')).toHaveValue('[email protected]');
await expect.soft(page.getByLabel('Phone')).toHaveValue('555-1234');
// Test continues even if some assertions fail
// All failures reported at end
});
オーバーレイとポップアップへの対応
オーバーレイがテスト操作を妨げる可能性がある場合は、addLocatorHandler を使います。
// Setup handler for cookie consent popup
await page.addLocatorHandler(
page.getByRole('dialog', { name: 'Cookie consent' }),
async () => {
await page.getByRole('button', { name: 'Accept' }).click();
}
);
// Now write test normally - handler auto-dismisses popup if it appears
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Settings' }).click();
テストの構成
ページオブジェクト、フィクスチャ、テストの構成については、reference/structure.md を参照してください。
クイックヒント:
- ページオブジェクトにはアサーションではなく、操作をカプセル化します
- フィクスチャで共通のセットアップ/ティアダウンを処理します
- 1つのテストにつき1つの動作を検証します
- 関連するテストのグループ化には
test.describeを使用します
パフォーマンス
並列実行、認証の最適化、API のショートカットについては、reference/performance.md を参照してください。
クイックヒント:
storageStateを使って認証状態を再利用します- テストデータは UI ではなく API 経由で作成します
fullyParallel: trueを使用しますwaitForLoadState('networkidle')は避けます
デバッグ
デバッグモード、トレース、一般的なシナリオについては、reference/debugging.md を参照してください。
クイックヒント:
- ローカルでのデバッグには
npx playwright test --debugを使用します - CI の失敗時はトレースを確認します:
npx playwright show-trace trace.zip await page.pause()を使って処理を停止し、確認します
CI の設定
GitHub Actions、シャーディング、CI 固有の設定については、reference/ci.md を参照してください。
クイックヒント:
forbidOnly: !!process.env.CIを設定すると、CI での test.only を防げます- CI で一時的な失敗に対応するため、
retries: 2を設定します - 失敗のデバッグ用にアーティファクトをアップロードします
品質チェックリスト
テストを確定する前に、該当するチェック項目ごとに TodoWrite アイテムを作成してください。
レイヤー 1:信頼性(不安定なテストがない)
-
waitForTimeout()の呼び出しがなく、条件に基づく待機を使用している - アサーションでは手動チェックではなく、自動再試行機能のある
expect()を使用している - テストが独立しており、テスト間で状態を共有していない
-
networkidleではなく、特定の API レスポンスを待機している - ロケーターが十分に具体的で、単一の要素に一致する
- テストが安定して成功する(コミット前にローカルで 5 回実行する)
レイヤー 2:高速性
-
storageStateを使って認証を再利用している - テストデータを UI ではなく API 経由で作成している
- テストが並列実行される(
fullyParallel: true) - 不要な
waitForLoadState('networkidle')がない - 時間のかかるセットアップをテストごとに繰り返さず、フィクスチャで処理している
- 大規模なテストスイート向けにシャーディングを設定している
レイヤー 3:保守性
- ロケーターで CSS セレクターではなくロール/ラベルを使用している
- ページオブジェクトに操作をカプセル化している
- テスト名にユーザーの操作と期待される結果を記述している
- 1つのテストにつき1つの動作を検証し、複数のことをテストしていない
- フィクスチャで共通のセットアップ/ティアダウンを処理している
- マジック文字列を使用せず、繰り返し使う値には定数を使用している
よく使うパターンのリファレンス
| 必要なもの | パターン |
|---|---|
| 認証済みユーザー | storageState フィクスチャ |
| テストデータのセットアップ | beforeEach またはフィクスチャでの API 呼び出し |
| データの読み込みを待機 | waitForResponse('/api/...') |
| クリックしてレスポンスを待機 | Promise.all([waitForResponse(...), click()]) |
| 類似した複数のテスト | test.describe + パラメーター化されたデータ |
| 時間のかかる操作 | 特定のテストのタイムアウトを延長 |
| モーダル/ダイアログ | getByRole('dialog') を使用してから、その範囲内に限定 |
| ドロップダウンの選択 | getByRole('combobox').selectOption() |
| ファイルのアップロード | ファイル入力で setInputFiles() を使用 |
| ホバーメニュー | locator.hover() を実行してから、表示された項目をクリック |
| ドラッグ&ドロップ | locator.dragTo(target) |
| iframe | frameLocator() を使用してから、その範囲内に限定 |
| 新しいタブ/ウィンドウ | page.waitForEvent('popup') |
| 複数の一致項目 | .first()、.last()、.nth(n)、.filter() |
| 複数の項目を確認 | ブロックしないアサーションには expect.soft() を使用 |
| ポップアップ/オーバーレイを閉じる | addLocatorHandler() |
data-testid を追加するタイミング
次のような場合は、最後の手段として data-testid を使用します。
- 要素にセマンティックロールがない(装飾用コンテナー)
- 安定したテキストがない動的コンテンツ(生成 ID、価格)
- 位置が重要な、同一の要素が複数ある場合
- アクセシブルなマークアップがないサードパーティ製コンポーネント
// Acceptable: price that changes dynamically
<span data-testid="cart-total">{formatCurrency(total)}</span>
page.getByTestId('cart-total')
// Still prefer scoping with semantic locators
page.getByRole('region', { name: 'Cart' }).getByTestId('total')
コマンドのクイックリファレンス
# Run all tests
npx playwright test
# Run specific file
npx playwright test login.spec.ts
# Run tests matching name
npx playwright test -g "login"
# Run in headed mode
npx playwright test --headed
# Debug mode with inspector
npx playwright test --debug
# Update snapshots
npx playwright test --update-snapshots
# Generate test from recording
npx playwright codegen localhost:3000
# Show last HTML report
npx playwright show-report