E2Eテストで最もつまずきやすいのが「認証の壁」です。テストケースごとにログイン操作を繰り返すと実行時間が膨れ上がり、MFA(多要素認証)やOAuth連携が絡むとテストコード自体の設計が難しくなります。

Playwrightには storageState やセットアッププロジェクトといった認証テスト向けの機能が組み込まれており、一度の認証操作で取得したブラウザ状態を複数テストへ再利用できます。

ここでは、Basic認証・フォームログイン・OAuth/SSO・2段階認証(MFA)それぞれのパターンについて、Playwrightでの具体的な実装手順をコード付きで整理します。

PlaywrightのstorageStateによる認証状態の保存と再利用

Playwrightの storageState は、CookieとローカルストレージをJSONファイルへ書き出し、別のテストで読み込める仕組みです。ログイン操作を「セットアッププロジェクト」として1回だけ実行し、その結果をすべてのテストで共有することで、テスト全体の実行時間を大幅に短縮できます。

ディレクトリの準備

まず認証状態ファイルの保存先を用意します。

mkdir -p playwright/.auth
echo "playwright/.auth" >> .gitignore

.gitignore への追加は必須です。認証状態ファイルにはCookieやセッショントークンが含まれるため、リポジトリにコミットするとセキュリティリスクとなります。

auth.setup.tsの記述

tests/auth.setup.ts を作成し、ログイン操作と状態保存を記述します。

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

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

setup('ログイン処理', async ({ page }) => {
  // ログインページへ遷移
  await page.goto('/login');

  // フォーム入力
  await page.getByLabel('メールアドレス').fill('test@example.com');
  await page.getByLabel('パスワード').fill('secure-password');
  await page.getByRole('button', { name: 'ログイン' }).click();

  // 認証完了の確認(リダイレクト先URLやDOM要素で判定)
  await page.waitForURL('/dashboard');
  await expect(page.getByText('ダッシュボード')).toBeVisible();

  // ブラウザ状態を保存
  await page.context().storageState({ path: authFile });
});

waitForURL での認証完了の確認は重要です。OAuth連携などではリダイレクトが複数回発生するため、最終URLに到達してからCookieが確定します。

playwright.config.tsの設定

セットアッププロジェクトを定義し、他のテストプロジェクトがその完了を待つように依存関係を組みます。

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

export default defineConfig({
  projects: [
    // セットアップ:認証処理を1回だけ実行
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    // テスト本体:セットアップ完了後に実行
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

dependencies: ['setup'] がポイントです。Playwrightはこの設定を見て、setup プロジェクトの完了後にテスト本体を実行します。

テストファイルでの利用

セットアップが完了していれば、個々のテストファイルではログイン操作を一切書く必要がありません。

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

test('ダッシュボードに注文一覧が表示される', async ({ page }) => {
  // すでにログイン済みの状態でテスト開始
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: '注文一覧' })).toBeVisible();
});

Basic認証をPlaywrightで通す設定

開発環境や検証環境ではBasic認証でアクセスを制限しているケースが多くあります。Playwrightでは httpCredentials を設定するだけで自動的にBasic認証を通過できます。

httpCredentialsの設定方法

playwright.config.ts で全テスト共通の認証情報を設定する方法が最もシンプルです。

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

export default defineConfig({
  use: {
    httpCredentials: {
      username: 'staging-user',
      password: 'staging-pass',
    },
  },
});

この設定により、すべてのHTTPリクエストに Authorization: Basic ... ヘッダーが自動付与されます。

テスト単位で認証情報を切り替える

環境によって認証情報が異なる場合は、テストファイル単位で上書きできます。

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

test.use({
  httpCredentials: {
    username: process.env.BASIC_USER ?? 'default-user',
    password: process.env.BASIC_PASS ?? 'default-pass',
  },
});

test('Basic認証がかかった環境にアクセスできる', async ({ page }) => {
  await page.goto('https://staging.example.com');
  // 認証ダイアログは表示されず、直接ページが読み込まれる
});

Basic認証とフォームログインの併用

「Basic認証で環境にアクセス → フォームでアプリケーションにログイン」という2段階の認証が必要なケースでは、httpCredentialsstorageState を組み合わせます。

// playwright.config.ts
export default defineConfig({
  use: {
    httpCredentials: {
      username: 'staging-user',
      password: 'staging-pass',
    },
  },
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

フォームログインのCookie保持と再利用

Webアプリケーションの多くはフォームログイン後にセッションCookieを発行します。Playwrightでは、このCookieを storageState で保存・復元することでログイン操作の繰り返しを回避できます。

Cookie保存の仕組み

page.context().storageState() を呼び出すと、その時点でブラウザが保持しているすべてのCookieとローカルストレージの内容がJSON形式で出力されます。

{
  "cookies": [
    {
      "name": "session_id",
      "value": "abc123...",
      "domain": ".example.com",
      "path": "/",
      "httpOnly": true,
      "secure": true
    }
  ],
  "origins": [
    {
      "origin": "https://example.com",
      "localStorage": [
        { "name": "user_preferences", "value": "{\"theme\":\"dark\"}" }
      ]
    }
  ]
}

セッションストレージへの対応

storageState はCookieとローカルストレージを保存しますが、セッションストレージ(sessionStorage)は対象外です。セッションストレージに認証トークンを格納するSPAでは、手動で保存・復元する必要があります。

// セッションストレージの保存
const sessionData = await page.evaluate(() => JSON.stringify(sessionStorage));

// 別のテストで復元
await context.addInitScript((data) => {
  const entries = JSON.parse(data);
  for (const [key, value] of Object.entries(entries)) {
    window.sessionStorage.setItem(key, value as string);
  }
}, sessionData);

Pythonでの実装例

Playwright for Pythonでも同様にCookieの保存と読み込みが可能です。

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()
    page = context.new_page()

    # ログイン操作
    page.goto("https://example.com/login")
    page.fill("#email", "test@example.com")
    page.fill("#password", "password")
    page.click("button[type='submit']")
    page.wait_for_url("**/dashboard")

    # Cookie保存
    context.storage_state(path="state.json")
    browser.close()

    # 保存済みCookieでテスト
    browser = p.chromium.launch()
    context = browser.new_context(storage_state="state.json")
    page = context.new_page()
    page.goto("https://example.com/dashboard")
    # すでにログイン済み

OAuth/SSO認証(Google認証など)のテスト戦略

Google認証やAzure AD、Auth0などのOAuth/SSOフローは、外部の認証プロバイダにリダイレクトするため、E2Eテストで最も対処が難しい認証方式です。

OAuthフローの3つの課題

課題内容
リダイレクトの多段構成アプリ → IdP → 同意画面 → コールバックURLと複数回遷移する
CAPTCHAやボット検知Googleなどの認証画面ではヘッドレスブラウザを検出・ブロックする場合がある
テストアカウントの制限本番IdPのテストアカウント数やAPIレート制限に抵触する可能性がある

対策1:APIベースの認証バイパス

UIからの認証フローを避け、APIで直接トークンを取得する方法が最も安定します。

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

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

setup('APIで認証トークンを取得', async ({ request }) => {
  // アプリケーションの認証APIを直接呼び出す
  const response = await request.post('/api/auth/login', {
    data: {
      email: 'test@example.com',
      password: 'test-password',
    },
  });

  // レスポンスからトークンを取得し、storageStateに反映
  const { token } = await response.json();

  // ブラウザコンテキストにCookieを設定
  const context = await request.newContext();
  await context.storageState({ path: authFile });
});

対策2:テスト用認証エンドポイントの用意

テスト環境専用のバイパスエンドポイント(例:/api/test/login)を用意し、OAuthフローを迂回する方法です。本番環境では無効化しておく必要があります。

対策3:UIフローを直接操作する

テストアカウントでIdPの画面を操作する方法もありますが、Googleのログイン画面はヘッドレスブラウザをブロックするため注意が必要です。Azure ADやAuth0などの自組織管理のIdPでは比較的安定して動作します。

setup('Azure AD認証', async ({ page }) => {
  await page.goto('/login');
  await page.getByRole('button', { name: 'Microsoftでサインイン' }).click();

  // Azure ADのログイン画面に遷移
  await page.waitForURL('**/login.microsoftonline.com/**');
  await page.getByPlaceholder('Email, phone, or Skype').fill('user@contoso.com');
  await page.getByRole('button', { name: 'Next' }).click();
  await page.getByPlaceholder('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // 同意画面が出る場合
  await page.getByRole('button', { name: 'Yes' }).click();

  // コールバック完了を待機
  await page.waitForURL('**/dashboard');
  await page.context().storageState({ path: authFile });
});

2段階認証(MFA/TOTP)を含むテストの実装方法

2段階認証が有効なアカウントでのE2Eテストでは、TOTP(Time-based One-Time Password)コードをテスト内で動的に生成するか、WebAuthn仮想認証器を利用するアプローチがあります。

TOTPコードの自動生成

otpauth パッケージを使用して、テスト実行時にTOTPコードを動的に生成できます。

npm install otpauth
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import { TOTP } from 'otpauth';

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

setup('MFA付きログイン', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill('mfa-user@example.com');
  await page.getByLabel('パスワード').fill('password');
  await page.getByRole('button', { name: 'ログイン' }).click();

  // MFA画面に遷移
  await page.waitForURL('**/mfa');

  // TOTPコードを生成
  const totp = new TOTP({
    secret: process.env.TOTP_SECRET ?? '',
    digits: 6,
    period: 30,
  });
  const code = totp.generate();

  await page.getByLabel('認証コード').fill(code);
  await page.getByRole('button', { name: '確認' }).click();

  await page.waitForURL('**/dashboard');
  await page.context().storageState({ path: authFile });
});

TOTPのシークレットキーは環境変数で管理し、テスト環境専用のアカウントに設定しておきます。

WebAuthn仮想認証器の活用

Playwrightはヘッドレスモードでも cdp セッションを通じてWebAuthn仮想認証器を作成できます。パスキーやFIDO2認証のテストに利用できます。

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

test('WebAuthn認証のテスト', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // CDPセッションでWebAuthn仮想認証器を有効化
  const cdpSession = await page.context().newCDPSession(page);
  await cdpSession.send('WebAuthn.enable');

  // 仮想認証器を作成
  const { authenticatorId } = await cdpSession.send(
    'WebAuthn.addVirtualAuthenticator',
    {
      options: {
        protocol: 'ctap2',
        transport: 'internal',
        hasResidentKey: true,
        hasUserVerification: true,
        isUserVerified: true,
      },
    }
  );

  // パスキー登録・認証フローをテスト
  await page.goto('/settings/security');
  await page.getByRole('button', { name: 'パスキーを追加' }).click();
  // WebAuthnダイアログが仮想認証器で自動応答される

  await browser.close();
});

複数ユーザーロールのテスト設計

管理者・一般ユーザー・閲覧専用ユーザーなど、権限の異なるロールを切り替えてテストする場合の構成方法です。

ロール別のstorageState管理

セットアップで複数のロール分の認証状態を保存します。

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

const adminFile = 'playwright/.auth/admin.json';
const userFile = 'playwright/.auth/user.json';

setup('管理者ログイン', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill('admin@example.com');
  await page.getByLabel('パスワード').fill('admin-pass');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await page.waitForURL('/admin/dashboard');
  await page.context().storageState({ path: adminFile });
});

setup('一般ユーザーログイン', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill('user@example.com');
  await page.getByLabel('パスワード').fill('user-pass');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: userFile });
});

プロジェクト構成によるロール分離

playwright.config.ts でロール別のプロジェクトを定義します。

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'admin-tests',
      testDir: './tests/admin',
      use: {
        storageState: 'playwright/.auth/admin.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'user-tests',
      testDir: './tests/user',
      use: {
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

同一テスト内で複数ロールを切り替える

管理者と一般ユーザーが同時に操作するシナリオ(例:管理者が権限を付与 → 一般ユーザーが確認)では、複数の BrowserContext を生成します。

test('管理者が権限を付与し、一般ユーザーが利用できる', async ({ browser }) => {
  // 管理者コンテキスト
  const adminContext = await browser.newContext({
    storageState: 'playwright/.auth/admin.json',
  });
  const adminPage = await adminContext.newPage();

  // 一般ユーザーコンテキスト
  const userContext = await browser.newContext({
    storageState: 'playwright/.auth/user.json',
  });
  const userPage = await userContext.newPage();

  // 管理者が権限を付与
  await adminPage.goto('/admin/permissions');
  await adminPage.getByRole('button', { name: '編集権限を付与' }).click();

  // 一般ユーザーが確認
  await userPage.goto('/dashboard');
  await expect(userPage.getByRole('button', { name: '編集' })).toBeVisible();

  await adminContext.close();
  await userContext.close();
});

特定テストで未認証状態にする

一部のテスト(ログインページのUI確認、未ログイン時のリダイレクト検証など)では認証状態をリセットできます。

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

// このファイル全体を未認証状態で実行
test.use({ storageState: { cookies: [], origins: [] } });

test('未ログイン時にログインページへリダイレクトされる', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveURL(/.*\/login/);
});

認証方式ごとの実装パターン早見表

認証方式Playwrightでの対処法主要API/設定安定性
Basic認証httpCredentials で自動送信playwright.config.tsuse.httpCredentials
フォームログインセットアッププロジェクトでCookie保存storageState, auth.setup.ts
OAuth/SSO(Azure AD等)IdP画面を操作 or APIバイパスwaitForURL, request.post()中〜高
Google認証APIバイパス推奨(ボット検知あり)テスト環境バイパス低〜中
TOTP(MFA)otpauth でコード生成TOTP.generate()
WebAuthn/パスキーCDP経由の仮想認証器WebAuthn.addVirtualAuthenticator
セッションストレージevaluate + addInitScript で手動復元page.evaluate(), context.addInitScript()

CI/CDパイプラインでの認証テスト運用

環境変数によるクレデンシャル管理

テスト用のID・パスワード・TOTPシークレットはCI/CDの環境変数またはシークレットストアで管理します。

# .github/workflows/e2e.yml
name: E2E Tests
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
          TOTP_SECRET: ${{ secrets.TOTP_SECRET }}
          BASIC_USER: ${{ secrets.BASIC_USER }}
          BASIC_PASS: ${{ secrets.BASIC_PASS }}

セットアップファイルでの環境変数参照

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

setup('ログイン', 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 page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

並列ワーカーでの認証状態の分離

Playwrightの並列テスト実行では、ワーカーごとに独立したブラウザコンテキストが生成されます。storageState を共有する場合は読み取り専用の参照として扱われるため、ワーカー間の干渉は発生しません。

ただし、サーバー側の状態を変更するテスト(例:パスワード変更)が含まれる場合は、ワーカーごとに別のアカウントを使用する設計が必要です。

// fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  // ワーカーごとに異なるアカウントを使用
  storageState: async ({ browser }, use, testInfo) => {
    const workerIndex = testInfo.parallelIndex;
    const authFile = `playwright/.auth/user-${workerIndex}.json`;

    // ワーカー固有のアカウントでログイン
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('/login');
    await page.getByLabel('メールアドレス').fill(`user${workerIndex}@example.com`);
    await page.getByLabel('パスワード').fill('password');
    await page.getByRole('button', { name: 'ログイン' }).click();
    await page.waitForURL('/dashboard');
    await page.context().storageState({ path: authFile });
    await context.close();

    await use(authFile);
  },
});

認証テストでよくあるトラブルと解決策

storageStateが読み込まれない

原因として多いのは、セットアッププロジェクトの testMatch パターンが正しく設定されていないケースです。

// NG: ファイル名がパターンに一致しない
{ name: 'setup', testMatch: '**/*.setup.ts' }

// OK: 正規表現で指定
{ name: 'setup', testMatch: /.*\.setup\.ts/ }

また、dependencies の設定漏れにより、セットアップの完了前にテスト本体が実行されてしまうケースもあります。

Cookieの有効期限切れ

セッションCookieの有効期限が短い場合、テスト実行時にはすでにCookieが失効していることがあります。対策としては以下があります。

  • テスト環境のセッション有効期限を十分に長く設定する
  • globalSetup の代わりに project setup を使用する(project setup はテスト開始直前に実行されるため、時間差が少ない)

CIでヘッドレス実行時のログイン失敗

ローカルでは成功するがCIで失敗する場合、以下を確認します。

  • ブラウザのビューポートサイズ:レスポンシブデザインによりログインフォームのレイアウトが変わっている可能性
  • タイムアウト値:CIサーバーの性能により、デフォルトの30秒では不足する場合がある
  • ネットワーク遅延:外部IdPへの通信が遅い場合、waitForURL のタイムアウトを延長する
// タイムアウトを延長する例
await page.waitForURL('/dashboard', { timeout: 60_000 });

beforeEachとstorageStateの使い分け

beforeEach で毎回ログインする方式と storageState による方式の違いは以下の通りです。

比較項目beforeEachstorageState
実行速度テストごとにログイン操作が発生し遅い初回のみ。2回目以降はCookie読み込みだけで高速
テストの独立性テストごとに新しいセッション同じセッションを共有
サーバー負荷高い(テスト数 × 認証リクエスト)低い(1回のみ)
適したケースセッション分離が必要な場合共有アカウントで十分な場合

storageState はテスト実行速度の面で圧倒的に有利です。テストケースが10個あれば、ログイン操作のオーバーヘッドが10回分削減されます。

まとめ

Playwrightの認証テストは、認証方式に応じて適切なアプローチを選ぶことが成功の鍵です。

  • Basic認証httpCredentials で設定するだけで完結する
  • フォームログインstorageState + セットアッププロジェクトで1回の認証を全テストに共有する
  • OAuth/SSOはAPIバイパスが安定性の面で優れている。IdP画面の直接操作はGoogle認証ではボット検知のリスクがある
  • MFA/TOTPotpauth ライブラリでコードを動的生成する
  • 複数ロールはロール別に storageState ファイルを分けて管理する
  • CI/CDではクレデンシャルを環境変数で管理し、ハードコーディングを避ける

公式ドキュメント(Playwright Authentication)にはさらに詳細なパターンが掲載されているため、プロジェクトの要件に応じて参照してみてください。