DEV Community

Cover image for Testing Email Flows in Cypress Without a Mail Server
zerodrop
zerodrop

Posted on

Testing Email Flows in Cypress Without a Mail Server

Cypress is one of the most popular E2E testing frameworks for web applications. But testing email flows in Cypress has always required either a running mail server or mocking the email layer entirely.

This guide shows how to test real email flows in Cypress — verification emails, OTP codes, magic links, and password resets — without Docker, without MailHog, and without mocking.


The problem with email testing in Cypress

Most Cypress setups for email testing fall into one of three categories:

1. Mock the email service

cy.intercept('POST', '/api/send-email', { statusCode: 200 });
Enter fullscreen mode Exit fullscreen mode

Fast, but tests nothing. If your email template has a broken link, this passes.

2. Use MailHog

services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025
Enter fullscreen mode Exit fullscreen mode

Requires Docker in CI, 15-30 seconds cold start, shared inbox breaks parallel tests, unmaintained since 2020.

3. Use a shared Gmail account
OAuth setup, manual cleanup, parallel tests collide. Not CI-friendly.

ZeroDrop replaces all three: real emails, no infrastructure, isolated per test.


Install

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

Basic email verification test

// cypress/e2e/auth.cy.js
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

describe('Email verification', () => {
  it('user can sign up and verify email', () => {
    const inbox = Cypress.env('TEST_INBOX') ?? mail.generateInbox();

    // Sign up with disposable email
    cy.visit('/signup');
    cy.get('[name="email"]').type(inbox);
    cy.get('[name="password"]').type('TestPass123!');
    cy.get('[type="submit"]').click();

    cy.url().should('include', '/check-email');

    // Wait for verification email — magic link auto-extracted
    cy.wrap(mail.waitForLatest(inbox, { timeout: 30000 }))
      .then((email) => {
        expect(email.subject).to.contain('Verify');
        expect(email.magicLink).to.not.be.null;

        // Click the verification link
        cy.visit(email.magicLink);
        cy.url().should('include', '/dashboard');
      });
  });
});
Enter fullscreen mode Exit fullscreen mode

OTP verification

ZeroDrop extracts OTP codes automatically — no regex needed in your tests:

it('OTP login flow', () => {
  const inbox = Cypress.env('TEST_INBOX') ?? mail.generateInbox();

  cy.visit('/login');
  cy.get('[name="email"]').type(inbox);
  cy.get('[type="submit"]').click();

  cy.wrap(mail.waitForLatest(inbox, { timeout: 30000 }))
    .then((email) => {
      // email.otp is auto-extracted at the edge
      expect(email.otp).to.not.be.null;

      cy.get('[name="otp"]').type(email.otp);
      cy.get('[type="submit"]').click();
      cy.url().should('include', '/dashboard');
    });
});
Enter fullscreen mode Exit fullscreen mode

Password reset flow

it('password reset flow', () => {
  const inbox = Cypress.env('TEST_INBOX') ?? mail.generateInbox();

  // Request password reset
  cy.visit('/forgot-password');
  cy.get('[name="email"]').type(inbox);
  cy.get('[type="submit"]').click();

  cy.contains('Check your email').should('be.visible');

  // Catch reset email
  cy.wrap(mail.waitForLatest(inbox, { timeout: 30000 }))
    .then((email) => {
      expect(email.magicLink).to.not.be.null;

      cy.visit(email.magicLink);
      cy.get('[name="password"]').type('NewPass123!');
      cy.get('[type="submit"]').click();
      cy.contains('Password updated').should('be.visible');
    });
});
Enter fullscreen mode Exit fullscreen mode

Parallel tests — zero configuration

Each test generates its own isolated inbox. No shared state, no race conditions:

describe('Parallel email flows', () => {
  it('flow A', () => {
    const inbox = mail.generateInbox(); // unique per test
    // ...
  });

  it('flow B', () => {
    const inbox = mail.generateInbox(); // different inbox
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

Run with cypress run --parallel — each spec gets its own inbox automatically.


Cypress custom command

Add a reusable command to your cypress/support/commands.js:

// cypress/support/commands.js
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

Cypress.Commands.add('waitForEmail', (inbox, options = {}) => {
  return cy.wrap(
    mail.waitForLatest(inbox, { timeout: 30000, ...options }),
    { timeout: 35000 }
  );
});

Cypress.Commands.add('generateInbox', () => {
  return cy.wrap(
    Cypress.env('TEST_INBOX') ?? mail.generateInbox()
  );
});
Enter fullscreen mode Exit fullscreen mode
// Usage in tests
it('verifies email', () => {
  cy.generateInbox().then((inbox) => {
    cy.visit('/signup');
    cy.get('[name="email"]').type(inbox);
    cy.get('[type="submit"]').click();

    cy.waitForEmail(inbox).then((email) => {
      cy.visit(email.magicLink);
      cy.url().should('include', '/dashboard');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

cypress.config.js

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    defaultCommandTimeout: 10000,
    pageLoadTimeout: 60000,
    env: {
      // TEST_INBOX injected by GitHub Actions
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

GitHub Actions workflow

name: Cypress E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Generate test inbox
        id: inbox
        uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0

      - name: Run Cypress tests
        run: npx cypress run
        env:
          CYPRESS_TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
          RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
          CYPRESS_BASE_URL: ${{ secrets.STAGING_URL }}
Enter fullscreen mode Exit fullscreen mode

The CYPRESS_ prefix automatically makes variables available as Cypress.env('TEST_INBOX').


Replacing MailHog

If you're migrating from MailHog, here's the before and after:

Before — MailHog:

# docker-compose.yml
services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025
Enter fullscreen mode Exit fullscreen mode
// cypress/e2e/auth.cy.js
cy.request('GET', 'http://localhost:8025/api/v2/messages')
  .then((response) => {
    const message = response.body.items[0];
    const link = message.Content.Body.match(/https?:\/\/\S+/)?.[0];
    cy.visit(link);
  });
Enter fullscreen mode Exit fullscreen mode

After — ZeroDrop:

cy.wrap(mail.waitForLatest(inbox, { timeout: 30000 }))
  .then((email) => {
    cy.visit(email.magicLink); // auto-extracted, no regex
  });
Enter fullscreen mode Exit fullscreen mode

No Docker. No service block. No regex. No cold start.


ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
zerodrop.dev · docs · npm

Top comments (0)