ローカル環境では完璧に動作するメールテストが、CIに投入した途端に崩壊する。そんな経験を持つ人は少なくありません。よくある対処法は、テストコードに sleep を散りばめたり、ビルドが通るまでリトライ回数を増やしたりすることです。それで一時的にノイズは収まるかもしれませんが、バグが修正されたわけではありません。単に隠しているだけなのです。
真の問題は、テストが「どのメールを開くべきか」をどのように特定しているか、という点にあります。
共有インボックスの問題
ローカルマシンでは、一度に1つのテストを実行します。メールが1通届き、それを取得する。非常にシンプルです。
CIは全く異なる環境です。1つのプルリクエストが、4つ、8つ、あるいは16もの並列ジョブをトリガーすることがあります。もしそれらがすべて共通のテスト用インボックス(Mailosaurサーバー、Mailtrapのインボックス、あるいはステージングドメイン上の実アカウントなど)を使用している場合、それらはすべて同時に同じバケットに書き込みを行っていることになります。ジョブAはパスワードリセットを送信し、ジョブBは招待を送り、ジョブCは失敗したウェルカムフローをリトライします。その間、バックグラウンドワーカーや配信キューが、制御不能なジッター(ゆらぎ)を生じさせます。
すべてのジョブがその共有インボックスにアクセスし、件名が「Reset your password」である最新のメッセージを要求すると、レース(競合状態)が発生します。勝利したテストは正しいメールを取得できますが、敗北したテストは別のジョブ用のリンクをクリックしてしまい、誤ったコンテンツに対してアサーションを行い、タイミングの問題のように見えるエラーで失敗します。これはタイミングの問題ではなく、識別(アイデンティティ)の問題なのです。
なぜ「最新のメッセージ」では失敗するのか
この壊れやすいパターンは、直感的に感じられるため、つい陥ってしまいがちです。
- ユーザーフローをトリガーする。
- 数秒おきにインボックスをポーリングする。
- 件名が一致する最新のメッセージを開く。
- 最初のリンクをクリックしてアサーションを実行する。
これは、単なる並列処理以外にも、いくつかの理由で破綻します。前回の失敗した実行によるリトライが遅れて届き、現在のテストがポーリングした瞬間に、それが突然「最新のメッセージ」になってしまうことがあります。アプリケーション内のバックグラウンドワーカーが2通のメールをキューに入れ、1通目よりも先に2通目を配信してしまう可能性もあります。件名だけでは識別子として弱すぎます。ステージング環境のアプリケーションが、異なるパスから似たようなメールを送信する可能性があるからです。タイムスタンプによるソートは、見た目以上に厄介です。CIランナーとメールプロバイダー間のクロックスキュー(時刻のズレ)は実際に存在しますし、メールAPIはインデックスをキャッシュしたりバッチ処理したりすることがよくあるからです。
混雑した環境では、タイムスタンプは曖昧になります。より直接的な手段が必要です。
「ラン・トークン(Run Token)」の正体
ラン・トークンとは、テストの開始時に生成され、アプリケーションが送信するメールに注入される一意の文字列に過ぎません。ユーザーに見える必要も、洗練されている必要もありません。ただ、「この特定のメッセージが、この特定のテスト実行に属していること」を証明できることを保証すればよいのです。
具体例を見るのが一番分かりやすいでしょう。テストを開始する前に、以下のようなトークンを生成します。
- UUID:
550e8400-e29b-41d4-a716-446655440001 - ビルドスコープのリクエストID:
req_ci_build_4821_a7f3 - 招待用スラッグまたはメタデータ接尾辞:
signup-token-8k2m9n - テストランナーによって生成されたランダムな16進数文字列:
test-run-a4f9c2d1
バックエンドのコードを制御できる場合は、トークンをメールのコンテキストに渡し、本文のどこかにレンダリングします。ブラックボックスなアプリケーションをテストしている場合は、アプリがすでに利用可能な参照フィールドを持っていないか確認してください。もしなければ、プラスアドレス(plus addressing)を使用して、受信者のローカルパートにトークンを埋め込む方法もあります(例:testuser+a4f9c2d1@example.com)。ただし、これはアプリケーションがそのアドレスを保持し、メール内でそのまま返してくれる場合に限ります。
重要なのは、メールシステムがすでに所有しているメタデータでマッチングさせるのをやめることです。テスト側が所有しているデータでマッチングさせてください。
信頼できるパターン
「最新のメッセージ」というアルゴリズムを、トークンに基づいた絞り込み検索に置き換えます。
- いかなるフローをトリガーする前にも、ラン・トークンを生成する。
- ユーザーのアクションを開始し、アプリケーションが送信メールにトークンを含めるようにする。
- そのトークンに限定したフィルタを使用して、メールプロバイダーをポーリングする。APIが本文検索をサポートしている場合はそれを使用します。サポートされていない場合は、候補となるメッセージを取得し、クライアント側で本文を
grepします。 - リンク、ボタン、または確認コードに触れる前に、メッセージ本文にトークンが存在することを確認(アサーション)する。
- その後で初めて、確認用URLやコードを抽出し、処理を続行する。
この順序が重要です。先にリンクを抽出してから後でトークンを確認すると、その時点で既に間違ったメールをクリックしてしまっています。アサーションこそが、あなたの「門番(ゲートキーパー)」なのです。
実用的な観点では、ヘルパーは Subject:"Welcome to AppName" sort:-received ではなく、Subject:"Welcome to AppName" AND Body:"a4f9c2d1" を探すべきです。多くのメールテストサービスは、本文の内容フィルタリングを受け付ける検索APIを提供しています。それらを活用しましょう。よりシンプルなプロバイダーを使用している場合は、ポーリングロジックを一箇所にまとめておき、すべてのテストで一貫してクライアントサイドのフィルタリングを追加できるようにしてください。
システムの整合性を保つための3つのルール
実行トークン(run token)によって選択対象は固定されますが、ポーリングの方法や、問題が発生した際の対応については、依然として規律が必要です。
失敗時に受信トレイの状態をログに記録する。 テストが失敗したときは、受信トレイの識別子、クエリした件名、正確なタイムスタンプの範囲、および条件に一致したメッセージの数を出力してください。これにより、漠然とした「メールが見つかりません」というエラーが、具体的な経緯へと変わります。もしジョブ7823が、3秒遅れて届いたジョブ7821のリトライメッセージを拾ってしまった場合、ログを見ればそれが一目瞭然であるべきです。このコンテキストがなければ、タイミングの問題だと決めつけ、さらに sleep を追加することになるでしょう。
すべてのメールポーリングを1つのヘルパーファイルにまとめる。 setTimeout や cy.task の呼び出しを20個ものテストファイルに分散させないでください。メッセージを待機し、APIコールをリトライし、バックオフを適用するロジックを中央集約してください。すべてのテストが同じヘルパーを使用していれば、フィルタリングルールは一貫性を保てますし、検索ロジックを改善した際にはすべてのテストにその恩恵が及びます。また、トークンチェックの強制も容易になります。ヘルパーがトークン引数を必須としていれば、誰も誤って「最新のメッセージ」という安易な手段に頼ることができなくなります。
リトライに注意する。 CIにおいてテストのリトライは一般的ですが、リトライのたびに受信トレイに別のメールが作成されます。3回目の試行でテストが成功した場合、喜んで次に進んでしまうかもしれません。見落としているのは、1回目と2回目の試行が、レースコンディション、重複送信、あるいはインデックスの欠落といった、余分なメッセージによって隠蔽された「真のバグ」を露呈させていた可能性があるということです。どうしてもリトライを使用する必要がある場合は、失敗後に受信トレイに予期しない重複メッセージが含まれていないか確認してください。プロバイダーがダイナミックな受信トレイをサポートしているなら、受信トレイをクリアするか、ジョブごとに一意のアドレスを使用することを検討するのがより良い方法です。リトライを、信頼性の低い選択ロジックを吸収するための戦略にしてはいけません。
真の教訓
受信トレイを日付順にソートして先頭の結果を取得するのは、テストではありません。それはコードの形をした「推測」です。実行トークンの導入コストはほとんどかかりません(文字列変数1つ、フィルタパラメータの追加1つ、あるいはテンプレートのわずかな変更程度です)。それだけで、テストに決定論的なアイデンティティ(deterministic identity)を与えることができます。それによって、目の前にあるメッセージが、まさに今実行しているランに属するものであることが証明されるのです。
sleep を追加してネットワークの挙動に期待するのはやめましょう。トークンを生成してメールに含め、それを直接検索してください。そうすれば、CIの実行速度は上がり、ログは読みやすくなり、メールテストスイートの結果をようやく信頼できるようになります。
