Inboxflip Disposable Email

Home › Guides › Developer Guides

How to Test Email Signup and OTP Flows with Playwright

Developer Guides · · 10 min read

Email verification is one of the most important flows in any web app, and one of the least tested. It crosses system boundaries: your frontend, your backend, a queue, an email provider, and an inbox. Unit tests cover pieces of it, but only an end-to-end test proves that a real user can sign up, receive a code, and get in.

This guide shows how to test those flows with Playwright, including how to capture emails, extract codes and links, and keep the tests fast and reliable.

What you're actually testing

A complete email flow test should prove that:

  1. the signup form accepts valid input and rejects bad input
  2. your backend sends exactly one email to the right address
  3. the email contains a working code or link
  4. using the code or link completes the flow
  5. expired or reused codes are rejected

Steps 2 and 3 are the ones most teams skip, and they're where bugs like broken template variables, wrong base URLs, and missing environment variables tend to hide.

Choosing where the emails go

You need an inbox your test code can read. There are three common approaches, each suited to a different environment.

Option A: A local mail catcher (best for local and CI)

Tools like Mailpit or MailHog run as a fake SMTP server. Point your app's SMTP settings at it, and every outgoing email is captured and exposed through a web UI and an HTTP API. Nothing leaves your machine.

docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
# SMTP on localhost:1025, UI and API on http://localhost:8025

This is the most reliable option: no network dependency, no spam filters, instant delivery.

Option B: Your email provider's sandbox

Many transactional providers offer a sandbox or test mode that accepts messages without delivering them and exposes them through an API. This is useful for checking that your production templates render correctly.

Option C: A real inbox (best for staging smoke tests)

To prove that email actually delivers (DNS, SPF/DKIM, provider configuration), you have to send to a real address. A disposable test inbox works well for manual checks on staging: generate an address, run the signup, and confirm the message arrives and renders. Keep automated tests on Options A or B, since external inboxes add network latency and can be affected by domain blocklists.

A working example with Mailpit

The example below signs up a new user, reads the verification email from Mailpit's API, extracts a six-digit code, and completes verification.

Helper: wait for an email

// tests/helpers/mail.ts
import { APIRequestContext, expect } from '@playwright/test';

const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';

export async function waitForEmail(
  request: APIRequestContext,
  to: string,
  timeoutMs = 15_000,
) {
  let message: any;
  await expect
    .poll(
      async () => {
        const res = await request.get(
          `${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${to}`)}`,
        );
        const data = await res.json();
        message = data.messages?.[0];
        return Boolean(message);
      },
      { timeout: timeoutMs, intervals: [500, 1000, 2000] },
    )
    .toBe(true);

  const full = await request.get(`${MAILPIT}/api/v1/message/${message.ID}`);
  return full.json(); // includes Subject, Text, HTML
}

export function extractCode(text: string): string {
  const match = text.match(/\b(\d{6})\b/);
  if (!match) throw new Error('No 6-digit code found in email');
  return match[1];
}

The test

// tests/signup.spec.ts
import { test, expect } from '@playwright/test';
import { waitForEmail, extractCode } from './helpers/mail';

test('new user can sign up and verify with an emailed code', async ({ page, request }) => {
  const email = `test+${Date.now()}@example.test`;

  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('Correct-Horse-9!');
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText('Check your email')).toBeVisible();

  const mail = await waitForEmail(request, email);
  expect(mail.Subject).toContain('Verify');

  const code = extractCode(mail.Text);
  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();

  await expect(page).toHaveURL(/dashboard/);
});

Testing magic links instead of codes

If your app sends a link rather than a code, extract it and navigate:

const link = mail.HTML.match(/href="(https?:\/\/[^"]+\/verify[^"]+)"/)?.[1];
expect(link, 'verification link present').toBeTruthy();
await page.goto(link!);
await expect(page.getByText('Email verified')).toBeVisible();

Assert on the link's host too. A common production bug is a link pointing at localhost or a staging domain because an environment variable wasn't set.

Test the unhappy paths

The happy path is only half the story. Add tests for:

  • Expired codes. Use a test-only endpoint or clock control to fast-forward past expiry, then confirm the code is rejected with a helpful message.
  • Reused codes. Verify once, log out, and try the same code again.
  • Resend behavior. Request a resend and assert that the old code is invalidated (or not, depending on your design), and that only the expected number of emails was sent.
  • Rate limiting. Hammer "Resend" and confirm the limit kicks in.
  • Wrong code. Enter 000000 and check the error, plus any lockout after repeated failures.
test('resend invalidates the previous code', async ({ page, request }) => {
  const email = `resend+${Date.now()}@example.test`;
  // ...sign up as above...
  const first = extractCode((await waitForEmail(request, email)).Text);

  await page.getByRole('button', { name: 'Resend code' }).click();
  // wait for the second message, then assert the first code fails
  // (search for the newest message, or clear the mailbox between steps)
});

Keeping email tests reliable

Flaky email tests usually come from a few causes.

  • Unique addresses per test. Use a timestamp or UUID in every address so parallel tests never read each other's mail.
  • Poll, don't sleep. expect.poll with sensible intervals is faster and more stable than a fixed waitForTimeout(5000).
  • Clear state between runs. Mailpit supports deleting messages through its API. Clean up in a beforeAll or scope your searches by recipient.
  • Plain-text parts are easier to parse. Send a text alternative alongside HTML; it's better for accessibility and deliverability, and your regexes will thank you.
  • Don't depend on external inboxes in CI. Network hiccups and domain blocking cause random failures. Keep real-inbox checks for scheduled smoke tests.

Running it in CI

In GitHub Actions, run Mailpit as a service container:

services:
  mailpit:
    image: axllent/mailpit
    ports:
      - 1025:1025
      - 8025:8025
env:
  SMTP_HOST: localhost
  SMTP_PORT: 1025
  MAILPIT_URL: http://localhost:8025

Your app sends mail to port 1025; your tests read it from 8025.

A manual pre-launch checklist

Before shipping changes to email flows, do one real-world check on staging:

  • Sign up with a fresh real or temporary address and confirm the email arrives within a minute
  • Check it renders in both light and dark mode
  • Confirm links use the correct production domain
  • Check the plain-text version is readable
  • Confirm the sender name and reply-to are correct
  • Confirm the email isn't landing in spam at a major provider

Frequently asked questions

Should I mock the email service in end-to-end tests? Mock it in unit tests. In end-to-end tests, use a mail catcher so the real sending code runs and only the final delivery is intercepted.

Can I use a disposable email service for automated tests? It's fine for occasional manual checks on staging. For CI, a local mail catcher is faster, private, and not affected by domain blocklists.

How do I test emails sent from a background queue? Make sure your test environment runs the queue worker (or processes jobs synchronously), then poll the mail catcher as shown above.


Need an inbox for a quick manual check on staging? Generate a temporary address in one click. See also our overview of developer email testing.