Warum Cypress dafür einen Task braucht
Eine Cypress-Spec läuft innerhalb des Browsers, im selben Fenster wie die getestete Seite. Das macht cy.get und cy.contains so direkt, und genau deshalb kann die Spec auch nicht einfach eine Minute lang über eine HTTP-API schleifen: Die Befehlswarteschlange ist kein Ort für eine while-Schleife mit einem Sleep darin, und eine Kette aus wiederholten cy.request-Aufrufen ist schwer zu lesen und noch schwerer zu stoppen.
Die drei üblichen Wege, die E-Mail-Hälfte eines Ablaufs zu testen, beweisen jeweils etwas anderes, und nur einer davon beweist das, was Sie tatsächlich ausgeliefert haben:
- Den Mailer stubben
- Beweist, dass
send()mit den richtigen Argumenten aufgerufen wurde. Sagt 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 Container 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 Preis dafür ist, dass der Test richtig warten muss, und in Cypress ist ein Task der richtige Ort zum Warten.
Die API, die der Task aufruft, besteht aus drei Endpunkten ohne Schlüssel — die Referenz ist kurz. Die runner-unabhängige Version dieser Disziplin steht in einen E-Mail-Bestätigungsablauf Ende-zu-Ende testen; die Playwright-Version, mit einer Fixture statt eines Tasks, steht in der Playwright-Anleitung.
Der Task: eine Polling-Schleife auf der Node-Seite
Alles, was warten muss, steckt hier, in setupNodeEvents. Das ist reines Node: fetch, eine Deadline, ein Lesevorgang pro Sekunde und ein 429-Zweig, der schläft statt zu scheitern. Die Spec bekommt nichts davon zu sehen.
import { defineConfig } from 'cypress';
const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
taskTimeout: 90_000, // above the mail deadline below, always
setupNodeEvents(on) {
on('task', {
/** Poll a mailbox until a matching message arrives, or the deadline passes. */
async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);
const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
const hit = messages.find(m =>
(!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
(!fromContains || m.from.toLowerCase().includes(fromContains.toLowerCase())));
if (hit) {
const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
return full.json(); // the whole message, both parts
}
await sleep(1000); // one read a second, never throttled
}
return null; // "not yet" is an answer, not an error
},
});
},
},
});Zwei Entscheidungen in dieser Datei sind es wert, genannt zu werden. Der Task gibt die vollständige Nachricht zurück, nicht die Zusammenfassung, weil jede Spec als Nächstes ohnehin den Body will und ein zweiter Task-Aufruf dafür nur Rauschen wäre. Und er gibt an der Deadline null zurück, statt einen Fehler zu werfen: „noch keine Nachricht“ ist eine legitime Antwort für einen Task, und der Befehl weiter unten ist die Stelle, an der daraus ein Fehlschlag mit einer nützlichen Meldung wird.
Zwei eigene Befehle und zwei Extraktoren
Die Befehle sind absichtlich schlank gehalten. freshAddress erfindet ein Postfach; waitForMail ruft den Task mit einem Timeout komfortabel über der Deadline auf und prüft die Antwort. Die Extraktoren sind einfache Funktionen, weil sie reine String-Arbeit sind und ein Cypress-Befehl sie nur schwerer unit-testbar machen würde.
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };
declare global {
namespace Cypress {
interface Chainable {
/** A mailbox nothing else in this run, or any previous run, is using. */
freshAddress(prefix?: string): Chainable<string>;
/** Block until a matching message arrives. Fails the test at the deadline. */
waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
}
}
}
Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));
Cypress.Commands.add('waitForMail', (address, opts = {}) =>
cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
.then(m => {
expect(m, `a message for ${address}`).not.to.be.null;
return cy.wrap(m as Message, { log: false });
}));
/** The whole body, both parts, with the entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Beachten Sie das eigene timeout des Befehls: Es ist die Deadline des Tasks plus zehn Sekunden, damit der Task immer die Gelegenheit bekommt, seine Antwort zu geben. Ohne das liefert sich Cypress' standardmäßiges Task-Timeout von sechzig Sekunden ein Rennen mit der sechzig Sekunden langen Mail-Deadline, gewinnt um ein paar Millisekunden, und der Fehlschlag gibt dann dem Task die Schuld.
Drei Specs, Ende-zu-Ende
Mit Task und Befehlen an Ort und Stelle liest sich jede Spec wie die Funktion, die sie prüft. Das Warten und das Parsen stecken woanders, und genau das ist der Sinn davon, sie dorthin auszulagern.
Anmeldung mit Bestätigungscode
import { codeFrom } from '../support/commands';
describe('sign-up', () => {
it('confirms the address with the emailed code', () => {
cy.freshAddress().then(address => {
cy.visit('/signup');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('correct-horse-battery-staple');
cy.contains('button', 'Create account').click();
cy.contains('Check your inbox').should('be.visible');
cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
cy.get('input[name="code"]').type(codeFrom(message));
cy.contains('button', 'Confirm').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});
});
});Eine Anmeldung, die einen per Mail verschickten Einmalcode verlangt
Der Nutzer muss zuerst existieren, und das ist Aufgabe der eigenen Testschnittstelle Ihrer Anwendung — ein interner Endpunkt, eine Datenbank-Fixture, eine CLI —, erreicht mit cy.request, nicht über den Browser.
import { codeFrom } from '../support/commands';
describe('login with an emailed one-time code', () => {
it('asks for the code and accepts it', () => {
cy.freshAddress().then(address => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('hunter2hunter2');
cy.contains('button', 'Log in').click();
cy.contains('Enter the code we emailed you').should('be.visible');
cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
cy.contains('button', 'Continue').click();
cy.url().should('include', '/dashboard');
});
});
});
});Ein zurückgesetztes Passwort, dann eine Anmeldung mit dem neuen Passwort
Der Reset-Link wird mit einem einfachen cy.visit aufgerufen, wenn er auf denselben Origin wie baseUrl zeigt. Wenn Ihre Anwendung Nutzer für die Reset-Seite auf einen anderen Origin schickt — etwa eine Auth-Subdomain —, verpacken Sie die Schritte auf dieser Seite in cy.origin(); die Link-Extraktion bleibt unverändert.
import { linkFrom } from '../support/commands';
describe('password reset', () => {
it('changes the password through the emailed link', () => {
cy.freshAddress().then(address => {
cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });
cy.visit('/forgot-password');
cy.get('input[name="email"]').type(address);
cy.contains('button', 'Send reset link').click();
cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
cy.visit(linkFrom(message, '/reset/')); // same origin as baseUrl: a plain visit
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Change password').click();
});
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/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.
| Symptom | Ursache | Lösung |
|---|---|---|
| Läuft lokal durch, scheitert in der CI | Der Runner erreicht das öffentliche Internet nicht, oder der ausgehende Datenverkehr wird gefiltert. | grabmail.io über HTTPS von der Node-Seite aus zulassen. Sonst nichts — kein SMTP-Port, kein eingehender Verkehr. |
| „cy.task timed out“ ohne ein Wort über Mail | taskTimeout (standardmäßig 60 s) liegt unter der Mail-Deadline. | Setzen Sie taskTimeout in der Konfiguration höher als die Deadline, und übergeben Sie das Timeout pro Aufruf, das der Befehl bereits berechnet. |
| Scheitert beim ersten Mal, läuft bei Wiederholung durch | Ihr 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üben | Mehrere Specs pollen dieselbe Adresse, oder der Runner liegt über 1200 Anfragen pro Minute. | Eine Adresse pro Spec. Die Obergrenze pro Client liegt bei zwanzig Postfächern, einmal pro Sekunde gepollt. |
| Grüner Build, kaputte Funktion | Eine wiederverwendete Adresse hat eine alte Nachricht ausgeliefert. | freshAddress in jedem Testkörper. Das ist die Regel, auf die es ankommt. |
| Funktioniert eine Woche lang, dann nie wieder | Eine Fixture hat eine Nachrichten-ID zwischengespeichert; hier wird alles nach 5 Tagen gelöscht. | Specs 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 Specs Zugangsdaten braucht, wurde etwas missverstanden. Ein GitHub-Actions-Workflow, der eine solche Suite ausführt, mit geklärter Frage des ausgehenden Datenverkehrs, steht in der CI-Anleitung.
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 der Spec — 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 derselbe Task 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 jede Spec, erfunden im Testkörper — nie eine Konstante.
- Das Warten in einem Task mit einer Deadline in Echtzeit;
nullan der Deadline, nieundefined. taskTimeoutund das Timeout des Befehls, beide über der Mail-Deadline.429wird durch Abwarten vonRetry-Afterbehandelt, nicht durch Scheitern.- Der Code oder Link wird mit Ihrem eigenen Wortlaut abgeglichen, nicht mit einem nackten Muster.
- 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. Die Extraktionsregeln für sich genommen, für jeden Runner, stehen in OTP-Codes in automatisierten Tests.
Fragen
Könnte ich statt eines Tasks cy.request in einer Schleife verwenden?
Das können Sie: cy.request läuft ebenfalls auf der Node-Seite, unterliegt also nicht CORS, und eine rekursive Funktion, die erneut anfragt, bis es einen Treffer gibt oder die Deadline erreicht ist, funktioniert. Sie ist nur schwerer zu lesen und schwerer zu stoppen als ein Task mit einer while-Schleife darin, und der Task hält die Spec frei von Wiederholungslogik.
Brauche ich einen API-Schlüssel oder eine Cypress-Umgebungsvariable?
Nein. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header, es gibt also nichts, was in cypress.env.json oder in die CI-Secrets gehört. Nur der kostenpflichtige Pool von Domains, der von den Wegwerf-Mail-Sperrlisten ferngehalten wird, verwendet ein Bearer-Token, und das ist ein eigenes Produkt.
Funktioniert das mit Cypress' Testwiederholungen und Parallelisierung?
Ja, genau weil die Adresse innerhalb des Testkörpers erfunden wird: Jede Wiederholung und jede parallele Maschine bekommt ihr eigenes Postfach. Die Obergrenze pro Client von 1200 Anfragen pro Minute entspricht zwanzig Postfächern, einmal pro Sekunde gepollt, was ein Cypress-Lauf nie auch nur annähernd erreicht.
Was, wenn die E-Mail ankommt, bevor der Task 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 die Spec 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 aus dem Task, 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.


