T-CREATOR

<div />

Playwright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術

Playwright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術

E2E テストの実行時間が 30 分を超え、CI/CD パイプラインのボトルネックになっている。そんな状況に直面したことはないでしょうか。本記事では、実際に 500 件以上のテストケースを持つプロジェクトで、実行時間を 35 分から 8 分に短縮した設計手法を解説します。

Playwright の shard による分散実行、grep によるテスト絞り込み、fixtures による効率的なセットアップを組み合わせた並列実行設計について、採用した理由・採用しなかった選択肢・運用で発生した問題と回避策を含めてお伝えします。

並列実行設計の要点

#項目概要主な用途
1shardテストを複数の CI ジョブに分割して並列実行CI/CD での分散実行、大規模テストスイートの高速化
2grepタグやパターンでテストをフィルタリング機能別・優先度別のテスト実行、デバッグ時の絞り込み
3fixturesテスト間で共有するセットアップを効率化認証状態の再利用、データベース接続の共有
4workers同一マシン内での並列ワーカー数を制御CPU コア数に応じた最適化、リソース競合の回避
5fullyParallelファイル内のテストも並列実行する設定テスト間に依存がない場合の最大並列化
6retries失敗したテストの自動リトライflaky テストの安定化、CI の成功率向上
7timeoutテスト・アクション単位のタイムアウト設定無限待機の防止、CI リソースの保護
8reporterテスト結果の出力形式を制御CI 連携、デバッグ情報の可視化

検証環境

本記事では、以下の環境で動作確認を行っています。

  • OS: macOS Sequoia 15.2 / Ubuntu 24.04 LTS(CI 環境)
  • Node.js: 22.13.0
  • 主要パッケージ:
    • Playwright: 1.50.1
    • @playwright/test: 1.50.1
    • TypeScript: 5.7.3
  • CI 環境: GitHub Actions
  • 検証日: 2026年1月24日

✓ 動作確認済み(Node.js 22.x / Playwright 1.50.x)

背景:E2E テスト実行時間の肥大化とビジネスへの影響

プロジェクト規模の拡大がもたらす課題

E2E テストは UI の品質を担保する重要な手段ですが、プロジェクトの成長とともにテストケースは増加し続けます。私が担当したプロジェクトでは、2 年間でテストケースが 50 件から 500 件以上に増加しました。

当初は 5 分程度で完了していたテストスイートが、35 分を超えるようになっていたのです。

以下の図は、テスト実行時間が開発フローに与える影響を示しています。

mermaidflowchart TD
    subgraph before["改善前の開発フロー"]
        pr1["PR 作成"] --> ci1["CI 実行開始"]
        ci1 --> wait1["35分待機"]
        wait1 --> result1["結果確認"]
        result1 --> fix1["修正が必要"]
        fix1 --> ci1
    end

    subgraph after["改善後の開発フロー"]
        pr2["PR 作成"] --> ci2["CI 実行開始"]
        ci2 --> wait2["8分待機"]
        wait2 --> result2["結果確認"]
        result2 --> merge["マージ可能"]
    end

図の要点:

  • 改善前は 1 回の CI 実行に 35 分かかり、修正が必要な場合はさらに待機時間が発生
  • 改善後は 8 分で結果が確認でき、開発者のコンテキストスイッチが減少
  • 1 日に 10 回の CI 実行がある場合、270 分の時間短縮効果

なぜ単純な workers 数の増加では解決できないのか

Playwright はデフォルトで並列実行をサポートしています。workers オプションで並列数を増やせば高速化できると考えるかもしれませんが、実際にはいくつかの壁にぶつかります。

1 つ目は リソース競合 です。同一マシン内で多くのブラウザインスタンスを起動すると、メモリ不足やCPU 競合が発生し、かえってテストが不安定になりました。

2 つ目は テスト間の依存関係 です。認証状態やデータベースの状態を共有するテストがあり、並列実行すると予期せぬ失敗が発生するケースがありました。

3 つ目は CI 環境の制約 です。GitHub Actions の標準ランナーは 2 コアであり、単一ジョブでの並列化には限界があったのです。

課題:並列実行設計で直面した 3 つの問題

テスト分割の粒度とバランス

shard を使ってテストを分割する際、どの粒度で分割するかが重要になります。当初は単純にファイル数で均等分割していましたが、テストファイルごとの実行時間にばらつきがあり、一部のジョブだけが長時間実行される状況が発生しました。

認証状態の効率的な共有

E2E テストでは、ほとんどのテストがログイン後の画面を対象とします。各テストで毎回ログイン処理を実行すると、500 件のテストで 500 回のログイン処理が走ることになり、これだけで数十分の時間を消費していました。

flaky テストの増加

並列実行を導入すると、タイミングに依存した flaky テストが顕在化しました。シーケンシャル実行では問題なく通っていたテストが、並列実行では失敗するケースが増えたのです。

解決策:shard・grep・fixtures を組み合わせた設計

shard による CI ジョブ分散

shard は Playwright の組み込み機能で、テストスイート全体を指定した数に分割し、それぞれを別々の CI ジョブで実行できます。

typescript// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // テストディレクトリの指定
  testDir: './tests',

  // 各ワーカーでの並列実行数
  workers: process.env.CI ? 2 : undefined,

  // shard は CLI から指定するため、config では設定しない
});

shard の実行は CLI から行います。

bash# 4 分割の 1 番目を実行
npx playwright test --shard=1/4

# 4 分割の 2 番目を実行
npx playwright test --shard=2/4

GitHub Actions での設定例を示します。

yaml# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

続いて、各ジョブのステップを定義します。

yamlsteps:
  - uses: actions/checkout@v4

  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: '22'
      cache: 'yarn'

  - name: Install dependencies
    run: yarn install --frozen-lockfile

Playwright のインストールとテスト実行を行います。

yaml- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium

- name: Run tests
  run: npx playwright test --shard=${{ matrix.shard }}/4

- name: Upload test results
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: test-results-${{ matrix.shard }}
    path: test-results/

✓ 動作確認済み(GitHub Actions / Ubuntu 24.04)

shard 分割数の決定基準

shard の分割数は、以下の観点から決定しました。

#分割数メリットデメリット
12 分割CI コスト低、設定シンプル高速化効果が限定的
24 分割バランスが良いテスト数が少ないと非効率
38 分割大規模プロジェクト向けCI コスト増、ジョブ起動オーバーヘッド
4動的分割テスト数に応じて最適化設定が複雑になる

私たちのプロジェクトでは、500 件のテストに対して 4 分割を採用しました。8 分割も検討しましたが、ジョブの起動オーバーヘッド(約 1 分)を考慮すると、4 分割が最も効率的でした。

grep によるテストのタグ付けとフィルタリング

すべてのテストを毎回実行する必要はありません。開発中は関連するテストだけを実行し、CI では優先度に応じて実行するテストを制御したいケースがあります。

Playwright の grep オプションを使うと、テストのタイトルやタグでフィルタリングできます。

typescript// tests/auth/login.spec.ts
import { test, expect } from '@playwright/test';

// タグを使ったテストの分類
test('ログインフォームが表示される @smoke @auth', async ({
  page,
}) => {
  await page.goto('/login');
  await expect(
    page.getByRole('heading', { name: 'ログイン' })
  ).toBeVisible();
});

優先度が高いテストには @critical タグを付与します。

typescripttest('正しい認証情報でログインできる @critical @auth', async ({
  page,
}) => {
  await page.goto('/login');
  await page
    .getByLabel('メールアドレス')
    .fill('test@example.com');
  await page.getByLabel('パスワード').fill('password123');
  await page
    .getByRole('button', { name: 'ログイン' })
    .click();

  await expect(page).toHaveURL('/dashboard');
});

エッジケースのテストには @edge タグを付与します。

typescripttest('無効なメールアドレスでエラーが表示される @edge @auth', async ({
  page,
}) => {
  await page.goto('/login');
  await page
    .getByLabel('メールアドレス')
    .fill('invalid-email');
  await page
    .getByRole('button', { name: 'ログイン' })
    .click();

  await expect(
    page.getByText('有効なメールアドレスを入力してください')
  ).toBeVisible();
});

CLI からタグを指定して実行します。

bash# smoke テストのみ実行(PR 時の簡易チェック)
npx playwright test --grep @smoke

# critical テストのみ実行(デプロイ前の重要チェック)
npx playwright test --grep @critical

# auth タグを除外して実行
npx playwright test --grep-invert @auth

タグ設計のベストプラクティス

実運用で効果的だったタグ設計を紹介します。

mermaidflowchart LR
    subgraph priority["優先度タグ"]
        critical["@critical<br/>必須機能"]
        smoke["@smoke<br/>基本動作"]
        edge["@edge<br/>エッジケース"]
    end

    subgraph feature["機能タグ"]
        auth["@auth<br/>認証"]
        payment["@payment<br/>決済"]
        user["@user<br/>ユーザー管理"]
    end

    subgraph timing["実行タイミングタグ"]
        pr["@pr<br/>PR 時に実行"]
        nightly["@nightly<br/>夜間バッチ"]
        manual["@manual<br/>手動実行のみ"]
    end

図の要点:

  • 優先度・機能・実行タイミングの 3 軸でタグを設計
  • 1 つのテストに複数のタグを付与可能
  • CI ワークフローごとに異なるタグの組み合わせで実行

fixtures による認証状態の効率的な共有

fixtures は Playwright の強力な機能で、テスト間で共有するセットアップを効率化できます。特に認証状態の共有は、テスト実行時間の短縮に大きく貢献しました。

まず、認証状態を保存するセットアップスクリプトを作成します。

typescript// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  // ログインページにアクセス
  await page.goto('/login');

  // 認証情報を入力
  await page
    .getByLabel('メールアドレス')
    .fill(process.env.TEST_USER_EMAIL!);
  await page
    .getByLabel('パスワード')
    .fill(process.env.TEST_USER_PASSWORD!);
  await page
    .getByRole('button', { name: 'ログイン' })
    .click();

  // ダッシュボードへの遷移を確認
  await expect(page).toHaveURL('/dashboard');

  // 認証状態を保存
  await page.context().storageState({ path: authFile });
});

次に、設定ファイルでセットアップとテストの依存関係を定義します。

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

export default defineConfig({
  testDir: './tests',

  projects: [
    // セットアップ用プロジェクト
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

認証済み状態を使用するテストプロジェクトを定義します。

typescript    // 認証済み状態でテストを実行
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

認証不要なテスト用のプロジェクトも用意します。

typescript    // 認証不要なテスト用
    {
      name: 'logged-out',
      use: {
        ...devices['Desktop Chrome'],
      },
      testMatch: /.*\.logged-out\.spec\.ts/,
    },
  ],
});

✓ 動作確認済み(Playwright 1.50.x)

カスタム fixtures の作成

認証だけでなく、テストで頻繁に使用するセットアップをカスタム fixtures として定義できます。

typescript// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

// カスタム fixtures の型定義
type CustomFixtures = {
  authenticatedPage: import('@playwright/test').Page;
  testUser: { email: string; name: string };
};

fixtures の実装を定義します。

typescriptexport const test = base.extend<CustomFixtures>({
  // 認証済みページを提供
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/dashboard');
    await expect(page.getByTestId('user-menu')).toBeVisible();
    await use(page);
  },

テストユーザー情報を提供する fixtures を定義します。

typescript  // テストユーザー情報を提供
  testUser: async ({}, use) => {
    const user = {
      email: 'test@example.com',
      name: 'テストユーザー',
    };
    await use(user);
  },
});

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

カスタム fixtures を使ったテストの例です。

typescript// tests/dashboard.spec.ts
import { test, expect } from './fixtures';

test('ダッシュボードにユーザー名が表示される', async ({
  authenticatedPage,
  testUser,
}) => {
  // authenticatedPage は既にログイン済み
  await expect(
    authenticatedPage.getByText(testUser.name)
  ).toBeVisible();
});

workers と fullyParallel の最適化

同一マシン内での並列実行を最適化するには、workersfullyParallel の設定が重要です。

typescript// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // ファイル内のテストも並列実行
  fullyParallel: true,

  // CI 環境では 2 ワーカー、ローカルでは CPU コア数の 50%
  workers: process.env.CI ? 2 : '50%',

  // flaky テスト対策として CI でのみリトライ
  retries: process.env.CI ? 2 : 0,

タイムアウト設定を追加します。

typescript  // テスト全体のタイムアウト
  timeout: 30000,

  // expect のタイムアウト
  expect: {
    timeout: 5000,
  },

  // アクションのタイムアウト
  use: {
    actionTimeout: 10000,
    navigationTimeout: 15000,
  },
});

fullyParallel を使う際の注意点

fullyParallel: true を設定すると、同一ファイル内のテストも並列実行されます。これにより高速化が期待できますが、テスト間で状態を共有している場合は問題が発生します。

私たちのプロジェクトでは、以下のケースで flaky テストが発生しました。

typescript// 問題のあるテスト例
test.describe('ユーザー設定', () => {
  test('プロフィールを更新できる', async ({ page }) => {
    await page.goto('/settings/profile');
    await page.getByLabel('表示名').fill('新しい名前');
    await page
      .getByRole('button', { name: '保存' })
      .click();
  });

  // 上のテストと並列実行されると失敗する可能性
  test('更新後の名前が表示される', async ({ page }) => {
    await page.goto('/settings/profile');
    await expect(page.getByLabel('表示名')).toHaveValue(
      '新しい名前'
    );
  });
});

解決策として、依存関係のあるテストは test.describe.serial を使用します。

typescript// 修正後:シリアル実行を明示
test.describe.serial('ユーザー設定の更新フロー', () => {
  test('プロフィールを更新できる', async ({ page }) => {
    await page.goto('/settings/profile');
    await page.getByLabel('表示名').fill('新しい名前');
    await page
      .getByRole('button', { name: '保存' })
      .click();
    await expect(
      page.getByText('保存しました')
    ).toBeVisible();
  });

  test('更新後の名前が表示される', async ({ page }) => {
    await page.goto('/settings/profile');
    await expect(page.getByLabel('表示名')).toHaveValue(
      '新しい名前'
    );
  });
});

具体例:500 件のテストを 8 分で実行する構成

全体アーキテクチャ

以下の図は、私たちが採用した並列実行アーキテクチャの全体像です。

mermaidflowchart TB
    subgraph trigger["トリガー"]
        pr["PR 作成/更新"]
    end

    subgraph setup["セットアップフェーズ"]
        auth["認証状態を生成<br/>auth.setup.ts"]
    end

    subgraph parallel["並列実行フェーズ"]
        shard1["Shard 1/4<br/>~125 テスト"]
        shard2["Shard 2/4<br/>~125 テスト"]
        shard3["Shard 3/4<br/>~125 テスト"]
        shard4["Shard 4/4<br/>~125 テスト"]
    end

    subgraph report["レポート集約"]
        merge["結果をマージ"]
        notify["Slack 通知"]
    end

    trigger --> setup
    setup --> parallel
    shard1 --> merge
    shard2 --> merge
    shard3 --> merge
    shard4 --> merge
    merge --> notify

図の要点:

  • セットアップフェーズで認証状態を 1 回だけ生成
  • 4 つの shard で並列実行(各 shard は 2 workers で実行)
  • 合計 8 並列でテストを実行

完全な playwright.config.ts

実際に使用している設定ファイルの全体像を示します。

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

// 認証状態の保存先
const STORAGE_STATE = path.join(
  __dirname,
  'playwright/.auth/user.json'
);

基本設定を定義します。

typescriptexport default defineConfig({
  testDir: './tests',

  // 並列実行の設定
  fullyParallel: true,
  workers: process.env.CI ? 2 : '50%',

  // リトライ設定
  retries: process.env.CI ? 2 : 0,

  // タイムアウト設定
  timeout: 30000,
  expect: { timeout: 5000 },

レポーター設定を追加します。

typescript  // レポーター設定
  reporter: process.env.CI
    ? [
        ['github'],
        ['html', { outputFolder: 'playwright-report' }],
        ['json', { outputFile: 'test-results.json' }],
      ]
    : [['html', { open: 'on-failure' }]],

共通の use 設定を定義します。

typescript  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
    actionTimeout: 10000,
    navigationTimeout: 15000,
  },

プロジェクト設定を定義します。

typescript  projects: [
    // 認証セットアップ
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
      use: { ...devices['Desktop Chrome'] },
    },

    // メインテスト(認証済み)
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: STORAGE_STATE,
      },
      dependencies: ['setup'],
    },

認証不要テスト用のプロジェクトを追加します。

typescript    // 認証不要テスト
    {
      name: 'logged-out',
      use: { ...devices['Desktop Chrome'] },
      testMatch: /.*\.logged-out\.spec\.ts/,
    },
  ],

  // ローカル開発サーバーの起動
  webServer: {
    command: 'yarn dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

GitHub Actions ワークフローの完全版

yaml# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

env:
  BASE_URL: http://localhost:3000

ジョブの定義を行います。

yamljobs:
  e2e-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'yarn'

依存関係のインストールとキャッシュを設定します。

yaml- name: Install dependencies
  run: yarn install --frozen-lockfile

- name: Cache Playwright browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}

- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium

テスト実行と結果のアップロードを行います。

yaml- name: Run E2E tests
  run: npx playwright test --shard=${{ matrix.shard }}/4
  env:
    TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
    TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report-${{ matrix.shard }}
    path: playwright-report/
    retention-days: 7

よくあるエラーと対処法

エラー 1: browserContext.storageState: Target page, context or browser has been closed

shard 実行時に認証状態の保存が失敗するケースで発生しました。

bashError: browserContext.storageState: Target page, context or browser has been closed

発生条件

  • セットアップスクリプトでページを閉じた後に storageState() を呼び出した場合
  • タイムアウトによりブラウザが自動終了した場合

解決方法

typescript// 修正前(エラーが発生)
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  // ... ログイン処理
  await page.close(); // NG: 閉じた後に storageState できない
  await page.context().storageState({ path: authFile });
});

ページを閉じる前に状態を保存するように修正します。

typescript// 修正後(正常動作)
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  // ... ログイン処理
  await page.context().storageState({ path: authFile }); // OK: 先に保存
  // page.close() は不要(Playwright が自動で処理)
});

エラー 2: Timeout 30000ms exceeded

並列実行時にタイムアウトが頻発するケースです。

bashError: Timeout 30000ms exceeded.
    waiting for locator('button:has-text("保存")')

原因

並列実行により、サーバーへのリクエストが集中し、レスポンスが遅延したためです。

解決方法

タイムアウトを適切に設定し、リトライを追加しました。

typescript// playwright.config.ts
export default defineConfig({
  timeout: 60000, // テスト全体を 60 秒に延長
  expect: { timeout: 10000 }, // expect を 10 秒に延長

  use: {
    actionTimeout: 15000,
    navigationTimeout: 30000,
  },

  retries: process.env.CI ? 2 : 0,
});

まとめ

採用した設計と効果

Playwright の shard、grep、fixtures を組み合わせた並列実行設計により、500 件のテストスイートの実行時間を 35 分から 8 分に短縮できました。

#施策効果導入コスト
1shard 4 分割実行時間 75% 削減低(CI 設定の変更のみ)
2fixtures による認証共有ログイン処理 99% 削減中(セットアップスクリプト作成)
3grep によるタグ分類開発時の待機時間削減低(テストタイトルの修正のみ)
4fullyParallel + serial最大並列化と安定性の両立中(テスト設計の見直し)

向いているケース

  • テストケースが 100 件以上ある大規模プロジェクト
  • CI/CD の実行時間がボトルネックになっている
  • テスト間の依存関係を適切に管理できるチーム

向かないケース

  • テストケースが 50 件未満の小規模プロジェクト(shard のオーバーヘッドが大きい)
  • CI の並列ジョブ数に制限がある環境
  • テスト間で共有状態が多く、分離が難しいレガシーコード

採用しなかった選択肢

当初は Cypress Cloud や BrowserStack の並列実行サービスも検討しましたが、以下の理由で採用しませんでした。

  • コスト: 月額費用が発生し、テスト数の増加に応じて費用も増加
  • 依存性: 外部サービスへの依存が増え、障害時に CI が停止するリスク
  • カスタマイズ性: 自社の要件に合わせた細かい調整が難しい

Playwright の組み込み機能で十分な高速化が実現でき、追加コストなしで運用できています。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;