Email testing in Playwright
Give every test its own real inbox, sign up through the UI, then type the verification code in or open the confirm link. No shared mailbox, no IMAP, no fixed sleeps.
Why a fresh inbox per test
Playwright runs tests in parallel. With one shared test mailbox, two workers signing up at the same moment read each other's codes, and a test that passes alone fails in CI. A new address for each test means each one reads only its own mail. It also means the "email already registered" error never comes up on the second run.
1. Set up
npm install -D @playwright/test
export MAILFORNET_API_KEY="mf_live_your_key" # in CI, add it as a secret
2. An inbox fixture
Put this in tests/fixtures.ts. Every test that asks for inbox gets its own address,
and it's deleted when the test ends:
import { test as base, expect } from '@playwright/test';
const API = 'https://api.mailfornet.com/v1';
const headers = { Authorization: `Bearer ${process.env.MAILFORNET_API_KEY}` };
type Inbox = {
address: string;
/** Waits for a verification mail and returns its code and confirm link. */
waitForCode(opts?: { subject?: string; from?: string; timeout?: number }): Promise<{ code: string | null; link: string | null }>;
};
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({ request }, use) => {
const created = await request.post(`${API}/inboxes`, { headers, data: { ttlMinutes: 30 } });
expect(created.ok()).toBeTruthy();
const { address } = await created.json();
const since = Date.now();
await use({
address,
async waitForCode({ subject, from, timeout = 60 } = {}) {
const res = await request.get(`${API}/inboxes/${encodeURIComponent(address)}/code`, {
headers,
params: { wait: Math.min(timeout, 60), since, ...(subject && { subject }), ...(from && { from }) },
timeout: (timeout + 10) * 1000,
});
if (!res.ok()) throw new Error(`No verification mail for ${address}: ${await res.text()}`);
return res.json();
},
});
await request.delete(`${API}/inboxes/${encodeURIComponent(address)}`, { headers });
},
});
export { expect };
3. Test signup with a code
import { test, expect } from './fixtures';
test('new user verifies their email with a code', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByLabel('Password').fill('Correct-Horse-9');
await page.getByRole('button', { name: 'Create account' }).click();
const { code } = await inbox.waitForCode({ subject: 'Verify' });
expect(code).toMatch(/^\d{6}$/);
await page.getByLabel('Verification code').fill(code!);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByText('Welcome')).toBeVisible();
});
4. Test a confirmation link
test('user confirms via the link in the email', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Sign up' }).click();
const { link } = await inbox.waitForCode({ subject: 'Confirm' });
expect(link).toBeTruthy();
await page.goto(link!);
await expect(page.getByText('Email confirmed')).toBeVisible();
});
5. Password reset
test('password reset email works', async ({ page, inbox }) => {
await createUserViaApi(inbox.address); // your own setup helper
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Send reset link' }).click();
const { link } = await inbox.waitForCode({ subject: 'Reset' });
await page.goto(link!);
await page.getByLabel('New password').fill('Another-Horse-7');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/login/);
});
Checking the email itself
To check the wording, the sender or the HTML, read the full message:
const { messageId } = await inbox.waitForCode();
const mail = await (await request.get(
`${API}/messages/${messageId}`, { headers, params: { address: inbox.address } }
)).json();
expect(mail.from).toContain('noreply@yourapp.com');
expect(mail.subject).toBe('Verify your email');
expect(mail.links.map((l) => l.url)).toContain(mail.verificationLink);
Running in CI
# .github/workflows/e2e.yml
- run: npx playwright test
env:
MAILFORNET_API_KEY: ${{ secrets.MAILFORNET_API_KEY }}
- One waiting call is one request. A 60-second wait costs one request, not sixty. A suite of 200 signup tests uses about 600 requests: create, wait and delete for each test.
- Give CI its own key. Set an expiry and, if your runners have fixed IPs, an allowlist.
- No
waitForTimeout. The request returns the moment the mail lands, so tests are as fast as your mail provider.
Related
Cypress guide · Selenium & Python · Code examples · API reference