この記事で得られること#
- WebAuthn(パスキー)認証をCI環境でテストする方法
- OTPメール取得の自動化(Mailpit API連携)
- 並列E2Eテストでのメール競合を防ぐテクニック
- 日本語UIを直接テストするlocale-specific testing
はじめに#
第1回では、マルチテナントSaaS「Saru」の全体像と自動化戦略を紹介した。今回は、その自動化の核となるE2Eテストの実装詳細を掘り下げる。
特に難しいのが認証フローのテストだ。Saruでは2種類の認証方式を採用している:
| ポータル | 認証方式 | 難しさ |
|---|
| System / Provider | OTP + パスキー | メール取得、WebAuthn |
| Reseller / Consumer | Keycloak 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を誤って取得してしまう問題が発生した。
例えば:
- テストA:
user-a@example.comにOTP送信 - テストB:
user-b@example.comにOTP送信 - テストA: Mailpitを検索 → テストBのOTPを取得してしまう
解決策:タイムスタンプ + 一意アドレスによるフィルタリング#
Saruでは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 Testing | Cookie設定依存 |
| CI実行時間 | Matrix並列化 + cross-portal分離 | ジョブ設計が複雑 |
これらの仕組みにより、Saruの主要な認証フローはCI上で自動テストできている。本番環境固有の問題(外部IdP障害、ブラウザ更新による挙動変化など)は別途手動確認が必要だが、日常的な開発サイクルでの手動テストは大幅に削減できた。
シリーズ記事