Testing email flows in Cypress, end to end.
Cypress makes email testing awkward in one specific, architectural way: your spec runs inside the browser, and the inbox it needs to read lives outside it. Teams bridge that gap with sleeps, browser-side API keys, or recursive polling — three different flavors of flake. Here's the anatomy of each, and the twenty-line task bridge that replaces them all.
Why email tests flake in Cypress
1. cy.wait(8000). The same disease as every other OTP test — too short when mail takes nine seconds, pure waste when it takes one — but Cypress actively invites it: await isn't natural inside the command queue, so a fixed wait is always the path of least resistance. Delivery latency is a distribution, and a sleep is a bet against its tail, renewed on every run.
2. The API key in the browser. cy.request runs from spec code, so checking a mail API directly from the spec means the key travels through Cypress.env() into code executing in the same browser as the app under test — one JavaScript runtime shared with every third-party script your staging page loads. A mail-API key that can read your test inboxes doesn't belong there any more than your database password does.
3. Recursive polling against the command queue. Cypress's built-in retry-ability re-runs DOM assertions, not third-party HTTP calls — so suites grow a hand-rolled pollForEmail() that recurses on cy.request with a cy.wait(1000) between attempts. That's a dozen requests per test eating your rate limit, a race in every gap between polls, and a sleep hiding at the bottom of it anyway.
The task bridge
The fix is the seam Cypress itself provides: cy.task, the hop from the browser to the Node process running cypress.config. The SDK — and your API key — live on the Node side; the spec sees two small, serializable task calls. And because the server holds a long-poll open until mail actually arrives, "wait for the code" is one task call, not a polling loop:
import { defineConfig } from 'cypress'; import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY — Node-side only export default defineConfig({ e2e: { setupNodeEvents(on) { on('task', { async createInbox() { const inbox = await mail.createInbox({ ttlSeconds: 900 }); return { id: inbox.id, address: inbox.address }; // data, not the handle }, waitForOtp(inboxId) { return mail.waitForOtp(inboxId, { timeout: 30_000 }); // server-held long-poll }, }); }, }, });
it('signup sends a one-time code', () => { cy.task('createInbox').then(({ id, address }) => { cy.visit('/signup'); cy.get('#email').type(address); cy.contains('Send code').click(); cy.task('waitForOtp', id, { timeout: 45_000 }).then((otp) => { cy.get('#otp').type(otp); cy.get('h1').should('have.text', 'Welcome'); }); }); });
The timeout is now a ceiling, not a duration: the task resolves the moment the email lands — typically well under a second — and only spends the full 30 when something is genuinely broken. This is the same code as the four-minute quickstart; what the quickstart doesn't have room for is the rules that keep the bridge from becoming its own flake source.
The rules the bridge imposes
- Return data, not handles. Task results cross a process boundary as JSON. Hand back
{ id, address }and look the inbox up by id in later tasks — a returned SDK object arrives in the spec with its methods silently stripped. - Give
cy.taska longer leash than the SDK. The task's own timeout must exceed the SDK's deadline (45s over 30s above). Get it backwards and Cypress kills a healthy long-poll mid-wait, reporting "task timed out" — the wrong failure, pointing at the wrong layer. process.env, notCypress.env. Anything inCypress.env()is readable from spec code in the browser. The SDK readingMAILFIXTURE_API_KEYinsidesetupNodeEventsnever leaves Node — locally from.env, in CI from a secret.- Retries re-run the body, not the hooks. With
retries: 2, an inbox created insideit()is fresh on every attempt. One created in abefore()hook is shared across attempts — and attempt two can happily re-read the stale code attempt one already consumed. Create inboxes in the test body or abeforeEach.
cypress run --parallel across CI containers has no shared state to fight over — and inboxes expire on their TTL, so aborted runs clean up after themselves.Magic links ride the same bridge
Nothing about the pattern is OTP-specific. Add a waitForLink task the same way — mail.waitForLink(inboxId, { kind: 'verify' }) returns the login link already classified as verify (never the logo link, never the tracking pixel) — and cy.visit(link.url) performs the click in the browser your assertions run in. The edge cases a passwordless flow deserves — single use, expiry, scanner prefetch — have their own guide. And everything else extraction produces is one more task away on the same message object: SPF/DKIM/DMARC verdicts, a SpamAssassin score, attachment metadata.
Local dev vs CI
None of this replaces watching emails render while you build — a local SMTP-capture tool like Mailpit does that job well, with zero setup. The split that works: capture locally, deliver in CI. The CI suite receives on real domains through your real provider, which is the only place a test can catch the staging config still pointing at the sandbox. The trade-offs are laid out in MailFixture vs. Mailpit.
And if your suite is Playwright, none of the bridge applies — specs run in Node there, so the SDK is a plain await. The OTP guide shows that shape.