DEV Community

Cover image for Testing email flows in Playwright without a mail server
zerodrop
zerodrop

Posted on

Testing email flows in Playwright without a mail server

Every QA engineer has written a test like this at some point:

test('user receives verification email', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('[name="email"]', 'test@example.com');
  await page.click('[type="submit"]');

  // 🤔 now what?
  // mock the email? skip the assertion?
  // hope it works in production?
});
Enter fullscreen mode Exit fullscreen mode

The verification email step gets skipped, mocked, or marked as "manual test only." The auth flow ships without end-to-end coverage. Six months later a misconfigured SendGrid template breaks signup and nobody catches it until users complain.

This is a solved problem. Here's how to test it properly.


Why mocking email is the wrong approach

Mocking your email provider in tests gives you confidence that your code calls sendEmail() — not that the email actually arrives, renders correctly, contains the right link, or doesn't get flagged as spam.

The things that actually break in production are never the things you mocked. They're the SendGrid template that got corrupted, the verification URL that points to staging instead of production, the email that arrives 45 seconds late and times out the user's session.

Real tests require real emails.


Why running a mail server is overkill

The traditional answer is to run a local SMTP server — Mailhog, Mailtrap, or Mailpit — in your test environment. This works but introduces real complexity:

You need the mail server running before your tests. In CI that means a Docker container, a service dependency, a health check, and a teardown step. Your test suite now has an infrastructure dependency that can fail independently of your application code.

For most teams this is more complexity than the problem warrants. You don't need a mail server. You need an inbox you can read from inside a test.


The pattern: real inboxes, real assertions

The correct abstraction is simple:

  1. Generate a unique email address per test run
  2. Use that address in your test flow
  3. Wait for the email to arrive
  4. Assert on the actual content

No SMTP server. No Docker dependency. No infrastructure to maintain.

import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('user receives password reset email', async ({ page }) => {
  // Generate a unique inbox for this test run
  const inbox = mail.generateInbox();

  // Use it in your app flow
  await page.goto('/forgot-password');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  // Wait for the actual email to arrive
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // Assert on real content
  expect(email.subject).toContain('Reset your password');
  expect(email.body).toContain('Click here to reset');

  // Extract the actual reset link and follow it
  const resetLink = email.body.match(/https?:\/\/[^\s]+reset[^\s]+/)?.[0];
  expect(resetLink).toBeTruthy();
  await page.goto(resetLink);

  // Assert you landed on the reset page
  await expect(page).toHaveURL(/reset-password/);
});
Enter fullscreen mode Exit fullscreen mode

That test covers the entire flow end to end — form submission, email delivery, link extraction, and the destination page. No mocks anywhere.


The timeout error matters

Notice waitForLatest throws a ZeroDropTimeoutError if the email never arrives within the timeout. That's intentional and important.

A test that times out and throws is a test that fails loudly. Your CI pipeline goes red. Someone investigates. They find that SendGrid stopped delivering because of a misconfigured API key.

Compare that to a test that catches the timeout, returns null, and passes silently. The broken email flow ships to production.

import { ZeroDrop, ZeroDropTimeoutError } from 'zerodrop-client';

try {
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });
  expect(email.subject).toContain('Reset your password');
} catch (err) {
  if (err instanceof ZeroDropTimeoutError) {
    throw new Error(
      `Email never arrived at ${inbox} — check your email provider config`
    );
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Explicit failures with useful error messages are what separate a test suite from a false confidence generator.


Cypress integration

The same pattern works in Cypress:

import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

describe('Email verification flow', () => {
  it('sends verification email on signup', () => {
    const inbox = mail.generateInbox();

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

    cy.wrap(
      mail.waitForLatest(inbox, { timeout: 15000 })
    ).then((email) => {
      expect(email.subject).to.contain('Verify your email');
      expect(email.body).to.contain('verify');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

CI/CD integration

In GitHub Actions the setup is zero — no services block, no Docker, no health checks:

- name: Run E2E tests
  run: npx playwright test
  env:
    ZERODROP_API_KEY: ${{ secrets.ZERODROP_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

That's the entire email testing infrastructure. One environment variable.

Compare to a Mailhog setup:

services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025

- name: Wait for Mailhog
  run: sleep 5

- name: Run E2E tests
  run: npx playwright test
  env:
    SMTP_HOST: localhost
    SMTP_PORT: 1025
Enter fullscreen mode Exit fullscreen mode

Both work. One requires zero infrastructure.


The free tier is enough for most teams

For local development and smaller test suites, the zero-auth mode requires no API key:

// No API key — uses public sandbox
const mail = new ZeroDrop();
const inbox = mail.generateInbox();
Enter fullscreen mode Exit fullscreen mode

Inboxes expire after 30 minutes and emails go through AI spam filtering. For CI pipelines that need custom domains, guaranteed delivery, and longer retention — that's what the Workspace tier is for.

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

Try it on your next auth flow test. The first email that arrives in a real inbox inside a Playwright test is a satisfying moment.

Top comments (1)

Collapse
 
xulingfeng profile image
xulingfeng

The "timeout throws an error" part is the most underrated design decision here. We've seen so many test suites where email verification silently returns null and the CI passes green — then production breaks and nobody knows why.

One question though: how does ZeroDrop handle rate limits on the inbox generation? If a CI pipeline runs 50 tests in parallel each generating its own inbox, does the service throttle? We hit this with a similar approach where the email provider started refusing connections after ~20 concurrent inboxes.