Test email verification with Playwright
Email verification is one of the harder flows to cover in E2E tests. You need a real inbox, a real email to arrive, and a way to extract the link or OTP from it — all inside a test that must be fast, repeatable, and leave no state behind.
The tempy.email API gives you programmable disposable inboxes. Create one per test, point your signup flow at it, fetch the verification email, and assert on it. The inbox expires automatically; no teardown needed.
Prerequisites
- A tempy.email API key — get one free at dev.tempy.email
- Playwright installed (
npm i -D @playwright/test)
The pattern
Every email verification test follows the same shape:
- Create a disposable mailbox before the test
- Use that address in your signup or verification flow
- Poll for the email (or receive it over a webhook)
- Extract the verification link or OTP from the email body
- Complete the flow and assert the outcome
A small helper
You can call the API directly with fetch. A thin wrapper keeps tests readable:
// tests/helpers/tempy.ts
const API_BASE = 'https://api.tempy.email/v1';
const API_KEY = process.env.TEMPY_API_KEY!;
export interface Mailbox {
email: string;
expires_at: string;
seconds_remaining: number;
}
export interface Message {
id: string;
subject: string;
from: string;
text: string;
html: string;
received_at: string;
}
/** Create a fresh disposable inbox. */
export async function createMailbox(): Promise<Mailbox> {
const res = await fetch(`${API_BASE}/mailbox`, {
method: 'POST',
headers: { 'X-Api-Key': API_KEY },
});
if (!res.ok) throw new Error(`tempy: failed to create mailbox (${res.status})`);
return res.json();
}
/**
* Poll for the first message in a mailbox.
* Throws if no message arrives within `timeout` ms.
*/
export async function waitForMessage(
email: string,
{ timeout = 30_000, interval = 1_500 }: { timeout?: number; interval?: number } = {},
): Promise<Message> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(
`${API_BASE}/mailbox/${encodeURIComponent(email)}/messages`,
{ headers: { 'X-Api-Key': API_KEY } },
);
if (!res.ok) throw new Error(`tempy: failed to fetch messages (${res.status})`);
const messages: Message[] = await res.json();
if (messages.length > 0) return messages[0];
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`tempy: no message arrived in ${email} within ${timeout}ms`);
}
Testing a verification link flow
// tests/signup.spec.ts
import { test, expect } from '@playwright/test';
import { createMailbox, waitForMessage } from './helpers/tempy';
test('user can sign up and verify their email', async ({ page }) => {
// 1. Fresh inbox for this test run
const { email } = await createMailbox();
// 2. Fill in the signup form
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('hunter2-but-longer');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your email')).toBeVisible();
// 3. Wait for the verification email to arrive
const message = await waitForMessage(email, { timeout: 30_000 });
// 4. Extract the verification link from the HTML body
const match = message.html.match(/href="(https?:\/\/[^"]*verif[^"]*)"/i);
expect(match, 'No verification link found in email').toBeTruthy();
const verifyUrl = match![1];
// 5. Follow it and assert the account is active
await page.goto(verifyUrl);
await expect(page.getByText('Email verified')).toBeVisible();
await expect(page).toHaveURL('/dashboard');
});
Testing an OTP flow
For flows that send a numeric code instead of a link:
test('user can log in with an OTP', async ({ page }) => {
const { email } = await createMailbox();
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Send code' }).click();
const message = await waitForMessage(email);
// Extract the OTP — adjust the pattern to match your format
const otp = message.text.match(/\b(\d{6})\b/)?.[1];
expect(otp, 'OTP not found in email body').toBeDefined();
await page.getByLabel('Code').fill(otp!);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL('/dashboard');
});
Using webhooks instead of polling
If you can expose a local server in your test environment, webhooks eliminate the polling loop entirely. Create the mailbox with a webhook_url and the email is pushed to you the moment it arrives:
const { email } = await fetch(`${API_BASE}/mailbox`, {
method: 'POST',
headers: {
'X-Api-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ webhook_url: 'https://your-tunnel.example.com/webhook' }),
}).then(r => r.json());
Tools like ngrok or localtunnel can expose a local endpoint during a CI run. This approach is faster than polling and removes the retry loop entirely.
Setting the API key in CI
Add TEMPY_API_KEY as a secret in your CI environment and pass it through to Playwright:
GitHub Actions:
- name: Run Playwright tests
run: npx playwright test
env:
TEMPY_API_KEY: ${{ secrets.TEMPY_API_KEY }}
GitLab CI:
test:
script: npx playwright test
variables:
TEMPY_API_KEY: $TEMPY_API_KEY
The helper reads process.env.TEMPY_API_KEY at runtime, so no other changes are needed.
Tips
One mailbox per test. Create a fresh inbox in each test case, not a shared one across the suite. Isolation prevents flaky failures when tests run in parallel.
Keep the timeout generous. Email delivery usually takes under three seconds, but give yourself 30 seconds in CI to account for queue conditions — and set Playwright's timeout for the test itself to something higher than your waitForMessage timeout.
Skip teardown. Mailboxes expire automatically. You do not need to delete them in afterEach.
Use text for OTP extraction, html for link extraction. The plain-text body is easier to regex against for codes; the HTML body preserves full link URLs with their query strings.
Check subject to target the right email. If your flow sends multiple emails (welcome + verification), match on message.subject to make sure you are acting on the right one.