Tests & CI

GitHub Actions: ein E2E-Job, der echte E-Mails liest

Der Anmeldetest, der seinen Bestätigungscode aus einem Postfach liest, funktioniert auf einem Laptop und trifft dann auf einen CI-Runner: kein Secret zu mounten, eine Frage des ausgehenden Datenverkehrs, eine Deadline, die in den Job passen muss, und ein Postfach, das für jeden Lauf und jede Wiederholung frisch sein muss. Hier ist der Workflow, für Playwright und für pytest, mit den Teilen, die nur auf einem Runner eine Rolle spielen.

  • Fortgeschritten
  • 15 Min. Lesezeit
Drei graue Zahnräder treiben ein Band an, das einen blauen Umschlag zu einem grauen Tor mit blauer Lampe befördert

Was sich auf einem Runner ändert

Der Test selbst ändert sich nicht. Was sich ändert, ist alles drum herum, und jeder dieser Punkte hat eine konkrete Antwort statt eines Achselzuckens:

Es gibt kein Secret zu mounten
Ein öffentliches Postfach zu lesen braucht weder Schlüssel noch Konto noch Header, die Postfach-Seite fügt secrets also nichts hinzu. Die einzigen Zugangsdaten im Job sind die, die Ihre Anwendung ohnehin zum Versenden von Mail braucht — SendGrid, Postmark, SES, was auch immer —, und die gehören zu Ihrer App, nicht zum Test.
Der Runner muss das Internet erreichen
Ausgehendes HTTPS zu grabmail.io, und ausgehend zu was auch immer Ihr Mailer verwendet. Von GitHub gehostete Runner erlauben beides standardmäßig; ein selbst gehosteter Runner hinter einem Filter für ausgehenden Datenverkehr braucht eine zusätzliche Regel.
Läufe überlappen sich
Zwei Pull Requests, vier Shards, die Wiederholung eines instabilen Jobs — mehrere Kopien desselben Tests lesen gleichzeitig Mail. Eine geteilte Adresse würde es ihnen erlauben, gegenseitig ihre Codes zu lesen; eine frische Adresse pro Test macht die ganze Klasse von Problemen unmöglich.
Zeit wird abgerechnet
Ein Test, der sechzig Sekunden auf Mail wartet, ist unproblematisch. Ein Job, der das in jedem von vierzig Tests tut, sind vierzig Minuten abgerechneter Runner-Zeit. Die Wartezeiten müssen begrenzt sein, und die Suite muss geshardet werden, sobald sie wächst.

Der Testcode, den diese Workflows ausführen, ist derselbe wie in der Playwright-Anleitung oder der Python-Anleitung: eine frische Adresse, ein Warten mit einer Deadline, ein Extraktor, verankert in Ihrer Vorlage. Nichts davon ist CI-spezifisch, und genau das ist der Punkt — die runner-spezifischen Teile stecken vollständig in der Workflow-Datei.

Der Workflow, für Playwright

Ein Job. Er startet Ihre Anwendung mit einem echten ausgehenden Mailer, wartet, bis sie antwortet, führt die Suite aus und behält den Bericht nur, wenn etwas fehlgeschlagen ist.

.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

Drei Zeilen tragen das Gewicht. timeout-minutes: 20 ist die äußere Grenze, in die sich alles andere einbettet. Die Anwendung wird mit ihren echten Mailer-Zugangsdaten gestartet, weil ein Test, der echte Mail liest, echte versendete Mail braucht. Und der Bericht wird nur bei einem Fehlschlag hochgeladen, mit kurzer Aufbewahrung — ein erfolgreicher Lauf hat nichts, was sich zu behalten lohnt.

Der Workflow, für pytest

Dieselbe Form mit der Python-Toolchain: die Anwendung starten, auf ihren Health-Endpoint warten, die Suite mit einem Timeout pro Test über der Mail-Deadline ausführen, den JUnit-Bericht bei einem Fehlschlag behalten.

.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=120 stammt vom Plugin pytest-timeout und ist die Obergrenze pro Test; die Mail-Deadline im Helfer beträgt sechzig Sekunden, ein Test, der auf eine Nachricht wartet und danach noch etwas Browser-Arbeit macht, passt also noch hinein. Die Health-Check-Schleife ist ausgeschrieben statt als Action eingebunden, weil sie acht Zeilen umfasst und es darin nichts gibt, was man falsch machen könnte.

Die eine Netzwerkregel

Ein Postfach zu lesen ist eine ausgehende HTTPS-Anfrage vom Runner zu grabmail.io. Das ist der gesamte Netzwerk-Fußabdruck:

  • Nichts Eingehendes. Nichts verbindet sich zum Runner. Es gibt keinen Webhook zu empfangen, keinen SMTP-Server zu betreiben, keinen Port freizugeben.
  • Kein SMTP vom Runner aus. Die Mail wird von Ihrer Anwendung über deren Provider verschickt, über die API oder den SMTP-Endpoint dieses Providers — genauso wie in der Produktion. Der Runner spricht selbst nie SMTP.
  • Nur grabmail.io:443 hinzuzufügen auf einem Runner mit einer Freigabeliste für ausgehenden Datenverkehr — dazu Ihr Mail-Provider und Ihre Paket-Registry, die der Job ohnehin schon brauchte.
auf einem abgesicherten Runner genau das erlauben
      - 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

Wenn die Suite lokal durchläuft und in der CI mit einem Verbindungsfehler vom Helfer scheitert, ist diese Regel das Erste, was zu prüfen ist, und fast immer schon die ganze Antwort. Ein selbst gehosteter Runner in einem Unternehmensnetzwerk hat ausgehendes HTTPS typischerweise nach Hostname gefiltert; der freizugebende Hostname ist der der API, und die Anfrage ist einfaches HTTPS auf Port 443.

Shards, Matrix-Jobs und Wiederholungen

Sobald die Suite langsam genug ist, um sie zu sharden, sharden Sie sie. Playwright teilt einen Lauf mit --shard auf mehrere Jobs auf, und weil jeder Test sein eigenes Postfach öffnet, brauchen die Shards nichts voneinander:

vier Shards, jeder ein eigener Job
  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

Dieselbe Eigenschaft deckt die anderen beiden Arten ab, wie ein Test am Ende gleichzeitig zweimal läuft:

Zwei Pull Requests gleichzeitig
Zwei Jobs, zwei Sätze zufälliger Adressen, keine Überschneidung. Die Obergrenze pro Client bei der API liegt bei 1200 Anfragen pro Minute, das sind zwanzig Postfächer, einmal pro Sekunde gepollt — ein Job, der jeweils ein Postfach pollt, ist davon weit entfernt.
Ein wiederholter Job
Eine Wiederholung führt den Testkörper erneut aus, der wieder eine neue Adresse erfindet. Das alte Postfach hält die alte Nachricht noch 5 Tage lang, und nichts liest sie — die Wiederholung sieht sie nie.
Playwrights eigenes retries
Dasselbe, eine Ebene tiefer: Jeder Versuch führt die Fixture erneut aus. Verschieben Sie die Adresse nicht in ein beforeAll, um Zeit zu sparen; genau das ist die Art von Teilen, die einen Versuch den Code des vorherigen Versuchs lesen lässt.

Timeouts, die in den Job passen

Es gibt vier Uhren, und sie müssen ineinander verschachtelt sein, die innerste am kürzesten. Ist das nicht so, wird der Fehlschlag von der falschen gemeldet und zeigt auf die falsche Ursache.

UhrWo gesetztEin vernünftiger Wert
Mail-DeadlineIm Helfer (timeoutMs, timeout=)60 s. Transaktionale Mail landet in Sekunden; eine Minute deckt eine langsame Provider-Warteschlange ab.
Test-Timeoutplaywright.config.ts / --timeout120 s. Über der Deadline plus der Browser-Arbeit drumherum.
Step-Timeouttimeout-minutes am Step, falls gesetztMeist nicht gesetzt; die Job-Grenze reicht.
Job-Timeouttimeout-minutes am Job20 min. Genug für Installation, Start, die Suite und den Upload; niedrig genug, dass eine hängende Anwendung nicht eine Stunde lang abgerechnet wird.

Das Symptom einer kaputten Verschachtelung ist eindeutig: Ein Test, der sechzig Sekunden innerhalb von Playwrights standardmäßigem Dreißig-Sekunden-Test-Timeout wartet, stirbt jedes Mal bei dreißig Sekunden mit einer Meldung über den Test und sagt nichts über die Mail aus. Setzen Sie zuerst das Test-Timeout, dann alles, was weiter außen liegt.

Einen Fehlschlag lesen

Ein fehlgeschlagener Lauf sollte Ihnen sagen, welches der drei Dinge passiert ist — die Mail kam nie, die falsche Mail kam, oder der Code darin war falsch —, ohne dass irgendetwas erneut laufen muss. Vier Gewohnheiten sorgen dafür:

  1. Loggen Sie die Adresse. Die Fehlermeldung des Helfers nennt das Postfach, auf das gewartet wurde. Geben Sie sie noch einmal am Anfang des Tests aus, damit sie im Job-Log steht, auch wenn die Prüfung an anderer Stelle scheitert.
  2. Öffnen Sie das Postfach von Hand. Nachrichten bleiben 5 Tage lang erhalten, sodass /inbox/<address> auf dieser Website für den Rest der Woche genau zeigt, was der Runner gesehen hat — oder eben nicht. Das ist der mit Abstand nützlichste Vorteil eines echten Postfachs gegenüber einem gemockten.
  3. Behalten Sie den Bericht bei einem Fehlschlag. Playwrights Trace zeigt den Klick, der die Mail hätte auslösen sollen; die JUnit-Datei zeigt, welcher Test und wie lange er gewartet hat.
  4. Prüfen Sie den Dienststatus, bevor Sie dem Test die Schuld geben. Die Statusseite wird von außen alle zwei Minuten geprüft; war eingehende Mail zum Zeitpunkt des Laufs gestört, war der Fehlschlag echt und nicht Ihrer.

Aufräumen, optional

Alles läuft nach 5 Tagen ab, ob es nun jemand löscht oder nicht, ein Lauf, der das Aufräumen auslässt, kostet also nichts. Zu löschen, was der Lauf gelesen hat, ist trotzdem einen Step wert, weil der nächste Fehlschlag dann gegen ein wirklich leeres Postfach gelesen wird. Es ist idempotent — zweimal löschen beantwortet immer noch mit 200 —, kann also von sich aus nie einen Build zum Scheitern bringen:

ein Cleanup-Step, der immer läuft
      - 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

Machen Sie ihn zu if: always() und niemals verpflichtend: ein Aufräumen, das fehlschlägt, sollte eine Warnung im Log sein, kein roter Build.

Bevor Sie es für fertig erklären

  • Keine Postfach-Zugangsdaten in secrets; nur der eigene Mailer-Schlüssel Ihrer Anwendung.
  • Ausgehendes HTTPS zu grabmail.io erlaubt, und nichts Eingehendes.
  • Eine frische Adresse pro Test, erfunden im Testkörper — sicher unter Shards und Wiederholungen.
  • Die vier Timeouts verschachtelt: Deadline < Test < Step < Job.
  • Der Bericht bei einem Fehlschlag hochgeladen, mit der Adresse im Log.
  • Aufräumen als Step, der immer läuft und nie verpflichtend ist.

Das ist alles, was der Runner hinzufügt. Die Testdisziplin darunter — Deadline, frische Adresse, verankertes Muster — steht in einen E-Mail-Bestätigungsablauf Ende-zu-Ende testen, und die Extraktionsregeln für sich genommen stehen in OTP-Codes in automatisierten Tests.

Fragen

Muss ich ein GrabMail-Secret zum Repository hinzufügen?

Nein. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header, es gibt also nichts, das zu secrets hinzugefügt werden müsste. Die einzigen Zugangsdaten im Workflow sind die, die Ihre Anwendung zum Versenden von Mail verwendet und die sie ohnehin bräuchte, um überhaupt zu laufen.

Funktioniert das in einem privaten Repository oder auf einem selbst gehosteten Runner?

Ja. Der Runner stellt ausgehende HTTPS-Anfragen an grabmail.io und sonst nichts; wo Sie den Runner hosten, ist irrelevant. Auf einem selbst gehosteten Runner hinter einem Filter für ausgehenden Datenverkehr geben Sie diesen Hostnamen auf Port 443 frei.

Stoßen gleichzeitige Jobs an das Ratenlimit?

In der Praxis nicht. Das Limit pro Adresse liegt bei einem Lesevorgang pro Sekunde, was der Helfer respektiert, und die Obergrenze pro Client liegt bei 1200 Anfragen pro Minute — zwanzig Postfächer, einmal pro Sekunde gepollt, von einem Runner aus. Mehrere Runner sind mehrere Clients. Ein 429 wird mit Retry-After beantwortet, und der Helfer wartet das ab, statt zu scheitern.

Kann ich das nach einem Zeitplan laufen lassen, als synthetischen Check der Produktions-Anmeldung?

Ja, und das ist eine gute Verwendung dafür: Ein on: schedule-Workflow, der sich stündlich mit einer frischen Adresse anmeldet und den Code liest, beweist den gesamten Mail-Pfad der Produktion, Provider inklusive. Halten Sie das Adresspräfix erkennbar, damit sich die Anmeldungen auf Ihrer Seite leicht bereinigen lassen.

Was, wenn meine Anwendung Wegwerf-Domains ablehnt?

Richten Sie eine Domain, die Ihnen gehört, auf den Dienst aus — ein MX-Eintrag, kein Konto — und verwenden Sie diese Domain in der Fixture. Die Einrichtung dauert ein paar Minuten, und unbegrenzte Testkonten auf einer Domain zeigt das Muster in einer Suite.

Ist irgendetwas im Postfach privat?

Nein. Wer eine Adresse kennt, kann sie lesen, auf einer öffentlichen Domain genauso wie auf Ihrer eigenen. Für eine zufällige Adresse mit einem einzigen Wegwerf-Code ist das irrelevant; für eine Staging-Umgebung, die echte Kundendaten verschickt, ist es ein Ausschlusskriterium — richten Sie so eine nicht hierhin aus.

Probieren Sie es aus, solange es frisch ist

Eine Adresse braucht einen Klick, kein Konto und keine Karte. Alles in dieser Anleitung funktioniert damit sofort.

Willkommen zurück

Ihre Postfächer und Ihre Domains an einem Ort.