Playwright フレーキーE2Eテスト対策完全ガイド|auto-wait・分離・Trace Viewer・リトライ・ネットワークモック

(更新: 2026年6月22日 ) Playwright E2E テスト TypeScript CI/CD QA
結論

Playwright E2Eのフレーキー対策は auto-waitingを信頼し、固定sleepを排除 し、テスト間の状態を完全分離 し、外部ネットワークをモック し、失敗時だけTrace Viewerを残す の5点セットが2026年の定石。locator + expect() を基本に、playwright.config.tsretries はCI限定の深層防御として使い、根本原因はトレースとメトリクスで潰す。

フレーキーE2Eテストがチームを蝕む理由

E2E(End-to-End)テストは、本番に近い環境でユーザーフロー全体を検証できる強力な安全網です。一方で フレーキー(flaky) — 同じコミットなのに green になったり red になったりするテスト — が混ざると、チームは次の悪循環に陥ります。

  1. CIが赤い → 「またフレーキーでしょ」とスルーされる
  2. 本当の回帰がmergeされる
  3. テストへの信頼が下がり、@skip や test.fixme が増える
  4. E2Eカバレッジが下がり、本番障害が増える

2026年時点で Playwright は Cypress や Selenium 系と並び主流の E2E フレームワークの一つですが、フレームワークを選んでもフレーキーは自動では消えません。Playwright が提供する auto-waiting・Trace Viewer・network routing を 設計として組み込む ことが、安定したパイプラインの前提になります。

フレーキーが多いチーム 対策済みチーム
sleep(3000) が everywhere expect(locator).toBeVisible() の条件待機
全テストが同一 admin でログイン worker ごとに独立ユーザー・テナント
本番 API を E2E から直叩き route.fulfill で決定的レスポンス
失敗ログはスクショ1枚だけ Trace Viewer で DOM・ネットワーク再生
リトライ無制限で green 偽装 リトライ率メトリクス + quarantine 運用

Playwrightのauto-waiting:フレーキー削減の中核

Playwright の最大の強みの一つが auto-waiting(自動待機) です。locator.click()locator.fill() は、内部的に次の条件が満たされるまで待ってから操作します。

  • 要素が DOM に存在する
  • 要素が表示されている(visible)
  • 要素が stable(アニメーション等で動いていない)
  • 要素が receive events 可能(他要素に覆われていない)
  • 要素が enabled(disabled でない)

従来の Selenium 系で必要だった明示的 WebDriverWait の多くが、Playwright では Locator API に組み込まれています。これを理解せず page.waitForTimeout(2000) で「なんとなく待つ」コードを書くと、環境差でフレーキーが発生します。

Locator vs 生のセレクタ

// ❌ フレーキーの温床:DOM がまだ無い・覆われている・disabled のまま click する
await page.click('#submit-button');

// ✅ Playwright 推奨:auto-wait + 自動リトライ付き assertion
const submitButton = page.getByRole('button', { name: '注文を確定する' });
await expect(submitButton).toBeEnabled();
await submitButton.click();

getByRolegetByLabelgetByTestId など ユーザーが知覚する属性 で要素を特定すると、CSS クラス変更に強く、テスト意図も読みやすくなります。2026年の Playwright ドキュメントも Testing Library と同様の Testing Library 哲学 を推奨しています。

expect による Web-first assertions

expect(locator).toBeVisible() のような Web-first assertion は、条件が真になるまで自動リトライします(デフォルト timeout は 5 秒、設定で変更可能)。

import { test, expect } from '@playwright/test';

test('ダッシュボードに売上グラフが表示される', async ({ page }) => {
  await page.goto('/dashboard');

  // API レスポンス後にグラフコンテナが描画されるまで待つ
  const chart = page.getByTestId('sales-chart');
  await expect(chart).toBeVisible({ timeout: 15_000 });

  // テキスト内容もリトライ付きで検証
  await expect(page.getByRole('heading', { level: 2 })).toHaveText('今月の売上');
});

固定 sleep との決定的な違いは、条件が早く満たされれば即座に次へ進む 点です。CI が遅い日でも必要なだけ待ち、速い日は無駄な待ち時間がありません。

waitForTimeout は eslint で禁止する

@typescript-eslint/no-restricted-syntax やカスタム ESLint ルールで page.waitForTimeout の使用を PR でブロックするチームが増えています。どうしてもイベントが無いレガシー UI だけ、コメント付きで例外許可リストに載せる運用が現実的です。

待機の優先順位チェックリスト

フレーキー修正時は、次の順で待機手段を検討してください。

1

getByRole / getByLabel 等の Locator で要素を特定する

2

expect(locator).toBeVisible() / toHaveText() 等で UI 状態を待つ

3

page.waitForResponse() で特定 API の完了を待つ(ネットワークモックと併用可)

4

page.waitForLoadState('networkidle') は SPA では乱れやすいので慎重に

5

page.waitForTimeout() は最後の手段(原則禁止)

playwright.config.ts:2026年推奨の完全設定

プロジェクトルートの playwright.config.ts は、フレーキー対策の 司令塔 です。以下は TypeScript 完全例です(省略なし)。

import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';

const CI = !!process.env.CI;
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173';

export default defineConfig({
  testDir: path.join(__dirname, 'e2e'),
  fullyParallel: true,
  forbidOnly: CI,
  retries: CI ? 2 : 0,
  workers: CI ? 4 : undefined,
  timeout: 60_000,
  expect: {
    timeout: 10_000,
  },
  reporter: CI
    ? [
        ['github'],
        ['html', { open: 'never', outputFolder: 'playwright-report' }],
        ['json', { outputFile: 'test-results/results.json' }],
        ['junit', { outputFile: 'test-results/junit.xml' }],
      ]
    : [['list'], ['html', { open: 'on-failure' }]],

  use: {
    baseURL: BASE_URL,
    trace: CI ? 'on-first-retry' : 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: CI ? 'on-first-retry' : 'off',
    actionTimeout: 15_000,
    navigationTimeout: 30_000,
    locale: 'ja-JP',
    timezoneId: 'Asia/Tokyo',
    extraHTTPHeaders: {
      'Accept-Language': 'ja',
    },
  },

  globalSetup: path.join(__dirname, 'e2e/global-setup.ts'),
  globalTeardown: path.join(__dirname, 'e2e/global-teardown.ts'),

  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'e2e/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'e2e/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: {
        ...devices['Desktop Safari'],
        storageState: 'e2e/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-chrome',
      use: {
        ...devices['Pixel 7'],
        storageState: 'e2e/.auth/user-mobile.json',
      },
      dependencies: ['setup'],
    },
  ],

  webServer: {
    command: CI ? 'npm run preview' : 'npm run dev',
    url: BASE_URL,
    reuseExistingServer: !CI,
    timeout: 120_000,
    stdout: 'pipe',
    stderr: 'pipe',
  },
});

設定項目の読み解き

設定フレーキー対策での役割
retries: CI ? 2 : 0ローカルは即失敗で原因調査、CI は一時障害を吸収
trace: 'on-first-retry'リトライ1回目でトレース取得。ストレージ節約
fullyParallel: true並列化の恩恵。ただし テスト分離が必須
webServerサーバー起動待ちを Playwright に任せ、race condition を減らす
actionTimeout / navigationTimeout無限待ちを防ぎ、失敗を早く確定させる
networkidle に頼らない

waitForLoadState('networkidle') はバックグラウンド polling や Analytics ビーコンがある SPA では いつまでも idle にならず フレーキーの原因になります。特定 UI または waitForResponse で待つのが安全です。

テスト分離:並列実行の前提条件

fullyParallel: true を有効にすると、テストファイル内のケースすら並列実行されます。共有状態 があるとフレーキーは確実に増えます。

分離すべき状態一覧

  • 認証セッション — Cookie / localStorage / IndexedDB
  • サーバー側データ — 同じユーザーIDに対する同時 POST
  • 時刻依存Date.now() 固定(page.addInitScript でモック)
  • Feature Flag — LaunchDarkly 等の共有フラグ
  • ファイルシステム — アップロードテストの同名ファイル競合

globalSetup と auth setup プロジェクト

認証状態を毎テスト login 画面から作ると遅く、並列時にレートリミットで落ちることがあります。Playwright 公式パターンの setup project + storageState を使います。

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';

const authFile = path.join(__dirname, '.auth/user.json');

setup('authenticate as standard user', async ({ page, request }) => {
  // API でテストユーザーを確実に作成(冪等)
  const email = `e2e-user-${process.env.TEST_WORKER_INDEX ?? '0'}@example.test`;
  const password = process.env.E2E_USER_PASSWORD ?? 'TestPassword123!';

  const registerResponse = await request.post('/api/v1/test/seed-user', {
    data: {
      email,
      password,
      displayName: 'E2E Standard User',
    },
  });

  if (registerResponse.status() === 409) {
    // 既存ユーザー — 問題なし
  } else {
    expect(registerResponse.ok()).toBeTruthy();
  }

  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill(email);
  await page.getByLabel('パスワード').fill(password);
  await page.getByRole('button', { name: 'ログイン' }).click();

  await expect(page.getByTestId('user-menu')).toBeVisible();

  await page.context().storageState({ path: authFile });
});
// e2e/global-setup.ts
import type { FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig): Promise<void> {
  const baseURL = config.projects[0].use.baseURL ?? 'http://127.0.0.1:4173';

  const healthCheck = await fetch(`${baseURL}/api/health`);
  if (!healthCheck.ok) {
    throw new Error(`Application not ready: ${healthCheck.status} ${baseURL}/api/health`);
  }

  // テスト用 DB マイグレーション(CI コンテナ内のみ)
  if (process.env.CI) {
    const migrate = await fetch(`${baseURL}/api/test/migrate`, { method: 'POST' });
    if (!migrate.ok) {
      throw new Error(`Test DB migration failed: ${migrate.status}`);
    }
  }

  console.log('[global-setup] Application ready for E2E');
}

export default globalSetup;
// e2e/global-teardown.ts
import type { FullConfig } from '@playwright/test';

async function globalTeardown(config: FullConfig): Promise<void> {
  const baseURL = config.projects[0].use.baseURL ?? 'http://127.0.0.1:4173';

  if (process.env.CI) {
    const cleanup = await fetch(`${baseURL}/api/test/cleanup`, { method: 'POST' });
    if (!cleanup.ok) {
      console.warn(`[global-teardown] cleanup returned ${cleanup.status}`);
    }
  }

  console.log('[global-teardown] E2E teardown complete');
}

export default globalTeardown;

Worker-scoped fixture でデータを分離

並列 worker ごとにユニークなリソース名を渡す fixture パターンです。

// e2e/fixtures/isolated-user.ts
import { test as base, expect } from '@playwright/test';

type IsolatedUserFixtures = {
  isolatedUser: {
    email: string;
    password: string;
    projectName: string;
  };
};

export const test = base.extend<IsolatedUserFixtures>({
  isolatedUser: async ({ request }, use, testInfo) => {
    const workerIndex = testInfo.parallelIndex;
    const timestamp = Date.now();
    const email = `worker-${workerIndex}-${timestamp}@example.test`;
    const password = 'IsolatedTestPass456!';
    const projectName = `proj-w${workerIndex}-${timestamp}`;

    const response = await request.post('/api/v1/projects', {
      data: { name: projectName, ownerEmail: email },
    });
    expect(response.ok()).toBeTruthy();

    await use({ email, password, projectName });

    // teardown: プロジェクト削除(失敗しても他テストに影響しないよう try)
    await request.delete(`/api/v1/projects/${encodeURIComponent(projectName)}`).catch(() => {
      /* cleanup best-effort */
    });
  },
});

export { expect } from '@playwright/test';
// e2e/projects/create-project.spec.ts
import { test, expect } from '../fixtures/isolated-user';

test('新規プロジェクトを作成できる', async ({ page, isolatedUser }) => {
  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill(isolatedUser.email);
  await page.getByLabel('パスワード').fill(isolatedUser.password);
  await page.getByRole('button', { name: 'ログイン' }).click();

  await page.goto('/projects/new');
  await page.getByLabel('プロジェクト名').fill(isolatedUser.projectName);
  await page.getByRole('button', { name: '作成' }).click();

  await expect(page.getByRole('heading', { level: 1 })).toHaveText(isolatedUser.projectName);
});
APIエラー時のUIもテストする

分離したユーザーで API が 422 / 409 を返すケースは、フロントのエラー表示が APIエラーハンドリング設計 に沿っているかも E2E で確認すると、本番障害時の UX 劣化を防げます。

Trace Viewer:フレーキー調査の最強ツール

Trace Viewer は、テスト実行の タイムマシン です。各アクション前後の DOM スナップショット、ネットワークログ、コンソール、ソースマップ付きスタックが1つの .zip に記録されます。

トレースの有効化と閲覧

# 失敗テストのトレースを表示
npx playwright show-trace test-results/artifacts/.../trace.zip

# 最新レポートから開く
npx playwright show-report

CI では GitHub Actions のアーティファクトとして playwright-report/test-results/ をアップロードします。

フレーキー調査ワークフロー

1

CI ログで flaky だったテスト名とリトライ回数を特定する

2

on-first-retry で保存された trace.zip をダウンロードする

3

Timeline で click 直前の DOM を確認 — ボタンが disabled / 覆われていないか

4

Network タブで API latency とレスポンス body を確認

5

Console に CORS / 422 エラーが出ていないか確認する

6

原因が wait 不足なら expect 追加、状態汚染なら fixture 分離、外部障害なら route モック

デバッグ用 trace のローカル設定

// playwright.debug.config.ts — ローカルフレーキー再現専用
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';

export default defineConfig({
  ...baseConfig,
  retries: 0,
  workers: 1,
  use: {
    ...baseConfig.use,
    trace: 'on',
    video: 'on',
    launchOptions: {
      slowMo: 100,
    },
  },
});
npx playwright test e2e/checkout/flaky-spec.ts --config=playwright.debug.config.ts

1 worker・slowMo・常時 trace で 競合を排除した再現 ができます。再現したら設定を戻し、並列環境でも通る修正(分離・モック・assert 強化)を入れます。

リトライ設定:深層防御とメトリクス

retries治療 ではなく 包帯 です。使い方を間違えると、フレーキーが本番まで届く穴になります。

CI 向け GitHub Actions 完全例

// .github/workflows/e2e.yml 内の jobs 定義(YAML ではなく actions 内スクリプト用 TS ヘルパー例)
// scripts/run-e2e-ci.ts
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

interface PlaywrightJsonReport {
  suites: Array<{
    specs: Array<{
      title: string;
      tests: Array<{
        results: Array<{
          retry: number;
          status: 'passed' | 'failed' | 'timedOut' | 'skipped';
        }>;
      }>;
    }>;
  }>;
}

function main(): void {
  const shardIndex = process.env.SHARD_INDEX ?? '1';
  const shardTotal = process.env.SHARD_TOTAL ?? '1';

  const result = spawnSync(
    'npx',
    [
      'playwright',
      'test',
      `--shard=${shardIndex}/${shardTotal}`,
      '--reporter=json,html,junit',
    ],
    {
      stdio: 'inherit',
      env: {
        ...process.env,
        CI: 'true',
      },
    },
  );

  const reportPath = path.join(process.cwd(), 'test-results/results.json');
  if (fs.existsSync(reportPath)) {
    const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8')) as PlaywrightJsonReport;
    let totalTests = 0;
    let retriedPassed = 0;

    for (const suite of report.suites) {
      for (const spec of suite.specs) {
        for (const test of spec.tests) {
          totalTests += 1;
          const last = test.results.at(-1);
          const hadRetry = test.results.some((r) => r.retry > 0);
          if (hadRetry && last?.status === 'passed') {
            retriedPassed += 1;
            console.log(`[flaky-recovered] ${spec.title}`);
          }
        }
      }
    }

    const flakyRate = totalTests > 0 ? (retriedPassed / totalTests) * 100 : 0;
    console.log(`Flaky recovery rate: ${flakyRate.toFixed(2)}% (${retriedPassed}/${totalTests})`);

    // 閾値超えで warn(ジョブは成功のまま — 別ダッシュボードで可視化も可)
    if (flakyRate > 2) {
      console.warn('::warning::E2E flaky rate exceeds 2%. Review Trace artifacts.');
    }
  }

  process.exit(result.status ?? 1);
}

main();

リトライ運用のルール例

ルール内容
ローカル retries: 0開発者は即座に失敗を見て Trace を開く
CI retries: 2コンテナ起動・DB seed の一時遅延を吸収
flaky rate > 2%スプリント内に修正 Issue を必須起票
3スプリント未修正test.fixme() で quarantine し、カバレッジレポートに明示
merge ブロックRequired Check は 最終結果 の pass/fail のみ

ネットワークモック:外部依存を決定的にする

E2E から本番 Stripe・Google Maps・社内別チーム API を叩くと、ネットワーク・レートリミット・データ変動 がフレーキーの温床になります。Playwright の page.route() / context.route() で HTTP をインターセプトし、route.fulfill() で固定レスポンスを返します。

REST API のモック完全例

// e2e/mocks/handlers/orders.ts
import type { Page, Route } from '@playwright/test';

export interface MockOrder {
  id: string;
  status: 'pending' | 'paid' | 'shipped';
  totalAmount: number;
  currency: 'JPY';
}

const defaultOrders: MockOrder[] = [
  { id: 'ord_001', status: 'paid', totalAmount: 9800, currency: 'JPY' },
  { id: 'ord_002', status: 'pending', totalAmount: 1200, currency: 'JPY' },
];

export async function mockOrdersApi(page: Page, orders: MockOrder[] = defaultOrders): Promise<void> {
  await page.route('**/api/v1/orders', async (route: Route) => {
    const method = route.request().method();

    if (method === 'GET') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        headers: {
          'Cache-Control': 'no-store',
          'X-Request-Id': 'e2e-mock-get-orders',
        },
        body: JSON.stringify({
          data: orders,
          meta: { total: orders.length, page: 1, perPage: 20 },
        }),
      });
      return;
    }

    if (method === 'POST') {
      const postBody = route.request().postDataJSON() as { productId: string; quantity: number };
      const created: MockOrder = {
        id: `ord_${Date.now()}`,
        status: 'pending',
        totalAmount: postBody.quantity * 1000,
        currency: 'JPY',
      };
      await route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({ data: created }),
      });
      return;
    }

    await route.continue();
  });
}

エラーレスポンスのモック(Problem Details)

APIエラーハンドリング設計 で定義した application/problem+json 形式を E2E でも再現し、UI のエラー分岐を安定させます。

// e2e/mocks/handlers/problem-details.ts
import type { Page, Route } from '@playwright/test';

export async function mockInsufficientStock(page: Page): Promise<void> {
  await page.route('**/api/v1/checkout', async (route: Route) => {
    if (route.request().method() !== 'POST') {
      await route.continue();
      return;
    }

    await route.fulfill({
      status: 422,
      contentType: 'application/problem+json',
      body: JSON.stringify({
        type: 'https://api.example.com/problems/insufficient-stock',
        title: 'Insufficient Stock',
        status: 422,
        detail: '商品 SKU-123 の在庫が不足しています。',
        instance: '/requests/e2e-req-insufficient-stock',
        error_code: 'INSUFFICIENT_STOCK',
        remaining: 0,
      }),
    });
  });
}
// e2e/checkout/insufficient-stock.spec.ts
import { test, expect } from '@playwright/test';
import { mockInsufficientStock } from '../mocks/handlers/problem-details';

test.beforeEach(async ({ page }) => {
  await mockInsufficientStock(page);
});

test('在庫不足時にエラーバナーと error_code が表示される', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'レジに進む' }).click();

  const alert = page.getByRole('alert');
  await expect(alert).toBeVisible();
  await expect(alert).toContainText('在庫が不足');
  await expect(page.getByTestId('error-code')).toHaveText('INSUFFICIENT_STOCK');
});

GraphQL と WebSocket のモック

// e2e/mocks/handlers/graphql-ws.ts
import type { Page } from '@playwright/test';

export async function mockGraphQL(page: Page): Promise<void> {
  await page.route('**/graphql', async (route) => {
    const postData = route.request().postDataJSON() as {
      operationName: string;
      variables: Record<string, unknown>;
    };

    if (postData.operationName === 'NotificationsFeed') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: {
            notifications: [
              { id: 'n1', message: 'E2E mock notification', read: false },
            ],
          },
        }),
      });
      return;
    }

    await route.continue();
  });
}

export async function mockNotificationWebSocket(page: Page): Promise<void> {
  await page.addInitScript(() => {
    class MockWebSocket extends EventTarget {
      static CONNECTING = 0;
      static OPEN = 1;
      static CLOSING = 2;
      static CLOSED = 3;
      readyState = MockWebSocket.OPEN;
      url: string;
      constructor(url: string) {
        super();
        this.url = url;
        queueMicrotask(() => {
          this.dispatchEvent(new MessageEvent('message', {
            data: JSON.stringify({ type: 'notification', payload: { id: 'ws-1', text: 'mock' } }),
          }));
        });
      }
      send(_data: string): void { /* noop */ }
      close(): void { this.readyState = MockWebSocket.CLOSED; }
    }
    (window as unknown as { WebSocket: typeof MockWebSocket }).WebSocket = MockWebSocket;
  });
}

HAR 再生と routeFromHAR

記録済み HAR から再現する方法は、クイックにモック基盤を作る際に有効です。

import { test } from '@playwright/test';

test.describe('HAR ベースの決定的テスト', () => {
  test.beforeEach(async ({ context }) => {
    await context.routeFromHAR('e2e/hars/checkout-flow.har', {
      url: '**/api/**',
      update: false,
    });
  });

  test('チェックアウトフローが HAR どおり動作する', async ({ page }) => {
    await page.goto('/checkout');
    await page.getByRole('button', { name: '支払う' }).click();
    await page.getByText('ご注文ありがとうございます').waitFor();
  });
});

update: true で HAR を再記録するメンテナンスモードを決めておくと、API 変更時の更新フローが明確になります。

フレーキーになりやすいパターンと修正レシピ

アニメーション・スケルトン UI

スケルトンから実コンテンツへの切り替え中に assert すると落ちます。

// ❌ スケルトン表示中に text を検証
await expect(page.getByTestId('product-title')).toHaveText('商品A');

// ✅ スケルトンが消えるまで待ってから検証
await expect(page.getByTestId('product-skeleton')).toBeHidden();
await expect(page.getByTestId('product-title')).toHaveText('商品A');

楽観的 UI 更新

React Query 等の楽観的更新は一瞬古い DOM が残ります。toHaveText より 最終状態の一意な marker を待つのが安全です。

await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByText('保存しました')).toBeVisible();
await expect(page.getByTestId('save-status')).toHaveAttribute('data-state', 'saved');

日付・タイムゾーン

CI は UTC、ローカルは JST だと「今日の日付」assert が落ちます。

await page.addInitScript(() => {
  const fixed = new Date('2026-06-22T12:00:00+09:00');
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (Date as any).now = () => fixed.getTime();
});

ファイルダウンロード

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'CSVをダウンロード' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/orders-2026-06-22\.csv/);

CI/CD統合:shard・キャッシュ・アーティファクト

2026年の中規模以上のリポジトリでは、E2E を shard 分割 し、Browser キャッシュを GitHub Actions Cache に載せます。

# .github/workflows/e2e.yml(抜粋 — 構造参考)
name: E2E

on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run Playwright
        run: npx tsx scripts/run-e2e-ci.ts
        env:
          CI: true
          SHARD_INDEX: ${{ matrix.shard }}
          SHARD_TOTAL: 4
          PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-shard-${{ matrix.shard }}
          path: |
            playwright-report/
            test-results/
          retention-days: 14

キャッシュ戦略の要点:

  • ブラウザバイナリ~/.cache/ms-playwright を actions/cache で key 固定
  • 認証 storageState — setup project の出力を artifact 経由で shard 間共有も可(短命トークンに注意)
  • webServer — ビルド成果物 dist/ を job 間で再利用

Quarantine と test tagging 戦略

フレーキーが直せない間は @flaky タグで quarantine し、merge 必須スイートから除外します。

// e2e/smoke/critical-path.spec.ts
import { test, expect } from '@playwright/test';

test('トップページが表示される @smoke', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

// 調査中 — main ブランチ nightly のみ実行
test.fixme('決済フロー @flaky @checkout', async ({ page }) => {
  await page.goto('/checkout');
  // 調査中のため skip
});
// playwright.smoke.config.ts — PR 用スモークスイート専用(完全設定)
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import baseConfig from './playwright.config';

const CI = !!process.env.CI;

export default defineConfig({
  ...baseConfig,
  testDir: path.join(__dirname, 'e2e'),
  retries: 0,
  workers: CI ? 2 : 1,
  reporter: [['list'], ['github']],
  projects: [
    {
      name: 'smoke-chromium',
      grep: /@smoke/,
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'e2e/.auth/user.json',
      },
    },
  ],
});
// playwright.nightly.config.ts — 夜間フルスイート(@flaky 除外)
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';

const CI = !!process.env.CI;

export default defineConfig({
  ...baseConfig,
  retries: CI ? 2 : 0,
  grepInvert: /@flaky/,
  reporter: CI
    ? [
        ['github'],
        ['html', { open: 'never', outputFolder: 'playwright-report-nightly' }],
        ['json', { outputFile: 'test-results/nightly-results.json' }],
      ]
    : [['list'], ['html', { open: 'on-failure' }]],
});

PR では npx playwright test --config=playwright.smoke.config.ts@smoke のみ、夜間は playwright.nightly.config.ts@flaky 除外のフルスイート——という リスクベース実行 がフレーキーと開発速度のバランスを取ります。

まとめ:2026年 Playwright E2E 安定化チェックリスト

1

playwright.config.ts: trace/screenshot/video/retries/webServer を環境別に設定

2

waitForTimeout を排除し Locator + Web-first assertions に統一

3

setup project + storageState + worker fixture で状態を分離

4

外部 API は route.fulfill / HAR / WebSocket モックで決定的に

5

失敗時 Trace Viewer で DOM・Network・Console を確認

6

CI flaky rate を計測し 2% 超えたテストは修正 or quarantine

7

エラー UI は RFC 9457 形式([APIエラーハンドリング設計](/blog/api-error-handling-設計-ベストプラクティス/))で E2E 検証

フレーキー E2E は「運が悪い日がある」ものではなく、待機・分離・依存・観測 の設計不足が表面化した信号です。Playwright の auto-waiting を信頼し、固定 sleep をやめ、Trace Viewer で原因を可視化し、ネットワークをモックして決定的にすれば、CI の信頼性は目に見えて回復します。

E2E は万能ではありません。契約テスト・統合テスト・ユニットテストと テストピラミッド を組み合わせ、E2E では「ユーザーが本当に困るフロー」に资源を集中させる——それが 2026 年のソフトウェア品質 engineering の実務的な落とし所です。

関連記事

あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
SSE vs WebSocket 使い分けSSE vs WebSocket 使い分け|EventSource 実装と再接続設計
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
CSRF対策の実装ガイドCSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード
Git Worktree 並行開発ワークフロー完全ガイドGit Worktree 並行開発ワークフロー完全ガイド|複数ブランチ同時作業・IDE設定・CI連携(2026)
Monorepo Turborepo Remote Cache 設計完全ガイドMonorepo Turborepo Remote Cache 設計完全ガイド|CI パイプライン・GitHub Actions・2026 実践
依存関係脆弱性管理とSBOM CycloneDX完全ガイド依存関係脆弱性管理とSBOM CycloneDX完全ガイド|npm audit・OSV・Dependabot・サプライチェーンセキュリティ2026
DNSリバインディング攻撃の防御DNSリバインディング攻撃の防御|Host検証・SameSite・Private Network Access・localhostバインド
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
OpenTelemetry Node.js 実装ガイドOpenTelemetry Node.js 実装ガイド|分散トレーシング設定とJaeger/Tempo連携