目次
- はじめに
- 今回の環境
- 前提:E2E専用DBとfixtureは作成済み
- PHPUnitとPlaywrightの責務を分ける
- Playwrightではユーザー視点のLocatorを優先する
- テスト構成
- suite開始前にE2E DBをresetする
- 各テストで通常のログイン画面を使う
- 退会確認モーダルをテストする
- 本人レビュー削除モーダルをテストする
- ページネーションも実ブラウザで確認する
- 固定時間待機を使わない
--headedでChromiumの動きを見る--debugとPlaywright Inspectorが分かりやすい- 最終的なテスト件数
- AIレビューで改善したポイント
- 補足:WSL上のNode.js異常終了は別問題として分離
- まとめ
- 参考
- 関連記事
はじめに
前回の【4. E2Eデータベース・fixture編】では、Playwrightから安全に利用できるE2E専用DBとfixture基盤を整備しました。
今回は、その基盤を使って実際のブラウザテストを実装します。
この記事で扱うのは、PHPUnitのFeatureテストでは保証しにくかった次のような操作です。
- モーダルを実際に開閉できるか
- 初期フォーカスが正しい要素へ移動するか
-
Tab/Shift + Tabでフォーカスがモーダル内を循環するか -
Escape、キャンセル、背景クリックで閉じられるか - モーダルを閉じたあと、起動元へフォーカスが戻るか
- 複数あるレビュー削除モーダルの状態が混線しないか
- 一覧画面のページネーションを実ブラウザ操作で遷移できるか
今回のポイントは、PHPUnitをPlaywrightへ置き換えるのではなく、実ブラウザでしか確認しにくい責務だけをPlaywrightへ分担することです。
今回の環境
実装完了時点の主な環境です。
| 項目 | バージョン / 構成 |
|---|---|
| OS | Windows + WSL |
| WSL | 2.7.12.0 |
| WSL kernel | 6.18.33.2-microsoft-standard-WSL2 |
| Ubuntu | 24.04.4 LTS |
| PHP | 8.4.24 |
| Laravel | 13.26.1 |
| Node.js | 24.20.0 |
| npm | 11.19.0 |
| Playwright Test | 1.62.1 |
| TypeScript | 7.0.2 |
| Browser | Chromium |
| E2E Laravel | http://localhost:83 |
| E2E DB | e2e_testing |
| workers | 1 |
| storageState | 未使用 |
Playwright / Node.js / npm / ChromiumはWSL側で管理し、Laravel側はDocker ComposeのE2E専用serviceを利用しています。
前提:E2E専用DBとfixtureは作成済み
今回のブラウザテストでは、前回までに作成したE2E専用fixtureを利用します。
主なfixtureは次のとおりです。
users : 4
categories : 1
items : 11
reviews : 11
ユーザーは用途別に分けています。
E2E Reviewer
→ 認証済み / レビュー11件投稿
E2E Unverified User
→ メール未認証
E2E No Review User
→ 認証済み / レビュー0件
E2E Verified User
→ 一般的な認証済み操作用
作品とレビューには固定値を持たせています。
E2E Movie 01
...
E2E Movie 11
E2E Review 01
...
E2E Review 11
本人レビュー一覧は created_at DESC で並ぶため、レビュー時刻も1分ずつずらしています。
1ページ目
E2E Review 11 ~ E2E Review 02
2ページ目
E2E Review 01
この固定fixtureが、後ほどページネーションや複数モーダルのテストで効いてきます。
PHPUnitとPlaywrightの責務を分ける
今回、まず意識したのが「既存PHPUnitと何を分担するか」です。
PHPUnitへ残すもの
PHPUnitでは、すでに次のようなサーバ側の保証があります。
- 認証・認可
- validation
- DB削除
- cascade
- rating cache更新
- redirect
- HTTP status
- session
- Blade属性やフォーム構造
- Paginatorの件数分割
例えば退会処理そのものはPHPUnitで確認済みなので、Playwrightから本当にユーザーを削除する必要はありません。
補足:Laravel 13公式では、テスト実行時はCSRF middlewareが自動的に無効化されます。そのため、ここでは「CSRF middlewareそのもの」をPHPUnit側の保証項目として数えていません。
@csrfによるhidden fieldなど、フォーム構造として確認する内容は別です。
Playwrightへ追加するもの
Playwrightでは、実ブラウザでなければ確認しにくい部分へ集中します。
- Alpine.js / JavaScriptの実動作
- モーダルのOpen / Close
- 初期フォーカス
-
Tab/Shift + Tab - focus trap
Escape- 背景クリック
- focus return
- 複数モーダルの状態分離
- 実際のリンククリックによるページ遷移
この分担にしたことで、E2Eテストを必要以上に大きくせずに済みました。
Playwrightではユーザー視点のLocatorを優先する
Playwrightでは、getByRole()、getByLabel()、getByText()を中心に使いました。
公式ドキュメント:
"Locators are the central piece of Playwright's auto-waiting and retry-ability."
また、Role Locatorについては、ユーザーや支援技術がページを認識する方法に近いLocatorとして、Playwright公式でも優先して使うことが推奨されています。
ただし、Role Locatorを使うこと自体がアクセシビリティ監査の代わりになるわけではありません。Playwright公式も、Role LocatorはARIAガイドラインに関する早期フィードバックにはなるものの、アクセシビリティ監査や適合性テストを置き換えるものではないと説明しています。
今回は、DBのIDやCSS selector、nth()を極力使わず、
レビュー本文
↓
article
↓
そのarticle内のボタン
↓
そのarticle内のdialog
という形で絞り込みます。
ARIAやaccessible nameを使ったLocatorがどの要素へ解決されるかを確認できるため、Playwright Inspectorでテストを追ったときも、要素の特定方法やfocus移動を理解しやすく感じました。
テスト構成
今回追加した主なファイルです。
e2e/
├── account-deletion-modal.spec.ts
├── global.setup.ts
├── pagination.spec.ts
├── review-deletion-modal.spec.ts
├── smoke.spec.ts
└── support/
└── login.ts
playwright.config.tsでは、E2E DB reset用のsetup projectを定義し、Chromium projectから依存させます。
概念的には次の流れです。
setup project
↓
e2e:reset
↓
chromium project
↓
各ブラウザテスト
Playwright公式でも、setup処理にはProject Dependencies方式が推奨されています。
公式ドキュメント:
"With project dependencies, you define a project that runs before all other projects."
suite開始前にE2E DBをresetする
ブラウザテストを毎回同じfixture状態から始めるため、setup projectからLaravel側の e2e:reset を呼び出します。
ローカル実行では、概ね次の経路です。
Playwright setup project
↓
docker compose exec
↓
laravel.e2e
↓
php artisan e2e:reset
↓
e2e_testingをreset
↓
E2eSeeder
e2e/global.setup.ts の考え方は次のようになります。
import { spawnSync } from 'node:child_process';
import { test } from '@playwright/test';
test('E2E専用データベースをresetする', () => {
const result = spawnSync(
'docker',
['compose', 'exec', '-T', 'laravel.e2e', 'php', 'artisan', 'e2e:reset'],
{ stdio: 'ignore' },
);
if (result.error !== undefined) {
throw new Error(
'E2E DB resetコマンドを起動できませんでした。Dockerとlaravel.e2e serviceの稼働状態を確認してください。',
);
}
if (result.status !== 0) {
throw new Error(
'E2E DB resetに失敗したため、ブラウザテストを中止します。laravel.e2e serviceとE2E専用DB設定を確認してください。',
);
}
});
resetに失敗した場合はChromiumテストへ進ませません。
つまり、
reset失敗
→ ブラウザテスト中止
というfail-closed構成です。
注:後続のCI統合では、このsetup処理にCI分岐を追加しています。本記事では#158時点のローカル実装を扱います。
各テストで通常のログイン画面を使う
今回は storageState を使いません。
Playwright Testは各テストごとに独立したBrowserContextを作成し、Cookie、localStorage、sessionStorageなどの状態を分離します。
公式ドキュメント:
今回も各テストは独立したBrowserContextから始め、通常のログイン画面を通します。
ログイン処理だけは小さなhelperへ切り出しました。
import { expect, type Page } from '@playwright/test';
const E2E_USER_PASSWORD = 'password';
export async function loginAs(page: Page, email: string): Promise<void> {
await page.goto('/login');
await page.getByLabel('メールアドレス', { exact: true }).fill(email);
await page.getByLabel('パスワード', { exact: true }).fill(E2E_USER_PASSWORD);
await page.getByRole('button', {
name: 'ログイン',
exact: true,
}).click();
await expect(page).toHaveURL('/');
}
fixtureのユーザーは、このプロジェクトの UserFactory の既定passwordと一致させています。
実ユーザーや本番credentialは利用しません。
退会確認モーダルをテストする
まず退会確認モーダルです。
この画面では、次を自動化しました。
- モーダルOpen
- role=dialog
- accessible name
- 初期フォーカス
- Tab / Shift+Tab
- focus trap
- Escape
- キャンセル
- 背景クリック
- Close後のfocus return
モーダルを開くhelper
const ACCOUNT_DIALOG_NAME = 'アカウントを削除して本当に大丈夫ですか?';
async function openAccountDeletionDialog(page: Page): Promise<{
dialog: Locator;
trigger: Locator;
}> {
const trigger = page.getByRole('button', {
name: 'アカウントを削除',
exact: true,
});
await trigger.click();
const dialog = page.getByRole('dialog', {
name: ACCOUNT_DIALOG_NAME,
});
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAccessibleName(ACCOUNT_DIALOG_NAME);
return { dialog, trigger };
}
HTMLを直接探すのではなく、
role=dialog
+
accessible name
で取得しています。
WAI-ARIAのModal Dialog Patternも確認する
今回確認する Tab / Shift + Tab のフォーカス循環や Escape によるCloseは、W3C WAI-ARIA Authoring Practices GuideのModal Dialog Patternも参考にしています。
公式ドキュメント:
Modal Dialog Patternでは、モーダルを開いたときにフォーカスをdialog内へ移し、Tab / Shift + Tab がdialog外へ抜けないこと、Escape でdialogを閉じることが示されています。また、dialogを閉じたあとは、多くの場合、起動元の要素へフォーカスを戻します。
ただし、初期フォーカスを具体的にどの要素へ置くかはdialogの内容や目的によって変わります。今回「現在のパスワード」へ初期フォーカスを置くのは、このアプリの退会モーダル実装として決めた動作です。
初期フォーカスとTab循環を確認する
退会モーダルのフォーカス順は次のとおりです。
現在のパスワード
↓
キャンセル
↓
アカウントを削除
↓
現在のパスワード
テストでは実際にキーボード入力を行います。
const { dialog, trigger } = await openAccountDeletionDialog(page);
const password = dialog.getByLabel('現在のパスワード');
const cancel = dialog.getByRole('button', {
name: 'キャンセル',
exact: true,
});
const deleteButton = dialog.getByRole('button', {
name: 'アカウントを削除',
exact: true,
});
await expect(password).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(deleteButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(password).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(deleteButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(password).toBeFocused();
単に「要素が存在する」ではなく、
ブラウザでキーを押す
↓
実際のfocus先をassertする
ところまで確認します。
Escapeで閉じて起動元へ戻る
続けて Escape を押します。
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
これで、
Escape
↓
modal Close
↓
起動ボタンへfocus return
を実ブラウザで保証できます。
キャンセルでも起動元へ戻る
await dialog
.getByRole('button', {
name: 'キャンセル',
exact: true,
})
.click();
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
Escapeとキャンセルの両方を別テストにして、失敗時にどのClose経路が壊れたか分かるようにしました。
背景クリックもテストする
手動確認していた背景クリックもE2Eへ入れました。
退会モーダルでは、role=dialog を持つ要素がモーダルパネル側にあるため、dialogのbounding boxからパネル外の位置を計算します。
const box = await dialog.boundingBox();
if (box === null || box.y <= 1) {
throw new Error(
'退会モーダルの背景クリック位置を特定できませんでした。',
);
}
await page.mouse.click(
box.x + box.width / 2,
box.y / 2,
);
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
背景クリック後も起動元へフォーカスが戻るところまで確認します。
本人レビュー削除モーダルをテストする
こちらは退会モーダルより少し難しくなりました。
本人レビュー一覧では1ページに複数のレビューがあり、それぞれが削除モーダルを持っています。
つまり、画面上には同じ名前の削除ボタンやdialogが複数存在します。
単純に、
page.getByRole('button', {
name: 'レビューを削除する',
});
とすると、一意に特定できません。
そこで、まずレビュー本文から対象の article を絞ります。
レビュー本文からarticleを絞り込む
function reviewArticle(
page: Page,
reviewBody: string,
): Locator {
return page
.getByRole('article')
.filter({
has: page.getByText(reviewBody, {
exact: true,
}),
});
}
そこから、そのレビュー内だけを探索します。
E2E Review 06
↓
article
↓
レビューを削除する button
↓
レビュー削除 dialog
なぜDOM先頭のレビューを使わなかったのか
最初は E2E Review 11 を操作対象にしていました。
しかしレビューで、
もし実装が壊れて
常に「先頭のレビュー」のtriggerへfocusを戻してしまった場合、
先頭レビューだけをテストしているとバグを検出できない
という問題が見つかりました。
そこで最終的には、
操作対象
→ E2E Review 06
比較対象
→ E2E Review 11
としました。
E2E Review 06 は1ページ目の途中にあるため、先頭要素へ誤ってフォーカス復帰するバグを検出できます。
対象レビューのモーダルだけを開く
const TARGET_REVIEW_BODY = 'E2E Review 06';
const article = reviewArticle(
page,
TARGET_REVIEW_BODY,
);
await expect(article).toHaveCount(1);
const trigger = article.getByRole('button', {
name: 'レビューを削除する',
exact: true,
});
await trigger.click();
const dialog = article.getByRole('dialog', {
name: 'レビューを削除しますか?',
});
await expect(dialog).toBeVisible();
DBのIDや nth() に依存しないため、テストの意図も読みやすくなりました。
別レビューのdialogがhiddenであることも確認する
比較対象として E2E Review 11 を取得します。
const otherArticle = reviewArticle(
page,
'E2E Review 11',
);
await expect(otherArticle).toHaveCount(1);
const otherTrigger = otherArticle.getByRole('button', {
name: 'レビューを削除する',
exact: true,
});
const otherDialog = otherArticle.getByRole('dialog', {
name: 'レビューを削除しますか?',
includeHidden: true,
});
await expect(otherDialog).toHaveCount(1);
対象モーダルを開いたあと、
await expect(page.getByRole('dialog')).toHaveCount(1);
await expect(otherDialog).toBeHidden();
とします。
これで、
E2E Review 06
→ open
E2E Review 11
→ hidden
を確認できます。
フォーカス循環を確認する
レビュー削除モーダルのTab順は次の構成です。
閉じる
↓
キャンセル
↓
削除する
↓
閉じる
初期フォーカスはキャンセルです。
const closeButton = dialog.getByRole('button', {
name: '閉じる',
exact: true,
});
const cancel = dialog.getByRole('button', {
name: 'キャンセル',
exact: true,
});
const deleteButton = dialog.getByRole('button', {
name: '削除する',
exact: true,
});
await expect(cancel).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(closeButton).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(deleteButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(closeButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(deleteButton).toBeFocused();
背景へフォーカスが抜けず、モーダル内を循環することを確認しています。
Close後のfocus returnが混線しないことを確認する
Escape後には、
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(otherDialog).toBeHidden();
await expect(trigger).toBeFocused();
await expect(otherTrigger).not.toBeFocused();
とします。
これで単に、
どこかの削除ボタンへ戻った
ではなく、
操作したE2E Review 06のtriggerへ戻った
E2E Review 11のtriggerへは戻っていない
ことまで確認できます。
複数のAlpine componentが存在する画面では、この確認がかなり重要でした。
キャンセル・閉じる・背景クリックも個別に確認
レビュー削除モーダルでは次の4テストに分けました。
1. focus循環 + Escape
2. キャンセル
3. 閉じるボタン
4. 背景クリック
背景クリックでは、dialog自体がviewport全体を覆うcontainerなので、パネル外にある左上padding領域をクリックします。
await dialog.click({
position: {
x: 4,
y: 4,
},
});
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
同じ「背景クリック」でも、退会モーダルとはDOM構造が違うため、クリック方法も分けています。
ページネーションも実ブラウザで確認する
ページネーションは、PHPUnitでも件数やPaginatorロジックをテストしています。
Playwrightでは同じロジックを重複テストするのではなく、
ユーザーが次ページリンクをクリック
↓
URLが変わる
↓
次ページのfixtureが表示される
というブラウザ操作を確認します。
作品一覧
fixtureを11件にしているため、10件 + 1件の2ページになります。
await page.goto('/items');
await expect(
page.getByRole('heading', {
name: 'E2E Movie 11',
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole('heading', {
name: 'E2E Movie 01',
exact: true,
}),
).toBeHidden();
ページネーションnavigationへscopeして次ページをクリックします。
const pagination = page.getByRole('navigation', {
name: 'ページネーション',
});
await pagination.getByRole('link', {
name: '次のページ',
exact: true,
}).click();
その後、
await expect(page).toHaveURL('/items?page=2');
await expect(
page.getByRole('heading', {
name: 'E2E Movie 01',
exact: true,
}),
).toBeVisible();
と確認します。
本人レビュー一覧
レビューも11件なので、2ページ目は固定した E2E Review 01 です。
await loginAs(
page,
'e2e-reviewer@example.test',
);
await page.goto('/my-reviews');
await expect(
page.getByText('E2E Review 11', {
exact: true,
}),
).toBeVisible();
await expect(
page.getByText('E2E Review 01', {
exact: true,
}),
).toBeHidden();
次ページリンクも、ページ全体から探さずnavigationへscopeします。
const pagination = page.getByRole('navigation', {
name: 'Pagination Navigation',
exact: true,
});
await pagination.getByRole('link', {
name: '次 »',
exact: true,
}).click();
遷移後は、
await expect(page).toHaveURL('/my-reviews?page=2');
await expect(
page.getByText('E2E Review 01', {
exact: true,
}),
).toBeVisible();
を確認します。
次 »というaccessible nameは、このプロジェクトのlang/ja/pagination.phpとLaravel既定pagination viewの組み合わせに由来する現行DOMの値です。HTML entityがaccessible nameへリテラルとして現れる点はUI側の別課題として扱い、本記事のPlaywright実装では現行DOMに合わせています。
固定時間待機を使わない
今回のテストでは、
await page.waitForTimeout(1000);
のような固定時間待機を入れていません。
PlaywrightのLocatorによるauto-waitingと、auto-retrying assertionsを利用します。
公式ドキュメント:
例えば、
await expect(dialog).toBeVisible();
await expect(trigger).toBeFocused();
await expect(page).toHaveURL('/');
のようなPlaywrightの非同期assertionは、条件が成立するまで再試行されます。また、locator.click() などの操作も、対象が操作可能な状態になるまでactionability checkを行ってから実行されます。
テストを「1秒待てば多分大丈夫」という作りにしないことを意識しました。
--headed でChromiumの動きを見る
通常のPlaywrightはheadlessで実行されます。
ブラウザ操作を実際に見たい場合は --headed を付けます。
公式ドキュメント:
npx playwright test \
e2e/account-deletion-modal.spec.ts \
--headed
実行結果:
4 passed
(setup project 1件 + 退会モーダル3件)
ただし、テストが速すぎてブラウザの動きはかなり一瞬でした。
--debug とPlaywright Inspectorが分かりやすい
動きを1ステップずつ確認するなら --debug が便利でした。
npx playwright test \
e2e/account-deletion-modal.spec.ts \
--debug
公式ドキュメント:
"The Playwright Inspector is a GUI tool to help you debug your Playwright tests."
--debug ではInspectorとheaded browserが開き、各操作をステップ実行できます。
今回特に分かりやすかったのが、
role
accessible name
Locator
focus移動
です。
Roleやaccessible nameを使ったLocatorがどの要素へ解決されるか、また実際のfocus移動をブラウザとInspectorを見ながら確認できました。
レビュー削除モーダルもdebug実行で確認しています。
npx playwright test \
e2e/review-deletion-modal.spec.ts \
--debug
実行結果:
5 passed
(setup project 1件 + レビュー削除モーダル4件)
最終的なテスト件数
今回の実装完了時点では次の構成になりました。
setup 1
smoke 1
退会modal 3
review modal 4
pagination 2
----------------
合計 11
通常実行:
npx playwright test
正常完走時:
11 passed
exit code 0
あわせて次も確認しています。
npm run typecheck:e2e
→ PASS
npm run build
→ PASS
git diff --check
→ PASS
AIレビューで改善したポイント
今回、実装後にCodexとClaude Codeで独立レビューを行いました。
特に大きかったのが、レビュー削除モーダルの操作対象です。
初期版では、
E2E Review 11
つまりDOM先頭のレビューを操作していました。
これでは、
壊れた実装が常に先頭のtriggerへfocusを戻す
というバグがあってもテストが通る可能性があります。
そこで最終的に、
操作対象
→ E2E Review 06
比較対象
→ E2E Review 11
へ変更しました。
さらに、
- 対象articleが1件存在する
- 比較articleが1件存在する
- 対象dialogだけvisible
- 比較dialogはhidden
- 操作対象triggerへfocus return
- 比較対象triggerはfocusedではない
まで確認するようにしています。
「テストが通る」だけでなく、どんな壊れ方を検出したいのかまで考える必要があると実感したポイントでした。
補足:WSL上のNode.js異常終了は別問題として分離
実装中、WSL上の /usr/bin/node が断続的にSIGSEGVし、Playwright自体が完走しないケースも確認しました。
一方、同じ#158のコードでPlaywrightが正常に開始・完走した実行では、setupを含む11件がすべてPASSしています。
そのため本記事では、
Node process crash
→ Playwrightテストは未完了
Playwrightが正常完走
→ テスト結果として評価
と切り分け、Node / WSL側の原因調査はブラウザテスト実装とは別テーマとして扱います。
まとめ
今回は、PHPUnitでは保証しにくかった実ブラウザ操作をPlaywrightで自動化しました。
実装した主な内容は次のとおりです。
- suite開始前のE2E DB reset
- 各testでUIログイン
- storageState不使用
- 退会モーダルのfocus制御
- Tab / Shift+Tab
- Escape
- キャンセル
- 背景クリック
- focus return
- 複数レビュー削除モーダルの状態分離
- 作品一覧ページネーション
- 本人レビュー一覧ページネーション
今回一番大きかった学びは、
PHPUnitとPlaywrightは競合するものではなく、責務を分けて使うと補完関係になる
という点でした。
PHPUnitにはサーバ側のロジックを任せ、Playwrightにはユーザーが実際にブラウザで行う操作を任せることで、テストの重複を抑えながら手動確認を減らせました。
また、Playwright Inspectorを使うことで、Roleやaccessible nameを使ったLocatorの解決先やfocus移動を確認しやすく、アクセシビリティを意識したUIの学習にも役立ちました。
次回は、今回作成したブラウザテストをGitHub Actionsへ統合し、CI上でもChromium E2Eを自動実行できるようにします。
参考
Playwright Locators
"Locators are the central piece of Playwright's auto-waiting and retry-ability."
Playwright Global setup and teardown
"With project dependencies, you define a project that runs before all other projects."
Playwright Debugging Tests
"The Playwright Inspector is a GUI tool to help you debug your Playwright tests."
Playwright Running and debugging tests
Playwright Auto-waiting
Playwright Assertions
Playwright Isolation / BrowserContext
WAI-ARIA APG Dialog (Modal) Pattern
Laravel 13 CSRF Protection
Playwright Command line