GUIDES / STRATEGY

Automated email testing, layer by layer.

Automated email testing means asserting on the email your application sends — that it sends at all, that it reaches a real inbox, and that it carries the right code, link, or attachment — from inside your test suite. Three layers do that job: a mocked mailer in unit tests, a local SMTP catcher in development, and a real inbound inbox in CI. Each catches failures the previous one is structurally blind to. This guide is the decision: what each layer proves, where each one runs, and the four rules that keep whichever you pick from flaking.

Email is a feature. Most suites don't test it.

Signup confirmation, OTP, magic link, password reset, invite, receipt — for most products, email is the load-bearing half of every authentication flow and the only user interface a billing event has. Yet the typical E2E suite drives the browser up to the "check your email" screen and stops, because the inbox sits outside the browser and nobody wired up a way to reach it. Everything after that point ships untested.

What's actually assertable, once you can reach the message, is more than "it arrived":

No single tool covers that list, which is why the answer to "how do I test email?" is a stack, not a product. The three layers below are ordered by how much reality each one touches.

Layer 1: mock the mailer

In unit tests, replace your mail-sending module with a fake and assert your code asked to send: the right template, the right recipient, the right variables. Every framework has a canonical shape for this — a nodemailer stub, Rails' ActionMailer :test adapter, a fake around your SES or Resend client. These tests are fast, free, and run anywhere, and they're the right place for branching logic: which template a state change picks, what happens when the address is missing.

What they can't do is tell you whether anything sends. A mock proves the call, not the effect — the provider credentials, the DNS records, the template rendering inside the provider, and the entire delivery path are invisible to it. A suite that stops at this layer regularly goes green while production emails silently stopped weeks ago.

Layer 2: capture SMTP locally

A local capture tool — Mailpit and its predecessor MailHog are the well-known ones — runs a fake SMTP server on your machine. Point your app's SMTP config at it and every outbound message lands in a local web UI instead of the network. Now you're one level more real: an actual MIME message left your application, and you can eyeball the HTML while you build the template. For the local development loop this is the right tool, with zero setup cost, and nothing in this guide argues against it.

Its boundary is the localhost socket. The message you're looking at is what your app handed the trap — not what your provider accepted, signed, and delivered. Provider configuration, API keys, sending-domain authentication, and the difference between staging's config and production's are all out of frame. And because the trap is one shared instance with one shared message list, parallel CI workers asserting "the latest message" read each other's mail.

Layer 3: receive on a real inbox

The third layer inverts the trick: instead of faking the receiving side, give the test a real address. An inbound inbox API mints a private, routable email address per test; your app sends to it through its real provider path; the test waits for arrival and asserts on the message as delivered — content, links, attachments, and the authentication verdicts a receiving server computed. This is the only layer where "the email arrived" is a fact rather than an inference, and it's the layer where the classic invisible failures surface: staging still pointed at the provider sandbox, a DKIM key rotated but not published, a template change that reads as spam. MailFixture is this layer — so from here on, examples use it, and the shape is the same for any tool in the category.

WHAT THE TEST PROVESMOCKCAPTUREREAL INBOX
Your code asked to send
A real MIME message left the app
Your provider accepted & delivered it
SPF / DKIM / DMARC as a receiver sees them
Parallel workers without shared stateshared listinbox per test
Runs with zero network

The layers compose like a pyramid: many mocked tests, the capture tool in everyone's local loop, and a small set of full flows on the real-inbox layer — signup, OTP, password reset, the receipt email. You don't need three hundred end-to-end email tests; you need the handful that would otherwise only fail in production, run where reality is available.

The four rules that keep email tests from flaking

Whatever your suite looks like, the flaky email test is always one of the same few diseases. The fixes are boring and mechanical:

  1. One inbox per test. A shared qa@company.com is mutable global state: parallel tests read each other's messages, retries re-match yesterday's email, and cleanup becomes archaeology. Minting a fresh address in setup makes every test's mail unambiguous and makes parallelism and retries safe by construction. The OTP guide walks through the failure in detail.
  2. Bounded waits, never sleeps. sleep(8) is both too long and too short. The reliable shape is a wait with a deadline that returns the moment the message arrives — MailFixture holds the HTTP request open server-side until delivery, so the timeout is a ceiling, not a duration. Anything that polls a list in a loop is a sleep wearing a loop.
  3. Typed extraction, not regex. Scraping codes with /\d{6}/ and links with an href regex breaks on the first copy change and matches the wrong number in a footer. Extraction belongs server-side, as data: a ranked OTP field, classified links (verify / reset / unsubscribe), attachment metadata.
  4. Teardown you don't have to remember. Delete the inbox in the fixture's teardown and give it a TTL as a backstop, so a killed run cleans up after itself and the next run never inherits state.

A reference implementation

The whole pattern in one Playwright spec — create an address, drive the app, wait for the mail, use what it carries:

signup.spec.tsnpm i -D mailfixture
import { test, expect } from '@playwright/test';
import { MailFixture } from 'mailfixture';

const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY

test('signup verifies by email OTP', async ({ page }) => {
  const inbox = await mail.createInbox({ ttlSeconds: 900 }); // rule 1 + 4
  try {
    await page.goto('/signup');
    await page.fill('#email', inbox.address); // a real, routable address
    await page.click('text=Create account');

    const otp = await inbox.waitForOtp({ timeout: 30_000 }); // rules 2 + 3
    await page.fill('#otp', otp);
    await expect(page.locator('h1')).toHaveText('Welcome');
  } finally {
    await inbox.delete(); // rule 4
  }
});

At suite scale, the create/try/delete moves into your framework's fixture seam so it's written once — Playwright's test.extend, Cypress's cy.task bridge (the SDK must stay in Node, out of the browser), or a pytest conftest fixture. And because the message arrives as delivered, the deliverability checks are one assertion each on the same object:

the same message, two more assertions
const message = await inbox.waitForMessage({ timeout: 30_000 });
expect(message.auth?.spf.result).toBe('pass');  // see the SPF/DKIM/DMARC guide
expect(message.spam?.verdict).toBe('ham');      // see the spam-score guide

Where each layer runs

ENVIRONMENTLAYERWHAT YOU'RE CHECKING
Unit tests, everywhereMockTemplate choice, variables, branching logic
Local developmentCaptureFlows fire, templates render — fast loop, zero network
CI against stagingReal inboxThe whole path: provider config, domain auth, content as delivered
Post-deploy smokeReal inboxA handful of critical flows against production config

The common mistake is running the wrong layer in the wrong place: capture tools promoted into CI (where parallelism breaks their shared message list, and where "it rendered locally" was never the question), or real-inbox tests multiplied into hundreds (paying network latency to re-test template logic a mock covers). Match the layer to the question the environment is asking.

What none of this proves

Honest boundaries, because a green suite invites overreading:

NOTE The reverse boundary also holds: a real-inbox layer only ever receives. MailFixture has no sending, relaying, or forwarding — your app's mail goes out through your provider, and the test observes the far end.

Where to go next

BY FRAMEWORK

Playwright · Cypress · pytest — the fixture architecture for where your test code runs.

BY FLOW

OTP · magic links · password reset · SMS OTP — each flow's edge-case matrix.

100 messages/mo free · works with the suite you already have
Start free Read the quickstart