DEV Community

Christian Potvin
Christian Potvin

Posted on

Testing Email Verification Flows with Playwright and a Disposable Inbox API

TL;DR: Per-test inbox isolation solves the flaky-email-test problem. This tutorial shows how to build a Playwright TypeScript fixture that creates a fresh disposable inbox per test, polls for the verification email, and extracts the OTP or magic link — using the MinuteMail API.


The Problem

Your sign-up flow looks like this:

  1. User fills in the registration form
  2. Your backend sends a verification email
  3. User clicks the link (or enters the OTP)
  4. User lands on the dashboard

You want to test this end-to-end in Playwright. So you write the test, use a shared test@yourdomain.com inbox, and it works — until you run two tests in parallel. Now test A reads the email that was meant for test B. Or test B reads a stale email from the previous run. The test suite becomes non-deterministic.

This is not a Playwright bug. It's a state isolation problem.


Why the Obvious Solutions Fall Short

Mocking the email service: Fast, but misses the real integration. If you're testing Auth0, Cognito, or Firebase Auth, you can't intercept the email before it's sent. The whole point is to verify the third-party service sends the right thing.

Shared Gmail account + IMAP polling: Fragile. Gmail rate-limits IMAP connections. You still have the shared-state problem. "Wait 5 seconds" fixes nothing when tests run in parallel.

Mailhog or MailDev (self-hosted SMTP): Great for testing your own email service, but useless when the email originates from a third-party provider that sends to real SMTP. You can't redirect Cognito's SES to your local Mailhog.

Mailinator: Known temp-email domain, increasingly blocked by sign-up forms and auth providers. No API ergonomics. No per-test TTL control.


The Right Approach: Per-Test Inbox Isolation

The fix is to give each test its own fresh inbox. Requirements:

  • Create a new inbox on demand (before each test)
  • Get a unique email address for that inbox
  • Poll the inbox for incoming mail
  • Extract OTP or link from the email body
  • Inbox expires automatically after the test (no cleanup needed)

This is exactly what a hosted disposable email API is for.


Implementation

We'll use MinuteMail — it has a REST API with per-mailbox TTL and a free tier (100 API calls/day) that's sufficient for local dev.

Get an API key from https://minutemail.co after signing up. Set it as an environment variable:

export MINUTEMAIL_API_KEY=your_key_here
Enter fullscreen mode Exit fullscreen mode

Step 1: Create the helper utilities

// tests/helpers/email.ts

const BASE_URL = 'https://api.minutemail.co/v1';
const API_KEY = process.env.MINUTEMAIL_API_KEY!;

const headers = {
  'Authorization': `Bearer ${API_KEY}`,
  'Content-Type': 'application/json',
};

export interface Mailbox {
  id: string;
  address: string;
  expiresAt: string;
}

export interface Message {
  id: string;
  subject: string;
  from: string;
  body: string;
  received_at: string;
}

export async function createMailbox(ttlMinutes = 10): Promise<Mailbox> {
  const response = await fetch(`${BASE_URL}/mailboxes`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ttl: ttlMinutes }),
  });

  if (!response.ok) throw new Error(`Failed to create mailbox: ${response.status}`);
  return response.json();
}

export async function waitForEmail(
  mailboxId: string,
  { timeout = 30000, pollInterval = 2000 } = {}
): Promise<Message> {
  const deadline = Date.now() + timeout;

  while (Date.now() < deadline) {
    const response = await fetch(`${BASE_URL}/mailboxes/${mailboxId}/mails`, { headers });
    const { items }: { messages: Message[] } = await response.json();

    if (items.length > 0) return items[0];
    await new Promise(resolve => setTimeout(resolve, pollInterval));
  }

  throw new Error(`No email received within ${timeout}ms`);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a Playwright fixture

// tests/fixtures/email-fixture.ts

import { test as base } from '@playwright/test';
import { createMailbox, waitForEmail, Mailbox, Message } from '../helpers/email';

type EmailFixtures = {
  inbox: {
    mailbox: Mailbox;
    waitForEmail: (options?: { timeout?: number }) => Promise<Message>;
  };
};

export const test = base.extend<EmailFixtures>({
  inbox: async ({}, use) => {
    const mailbox = await createMailbox(10); // 10-minute TTL

    await use({
      mailbox,
      waitForEmail: (options) => waitForEmail(mailbox.id, options),
    });

    // Mailbox expires automatically — no cleanup needed
  },
});

export { expect } from '@playwright/test';
Enter fullscreen mode Exit fullscreen mode

Step 3: Write the test

// tests/registration.spec.ts

import { test, expect } from './fixtures/email-fixture';

test('user can complete email verification after sign-up', async ({ page, inbox }) => {
  await page.goto('/register');

  // Use the unique inbox address — isolated from other parallel tests
  await page.fill('[data-testid=email]', inbox.mailbox.address);
  await page.fill('[data-testid=password]', 'SecurePass123!');
  await page.click('[data-testid=submit]');

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

  // Poll for the verification email
  const email = await inbox.waitForEmail({ timeout: 45000 });

  // Extract 6-digit OTP (adjust regex for your format)
  const otp = email.body.match(/\b\d{6}\b/)?.[0];
  expect(otp, 'OTP not found in email body').toBeDefined();

  await page.fill('[data-testid=otp]', otp!);
  await page.click('[data-testid=verify]');

  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Run in parallel with confidence

Each test worker gets its own inbox fixture — a completely separate MinuteMail address. No shared state. You can run with --workers=8 and every test reads only its own email.

npx playwright test --workers=8
Enter fullscreen mode Exit fullscreen mode

CI/CD Configuration

Add the API key to your CI secrets:

# .github/workflows/e2e.yml
- name: Run E2E tests
  env:
    MINUTEMAIL_API_KEY: ${{ secrets.MINUTEMAIL_API_KEY }}
  run: npx playwright test
Enter fullscreen mode Exit fullscreen mode

The free tier handles 100 API calls/day. Each test uses 1–2 calls (create mailbox + poll). For larger CI pipelines, the Pro plan ($15/month) gives 10,000 calls/day.


What About the Email Delivery Delay?

The waitForEmail helper polls every 2 seconds with a 30-second default timeout. In practice, transactional emails from services like Cognito or SendGrid arrive within 2–5 seconds. The 30-second timeout is a safety net, not the expected wait.

If your email provider is consistently slow, increase the timeout:

const email = await inbox.waitForEmail({ timeout: 60000 });
Enter fullscreen mode Exit fullscreen mode

Summary

  • Shared inboxes cause flaky parallel tests — fix it with per-test isolation
  • A hosted disposable inbox API (MinuteMail, or similar) makes this clean and infrastructure-free
  • The Playwright fixture pattern wraps this in a reusable, type-safe abstraction
  • Free tier is enough for local development; scale up for CI

Top comments (0)