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:
- User fills in the registration form
- Your backend sends a verification email
- User clicks the link (or enters the OTP)
- 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
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`);
}
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';
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');
});
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
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
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 });
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)