GUIDE

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:

cypress.config.ts
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
        },
      });
    },
  },
});
cypress/e2e/signup.cy.ts
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

NOTE Because every attempt creates its own inbox, 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.

100 messages/mo free · the bridge is twenty lines, written once
Start free Read the Cypress quickstart