はじめに
E2Eテストを書いていると、こんなモヤモヤに出会いませんか?
「モックでは通るけど、本番やステージングで本当に動くのか…?」
「検証環境で試したいのに、MFAが邪魔してログインできない…」
この記事は、そんな悩みを抱えた自分が、検証環境のテスト用アカウントを対象に、 TOTP(ワンタイムパスワード)認証を自動化 して、実環境に近いE2Eテストを回せるようになるまでの記録です。
対象読者
- E2Eテストを実環境(QA/Staging等)で動かしたい人
- MFA認証がテスト自動化の壁になっている人
- TOTPの仕組みを理解したい人
技術スタック
| 技術 | 用途 |
|---|---|
| Playwright | E2Eテストフレームワーク |
| TypeScript | 実装言語 |
| Node.js crypto | HMAC-SHA1によるTOTP生成 |
| Prism | OpenAPIモックサーバー(比較用) |
この記事でわかること
- モックテストの限界と実環境テストの必要性
- TOTP認証を突破する3つの選択肢
- TOTPの仕組み(RFC 6238)
- TypeScriptでのTOTP実装方法
- 実装時のハマりポイントと対処法
本編
1. モックだけじゃ怖かった話
最初は Prism のモックサーバーでサクサクE2Eを進めていました。
✅ 良かったところ
- ローカル完結で速い
- バックエンドが無くても開発できる
❌ もやっとしたところ
- ダミーデータ固定で現実味が薄い
- 本物のAPIとは微妙に挙動が違う
- 「本番で本当に動くの?」の不安が残る
検証環境につないで、リアルな挙動でテストしたい!
2. 立ちはだかったMFAの壁
検証環境に向けてテストを書き換えた途端、ログイン画面で足が止まりました。
メール & パスワード入力 ✅ ここは余裕
↓
MFAコード入力 😱 ここで詰まる
スマホの Google Authenticator に出る6桁コード、これを自動で入れられないと話が進みません。
3. どう突破するかを考えた
候補を洗い出してみました。
| 方法 | メリット | デメリット |
|---|---|---|
| テストユーザーだけMFA無効化 | 実装が一番ラク | セキュリティポリシー違反の香り、本番と挙動がズレる |
| 認証スキップ用バックドアAPI | 柔軟 | バックエンド改修が必要 & セキュリティリスク大 |
| 秘密鍵からTOTPを自前生成 | 本番と同じフローで試せる、改修不要 | 秘密鍵の管理が必要 |
今回は 「秘密鍵から自前でTOTPを生成する」 を選びました。理由はシンプルで、余計な改修をしたくなかったし、本番に限りなく近い流れでテストしたかったからです。
4. そもそもTOTPって何?
TOTP = Time-based One-Time Password(時間ベースのワンタイムパスワード)
Google AuthenticatorやMicrosoft Authenticatorで表示される、あの6桁のコードです。
┌─────────────────────────────────────────────────────────────┐
│ 📱 認証アプリ (Google Authenticator) │
│ │
│ 秘密鍵: "JBSWY3DPEHPK3PXP" ← 最初にQRコードで共有された │
│ + │
│ 現在時刻: 14:30:15 │
│ ↓ │
│ 🔐 暗号計算(HMAC-SHA1) │
│ ↓ │
│ 6桁コード: 123456 │
└─────────────────────────────────────────────────────────────┘
💡 ポイント
秘密鍵と現在時刻さえあれば、誰でも同じコードが作れる!
つまり、テストコードでも生成可能なんです。
30秒ごとに変わる仕組み
14:30:00 〜 14:30:29 → カウンター = 1000 → コード "123456"
14:30:30 〜 14:30:59 → カウンター = 1001 → コード "789012"
14:31:00 〜 14:31:29 → カウンター = 1002 → コード "345678"
時刻をカウンター(30秒ごとに1増える数値)に変換し、そこから6桁コードを生成しています。
秘密鍵はどこから持ってくる?
一般的なMFAサービスでは、初回設定時にQRコード(otpauth://...形式のURI)が表示されます。このURIに含まれるBase32エンコードされた文字列が「秘密鍵」です。
- ローカル開発では
.envファイルに秘密鍵を設定し、テスト実行時に読み込みます - CI環境では GitHub Actions Secrets や AWS Secrets Manager などの環境変数ストアに登録し、コード側は
process.env経由で参照します(リポジトリに直書きしない) - 秘密鍵はQR発行時の一度きりしか表示されないことが多いので、紛失した場合はMFA設定をリセットして再発行する必要があります
5. TypeScriptでTOTPを作る
流れはこうなります。
秘密鍵(Base32) → デコード → HMAC-SHA1(カウンター) → 動的切り詰め → 6桁に整形
サンプルコードはこんな感じ(Playwrightのテストでそのまま使えます)。
import { createHmac } from 'crypto';
export const generateTotp = (secret: string): string => {
const period = 30; // 30秒区切り
const counter = BigInt(Math.floor(Date.now() / 1000 / period));
const decodedSecret = decodeBase32(secret);
const hmac = createHmac('sha1', decodedSecret);
hmac.update(counterToBuffer(counter));
const digest = hmac.digest();
const offset = digest[digest.length - 1] & 0x0f; // 動的切り詰め
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return (code % 1000000).toString().padStart(6, '0');
};
「カウンターを作る → HMACを取る → 6桁に整形する」だけ。ライブラリに頼らずに済むので、挙動を把握しやすいのも良いところです。
6. 詰まったポイントと対処法
タイミングずれ問題
生成したコードを入力する間に30秒が切り替わると、認証に失敗します。
14:30:28 に生成 → "123456"
14:30:31 に入力 → もう期限切れ…
解決:残り時間を見て、危なそうなら待つ
const periodMs = 30 * 1000;
const timeRemaining = periodMs - (Date.now() % periodMs);
if (timeRemaining < 5000) {
await page.waitForTimeout(timeRemaining + 1000); // 1秒余裕を持たせる
}
const otp = generateTotp(secret);
これで「ギリギリでコードが変わる」問題をほぼ潰せました。
7. Playwrightで組み込む
実際にPlaywrightのテストに組み込むには、globalSetup を使います。
// globalSetup.ts - テスト実行前に1度だけ走る認証処理
import { chromium } from '@playwright/test';
import { generateTotp } from './totp';
export default async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();
// ログイン
await page.goto('https://your-app.com/');
await page.getByRole('textbox', { name: /email/i }).fill(process.env.E2E_EMAIL!);
await page.getByRole('textbox', { name: /password/i }).fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: /sign in/i }).click();
// MFA画面でTOTPを入力
await page.waitForURL(/mfa/i);
const otp = generateTotp(process.env.E2E_TOTP_SECRET!);
await page.getByPlaceholder(/code/i).fill(otp);
await page.getByRole('button', { name: /verify/i }).click();
// セッション保存 → 2回目以降はログイン不要
await page.context().storageState({ path: 'storageState.json' });
await browser.close();
}
ポイント:
-
globalSetupでテスト前に1度だけ認証処理を実行 -
storageStateで認証状態(Cookie、localStorage等)を保存 - 各テストはログイン済みの状態で開始できる
この方法でうまくTOTPを突破することができました!
8. セッションを保存してさらにラクする
毎回TOTPを生成するのは面倒なので、Playwrightの storageState で認証後セッションを保存します。
await page.context().storageState({ path: 'storageState.json' });
- 2回目以降はログインステップが丸ごとスキップ
- 実行時間も短縮
- TOTPの計算も最小限
9. セキュリティ上の注意事項
⚠️ この手法を使う際は、以下の点に十分注意してください。
絶対に守るべきこと
-
本番環境の秘密鍵は使わない
- テスト用に専用のアカウントを作成し、そのアカウントの秘密鍵のみを使用する
- 本番アカウントの秘密鍵をテストコードに含めると、漏洩時のリスクが甚大
-
テスト用アカウントは最小権限にする
- E2Eテストに必要な最低限の権限のみを付与
- 管理者権限や機密データへのアクセス権は不要
-
秘密鍵は安全に管理する
- Gitリポジトリに直書きは絶対NG
-
.envファイルは.gitignoreに追加 - CI/CDでは GitHub Actions Secrets、AWS Secrets Manager、HashiCorp Vault などを活用
# .env(リポジトリには含めない)
TOTP_SECRET_FOR_TESTS=XXXXXXXXXXXXXXXX
おわりに
「MFA認証があるからE2Eテスト無理かもな...」と思っていましたが、「秘密鍵からTOTPを自前生成する」方法で、仕組みを理解したら意外とシンプルに解決できました。
同じ壁にぶつかっている人の参考になれば嬉しいです!




