テスト・CI

GitHub Actionsで本物のメールを読むE2Eジョブ

メールボックスから確認コードを読み取るサインアップテストは、ノートPC上では動きますが、やがてCIランナーに直面します。マウントするシークレットもなく、アウトバウンド通信の問題もあり、ジョブに収まる期限も必要で、実行やリトライのたびに新しいメールボックスでなければなりません。ここでは、Playwright向けとpytest向けのワークフローを、ランナー上でしか問題にならない部分とともに紹介します。

  • 中級
  • 読了15分
3つの灰色の歯車が回すベルトが、青いランプの付いた灰色の門へ青い封筒を運んでいく

ランナー上で何が変わるか

テストそのものは変わりません。変わるのはその周辺のすべてであり、それぞれには、あいまいにごまかすのではない具体的な答えがあります。

マウントすべきシークレットがない
公開メールボックスの読み取りにはキーもアカウントもヘッダーも不要なので、メールボックス側はsecretsに何も追加しません。ジョブの中にある唯一の認証情報は、アプリケーションがメールを送信するためにすでに必要としているもの — SendGrid、Postmark、SESなど、どれであれ — であり、それはテストのものではなく、アプリケーションのものです。
ランナーはインターネットに到達できなければならない
grabmail.ioへのアウトバウンドHTTPSと、自分のメーラーが使っている先へのアウトバウンドです。GitHubホストランナーはデフォルトで両方を許可しています。アウトバウンド通信をフィルタしているセルフホストランナーの場合は、ルールを1つ追加する必要があります。
実行が重なる
2つのプルリクエスト、4つのシャード、不安定なジョブのリトライ — 同じテストの複数のコピーが、同時にメールを読むことになります。アドレスを共有していれば、互いのコードを読んでしまう可能性がありますが、テストごとに新しいアドレスを使えば、この種の問題はそもそも起こり得ません。
時間は課金される
メールを60秒待つテスト1つなら問題ありません。しかし、40個のテストすべてで60秒ずつ待つジョブは、課金されるランナー時間にして40分になります。待機には上限が必要であり、スイートが大きくなったらシャーディングする必要があります。

これらのワークフローが実行するテストコードは、PlaywrightガイドPythonガイドにあるものと同じです。新しいアドレス、期限付きの待機、自分のテンプレートに固定した抽出コード。そこにはCI固有のものは何もなく、それこそが重要な点です — ランナー固有の部分は、すべてワークフローファイルの中にあります。

ワークフロー、Playwright向け

ジョブは1つです。本物の送信用メーラーでアプリケーションを起動し、応答があるまで待ち、スイートを実行し、何かが失敗したときだけレポートを保持します。

.github/workflows/e2e.yml
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レポートを保持します。

.github/workflows/e2e.yml
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=120pytest-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で実行を複数のジョブに分割でき、すべてのテストが自分自身のメールボックスを開くため、シャード同士は互いに何も必要としません。

4つのシャード、それぞれ別々のジョブ
  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つあり、内側ほど短くなるように入れ子になっていなければなりません。そうなっていないと、間違った時計が失敗を報告し、間違った原因を指し示すことになります。

時計設定場所妥当な値
メールの期限ヘルパーの内部(timeoutMstimeout=)60秒。トランザクションメールは数秒で届きますが、1分あれば遅いプロバイダのキューにも対応できます。
テストのタイムアウトplaywright.config.ts / --timeout120秒。期限に、その前後のブラウザ操作の分を加えた値より大きくします。
ステップのタイムアウトステップのtimeout-minutes(設定する場合)通常は未設定で構いません。ジョブの上限で十分です。
ジョブのタイムアウトジョブのtimeout-minutes20分。インストール、起動、スイートの実行、アップロードには十分な長さでありながら、アプリがハングしても1時間分課金されることのないよう十分に短くなっています。

入れ子が崩れているときの症状は特徴的です。Playwrightのデフォルトである30秒のテストタイムアウトの中で60秒待とうとするテストは、毎回、30秒でテストについてのメッセージとともに死に、メールについては何も語りません。まずテストのタイムアウトを設定し、それから外側へ向かって順に設定してください。

失敗を読み解く

失敗した実行は、何も再実行しなくても、次の3つのうちどれが起きたのかを教えてくれるべきです — メールがまったく来なかったのか、間違ったメールが来たのか、それとも中のコードが間違っていたのか。それを実現する4つの習慣があります。

  1. アドレスをログに残す。ヘルパーの失敗メッセージには、待っていたメールボックスの名前が入っています。テストの冒頭でもう一度出力しておけば、アサーションが別の場所にあっても、ジョブログの中に残ります。
  2. メールボックスを手動で開く。メッセージは5日間残るため、このサイトの/inbox/<address>で、ランナーが実際に何を見た(あるいは見なかった)かを、その週の残りずっと確認できます。これこそが、モックされたメールボックスに対する、本物のメールボックスの最大の利点です。
  3. 失敗時にはレポートを保持する。Playwrightのトレースには、メールを送信するはずだったクリックが記録されており、JUnitファイルには、どのテストがどれだけ待ったかが記録されています。
  4. テストを疑う前に、サービスのステータスを確認する。ステータスページは外部から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
          done

if: 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つだけ保持するランダムなアドレスにとって、これは無関係な話です。しかし、実際の顧客データを送るステージング環境にとっては、これは決定的な問題です — そちらをここに向けてはいけません。

新しいうちに試してみてください

アドレスの取得はワンクリックで、アカウントもカードも不要です。このガイドの内容はすべて、そのアドレスですぐに試せます。

おかえりなさい

受信箱とドメインを、ひとつの場所に。