この記事で得られること

  • WebAuthn(パスキー)認証をCI環境でテストする方法
  • OTPメール取得の自動化(Mailpit API連携)
  • 並列E2Eテストでのメール競合を防ぐテクニック
  • 日本語UIを直接テストするlocale-specific testing

はじめに

第1回では、マルチテナントSaaS「Saru」の全体像と自動化戦略を紹介した。今回は、その自動化の核となるE2Eテストの実装詳細を掘り下げる。

特に難しいのが認証フローのテストだ。Saruでは2種類の認証方式を採用している:

ポータル認証方式難しさ
System / ProviderOTP + パスキーメール取得、WebAuthn
Reseller / ConsumerKeycloak OAuth外部IdP連携

これらをすべてCIで自動テストする方法を解説する。

1. WebAuthn仮想認証器:パスキーをCIでテスト

パスキー認証の課題

WebAuthn(パスキー)は物理的なセキュリティキーや生体認証を使う。普通に考えると、CI環境でテストするのは不可能に思える。

解決策:Chrome DevTools Protocol (CDP) の仮想認証器

Playwrightでは、CDPを通じて仮想的な認証器を作成できる。これにより、物理デバイスなしでWebAuthnのフルフローをテストできる。

注意: CDP仮想認証器はChromium系ブラウザ限定の機能。Safari(WebKit)やFirefoxでは使用できない。クロスブラウザ対応が必要な場合、WebAuthnテストはChromiumでのみ実行し、他ブラウザでは認証済み状態をモックする等の対策が必要になる。

実装コード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { test, expect, type BrowserContext } from '@playwright/test';

test('should complete signup with Passkey registration', async ({ page, context }) => {
  // 仮想認証器を有効化
  const cdpSession = await context.newCDPSession(page);
  await cdpSession.send('WebAuthn.enable');

  // 仮想認証器を追加
  await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',           // CTAP2プロトコル
      transport: 'usb',            // USB接続をエミュレート
      hasResidentKey: true,        // パスキー対応
      hasUserVerification: true,   // 生体認証をエミュレート
      isUserVerified: true,        // 常に認証成功
      automaticPresenceSimulation: true, // 自動応答
    },
  });

  // ... サインアップフローを実行 ...

  // Passkey登録ボタンをクリック
  await page.getByRole('button', { name: 'Passkey' }).click();

  // 仮想認証器が自動的に応答し、登録が完了する
  await expect(page.getByText('Passkey registered')).toBeVisible();

  // クリーンアップ
  await cdpSession.send('WebAuthn.disable');
});

transport設定とサーバー設定の整合性

WebAuthn仮想認証器を設定する際、サーバー側の設定との整合性が重要になる。

Saruの場合、バックエンドでWebAuthn登録オプションを生成する際にAuthenticatorAttachment: CrossPlatformを指定していた。これは「ローミング認証器(USBキー等)を推奨する」設定だ。

一方、最初のテスト実装ではtransport: 'internal'(プラットフォーム内蔵認証器)を使っていたため、登録が失敗した。

1
2
3
// サーバーがCrossPlatformを推奨している場合、整合性に注意
transport: 'internal',  // プラットフォーム認証器 → 動作しないケースあり
transport: 'usb',       // ローミング認証器 → サーバー設定と整合

ポイント: 仮想認証器のtransport設定は、サーバー側のAuthenticatorAttachment設定と整合が取れている必要がある。失敗した場合は、まずサーバー側の設定を確認しよう。WebAuthnの仕様上、必ずしも1:1で対応するわけではないが、設定の不整合は失敗の原因になりやすい。

2. OTPメール取得の自動化:Mailpit API連携

従来のアプローチの問題点

多くのE2Eテストでは、OTPをテスト用エンドポイントから取得する:

1
2
3
// テストモードでOTPを取得(非推奨)
const response = await request.get(`/signup/${sessionId}/test/otp`);
const { otp } = await response.json();

問題点:

  • 本番コードにTEST_MODE分岐が入る
  • メール送信自体がテストされない
  • 実際のユーザーフローと乖離する

Mailpit APIによる解決

SaruではMailpit(開発用メールサーバー)のAPIを直接叩いて、実際に送信されたメールからOTPを取得する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const MAILPIT_API_URL = 'http://localhost:8025/api/v1';

export async function waitForOtpEmail(
  email: string,
  type: 'login' | 'signup',
  maxAttempts = 30,
  sentAfter?: string
): Promise<string | null> {
  const subjectPatterns = {
    login: ['ログインコード', 'Login Code'],
    signup: ['認証コード', 'Verification Code'],
  };

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    await new Promise(resolve => setTimeout(resolve, 1000));

    const response = await fetch(`${MAILPIT_API_URL}/messages`);
    const data = await response.json();

    // メールを検索
    const otpEmail = data.messages.find(msg => {
      // 宛先チェック
      if (!msg.To.some(to => to.Address === email)) return false;
      // 件名パターンチェック
      if (!subjectPatterns[type].some(p => msg.Subject.includes(p))) return false;
      // タイムスタンプフィルタ(後述)
      if (sentAfter && new Date(msg.Created) < new Date(sentAfter)) return false;
      return true;
    });

    if (otpEmail) {
      // 6桁のOTPを抽出
      const match = otpEmail.Snippet.match(/(\d{6})/);
      if (match) return match[1];
    }
  }
  return null;
}

ポイント:

  • 実際のメール送信フローをテスト
  • 日本語/英語両方の件名パターンに対応
  • 最大30秒間ポーリング(SMTP初回起動の遅延に対応)

3. 並列テストでのメール競合を防ぐ

問題:並列実行時のOTP取り違え

CIで複数のテストを並列実行すると、別のテストが送信したOTPを誤って取得してしまう問題が発生した。

例えば:

  1. テストA: user-a@example.comにOTP送信
  2. テストB: user-b@example.comにOTP送信
  3. テストA: Mailpitを検索 → テストBのOTPを取得してしまう

解決策:タイムスタンプ + 一意アドレスによるフィルタリング

Saruでは2つの方法を組み合わせて競合を防いでいる:

  1. 一意のメールアドレス: 各テストで異なるメールアドレスを使用
  2. タイムスタンプフィルタ: OTPリクエスト前の時刻を記録し、それ以降のメールのみ検索
1
2
3
4
5
6
7
8
9
// ログイン前にタイムスタンプを記録
const sentAfter = new Date().toISOString();

// メールアドレスを送信(テストごとに一意)
await page.fill('input[type="email"]', email);
await page.getByRole('button', { name: 'ログイン' }).click();

// タイムスタンプでフィルタリングしてOTPを取得
const otp = await waitForOtpEmail(email, 'login', 30, sentAfter);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// waitForOtpEmail内のフィルタリング
const otpEmail = data.messages.find(msg => {
  // 宛先チェック(一意アドレスで絞り込み)
  if (!msg.To.some(to => to.Address === email)) return false;

  // タイムスタンプフィルタ(古いメールを除外)
  if (sentAfter) {
    const emailTime = new Date(msg.Created).getTime();
    const filterTime = new Date(sentAfter).getTime();
    if (emailTime < filterTime) return false;
  }
  return true;
});

他の並列対策アプローチ

より堅牢な方法として、以下のアプローチも検討できる:

方法メリットデメリット
一意アドレス + タイムスタンプ(Saruの方式)シンプル、バックエンド変更不要時計ズレに弱い
X-Request-IDをメールに埋め込み完全に一意に特定可能バックエンド変更が必要
Mailpit Search API検索条件で直接絞り込みAPIの機能に依存

Saruの方式は「シンプルで十分機能する」ことを優先した選択だ。

非推奨になった clearMailpit()

以前は各テスト前にclearMailpit()で全メールを削除していたが、並列実行では他のテストのメールも消してしまう。タイムスタンプフィルタリングにより、この関数は非推奨になった。

1
2
3
4
5
6
7
/**
 * @deprecated Use timestamp-based filtering instead.
 * This function causes race conditions in parallel tests.
 */
export async function clearMailpit(): Promise<void> {
  await fetch(`${MAILPIT_API_URL}/messages`, { method: 'DELETE' });
}

4. 補足:Locale-Specific Testing

認証テストとは直接関係ないが、多言語対応アプリのE2Eで有用なテクニックを紹介する。

多言語対応E2Eの課題

よくあるアプローチ:

1
2
3
4
// 正規表現で複数言語に対応(旧方式)
await expect(page.getByRole('button', {
  name: /(ログイン|Login|登录)/
})).toBeVisible();

問題点: 言語追加のたびに正規表現を更新する必要がある。

Locale-Specific Testingパターン

Saruでは、テスト時に言語を固定して、その言語のテキストを直接検証する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// e2e/utils/locale.ts
export async function setLocale(
  context: BrowserContext,
  locale: 'ja' | 'en'
): Promise<void> {
  // 注意: domain: 'localhost' はブラウザによって挙動が異なる場合がある
  // 問題が発生した場合は url オプションの使用を検討
  await context.addCookies([{
    name: 'locale',
    value: locale,
    domain: 'localhost',
    path: '/',
  }]);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// テストファイル
const TEXT = {
  LOGIN: 'ログイン',
  PRODUCT_NAME: '商品名',
  CREATE: '作成',
} as const;

test.beforeEach(async ({ context }) => {
  await setLocale(context, 'ja');
});

test('should create a product', async ({ page }) => {
  await page.getByLabel(TEXT.PRODUCT_NAME).fill('テスト商品');
  await page.getByRole('button', { name: TEXT.CREATE }).click();
});

メリット: テキストが明示的で読みやすく、言語追加時の影響範囲が明確。

5. CI設定:Self-hosted Runnerでの並列実行

Matrix戦略による並列化

GitHub Actionsでは、matrixを使ってポータルごとに並列実行する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# .github/workflows/e2e-tests.yml
jobs:
  e2e:
    runs-on: [self-hosted, linux, x64]
    strategy:
      fail-fast: false
      matrix:
        portal:
          - name: system
            tests: "e2e/system-*.spec.ts e2e/system-portal/*.spec.ts"
            api_port: 8080
          - name: provider
            tests: "e2e/provider-portal/*.spec.ts"
            api_port: 8081
          # ... 他のポータル

Cross-Portal Tests の分離

複数ポータルにまたがるテスト(例:Provider→Resellerの連携)は、別ジョブとして実行する。

理由:

  • 同じユーザーでログインするテストが競合する
  • OTP取得のタイミングが重なる
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
e2e-cross-portal:
  needs: [db-setup, e2e]  # 他のE2Eテスト完了後に実行
  runs-on: [self-hosted, linux, x64]
  steps:
    - name: Run cross-portal tests
      run: |
        pnpm exec playwright test \
          e2e/auth.spec.ts \
          e2e/dashboard.spec.ts \
          e2e/search-filters.spec.ts

6. ローカルでのcross-portalテスト実行

CIでcross-portalテストに到達するまで15-20分かかるため、ローカルで先に検証できるスクリプトを用意している。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 全cross-portalテスト実行
./scripts/run-e2e-cross-portal.sh

# smokeテストのみ
./scripts/run-e2e-cross-portal.sh smoke

# ブラウザを表示して実行
./scripts/run-e2e-cross-portal.sh --headed

# Playwright UIモード
./scripts/run-e2e-cross-portal.sh --ui

まとめ

課題解決策制約・注意点
WebAuthn認証のテストCDP仮想認証器Chromium限定
OTPメールの取得Mailpit API連携ポーリング必要
並列テストでのメール競合一意アドレス + タイムスタンプ時計ズレに注意
多言語UIのテストLocale-Specific TestingCookie設定依存
CI実行時間Matrix並列化 + cross-portal分離ジョブ設計が複雑

これらの仕組みにより、Saruの主要な認証フローはCI上で自動テストできている。本番環境固有の問題(外部IdP障害、ブラウザ更新による挙動変化など)は別途手動確認が必要だが、日常的な開発サイクルでの手動テストは大幅に削減できた。


シリーズ記事