ランナー上で何が変わるか
テストそのものは変わりません。変わるのはその周辺のすべてであり、それぞれには、あいまいにごまかすのではない具体的な答えがあります。
- マウントすべきシークレットがない
- 公開メールボックスの読み取りにはキーもアカウントもヘッダーも不要なので、メールボックス側は
secretsに何も追加しません。ジョブの中にある唯一の認証情報は、アプリケーションがメールを送信するためにすでに必要としているもの — SendGrid、Postmark、SESなど、どれであれ — であり、それはテストのものではなく、アプリケーションのものです。 - ランナーはインターネットに到達できなければならない
grabmail.ioへのアウトバウンドHTTPSと、自分のメーラーが使っている先へのアウトバウンドです。GitHubホストランナーはデフォルトで両方を許可しています。アウトバウンド通信をフィルタしているセルフホストランナーの場合は、ルールを1つ追加する必要があります。- 実行が重なる
- 2つのプルリクエスト、4つのシャード、不安定なジョブのリトライ — 同じテストの複数のコピーが、同時にメールを読むことになります。アドレスを共有していれば、互いのコードを読んでしまう可能性がありますが、テストごとに新しいアドレスを使えば、この種の問題はそもそも起こり得ません。
- 時間は課金される
- メールを60秒待つテスト1つなら問題ありません。しかし、40個のテストすべてで60秒ずつ待つジョブは、課金されるランナー時間にして40分になります。待機には上限が必要であり、スイートが大きくなったらシャーディングする必要があります。
これらのワークフローが実行するテストコードは、PlaywrightガイドやPythonガイドにあるものと同じです。新しいアドレス、期限付きの待機、自分のテンプレートに固定した抽出コード。そこにはCI固有のものは何もなく、それこそが重要な点です — ランナー固有の部分は、すべてワークフローファイルの中にあります。
ワークフロー、Playwright向け
ジョブは1つです。本物の送信用メーラーでアプリケーションを起動し、応答があるまで待ち、スイートを実行し、何かが失敗したときだけレポートを保持します。
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20 # the whole job, comfortably above every wait inside it
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Your application, started the way it runs in staging: a REAL outbound
# mailer. Its credentials are YOUR secret; the mailbox side needs none.
- name: Start the application
run: npm run start:test &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:3000
- name: Wait for the application
run: npx wait-on --timeout 60000 http://localhost:3000/health
- name: Run the suite
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7重要なのは3行です。timeout-minutes: 20は、他のすべてがその内側に入れ子になる外枠です。アプリケーションは本物のメーラー認証情報で起動されます。本物のメールを読むテストには、本物のメールが送信されている必要があるからです。そしてレポートは、失敗したときだけ、短い保存期間でアップロードされます — 成功した実行には、残しておく価値のあるものは何もありません。
ワークフロー、pytest向け
Pythonのツールチェーンでも形は同じです。アプリケーションを起動し、ヘルスチェック用のエンドポイントを待ち、メールの期限より長いテストごとのタイムアウトでスイートを実行し、失敗時にはJUnitレポートを保持します。
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Start the application
run: python -m app.server &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:8000
- name: Wait for the application
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:8000/health && exit 0
sleep 1
done
echo "application did not come up" >&2; exit 1
- name: Run the suite
run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
env:
BASE_URL: http://localhost:8000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-report
path: report.xml--timeout=120はpytest-timeoutプラグインによるもので、テストごとの上限です。ヘルパー内のメールの期限は60秒なので、1通のメッセージを待ってからブラウザ操作を少し行うテストでも、余裕を持って収まります。ヘルスチェックのループは、Actionとして取り込むのではなく直接書き出しています。わずか8行で、間違えようがないからです。
唯一のネットワークルール
メールボックスを読むというのは、ランナーからgrabmail.ioへのアウトバウンドHTTPSリクエストです。ネットワーク上必要なのは、それがすべてです。
- インバウンドはなし。ランナーに接続してくるものは何もありません。受け取るWebhookも、動かすSMTPサーバーも、公開すべきポートもありません。
- ランナーからのSMTPもなし。メールは、アプリケーションがそのプロバイダを通じて、プロバイダのAPIまたはSMTPエンドポイント経由で送信します — 本番環境と同じやり方です。ランナー自身がSMTPを話すことは決してありません。
- 追加が必要なのは
grabmail.io:443だけです。アウトバウンドの許可リストを使っているランナーの場合、これに加えて、ジョブがすでに必要としていたメールプロバイダとパッケージレジストリがあります。
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
grabmail.io:443
api.your-mail-provider.example:443
registry.npmjs.org:443スイートがローカルでは通るのにCIではヘルパーの接続エラーで失敗する場合、まず確認すべきはこのルールであり、それがほぼ答えのすべてであることが多いです。企業ネットワーク内のセルフホストランナーは、通常、ホスト名単位でHTTPSのアウトバウンド通信をフィルタしています。許可すべきホスト名はAPIのものであり、リクエストは443番ポート上のごく普通のHTTPSです。
シャード、マトリクスジョブ、リトライ
スイートがシャーディングするに値するほど遅くなったら、シャーディングしてください。Playwrightは--shardで実行を複数のジョブに分割でき、すべてのテストが自分自身のメールボックスを開くため、シャード同士は互いに何も必要としません。
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# ... the same steps as above, then:
- run: npx playwright test --shard=${{ matrix.shard }}/4同じ性質が、テストが同時に2回実行されてしまう他の2つのケースもカバーします。
- 同時に2つのプルリクエスト
- 2つのジョブ、2組のランダムなアドレス、重なりはありません。APIのクライアントごとの上限は1分間に1200リクエストで、これは1秒に1回ポーリングするメールボックス20個分にあたります — 一度に1つのメールボックスをポーリングするジョブは、その上限にはまったく届きません。
- リトライされたジョブ
- リトライはテスト本体をもう一度実行し、それによって新しいアドレスがまた生成されます。古いメールボックスは古いメッセージを5日間保持し続けますが、それを読むものは何もありません — リトライがそれを目にすることは決してありません。
- Playwright自身の
retries - 同じことが、1段階下のレベルでも起きます。各試行が、フィクスチャをもう一度実行します。時間を節約しようとしてアドレスを
beforeAllに移さないでください。それはまさに、ある試行が前の試行のコードを読んでしまう共有状態そのものです。
ジョブの中に収まるタイムアウト
時計は4つあり、内側ほど短くなるように入れ子になっていなければなりません。そうなっていないと、間違った時計が失敗を報告し、間違った原因を指し示すことになります。
| 時計 | 設定場所 | 妥当な値 |
|---|---|---|
| メールの期限 | ヘルパーの内部(timeoutMs、timeout=) | 60秒。トランザクションメールは数秒で届きますが、1分あれば遅いプロバイダのキューにも対応できます。 |
| テストのタイムアウト | playwright.config.ts / --timeout | 120秒。期限に、その前後のブラウザ操作の分を加えた値より大きくします。 |
| ステップのタイムアウト | ステップのtimeout-minutes(設定する場合) | 通常は未設定で構いません。ジョブの上限で十分です。 |
| ジョブのタイムアウト | ジョブのtimeout-minutes | 20分。インストール、起動、スイートの実行、アップロードには十分な長さでありながら、アプリがハングしても1時間分課金されることのないよう十分に短くなっています。 |
入れ子が崩れているときの症状は特徴的です。Playwrightのデフォルトである30秒のテストタイムアウトの中で60秒待とうとするテストは、毎回、30秒でテストについてのメッセージとともに死に、メールについては何も語りません。まずテストのタイムアウトを設定し、それから外側へ向かって順に設定してください。
失敗を読み解く
失敗した実行は、何も再実行しなくても、次の3つのうちどれが起きたのかを教えてくれるべきです — メールがまったく来なかったのか、間違ったメールが来たのか、それとも中のコードが間違っていたのか。それを実現する4つの習慣があります。
- アドレスをログに残す。ヘルパーの失敗メッセージには、待っていたメールボックスの名前が入っています。テストの冒頭でもう一度出力しておけば、アサーションが別の場所にあっても、ジョブログの中に残ります。
- メールボックスを手動で開く。メッセージは5日間残るため、このサイトの
/inbox/<address>で、ランナーが実際に何を見た(あるいは見なかった)かを、その週の残りずっと確認できます。これこそが、モックされたメールボックスに対する、本物のメールボックスの最大の利点です。 - 失敗時にはレポートを保持する。Playwrightのトレースには、メールを送信するはずだったクリックが記録されており、JUnitファイルには、どのテストがどれだけ待ったかが記録されています。
- テストを疑う前に、サービスのステータスを確認する。ステータスページは外部から2分おきにプローブされています。実行時に受信メールが止まっていたのであれば、その失敗は本物であり、あなたのせいではありません。
クリーンアップ(任意)
誰かが削除するかどうかにかかわらず、すべては5日後に期限切れになるため、クリーンアップを省略した実行にもコストはかかりません。それでも、実行が読んだものを削除するステップには価値があります。次の失敗を、本当に空のメールボックスに対して読めるようになるからです。これは冪等です — 2回削除しても200が返ります — そのため、これ自体がビルドを失敗させることは決してありません。
- name: Delete what the run read
if: always()
run: |
for addr in $(cat .e2e-addresses 2>/dev/null); do
curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
| jq -r '.messages[].id' \
| xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
doneif: always()にしてください。そして絶対に必須にしないでください。クリーンアップの失敗は、レッドのビルドではなく、ログ上の警告であるべきです。
完了と呼ぶ前に
secretsにメールボックス用の認証情報はない。あるのはアプリケーション自身のメーラーキーだけ。grabmail.ioへのアウトバウンドHTTPSを許可し、インバウンドは一切なし。- テストごとに新しいアドレスを、テスト本体の中で生成する — シャードやリトライの下でも安全。
- 4つのタイムアウトが入れ子になっている: 期限 < テスト < ステップ < ジョブ。
- レポートは失敗時にアップロードし、ログにはアドレスを含める。
- クリーンアップは、常に実行されるが必須ではないステップにする。
ランナーが追加するのは、それだけです。その土台にあるテストの流儀 — 期限、新しいアドレス、固定したパターン — については確認フローをエンドツーエンドでテストするにあり、抽出ルールだけを取り出したものは自動テストにおけるOTPコードにあります。
質問
リポジトリにGrabMailのシークレットを追加する必要がありますか?
いいえ。公開ドメインはキーもアカウントもヘッダーも不要なので、secretsに追加するものは何もありません。ワークフロー内の唯一の認証情報は、アプリケーションがメールを送信するために使っているものであり、それはそもそも動かすために必要なものです。
プライベートリポジトリやセルフホストランナーでも動きますか?
動きます。ランナーが行うのはgrabmail.ioへのアウトバウンドHTTPSリクエストだけであり、ランナーをどこでホストしているかは関係ありません。アウトバウンド通信をフィルタしているセルフホストランナーでは、そのホスト名の443番ポートを許可してください。
同時実行されるジョブはレート制限に引っかかりますか?
実際にはほぼ引っかかりません。アドレスごとの上限は1秒に1回の読み取りで、ヘルパーはこれを守ります。クライアントごとの上限は1分間に1200リクエストです — 1つのランナーから、1秒に1回ポーリングするメールボックス20個分にあたります。ランナーが複数あれば、クライアントも複数になります。429にはRetry-Afterが付いて返り、ヘルパーは失敗させるのではなく、その分だけスリープします。
本番のサインアップを合成監視するために、これをスケジュール実行できますか?
できますし、それは良い使い方です。on: scheduleのワークフローで、毎時間新しいアドレスでサインアップしてコードを読み取れば、プロバイダを含む本番のメール経路全体を証明できます。アドレスの接頭辞をわかりやすくしておけば、そちら側でサインアップを消し込みやすくなります。
アプリケーションが使い捨てドメインを拒否する場合はどうなりますか?
独自ドメインをこのサービスに向けてください — MXレコード1件、アカウント不要です — そして、そのドメインをフィクスチャで使います。設定方法は数分で終わり、1つのドメインで無制限のテストアカウントを持つでは、それをスイートの中でどう使うかを紹介しています。
メールボックスの中身に、プライベートなものはありますか?
ありません。公開ドメインでも独自ドメインでも、アドレスを知っている人なら誰でも読めます。使い捨てのコードを1つだけ保持するランダムなアドレスにとって、これは無関係な話です。しかし、実際の顧客データを送るステージング環境にとっては、これは決定的な問題です — そちらをここに向けてはいけません。


