Playwright Email Testing: A Tutorial with Real Inboxes
This tutorial walks you through integrating the TempMailGrab API with Playwright to test real email flows — from OTP entry to magic-link click-through — with working code you can copy into your test suite.
Prerequisites
This tutorial assumes: Node.js 18+, Playwright installed (npm install @playwright/test), a TempMailGrab API key (from your developer dashboard), and TypeScript configured (optional but recommended). The code examples are TypeScript — remove the type annotations for plain JavaScript.
Step 1: Build an email helper module
Create tests/helpers/email.ts. This module wraps the TempMailGrab API with typed helpers that your Playwright tests will use:
// tests/helpers/email.ts
const TMG_BASE = 'https://tempmailgrab.com/api/v1';
const TMG_KEY = process.env.TMG_API_KEY!;
const headers = {
Authorization: `Bearer ${TMG_KEY}`,
'Content-Type': 'application/json',
};
export interface Inbox {
id: string;
address: string;
expires_at: string;
}
export interface Message {
id: string;
subject: string;
sender: string;
timestamp: string;
extracted_otp: string | null;
extracted_links: string[];
text_body: string;
html_body: string;
}
export async function createInbox(): Promise<Inbox> {
const res = await fetch(`${TMG_BASE}/inbox`, { method: 'POST', headers });
if (!res.ok) throw new Error(`TempMailGrab: createInbox failed ${res.status}`);
return res.json() as Promise<Inbox>;
}
export async function getMessages(inboxId: string): Promise<Message[]> {
const res = await fetch(`${TMG_BASE}/inbox/${inboxId}/messages`, { headers });
if (!res.ok) throw new Error(`TempMailGrab: getMessages failed ${res.status}`);
const data = await res.json() as { messages: Message[] };
return data.messages;
}
export async function waitForOtp(
inboxId: string,
timeoutMs = 30_000,
pollMs = 2_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const messages = await getMessages(inboxId);
const msg = messages.find(m => m.extracted_otp);
if (msg?.extracted_otp) return msg.extracted_otp;
await new Promise(r => setTimeout(r, pollMs));
}
throw new Error(`OTP not received within ${timeoutMs}ms`);
}
export async function waitForVerificationLink(
inboxId: string,
pattern: RegExp = /verify|confirm|activate/i,
timeoutMs = 30_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const messages = await getMessages(inboxId);
for (const msg of messages) {
const link = msg.extracted_links.find(l => pattern.test(l));
if (link) return link;
}
await new Promise(r => setTimeout(r, 2_000));
}
throw new Error(`Verification link not received within ${timeoutMs}ms`);
}
Step 2: Configure Playwright fixtures
Playwright fixtures let you inject the inbox into every test that needs it. Create tests/fixtures.ts:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { createInbox, type Inbox } from './helpers/email';
type EmailFixtures = {
inbox: Inbox;
};
export const test = base.extend<EmailFixtures>({
inbox: async ({}, use) => {
const inbox = await createInbox();
await use(inbox);
// Inbox expires automatically — no teardown needed
},
});
export { expect } from '@playwright/test';
Step 3: Write an OTP sign-up test
// tests/auth/signup-otp.spec.ts
import { test, expect } from '../fixtures';
import { waitForOtp } from '../helpers/email';
test('OTP sign-up completes successfully', async ({ page, inbox }) => {
// Navigate to sign-up
await page.goto('/signup');
// Fill the form with the temporary address
await page.fill('[name=email]', inbox.address);
await page.fill('[name=password]', 'SecureTestPass123!');
await page.click('[data-testid=signup-submit]');
// Wait for the OTP input to appear
await expect(page.locator('[data-testid=otp-step]')).toBeVisible({ timeout: 5_000 });
// Fetch the OTP from the temporary inbox
const otp = await waitForOtp(inbox.id, 30_000);
expect(otp).toMatch(/^d{4,8}$/);
// Enter the OTP
await page.fill('[data-testid=otp-input]', otp);
await page.click('[data-testid=otp-submit]');
// Assert the success state
await expect(page.locator('[data-testid=dashboard-welcome]')).toBeVisible({ timeout: 5_000 });
});
Step 4: Write a magic-link test
// tests/auth/magic-link.spec.ts
import { test, expect } from '../fixtures';
import { waitForVerificationLink } from '../helpers/email';
test('magic link login completes successfully', async ({ page, inbox }) => {
await page.goto('/login');
await page.fill('[name=email]', inbox.address);
await page.click('[data-testid=send-magic-link]');
// Wait for confirmation on page
await expect(
page.locator('text=Check your email')
).toBeVisible({ timeout: 5_000 });
// Get the magic link from the inbox
const link = await waitForVerificationLink(inbox.id, /magic|login|verify/i, 30_000);
// Navigate to the magic link
await page.goto(link);
// Assert the authenticated state
await expect(page.locator('[data-testid=user-menu]')).toBeVisible({ timeout: 5_000 });
});
Step 5: Run tests in parallel safely
Because each test creates its own inbox via the fixture, tests are completely isolated and safe to parallelize. Configure Playwright to run them in parallel in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4, // 4 parallel workers, each with independent inboxes
timeout: 60_000, // 60s per test (email delivery takes time)
use: {
baseURL: process.env.APP_URL || 'http://localhost:3000',
},
});
Step 6: CI integration
Add the API key as a CI secret (TMG_API_KEY) and include the test run in your pipeline:
# .github/workflows/e2e.yml
- name: Run email E2E tests
run: npx playwright test tests/auth/
env:
TMG_API_KEY: ${{ secrets.TMG_API_KEY }}
APP_URL: ${{ env.STAGING_URL }}
Troubleshooting
OTP never arrives: Check that the sign-up form submission succeeded. The OTP is only sent after a successful form submission. Add a console.log in getMessages to verify messages are returning. Check the application logs for email delivery errors.
Timeout in CI: Email delivery through real SMTP takes longer than a mock. If your OTP validity is 5 minutes and your CI runner is slow, the OTP might expire. Increase the validity window in your test environment, or switch to webhook-based delivery to eliminate polling latency.
Rate limit errors: If you are running many tests in parallel, you may hit the 100 req/10s limit. Stagger inbox creation with a small delay between parallel test setups, or batch inbox creation in the globalSetup hook and distribute pre-created inboxes to tests.
Related articles
See also: API docs · Cypress alternative