
はじめに
認証基盤を別ドメインの共通ログインへ切り替えたところ、本番デプロイ後のスモークテスト(Playwright の setup project)が毎回タイムアウトで落ちるようになりました。
ログインボタンを押したあとの遷移先が、旧SSOのフォームから新しいログイン画面に変わり、旧フォーム前提のセレクタが見つからなくなったのが原因です。
この記事では、クリック後に着いたURLで新旧のログインフローを振り分ける方法を紹介します。
あわせて、成功と失敗の競争待ち、すぐ消えるエラー理由の記録、SSR直後のクリック失敗への対処をまとめます。
フィーチャーフラグを戻すロールバックをしてもスモークが壊れない構成が着地点です。
こんな人におすすめ
- 認証基盤の移行中で、E2Eのログイン手順が新旧混在している方
- Playwright の
setupproject でログイン状態を保存している方 - ログイン失敗時の原因がテストログから分からず困っている方
- SSR のページで「クリックしたのに反応しない」不安定さに悩んでいる方
遷移先URLで認証フローを分岐する
最初に避けたいのは、テストがフィーチャーフラグの値を読んで分岐する作り方です。
フラグ状態とテストが結合し、ロールバックのたびにテストの修正が必要になります。
代わりに、ログインボタンを押した「あと」に実際に着いたURLで判定します。
waitForURL に「新ログインURL または 旧SSO URL」という述語を渡し、着地点に応じて処理を振り分けます。
await page.getByTestId('login-button').first().click();
await page.waitForURL(
(url) => ACCOUNT_LOGIN_URL.test(url.href) || LEGACY_SSO_URL.test(url.href),
{ timeout: 30000 }
);
if (ACCOUNT_LOGIN_URL.test(page.url())) {
await loginWithAccount(page);
} else {
await loginWithLegacySso(page);
}
新ログインではメール・パスワードを入力し、初回のみ同意画面を挟んでアプリへ戻ります。
旧SSOは従来の手順のままです。
どちらに着いても処理できるので、フラグをOFFに戻したときもスモークは通ります。
成功と失敗の競争を Promise.any で待つ
ログイン送信後は、次の画面へ進むか、エラー表示が出るかのどちらかになります。
片方だけを待つと、失敗時に30秒のタイムアウトまで原因が分かりません。
両方の待機を作り、先に決着したほうを採用します。
const moved = page
.waitForURL((url) => isApp(url) || url.pathname.startsWith(CONSENT_PATH), { timeout: 30000 })
.then(() => 'moved' as const);
const failed = loginError
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'error' as const);
// 負けた側の待機は後から失敗するので握りつぶす
moved.catch(() => undefined);
failed.catch(() => undefined);
const result = await Promise.any([moved, failed]);
負けた側の Promise は、あとからタイムアウトで reject されます。
.catch(() => undefined) で握りつぶしておかないと、unhandled rejection でテストプロセスが不安定になります。
すぐ消えるエラー理由を framenavigated で控える
今回の画面では、失敗理由がクエリ文字列(たとえば ?auth_error=denied)で返ってきます。
しかもアプリ側が、受け取った直後にURLから取り除く設計でした。
テストの最後にURLを見ても、すでに理由は消えています。
そこで、遷移のたびに値を保存しておきます。
let authFailure: string | null = null;
page.on('framenavigated', (frame) => {
if (frame !== page.mainFrame()) return;
const reason = new URL(frame.url()).searchParams.get('auth_error');
if (reason !== null) authFailure = reason;
});
失敗時のアサートでは、この authFailure を理由として添えられます。
メッセージには、運用者がそのまま動ける次の確認事項も入れておくと便利です。
たとえば「アカウント基盤で直接ログインできるか」「許可リスト運用なら登録済みか」といった内容です。
ただし、資格情報の値そのものはログにもメッセージにも出さないでください。
SSR 直後の同意バナーは toPass で再試行する
SSR で先に描画された同意バナーは、hydration が終わるまでクリックハンドラが接続されていないことがあります。
見えているので click() は成功しますが、バナーは閉じません。
await expect(async () => {
await page.getByRole('button', { name: '同意する' }).click();
await expect(page.getByTestId('consent-banner')).toBeHidden({ timeout: 1000 });
}).toPass({ timeout: 15000 });
「クリック → 非表示の確認」までをひとまとまりにして、閉じるまで再試行します。
固定の waitForTimeout を置くより安定します。
実資格情報なしで確認できる範囲
本物のアカウントを使わなくても、確認できる範囲は意外と広いです。
ログイン送信のAPIを page.route で横取りして応答を差し替えれば、エラー表示の分岐や同意画面後の失敗分岐を、本番を汚さずに検証できます。
一方で、本物のアカウントでの成功パスは確認できません。
未確認の範囲は、PRの説明に明記しておくと後からの追跡が楽になります。
つまづきやすいポイント
Promise.anyの負け側を握りつぶし忘れると、テスト終了後に unhandled rejection が出ますframenavigatedはサブフレームでも発火します。mainFrame()との比較を忘れないでくださいwaitForURLの述語は、正規表現の書き方次第で新旧両方にマッチしてしまいます。分岐の順序を確認してください- 失敗メッセージに資格情報や、それが推測できる値を含めないよう注意してください
まとめ
認証基盤の切り替えでスモークが落ちたら、フラグを読むのではなく、クリック後の着地URLで新旧を分岐させるのが有効でした。
成功と失敗は Promise.any で競争させ、すぐ消えるエラー理由は framenavigated で控えます。
SSR直後のUIは toPass で再試行すると安定します。
この構成なら、フラグを戻すロールバックでもテストの修正は不要です。

