Tests & CI

E-Mail-Tests mit Playwright: einen echten Code auslesen

Ein Playwright-Test kann ein Anmeldeformular in zwei Sekunden ausfüllen und hat danach keine Ahnung, was als Nächstes passiert, weil der nächste Schritt eine E-Mail ist. Hier ist die Fixture, die sie liest — eine echte Nachricht, aus einem echten Postfach, ohne API-Schlüssel — und die drei Gewohnheiten, die den Test davor bewahren, instabil zu werden.

  • Fortgeschritten
  • 20 Min. Lesezeit
Ein graues Browserfenster mit blauem Mauszeiger, ein blauer Umschlag, der in einen Schlitz an der Seite gleitet, und eine graue Stoppuhr davor

Wo ein Playwright-Test normalerweise aufhört

Die meisten Anmeldetests enden beim Satz „Prüfen Sie Ihr Postfach“. Das Formular wurde ausgefüllt, der Button wurde geklickt, die Seite hat das Richtige gesagt — und alles, was nach diesem Satz passiert, wird einfach angenommen. Ob die Mail rausgegangen ist, ob der Code darin der Code ist, den der Server erwartet, ob der Bestätigungslink eine Seite öffnet, die funktioniert: All das wird der Produktion überlassen.

Es bleibt dort liegen, weil der nächste Schritt asynchron ist und außerhalb des Browsers stattfindet, und Playwright hat nichts, worauf es klicken könnte. Die drei üblichen Wege darum herum beweisen jeweils etwas anderes:

Den Mailer mocken
Beweist, dass Ihr Code send() aufgerufen hat. Beweist nichts über die Vorlage, den Link oder den Provider, der die Nachricht abgelehnt hat.
Eine lokale SMTP-Senke (Mailpit, MailHog, smtp4dev)
Beweist, dass eine wohlgeformte Nachricht die Anwendung verlassen hat. Ein weiterer Dienst in der CI, und nichts von dem, was nur im öffentlichen Internet passiert — ein echter MX-Lookup, ein echter Provider, ein echter Empfänger —, passiert hier.
Ein echtes Wegwerf-Postfach
Beweist, dass die Nachricht die Anwendung verlassen, das Internet durchquert hat, von einem echten Mailserver angenommen wurde und einen Code trägt, der funktioniert. Der einzige Preis dafür ist, dass der Test richtig warten muss — und genau darum geht es in dieser Anleitung.

Die API dahinter besteht aus drei Endpunkten ohne Schlüssel, dokumentiert in der Referenz. Wer die allgemeine Disziplin vor den Playwright-Spezifika sehen möchte: einen E-Mail-Bestätigungsablauf Ende-zu-Ende testen deckt das für jeden Runner ab; dies hier ist die Playwright-Version, mit der Fixture, die es angenehm macht.

Eine Fixture, die jedem Test sein eigenes Postfach gibt

Playwrights test.extend ist dafür der richtige Ort: Ein Postfach wird zu etwas, das ein Test namentlich anfordert, wie page, und die Adresse wird jedes Mal frisch erfunden. Auf dem Server muss nichts angelegt werden — ein Postfach existiert in dem Moment, in dem Mail es erreicht —, also ist die Fixture eine Klasse mit einer zufälligen Adresse darin und drei kleinen Methoden.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

Zwei Dinge in dieser Datei sind bewusst so gewählt. Die Adresse ist zufällig pro Test, nicht pro Datei oder pro Lauf, sodass parallele Worker nie gegenseitig ihre Mail lesen können. Und waitFor gibt die vollständige Nachricht zurück statt der Zusammenfassung — in der Praxis will man als Nächstes ohnehin immer den Body, und ein Aufruf weniger in jedem Test summiert sich.

Auf die Nachricht warten, ohne zu schlafen

Mail ist nicht synchron. Sie kommt normalerweise in zwei oder drei Sekunden an und braucht gelegentlich zwanzig, und die Art, wie der Test wartet, entscheidet, ob man der Suite vertrauen kann. Die Regeln sind kurz:

  • Eine Deadline, keine Anzahl von Versuchen. for (let i = 0; i < 30; i++) ist dreißig Versuche in welchem Tempo die Schleife auch gerade läuft — kürzer, wenn die API schneller wird, länger, wenn Ihr Sender langsamer wird. Eine Deadline in Echtzeit bedeutet auf jeder Maschine dasselbe.
  • Ein Lesevorgang pro Sekunde. Das ist der dokumentierte Rhythmus, und er wird nie gedrosselt. Schneller wird mit 429 und einem Retry-After-Header abgelehnt, und schnelleres Pollen würde die Mail nicht früher ankommen lassen.
  • Kein waitForTimeout. Ein festes Sleep ist an einem langsamen Tag entweder zu kurz oder an jedem anderen Tag zu lang. Die Schleife stoppt in dem Moment, in dem die Nachricht existiert.
  • Filtern Sie; nehmen Sie nicht blind die neueste Nachricht. Übergeben Sie subjectContains oder fromContains. Wenn ein Ablauf zwei Nachrichten verschickt — eine Willkommensmail und einen Code —, ist die neueste nicht immer die, die Sie wollen.

Die Statuscodes, denen die Schleife begegnet, und was sie bei jedem tun sollte:

CodeBedeutetWas die Schleife tut
200Das Postfach wurde gelesen. count kann 0 sein — ein leeres Postfach ist nie ein 404.Nach einem Treffer suchen; gibt es keinen, eine Sekunde schlafen und erneut versuchen.
400Die Adresse ist fehlerhaft aufgebaut.Einen Fehler werfen. Einen Tippfehler zu wiederholen behebt ihn nicht.
404Die Domain wird hier nicht gehostet.Einen Fehler werfen und den MX-Eintrag prüfen, falls es die eigene Domain ist.
429Mehr als ein Lesevorgang pro Sekunde für diese Adresse, oder mehr als 1200 Anfragen pro Minute von diesem Runner.Retry-After Sekunden schlafen und weitermachen. Den Test nie wegen eines 429 scheitern lassen.

Den Code oder den Link aus der Nachricht holen

Die Nachricht kommt mit beiden Teilen zurück, und welchen man parst, hängt davon ab, was Ihre Anwendung verschickt:

text
Der Klartext-Teil. Parsen Sie diesen, wenn er existiert — kein Markup, und ein sechsstelliger Code ist ein sechsstelliger Code.
html
Der HTML-Teil, oder null, wenn der Absender nur Text verschickt hat. Bestätigungs-Links stecken oft nur hier, innerhalb eines <a href>, wobei & als &amp; geschrieben wird.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

Das Muster ist absichtlich am Wortlaut Ihrer eigenen Vorlage verankert. [0-9]{6} allein passt bereitwillig auf eine Jahreszahl, einen Preis oder eine Bestellnummer, die zufällig zuerst auftaucht; code is ([0-9]{6}) passt auf Ihren Code und sonst nichts — und an dem Tag, an dem jemand die E-Mail umformuliert, schlägt der Test fehl und sagt Ihnen das, statt mit der falschen Zahl zu bestehen.

Links werden anhand eines Pfad-Fragments abgeglichen, das Sie kennen — /confirm/, /reset/ — statt anhand von „der ersten URL“, denn eine transaktionale E-Mail trägt meist fünf: das Logo, das Abbestellen, das Hilfe-Center, das App-Store-Abzeichen und die eine, die Sie wollen.

Drei Abläufe, Ende-zu-Ende

Mit Fixture und Extraktoren an Ort und Stelle liest sich jeder Test wie die Funktion, die er prüft. Das Warten, das Pollen und das Parsen stecken woanders, und genau deshalb wurden sie dorthin ausgelagert.

Anmeldung mit Bestätigungscode

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Ein Magic Link, der den Nutzer anmeldet

Nichts zu tippen: Der Test ruft den Link auf, den die Nachricht enthält, und prüft, wo er landet.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

Ein zurückgesetztes Passwort, dann eine Anmeldung mit dem neuen Passwort

Der Reset-Test braucht einen bereits existierenden Nutzer, und dafür ist die eigene Testschnittstelle Ihrer Anwendung zuständig — ein interner Endpunkt, eine Datenbank-Fixture, eine CLI — nicht der Browser. Danach hat der Ablauf dieselbe Form wie die anderen: anfragen, warten, folgen, prüfen.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Wie es die CI übersteht

Alles oben Genannte funktioniert auf einem Laptop. Das hier sind die Dinge, die erst kaputtgehen, sobald es zwanzigmal am Tag auf dem Rechner einer anderen Person läuft.

SymptomUrsacheLösung
Läuft lokal durch, scheitert in der CIDer Runner erreicht das öffentliche Internet nicht, oder der ausgehende Datenverkehr wird gefiltert.grabmail.io über HTTPS zulassen. Sonst nichts — kein SMTP-Port, kein eingehender Verkehr.
Scheitert beim ersten Mal, läuft bei Wiederholung durchIhr Sender reiht Mail in eine Warteschlange ein, und die Deadline ist kürzer als die Warteschlange.Erhöhen Sie die Deadline, bevor Sie irgendetwas anderes anfassen. Sechzig Sekunden sind eine faire Obergrenze für eine transaktionale Mail.
429 in SchübenMehrere Tests pollen dieselbe Adresse, oder der gesamte Runner liegt über 1200 Anfragen pro Minute.Eine Adresse pro Test — das erledigt die Fixture. Die Obergrenze pro Client liegt bei zwanzig Postfächern, einmal pro Sekunde gepollt.
Grüner Build, kaputte FunktionEine wiederverwendete Adresse hat eine alte Nachricht ausgeliefert.Eine zufällige Adresse pro Test. Das ist die Regel, auf die es ankommt.
Nur bei mehreren Workern instabilZwei Tests teilen sich ein Postfach, oder eine Prüfung darauf, welche Nachricht die neueste ist.Eine frische Adresse pro Test und ein subjectContains-Filter; nie blind die neueste Nachricht.
Funktioniert eine Woche lang, dann nie wiederEine Fixture, die eine Nachrichten-ID zwischengespeichert hat; hier wird alles nach 5 Tagen gelöscht.Tests müssen bei jedem Lauf ihre eigene Mail auslösen. Nichts übersteht 5 Tage.

Es gibt kein Secret zu speichern. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header — wenn Ihre Pipeline für diese Tests Zugangsdaten braucht, wurde etwas missverstanden. Die eine Einstellung, die es wert ist, festgehalten zu werden, ist das Timeout, denn das ist die eine Sache, bei der Playwrights Standardwerte für einen Test, der auf Mail wartet, falsch liegen:

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

Ein GitHub-Actions-Workflow, der diese Suite ausführt, mit geklärter Deadline und geklärter Frage des ausgehenden Datenverkehrs, ist in der CI-Anleitung ausformuliert.

Wenn Ihre Anwendung Wegwerf-Domains ablehnt

Manche Anmeldeformulare gleichen die Adresse mit den öffentlichen Listen von Wegwerf-Domains ab und lehnen grabmail.io auf den ersten Blick ab. Das ist eine Funktion Ihrer Anwendung, kein Fehler des Tests — und die Lösung besteht nicht darin, die Prüfung für die Testumgebung abzuschwächen. Richten Sie stattdessen eine Domain, die Ihnen gehört, auf diesen Dienst aus: ein MX-Eintrag, kein Konto, und jede Adresse darauf wird zu einem Postfach, das dieselbe Fixture lesen kann, sobald man eine einzige Konstante ändert.

Eine Domain in ein Catch-all-Postfach verwandeln ist die Einrichtung; unbegrenzte Testkonten auf einer Domain zeigt, wie das in einer Testsuite aussieht.

Bevor Sie es für fertig erklären

  • Eine andere Adresse für jeden Test, aus der Fixture — nie eine Konstante.
  • Eine Deadline in Echtzeit, und ein Fehlschlag, der die Adresse nennt, auf die gewartet wurde.
  • 429 wird durch Abwarten von Retry-After behandelt, nicht durch Scheitern.
  • Der Code oder Link wird mit Ihrem eigenen Wortlaut abgeglichen, nicht mit einem nackten Muster.
  • Das Test-Timeout liegt komfortabel über der Mail-Deadline.
  • Ein Filter auf Betreff oder Absender, damit die richtige Nachricht gewinnt, wenn zwei ankommen.
  • Keine Prüfung, wie schnell die Mail kam — nur, dass sie kam.

Das ist die ganze Disziplin. Alles andere am Testen von Mail in Playwright ist dasselbe wie beim Testen von allem anderen Asynchronen. Derselbe Helfer als Cypress-Commands steht in der Cypress-Anleitung; die Extraktionsregeln für sich genommen, für jeden Runner, stehen in OTP-Codes in automatisierten Tests.

Fragen

Brauche ich einen API-Schlüssel, um das Postfach aus Playwright zu lesen?

Nein. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header. Nur der kostenpflichtige Pool von Domains, der von den Wegwerf-Mail-Sperrlisten ferngehalten wird, braucht einen Authorization: Bearer-Header, und das ist ein eigenes Produkt.

Können die Tests in parallelen Workern laufen?

Ja, und genau das ist der Sinn einer zufälligen Adresse pro Test: Zwei Worker können nie gegenseitig ihre Mail lesen. Die Obergrenze pro Client liegt bei 1200 Anfragen pro Minute, das sind zwanzig Postfächer, einmal pro Sekunde gepollt — reichlich für eine Suite, und die Fixture pollt ohnehin nie schneller als einmal pro Sekunde.

Sollte ich Playwrights request-Fixture statt fetch verwenden?

Beides funktioniert. fetch wird hier verwendet, weil der Helfer dann unverändert in einem einfachen Node-Skript, einem globalen Setup oder einem anderen Runner läuft. Playwrights request fügt ein Tracing der Aufrufe hinzu, was sich lohnt, wenn das Pollen im Trace Viewer auftauchen soll.

Was, wenn die E-Mail ankommt, bevor der Test mit dem Pollen beginnt?

Nichts ändert sich. Der erste Poll liefert sie zurück. Ein Postfach hält, was ankommt, 5 Tage lang, ob nun jemand liest oder nicht, also ist eine Nachricht, die während des Klicks landet, beim nächsten Request einfach da.

Ist das Postfach privat, während der Test es verwendet?

Nein. Wer die Adresse kennt, kann es lesen, auf einer öffentlichen Domain genauso wie auf Ihrer eigenen. Für eine zufällige Adresse, die elf Sekunden existiert und einen einzigen Wegwerf-Code enthält, ist das irrelevant; für eine Staging-Umgebung, die echte Kunden-Mail verschickt, ist es ein Ausschlusskriterium — richten Sie so eine nicht hierhin aus.

Wie räume ich danach auf?

Optional, mit einem DELETE auf die Nachricht, das idempotent ist. Ohnehin läuft alles nach 5 Tagen ab, ein Lauf, der das Aufräumen auslässt, kostet also nichts — Löschen macht nur den nächsten Fehlschlag leichter lesbar.

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.