Guides / Playwright
Guide
Playwright email verification without an API key
Your test can fill the signup form. It cannot open an inbox. Here is the missing half, as a fixture you can paste in.
Every step below runs against a disposable inbox that is created on demand and needs no account, no key and no dashboard. Nothing has to be provisioned before the run, and there is no secret to put in CI.
Why the usual workarounds hurt
A signup test normally stops at the success banner. The flow it is supposed to cover ends one step later, in an inbox the browser cannot see. The three common ways around that each give something up:
| Workaround | What it stops catching |
|---|---|
| Mock the sender | Asserts that your code called send(). A broken template, a wrong link host or a mail that never leaves the queue all still pass. |
| One shared test inbox | Breaks the moment you run workers in parallel, because tests read each other's codes. |
| Scrape a temp-mail website | A second page context and a DOM that is not yours to depend on. |
| Self-host MailHog | Real mail, but now the suite needs infrastructure and CI needs a service container. |
What you want instead is an inbox with an HTTP interface: one address per test, and a call that blocks until the code lands.
The fixture
A test-scoped fixture gives every test its own address and tears nothing down, because a disposable inbox expires on its own after 6 hours of silence.
tests/fixtures/inbox.ts
import { test as base, request, type APIRequestContext } from '@playwright/test';
const MAIL_API = 'https://dev-mail.com';
export type Inbox = {
address: string;
/** Blocks until a one-time code arrives. Throws if none does. */
waitForCode(seconds?: number): Promise<string>;
};
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
const api: APIRequestContext = await request.newContext({ baseURL: MAIL_API });
const created = await api.post('/v1/inboxes');
if (!created.ok()) {
throw new Error(`could not create an inbox: HTTP ${created.status()}`);
}
const { address, token } = await created.json();
await use({
address,
async waitForCode(seconds = 60) {
const res = await api.get(
`/v1/inboxes/${encodeURIComponent(address)}/code?wait=${seconds}`,
{
headers: { Authorization: `Bearer ${token}` },
timeout: (seconds + 10) * 1000,
},
);
const body = await res.json();
if (!body.found) {
throw new Error(`no verification code reached ${address} within ${seconds}s`);
}
return body.code;
},
});
await api.dispose();
},
});
export { expect } from '@playwright/test';
Two details that matter. The request timeout is the long-poll window plus a margin, otherwise Playwright aborts the call while the server is still legitimately holding it open. And the address is URL-encoded, because it contains an @.
Using it
Import test from the fixture instead of from @playwright/test, and the inbox is just another argument.
tests/signup.spec.ts
import { test, expect } from './fixtures/inbox';
test('a new account can confirm its email', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Create account' }).click();
const code = await inbox.waitForCode();
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
No waitForTimeout anywhere. The wait parameter holds one HTTP request open until the message is stored, so the test resumes the moment the mail lands rather than after a duration you guessed.
When it times out
The address is the only artifact worth having in the report, so attach it. A failure then tells you which inbox to inspect instead of only that the code never came.
test('a new account can confirm its email', async ({ page, inbox }, testInfo) => {
testInfo.annotations.push({ type: 'inbox', description: inbox.address });
// ...
});
To see what actually arrived, list the inbox instead of asking for a code. Reads are idempotent, so this never consumes anything your test is waiting on:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://dev-mail.com/v1/inboxes/$ADDRESS/messages" | jq '.messages[] | {subject, body_text}'
If body_text holds a code but found was false, the extractor did not recognise it. It looks for digits near words like verification, code, OTP and 验证码, in the subject and the body. Fall back to /messages and your own regex for an unusual template.
Parallel runs and the rate limit
The API is open, so the only thing standing between it and abuse is a per-IP limit: 60 requests per minute and 20 new inboxes per hour. A fixture that creates one inbox per test is the clearest design and it fits comfortably until you have around 20 email-dependent tests running in an hour. CI runners that share one egress IP across jobs reach that sooner.
Past that point, move the fixture to worker scope so each worker creates one inbox and reuses it. The catch is that a reused inbox still holds the previous test's code, and the code endpoint would return it immediately. Draw a line in time with since:
tests/fixtures/inbox.ts (worker-scoped variant)
export const test = base.extend<{}, { inbox: Inbox }>({
inbox: [async ({}, use) => {
const api = await request.newContext({ baseURL: MAIL_API });
const { address, token } = await (await api.post('/v1/inboxes')).json();
await use({
address,
async waitForCode(seconds = 60) {
// Only codes that arrive from here on count.
const since = Math.floor(Date.now() / 1000);
const res = await api.get(
`/v1/inboxes/${encodeURIComponent(address)}/code` +
`?wait=${seconds}&since=${since}`,
{ headers: { Authorization: `Bearer ${token}` }, timeout: (seconds + 10) * 1000 },
);
const body = await res.json();
if (!body.found) throw new Error(`no code within ${seconds}s`);
return body.code;
},
});
await api.dispose();
}, { scope: 'worker' }],
});
Take since before you submit the form, not after. Taking it afterwards races the mail: a fast sender can land the message before the timestamp you are about to compare against.
What this does not do
Being straight about the edges, because finding them at 2am is worse.
| Sending | Receive only. Dev-Mail cannot send mail, so it cannot test your inbound handling. |
| Attachments | Not stored. Subject, HTML body and plain text only. |
| Durability | Inboxes expire after 6 hours of inactivity and addresses are recycled. Nothing here is storage. |
| Blocklists | Some products reject known disposable domains at signup. If yours does, test against a domain you control instead — that is your product working as designed. |
| Magic links | There is no link-extraction endpoint. Pull the URL out of body_text yourself with /messages. |