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 });
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
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
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');
});
});
});
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');
});
});
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');
});
});
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
// ...
});
});
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()
);
});
// 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');
});
});
});
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
},
},
});
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 }}
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
// 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);
});
After — ZeroDrop:
cy.wrap(mail.waitForLatest(inbox, { timeout: 30000 }))
.then((email) => {
cy.visit(email.magicLink); // auto-extracted, no regex
});
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)