メインコンテンツにスキップ

【2026年完全ガイド】Playwright E2Eテスト — Page Objectパターンと安定セレクタで壊れないテストを作る

Page Objectパターン、data-testidセレクタ、マルチブラウザ対応、CI連携、アクセシビリティ検証まで。Next.jsアプリで壊れにくいPlaywright E2Eテストを構築する2026年版の実践ガイド。

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.4k 語数|

単体テストは、個々の関数が正しく動作することを検証します。統合テストでは、サービスが連携して動作することを検証します。しかし、どちらも、ユーザーが実際にアプリケーションをクリックしたときに何が壊れるか、つまりナビゲーション フローの破損、JavaScript ハイドレーション エラー、ネットワーク依存のレンダリングの失敗、ブラウザ間レンダリングのバグを捕捉できません。 Playwright によるエンドツーエンドのテストはこのギャップを埋めており、Cypress や Selenium の前任者よりもはるかに信頼性が高くなります。

Playwright は、実際の Chromium、Firefox、WebKit ブラウザを並行して実行します。対話する前に、要素が表示されて安定するまで自動的に待機します。障害時のスクリーンショット、ビデオ、ネットワーク トレースをキャプチャします。 TypeScript ファーストの API により、保守可能なテストの作成が楽しくなります。このガイドでは、構成からページ オブジェクト、Next.js アプリケーションの CI 統合まで、すべてを説明します。

重要なポイント

  • data-testid セレクターを排他的に使用します — CSS セレクターとテキスト セレクターは UI の変更時に壊れます
  • ページ オブジェクト モデル (POM) はセレクターを再利用可能なクラスに抽象化します - UI 要素ごとに 1 つの変更場所
  • 常に await page.goto() + await expect(page).toHaveURL() を使用します。ナビゲーションが同期的に完了すると想定しないでください。
  • CI の実行を高速化するには、test.describe.parallel() および --workers フラグを使用します
  • playwright codegen を使用してテストを記録しますが、コミットする前に常にページ オブジェクトにリファクタリングします
  • 構成内の screenshot: 'only-on-failure' を介して失敗時にスクリーンショットをキャプチャする
  • @axe-core/playwright を使用して動作と並行してアクセシビリティをテストする
  • 不安定なサードパーティ呼び出しに対する API 応答を模擬します。クリティカルなハッピー パスには実際の API を使用する

構成

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'playwright-report/results.json' }],
    ...(process.env.CI ? [['github'] as ['github']] : []),
  ],
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

テストディレクトリ構造

tests/
  public/           # Tests for unauthenticated public pages
    homepage.spec.ts
    blog.spec.ts
    services.spec.ts
    sitemap.spec.ts
    i18n.spec.ts
    seo.spec.ts
  admin/            # Tests for authenticated admin pages
    contacts.spec.ts
    orders.spec.ts
    dashboard.spec.ts
  fixtures/
    auth.fixture.ts   # Authentication fixtures
    data.fixture.ts   # Test data factories
  pages/            # Page Object Model classes
    HomePage.ts
    BlogPage.ts
    ContactsPage.ts
    LoginPage.ts
  helpers/
    api.helper.ts    # API call helpers for test setup

ページオブジェクトモデル

ページ オブジェクト モデルは、保守可能な E2E テストにとって最も重要なパターンです。セレクターが変更されると、それを使用するすべてのテストではなく、1 か所が更新されます。

// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    this.page = page;
    // Always use data-testid selectors
    this.emailInput       = page.getByTestId('login-email');
    this.passwordInput    = page.getByTestId('login-password');
    this.submitButton     = page.getByTestId('login-submit');
    this.errorMessage     = page.getByTestId('login-error');
    this.forgotPasswordLink = page.getByTestId('forgot-password-link');
  }

  async goto() {
    await this.page.goto('/auth/login');
    await expect(this.page).toHaveURL('/auth/login');
    await expect(this.submitButton).toBeVisible();
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async loginAndWaitForRedirect(email: string, password: string, expectedUrl: string) {
    await this.login(email, password);
    await expect(this.page).toHaveURL(expectedUrl, { timeout: 10_000 });
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toBeVisible();
    await expect(this.errorMessage).toContainText(message);
  }
}
// tests/pages/ContactsPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class ContactsPage {
  readonly page: Page;
  readonly addButton: Locator;
  readonly searchInput: Locator;
  readonly table: Locator;
  readonly dialog: Locator;
  readonly nameInput: Locator;
  readonly emailInput: Locator;
  readonly saveButton: Locator;
  readonly successToast: Locator;

  constructor(page: Page) {
    this.page        = page;
    this.addButton   = page.getByTestId('contacts-add-button');
    this.searchInput = page.getByTestId('contacts-search');
    this.table       = page.getByTestId('contacts-table');
    this.dialog      = page.getByTestId('contact-dialog');
    this.nameInput   = page.getByTestId('contact-name-input');
    this.emailInput  = page.getByTestId('contact-email-input');
    this.saveButton  = page.getByTestId('contact-save-button');
    this.successToast = page.getByTestId('toast-success');
  }

  async goto() {
    await this.page.goto('/dashboard/contacts');
    await expect(this.table).toBeVisible();
  }

  async openAddDialog() {
    await this.addButton.click();
    await expect(this.dialog).toBeVisible();
  }

  async createContact(name: string, email: string) {
    await this.openAddDialog();
    await this.nameInput.fill(name);
    await this.emailInput.fill(email);
    await this.saveButton.click();
    await expect(this.successToast).toBeVisible();
  }

  async searchFor(query: string) {
    await this.searchInput.fill(query);
    await this.page.waitForTimeout(500); // Debounce delay
  }

  async getRowCount() {
    return this.table.getByRole('row').count();
  }

  async expectContactInTable(name: string) {
    await expect(this.table).toContainText(name);
  }
}

認証フィクスチャ

// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';

type AuthFixture = {
  adminPage: Page;
  userPage: Page;
};

export const test = base.extend<AuthFixture>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();

    // Login via API to bypass UI (faster, more reliable)
    const response = await page.request.post('/auth/login', {
      data: {
        email: process.env.TEST_ADMIN_EMAIL || 'admin@test.com',
        password: process.env.TEST_ADMIN_PASSWORD || 'testpassword123',
      },
    });

    if (!response.ok()) {
      throw new Error(`Auth failed: ${response.status()}`);
    }

    // Cookies are set automatically on the context
    await use(page);
    await context.close();
  },

  userPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();

    await page.request.post('/auth/login', {
      data: {
        email: process.env.TEST_USER_EMAIL || 'user@test.com',
        password: process.env.TEST_USER_PASSWORD || 'testpassword123',
      },
    });

    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

テストの作成

// tests/admin/contacts.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { ContactsPage } from '../pages/ContactsPage';

test.describe('Contacts Management', () => {
  test('admin can create, search, and delete a contact', async ({ adminPage }) => {
    const contactsPage = new ContactsPage(adminPage);
    const testName = `Test User ${Date.now()}`;
    const testEmail = `test-${Date.now()}@example.com`;

    await contactsPage.goto();

    // Create
    await contactsPage.createContact(testName, testEmail);
    await contactsPage.expectContactInTable(testName);

    // Search
    await contactsPage.searchFor(testName);
    const rowCount = await contactsPage.getRowCount();
    expect(rowCount).toBe(2); // 1 header + 1 result

    // Delete
    await adminPage.getByTestId(`contact-delete-${testEmail}`).click();
    await adminPage.getByTestId('confirm-delete').click();
    await expect(contactsPage.successToast).toBeVisible();
    await expect(contactsPage.table).not.toContainText(testName);
  });

  test('shows validation errors for invalid contact data', async ({ adminPage }) => {
    const contactsPage = new ContactsPage(adminPage);
    await contactsPage.goto();
    await contactsPage.openAddDialog();

    // Submit without filling required fields
    await contactsPage.saveButton.click();

    await expect(adminPage.getByTestId('error-name')).toContainText('required');
    await expect(adminPage.getByTestId('error-email')).toContainText('required');
  });
});

公開ページのテスト

// tests/public/homepage.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Homepage', () => {
  test('renders hero section with correct heading', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveTitle(/ECOSIRE/);

    const hero = page.getByTestId('hero-heading');
    await expect(hero).toBeVisible();
    await expect(hero).not.toBeEmpty();
  });

  test('navigation links are all accessible', async ({ page }) => {
    await page.goto('/');

    const navLinks = page.getByRole('navigation').getByRole('link');
    const count = await navLinks.count();

    for (let i = 0; i < count; i++) {
      const link = navLinks.nth(i);
      const href = await link.getAttribute('href');
      // All nav links should have valid hrefs
      expect(href).toBeTruthy();
      expect(href).not.toBe('#');
    }
  });

  test('CTA button links to services page', async ({ page }) => {
    await page.goto('/');
    await page.getByTestId('hero-cta').click();
    await expect(page).toHaveURL(/\/services/);
  });
});

i18n テスト

// tests/public/i18n.spec.ts
import { test, expect } from '@playwright/test';

const testLocales = ['zh', 'es', 'ar', 'fr'];

test.describe('Internationalization', () => {
  for (const locale of testLocales) {
    test(`/${locale}/ renders with correct lang attribute`, async ({ page }) => {
      await page.goto(`/${locale}/`);

      const html = page.locator('html');
      await expect(html).toHaveAttribute('lang', locale);
    });
  }

  test('Arabic locale sets dir="rtl"', async ({ page }) => {
    await page.goto('/ar/');
    const html = page.locator('html');
    await expect(html).toHaveAttribute('dir', 'rtl');
  });

  test('language switcher changes locale', async ({ page }) => {
    await page.goto('/');
    await page.getByTestId('language-switcher').click();
    await page.getByTestId('locale-option-fr').click();
    await expect(page).toHaveURL('/fr/');
    await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
  });

  test('hreflang tags are present on all pages', async ({ page }) => {
    await page.goto('/');
    const hreflangs = await page.locator('link[rel="alternate"][hreflang]').all();
    // Should have 11 locale hreflangs + x-default
    expect(hreflangs.length).toBeGreaterThanOrEqual(12);
  });
});

アクセシビリティテスト

// tests/public/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility', () => {
  const pages = ['/', '/blog', '/services', '/about', '/contact'];

  for (const path of pages) {
    test(`${path} has no WCAG 2.1 AA violations`, async ({ page }) => {
      await page.goto(path);

      const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
        .analyze();

      if (results.violations.length > 0) {
        const report = results.violations.map((v) =>
          `${v.id}: ${v.description} — ${v.nodes.length} element(s)`
        ).join('\n');
        expect.fail(`Accessibility violations on ${path}:\n${report}`);
      }
    });
  }

  test('skip link is the first focusable element', async ({ page }) => {
    await page.goto('/');
    await page.keyboard.press('Tab');
    const focused = page.locator(':focus');
    await expect(focused).toHaveAttribute('href', '#main-content');
  });
});

CI の統合

# .github/workflows/ci.yml (E2E section)
e2e-tests:
  runs-on: ubuntu-latest
  needs: [unit-tests, build]
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v3
    - uses: actions/setup-node@v4
      with:
        node-version: 22
        cache: 'pnpm'
    - run: pnpm install --frozen-lockfile

    - name: Install Playwright browsers
      run: cd apps/web && npx playwright install --with-deps chromium firefox

    - name: Run E2E tests
      run: cd apps/web && npx playwright test --project=chromium --project=firefox
      env:
        PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
        TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
        TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}

    - uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: playwright-report
        path: apps/web/playwright-report/
        retention-days: 7

よくある質問

data-testid、ARIA ロール、テキスト セレクターをどのような場合に使用する必要がありますか?

data-testid をプライマリ セレクターとして優先します。これは、UI の再設計、コピーの変更、およびリファクタリングの後も存続します。アクセシビリティを同時にテストするには、ARIA ロール セレクター (getByRole('button', { name: 'Submit' })) を使用します。テキスト コンテンツ セレクター (getByText('Submit')) は、コピーが変更されると壊れるため、避けてください。 CSS クラス セレクターは決して使用しないでください。CSS クラス セレクターは、スタイル設定作業によって変更される実装の詳細です。

CI ではいくつのプロジェクト (ブラウザ) を実行する必要がありますか?

プル リクエストの場合は、CI の高速性を維持するためにのみ Chromium を実行してください。メイン ブランチのマージの場合は、3 つのデスクトップ ブラウザー (Chromium、Firefox、WebKit) をすべて実行します。リリース候補の場合は、モバイル ビューポートを追加します。 WebKit は Safari 固有のレイアウトのバグを捕捉します。 Firefox は、Chrome と Safari の両方が隠している癖を見つけます。階層化により、リリース前にクロスブラウザーのバグを検出しながら、CI 時間を節約できます。

タイミングの問題が原因で不安定なテストを処理するにはどうすればよいですか?

Playwright の自動待機は、ほとんどのタイミング問題を処理します。page.waitForTimeout() ではなく await expect(locator).toBeVisible() を使用してください。自動待機が十分でない場合は、ネットワーク依存状態には page.waitForResponse()、重いページには page.waitForLoadState('networkidle')、カスタム条件には page.waitForFunction() を使用します。 CI で retries: 2 を設定して、本物の障害とインフラストラクチャの脆弱性を区別します。

E2E テストでは実際のデータベースを使用する必要がありますか? またはシードされたテスト データベースを使用する必要がありますか?

E2Eテストには、シードされたテスト・データベースを備えた専用のステージング環境を使用します。運用環境に対して E2E テストを実行しないでください。 CI の場合は、テスト データをシードした新しいデータベースを使用して Docker Compose スタックをスピンアップします。ドリフトする可能性のある事前にシードされたデータに依存するのではなく、beforeAll/afterAll フックの API 呼び出しを介してテスト データを作成およびクリーンアップします。

Playwright でファイルのダウンロードをテストするにはどうすればよいですか?

page.waitForDownload() パターン: const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy(); を使用します。 download.suggestedFilename() をチェックし、ファイルの内容を読んで検証することもできます。


次のステップ

Playwright E2E テストは、ユーザーがアプリケーションを目にする前の最後のセーフティ ネットです。ページ オブジェクト、データテスト ID セレクター、認証フィクスチャ、CI 統合などを適切に投資すると、単体テストで見逃した部分が検出され、自信を持って金曜日にデプロイできるようになります。

ECOSIRE は、公開ページ、管理ワークフロー、11 ロケールすべてのアクセシビリティにわたる 416 以上のテスト ケースをカバーする 29 の Playwright E2E テスト ファイルを維持しています。 フロントエンド エンジニアリング サービスを探索 して、品質を重視した Next.js アプリケーションを構築する方法をご覧ください。

E

執筆者

ECOSIRE Research and Development Team

テクニカルライティング

ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。

ECOSIRE

ECOSIRE でビジネスを成長させましょう

ERP、eコマース、AI、分析、自動化にわたるエンタープライズ ソリューション。