Reading time: 11 min | Topics: Playwright email testing, QA automation, OTP verification, magic links, private testing domains | Last updated: September 8, 2026
TL;DR: Playwright email testing works by sending your application’s mail to a private testing domain, then reading it back through the Mailinator REST API. Your spec generates a unique address, triggers the flow, polls for the message, and extracts the code or link. Playwright then types it back into the page and finishes the journey.
Playwright is very good at everything a user can see in a browser. Email, however, lives outside the browser. So the moment your signup flow sends a verification code, your test suite hits a wall. Most teams paper over that gap. They stub the email service, read the code straight from the database, or skip the step entirely. As a result, the one flow that every single user must complete is the flow nobody covers.
This guide fixes that. Below you will find a complete Playwright email testing setup, real specs, and the parallel-safety details that decide whether your suite stays green.
What is Playwright email testing?
Playwright email testing is the practice of driving a browser flow with Playwright while reading the resulting email through an API. The browser handles the user interface. Meanwhile, an email testing service holds the inbox and hands the message back over HTTP. Together they cover signup, password reset, OTP, and magic link journeys end to end.
The pattern is simple, and it never changes:
- Build a unique test address on a domain you control.
- Drive the form in the browser, so your app sends a real email.
- Poll the API until that message lands.
- Pull the code or link out of the body.
- Feed it back into the page and assert the result.

Why can’t Playwright read your inbox on its own?
Playwright automates browsers. It does not speak SMTP, and it has no mailbox of its own. Therefore it cannot see a message until something outside the browser receives it. You could point the test at a real webmail account, but that route breaks fast. Logins expire, providers throw bot checks, and two parallel workers fight over the same inbox.
An API-backed inbox removes all three problems. Because the message arrives at an address you own, no login is required. Furthermore, every test can claim its own inbox, so parallel runs stop colliding.
What do you need before you start?
Playwright email testing needs four things, and the setup takes about ten minutes:
- Node 18 or newer and a Playwright project.
- A private testing domain. Public inboxes are readable by anyone, and many signup forms block them outright. A private domain avoids both issues. For more detail, see why test emails get blocked.
- An API token from your Mailinator team settings.
- A staging environment that actually sends mail, rather than logging it.
That last point trips up a lot of teams. If staging swallows outbound mail, nothing will ever arrive. So check that first.
How to set up Playwright email testing with Mailinator
Start with a plain Playwright install. Then add dotenv so your token stays out of the repository.
npm init playwright@latest
npm install --save-dev dotenv
Next, put your credentials in an .env file and load them in the config.
MAILINATOR_TOKEN=your_api_token
MAILINATOR_DOMAIN=yourteam.mailinator.com
PLEASE NOTE: yourteam.mailinator.com is an example domain.
Now write one small helper module. Notice that it uses Playwright’s own APIRequestContext rather than axios. That keeps the request inside Playwright’s tracing and retry story, and it drops a dependency.
// lib/mailinator.ts
import { APIRequestContext, expect } from '@playwright/test';
const BASE = 'https://api.mailinator.com/api/v2';
const DOMAIN = process.env.MAILINATOR_DOMAIN as string;
const TOKEN = process.env.MAILINATOR_TOKEN as string;
export function testAddress(prefix: string) {
const unique = `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
return { inbox: unique, address: `${unique}@${DOMAIN}` };
}
export async function waitForMessage(
api: APIRequestContext,
inbox: string,
{ timeout = 60000, interval = 2000 } = {}
) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await api.get(`${BASE}/domains/${DOMAIN}/inboxes/${inbox}`, {
headers: { Authorization: TOKEN },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
if (body.msgs && body.msgs.length > 0) {
const full = await api.get(
`${BASE}/domains/${DOMAIN}/messages/${body.msgs[0].id}`,
{ headers: { Authorization: TOKEN } }
);
return await full.json();
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`No mail arrived in ${inbox} within ${timeout}ms`);
}
export function bodyText(message: any): string {
const part =
message.parts.find((p: any) =>
String(p.headers['content-type']).includes('text/plain')
) || message.parts[0];
return part.body;
}
That single file covers most of what follows.
Your first Playwright email testing spec: OTP login
First, expose the mailbox as a fixture. Consequently every test gets a fresh address without repeating setup code.
// fixtures.ts
import { test as base } from '@playwright/test';
import { testAddress } from './lib/mailinator';
type MailFixture = { mailbox: { inbox: string; address: string } };
export const test = base.extend<MailFixture>({
mailbox: async ({}, use, testInfo) => {
await use(testAddress(`w${testInfo.workerIndex}`));
},
});
export { expect } from '@playwright/test';
Then the spec itself reads almost like the user story.
// tests/otp-signup.spec.ts
import { test, expect } from '../fixtures';
import { waitForMessage, bodyText } from '../lib/mailinator';
test('a new user verifies an OTP and reaches the dashboard', async ({
page,
request,
mailbox,
}) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(mailbox.address);
await page.getByRole('button', { name: 'Create account' }).click();
const message = await waitForMessage(request, mailbox.inbox);
expect(message.subject).toContain('verification code');
const code = bodyText(message).match(/[0-9]{6}/)?.[0];
expect(code, 'no six digit code found in the email').toBeTruthy();
await page.getByLabel('Verification code').fill(code as string);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Note the assertion on the subject line. It costs one line, yet it catches a whole class of bugs. For example, a template rename or a wrong sender will now fail loudly instead of silently.
For a deeper treatment of code extraction, read our guide to testing OTP and 2FA emails in automated tests.
How do you grab a magic link instead of a code?
Magic links follow the same shape, but you parse HTML rather than digits. Pull the href, then navigate straight to it. Above all, avoid clicking the link inside a rendered email preview. That adds a fragile step for no benefit.
const message = await waitForMessage(request, mailbox.inbox);
const html = message.parts[0].body as string;
const link = html.match(/href="([^"]*verify[^"]*)"/)?.[1];
expect(link, 'no verification link found in the email').toBeTruthy();
await page.goto(link as string);
await expect(page.getByText('You are signed in')).toBeVisible();
Single-use tokens deserve one extra assertion. Visit the link a second time and confirm that your app rejects it. Otherwise a broken expiry rule can ship unnoticed. Magic links and SSO overlap heavily, so our post on testing SSO and magic link logins goes further.
How do you keep Playwright email testing stable in parallel?
Flaky Playwright email testing specs almost always share one root cause: two tests reading the same inbox. Playwright runs workers in parallel by default, so this bites early. Fortunately the fix is cheap. Give every test its own address, and never reuse one.
Four rules keep a suite calm:
- Make addresses unique per test. Combine the worker index, a timestamp, and a random suffix.
- Poll, never sleep. A fixed
waitForTimeoutis either too slow or too short. Polling adapts. - Match on more than arrival. Check the subject or sender before you trust the body.
- Fail with a useful message. “No mail arrived in inbox w3-1757…” beats a bare timeout every time.
Additionally, keep the polling interval at two seconds or higher. Tighter loops burn API quota and rarely make the suite faster.
Should you poll the API or use a webhook?
Both work, and the right pick depends on your suite. In short, polling suits ordinary end-to-end specs, while webhooks suit high-volume or event-driven pipelines.
| Factor | Polling the REST API | Webhooks |
|---|---|---|
| Setup effort | Low. One helper function. | Higher. Needs a reachable endpoint. |
| Best for | Standard browser journeys | Bulk runs and backend event tests |
| Latency | Interval plus delivery time | Near instant |
| CI friendliness | Works anywhere | Needs a public URL or tunnel |
| API calls used | One per poll | None |
Most Playwright email testing suites should start with polling. Move to webhooks only once quota or speed becomes a real constraint.
How do you run Playwright email testing in CI?
Nothing special is required, because the API call is plain HTTPS. Store the token as a secret, then run the suite as usual.
name: e2e
on: [push]
jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
MAILINATOR_TOKEN: ${{ secrets.MAILINATOR_TOKEN }}
MAILINATOR_DOMAIN: ${{ secrets.MAILINATOR_DOMAIN }}
Raise the timeout a little on CI. Shared runners are slower than a laptop, and mail delivery adds a second or two. Our walkthrough of testing transactional email in a CI/CD pipeline covers scheduling and quota planning in depth.
Five Playwright email testing mistakes to avoid
These five errors account for most broken suites. Certainly they are easy to fix once you spot them.
- Using a public inbox for real assertions. Anyone can read it, and forms often reject it.
- Hardcoding one address. The second worker will read the first worker’s mail.
- Trusting the newest message blindly. Always confirm the subject or sender first.
- Parsing the HTML part when a text part exists. Plain text is far easier to match.
- Leaving the token in the repository. Use environment variables and CI secrets instead.
Playwright or Cypress for email testing?
Both frameworks handle email the same way, so the choice rarely hinges on mail. Still, a few differences matter in practice.
| Consideration | Playwright | Cypress |
|---|---|---|
| API requests in test | Built-in request context | cy.request |
| Parallelism | Workers out of the box | Parallel via orchestration |
| Polling for mail | Plain async loop | Recursive command chain |
| Multi-tab journeys | Native support | Limited |
| Browser coverage | Chromium, Firefox, WebKit | Chromium family, Firefox |
If your team already runs Cypress, stay there. Our companion guide on how to test emails in Cypress mirrors this one step for step, because the Playwright email testing pattern above ports across almost unchanged. For a wider view of the tooling landscape, compare the best email testing tools for QA teams.
Frequently asked questions
Can Playwright test SMS codes as well as email?
Yes. SMS messages land in an inbox keyed to a test phone number, and the same API call retrieves them. Therefore your helper barely changes. Swap the inbox name for the number, then match the code exactly as before.
Do you need a paid plan for Playwright email testing?
Not to begin with. A free Verified Pro account includes a private domain, API access, and webhooks, which covers a small suite comfortably. Larger teams move to a paid plan mainly for higher message volume and API limits.
How long should a test wait for an email?
Sixty seconds is a sensible ceiling locally, and ninety works well on CI. Most mail arrives in under five seconds. However, a generous ceiling costs nothing when delivery is fast, since the poll exits as soon as the message lands.
Does this work with Playwright API testing?
It does. You can skip the browser entirely, call your signup endpoint with request.post, and then assert on the resulting email. That makes a fast contract test for the mail layer alone, without any page interaction.
Start testing the flow your users cannot skip
Email is the one step between a signup click and an active account. Yet it is usually the least tested part of the journey. The Playwright email testing setup above closes that gap in an afternoon, and it scales from one spec to a full regression suite.
Ready to try it? Grab a private testing domain and an API token, then run your first spec today. Start a free Mailinator trial and point your suite at a domain you own.