Email testing with Playwright, end to end
Give every Playwright test its own real, receivable inbox: a fixture, a signup spec that waits for the verification code, assertions on the mail itself, and what to change for CI.
On this page
Playwright email testing stops being hard the moment the mailbox is just an HTTP API. The test creates a throwaway inbox, types that address into your signup form, then blocks until the message lands and reads the code out of it. No SMTP server to run, no IMAP polling loop, and no shared team mailbox that two parallel workers fight over.
What follows is the whole setup: the helper, the fixture, the spec, the assertions worth adding, and the two settings that decide whether it survives CI.
Why the email step is where suites go flaky
Most teams take one of two bad routes. They stub the mail out and assert that a sendEmail
function was called, which proves the code path ran and nothing about whether a user could ever
receive the message. Or they point every test at one real mailbox and search it for the newest
subject line, which works fine on a laptop and falls apart the moment two workers sign up in the
same second and each grabs the other’s code.
The fix is one fresh address per test. If the address is unique, there is nothing to race over, and the test can say exactly which message it is waiting for. Every other decision in playwright email testing follows from that one.
Get a key and write a small helper
Any mailbox that speaks HTTP will do here, and if you are still weighing options, which email API a test suite can actually drive sets out what to compare. Create a free key on the Email Testing API reference page, then export it:
export INBOXDOME_KEY=idk_live_...
One thin wrapper is enough. Everything here is a plain REST call with a bearer token.
// tests/helpers/inboxdome.ts
const BASE = 'https://inboxdome.com';
const KEY = process.env.INBOXDOME_KEY!;
export async function api(path: string, init: RequestInit = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (!res.ok) throw new Error(`InboxDome ${path} failed: ${res.status}`);
return res.json();
}
export async function createInbox(ttlSeconds = 3600) {
const { inbox } = await api('/v1/inboxes', {
method: 'POST',
body: JSON.stringify({ domain: 'smart', ttlSeconds }),
});
return inbox as { id: string; emailAddress: string };
}
export async function waitForOtp(id: string, timeoutSeconds = 30) {
const { otp } = await api(`/v1/inboxes/${id}/wait?for=otp&timeoutSeconds=${timeoutSeconds}`);
return otp.value as string;
}
ttlSeconds accepts 600 to 604800 and defaults to 86400, so an inbox created mid-run disappears
on its own long after the suite has finished.
Make the inbox a Playwright fixture
A fixture is the right home for this. Playwright builds it before the test body and tears it down after, and every test gets its own instance.
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { createInbox } from './helpers/inboxdome';
type Fixtures = { inbox: { id: string; emailAddress: string } };
export const test = base.extend<Fixtures>({
inbox: async ({}, use) => {
await use(await createInbox());
},
});
export { expect } from '@playwright/test';
Keep it test scoped rather than worker scoped. A worker-scoped inbox is reused by every test that worker runs, which reintroduces the exact cross-talk you are trying to design out.
The signup test
// tests/signup.spec.ts
import { test, expect } from './fixtures';
import { waitForOtp } from './helpers/inboxdome';
test('new user verifies by email', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.emailAddress);
await page.getByLabel('Password').fill('S3cure!pass');
await page.getByRole('button', { name: 'Create account' }).click();
const code = await waitForOtp(inbox.id, 30);
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByText('Welcome')).toBeVisible();
});
Notice there is no waitForTimeout anywhere in it. The wait endpoint long-polls: it holds the
connection open until a message arrives, up to a timeoutSeconds ceiling of 50, and answers
408 when nothing turned up. That 408 is a useful failure. It means your application did not
send, which is a real bug rather than a slow test.
Assert on the email, not only on the app
The browser flow proves the code worked. It says nothing about whether the message was well formed or authenticated. A single call covers both, and waits for the mail while it is at it:
const run = await api(`/v1/inboxes/${inbox.id}/assert`, {
method: 'POST',
body: JSON.stringify({
timeoutSeconds: 45,
assertions: [
{ type: 'otpPresent' },
{ type: 'containsText', text: 'Confirm your email' },
{ type: 'auth', check: 'dkim', expect: 'pass' },
{ type: 'auth', check: 'spf', expect: 'pass' },
],
}),
});
expect(run.passed, JSON.stringify(run.results, null, 2)).toBe(true);
The response carries passed, arrived, arrivalSeconds and a results array with a detail
string per assertion, so a red build tells you which condition failed, not just that one did.
There is also a linkResponds assertion that fetches a detected link server side. It is excellent
for catching a dead route, but treat it carefully on flows with single-use tokens, for the reason
set out in testing a password reset email end to end.
Two settings that decide whether CI stays green
The first is the test timeout. Playwright’s default is 30 seconds, and a 45 second mail wait nested inside it gets killed by the runner before the API ever answers. Raise the test, not the wait:
test.setTimeout(90_000);
The second is concurrency. The free plan allows 100 inbox tests a month with a 20 per day
burst, 30 requests a minute, and 2 concurrent waits. A long-poll counts for as long as it is open,
so eight Playwright workers all parked in a wait will hit that ceiling. Either cap workers in
playwright.config.ts to your plan’s concurrent wait limit, or move the email specs into their
own project and run that project with fewer workers than the rest of the suite.
Running it in CI
Playwright email testing needs almost nothing extra in a pipeline. Store INBOXDOME_KEY as a
repository secret and nothing else changes: the runner reaches the same
API your laptop does, so there is no mail service to stand up in the pipeline. Send an
Idempotency-Key header when creating an inbox and a retried job returns the original inbox
instead of spending a second one from the monthly allowance.
For a ready-made workflow, see the GitHub Actions guide. For the shorter copy-paste version of the test above, see the Playwright OTP guide.
Disclosure: InboxDome publishes this blog and makes the products it mentions. We describe what they do rather than promise results.
Key takeaways
- Playwright email testing works once the mailbox is an HTTP API: create an inbox, long-poll for the message, read the code.
- Give every test its own inbox through a fixture. A shared address is what makes parallel email tests flaky.
- Long-polling replaces sleep loops. The wait endpoint blocks up to 50 seconds and returns 408 if nothing arrives.
- Assert on the mail itself, not only on the app: OTP present, expected copy, and SPF, DKIM and DMARC results.
- Set the Playwright test timeout higher than the mail timeout, or the runner kills the test before the wait resolves.
Sources
- Playwright test fixtures — How to extend the base test object with a per-test fixture.
- Playwright timeouts — Test timeout versus expect timeout, and how to raise either.
- Playwright parallelism and workers — Worker processes and the workers option used to cap concurrency.
- RFC 7208: Sender Policy Framework — The SPF specification behind the auth assertions.
Links last checked August 30, 2026.
Frequently asked questions
Can Playwright read email on its own?
No. Playwright drives a browser and has no mail client. You need a mailbox it can reach over HTTP, so the test creates an inbox through an API, fills the signup form with that address, then asks the API for the message.
Do I need a real SMTP server for this?
No. Your application keeps sending through whatever provider it already uses. The test only needs an address that genuinely receives mail, which is what a disposable test inbox gives you. Nothing extra runs on your machine or in CI.
How do I stop email tests from being flaky in parallel?
Create one inbox per test inside a fixture so no two workers share an address, and long-poll instead of sleeping. Then check the concurrent wait limit on your plan and cap Playwright workers to match it.
Should tests delete their inboxes afterwards?
Only if you want the mail gone immediately. Inboxes expire on their own, with a default time to live of 86400 seconds, and a cleanup job runs every 15 minutes. A DELETE call on the inbox purges the address and its mail right away.