Playwright を使用した E2E テスト作成の統合ワークフロー。ページ遷移、フォーム操作、認証フロー、アクセシビリティ検証など、エンドツーエンドテスト全般で使用する。「E2Eテストを追加」「ページの動作をテスト」「認証フローをテスト」など、E2Eテスト作成時に使用。
Discalendar プロジェクトで Playwright E2E テストを作成する際の統合ワークフロー。
| # | 成果物 | パス | 必須 |
|---|---|---|---|
| 1 | E2Eテストファイル | e2e_tests/feature-name.spec.ts | Yes |
e2e_tests/機能名.spec.ts
auth.spec.ts, calendar-events.spec.ts, dashboard.spec.ts/**
* 機能名 E2Eテスト
*
* タスク番号: Task X.X
* 要件: 1.1, 2.1, ...
*
* Note: テストの概要説明
*/
import { expect, test } from "@playwright/test";
// Top-level regex patterns(パフォーマンス最適化)
const BUTTON_PATTERN = /ボタン名/;
const ERROR_PATTERN = /エラーメッセージ/;
test.describe("機能名E2Eテスト", () => {
test.describe("Task X.X: 小機能名", () => {
test("Req Y.Y: テストの説明", async ({ page }) => {
// テスト実装
});
});
});
test("ページ遷移が正しく動作する", async ({ page }) => {
// ページに移動
await page.goto("/target-path");
// URLの確認
expect(page.url()).toContain("/target-path");
// または waitForURL で非同期遷移を待つ
await page.waitForURL("**/expected-path");
});
test("未認証で保護ルートにアクセスするとリダイレクトされる", async ({
page,
}) => {
await page.goto("/protected-route");
// リダイレクト先を待つ
await page.waitForURL("**/auth/login");
expect(page.url()).toContain("/auth/login");
});
test("ボタンが表示されクリック可能", async ({ page }) => {
await page.goto("/page");
// ボタンを取得(アクセシブルな方法で)
const button = page.getByRole("button", { name: /ボタン名/ });
// 表示・有効状態を確認
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
});
test("フォーム入力と送信が動作する", async ({ page }) => {
await page.goto("/form-page");
// ダイアログ/フォームを開く
const openButton = page.getByRole("button", { name: /追加/ });
await openButton.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// フォームフィールドに入力
const titleInput = dialog.getByLabel(/タイトル/);
await titleInput.fill("テスト入力");
// 送信
const saveButton = dialog.getByRole("button", { name: /保存/ });
await saveButton.click();
});
test("バリデーションエラーが表示される", async ({ page }) => {
await page.goto("/form-page");
// フォームを開く
const dialog = page.getByRole("dialog");
// 空のまま送信
const saveButton = dialog.getByRole("button", { name: /保存/ });
await saveButton.click();
// エラーメッセージを確認
const errorText = dialog.getByText(/必須項目です/);
await expect(errorText).toBeVisible();
// ダイアログが閉じていないことを確認
await expect(dialog).toBeVisible();
});
test("URLパラメータでエラーメッセージが表示される", async ({ page }) => {
await page.goto("/page?error=error_code");
// Next.jsのroute-announcerを除外
const alert = page.locator('[role="alert"]:not(#__next-route-announcer__)');
await expect(alert).toBeVisible();
await expect(alert).toContainText(/エラーメッセージ/);
});
test("キーボードでナビゲート可能", async ({ page, browserName }) => {
await page.goto("/page");
const targetElement = page.getByRole("button", { name: /ボタン/ });
await targetElement.focus();
// webkitではフォーカステストをスキップ
if (browserName !== "webkit") {
await expect(targetElement).toBeFocused();
// Tabキーで次の要素に移動
await page.keyboard.press("Tab");
}
});
test("Escキーでダイアログが閉じる", async ({ page }) => {
// ダイアログを開く
// ...
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
});
test("正しいスタイルが適用されている", async ({ page }) => {
await page.goto("/page");
const element = page.getByRole("button", { name: /ボタン/ });
// 背景色を確認
const backgroundColor = await element.evaluate(
(el) => window.getComputedStyle(el).backgroundColor
);
// #5865F2 = rgb(88, 101, 242)
expect(backgroundColor).toBe("rgb(88, 101, 242)");
});
const OAUTH_REDIRECT_PATTERN = /supabase\.co|discord\.com/;
test("OAuthリダイレクトが発生する", async ({ page }) => {
await page.goto("/auth/login");
const loginButton = page.getByRole("button", { name: /ログイン/ });
await Promise.all([
page.waitForURL(OAUTH_REDIRECT_PATTERN, { timeout: 10_000 }),
loginButton.click(),
]);
const url = page.url();
expect(url.includes("supabase.co") || url.includes("discord.com")).toBe(true);
});
test.describe("モバイル対応検証", () => {
test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE
test("モバイル画面でも正しく表示される", async ({ page }) => {
await page.goto("/page");
const element = page.getByRole("button", { name: /ボタン/ });
await expect(element).toBeVisible();
// サイズを確認
const box = await element.boundingBox();
expect(box).not.toBeNull();
if (box) {
expect(box.width).toBeGreaterThanOrEqual(300);
}
});
});
test("アクセシブルなマークアップを持つ", async ({ page }) => {
await page.goto("/page");
// main要素の存在確認
const main = page.getByRole("main");
await expect(main).toBeVisible();
// 見出し階層の確認
const h1 = page.getByRole("heading", { level: 1 });
await expect(h1).toBeVisible();
// aria属性の確認
const requiredInput = page.getByLabel(/タイトル/);
await expect(requiredInput).toHaveAttribute("aria-required", "true");
});
getByRole, getByLabel, getByText を優先test.describe でタスク・機能ごとにグループ化Req X.X: プレフィックスで要件との紐付けを明示| 優先度 | セレクタ | 用途 |
|---|---|---|
| 1 | getByRole("button", { name: /text/ }) | ボタン、リンク、見出し |
| 2 | getByLabel(/ラベル/) | フォームフィールド |
| 3 | getByText(/テキスト/) | 表示テキスト |
| 4 | locator('[data-testid="id"]') | テスト専用属性 |
| 5 | locator('.class') | 最終手段 |
認証が必要な機能のテストでは、認証不要のテスト用ルートを使用:
// 認証不要のテストルート
const TEST_CALENDAR_PATH = "/test/calendar";
test("カレンダー機能のテスト", async ({ page }) => {
await page.goto(TEST_CALENDAR_PATH);
// ...
});
要素が存在しない場合のスキップパターン:
test("オプショナル機能のテスト", async ({ page }) => {
await page.goto("/page");
const optionalButton = page.getByRole("button", { name: /オプション/ });
const isVisible = await optionalButton.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// テスト続行
});
# 全テスト実行
npm run test:e2e
# 特定ファイルのテスト
npm run test:e2e -- e2e_tests/auth.spec.ts
# UIモードで実行(デバッグに便利)
npx playwright test --ui
# ヘッドフルモード(ブラウザ表示)
npx playwright test --headed
# 特定のブラウザのみ
npx playwright test --project=chromium
# デバッグモード
npx playwright test --debug
# トレース付き実行
npx playwright test --trace on
# HTMLレポートを開く
npx playwright show-report
playwright.config.ts の主要設定:
export default defineConfig({
testDir: "./e2e_tests",
fullyParallel: !process.env.CI, // ローカルは並列、CIは直列
retries: process.env.CI ? 2 : 0, // CIではリトライあり
workers: process.env.CI ? 1 : 2, // CIでは1ワーカー
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
/**
* 認証機能E2Eテスト
* Task 10.1: Discord認証機能のE2Eテスト
* Requirements: 1.1, 5.1, 6.2
*/
import { expect, test } from "@playwright/test";
const DISCORD_LOGIN_BUTTON_PATTERN = /discord.*ログイン/i;
test.describe("認証機能E2Eテスト", () => {
test.describe("Task 10.1: ログインページの表示検証", () => {
test("Req 1.1: Discordログインボタンが表示される", async ({ page }) => {
await page.goto("/auth/login");
const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toHaveText("ログイン");
const discordButton = page.getByRole("button", {
name: DISCORD_LOGIN_BUTTON_PATTERN,
});
await expect(discordButton).toBeVisible();
await expect(discordButton).toBeEnabled();
});
});
test.describe("Task 10.1: ルート保護検証", () => {
test("Req 5.1: 未認証で/dashboardにアクセスするとリダイレクト", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForURL("**/auth/login");
expect(page.url()).toContain("/auth/login");
});
});
});
/**
* フォーム操作E2Eテスト
* Task 9.5: カレンダーイベント管理
*/
import { expect, test } from "@playwright/test";
const ADD_BUTTON_PATTERN = /追加|新規/;
const TITLE_LABEL_PATTERN = /タイトル/;
const SAVE_BUTTON_PATTERN = /保存/;
test.describe("フォーム操作E2Eテスト", () => {
test("フォーム入力と保存が動作する", async ({ page }) => {
await page.goto("/test/calendar");
const toolbar = page.locator('[data-testid="calendar-toolbar"]');
const addButton = toolbar.getByRole("button", { name: ADD_BUTTON_PATTERN });
await addButton.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
const titleInput = dialog.getByLabel(TITLE_LABEL_PATTERN);
await titleInput.fill("E2Eテスト予定");
const saveButton = dialog.getByRole("button", { name: SAVE_BUTTON_PATTERN });
await expect(saveButton).toBeEnabled();
});
});
e2e_tests/ にテストファイルを作成npm run test:e2e で動作確認--debug や --ui で原因調査