GUIDE

Testing email flows in Playwright, end to end.

Playwright is the easy runner for email testing — specs run in Node, so an inbox API is a plain await, no bridge required. Which is exactly how suites drift: setup gets copy-pasted into every spec, someone "optimizes" it into one shared inbox, and Playwright's parallel workers turn that inbox into a race. Here's the anatomy of each flake, and the fixture that replaces them all.

Why email tests flake in Playwright

1. page.waitForTimeout(8000). Playwright's auto-waiting is so good inside the browser that its absence outside the browser catches people off guard: locators retry, third-party inboxes don't. So the wait for an email becomes a fixed sleep — too short when delivery takes nine seconds, pure waste when it takes one. The same disease as every other OTP test, wearing Playwright's API.

2. One inbox, many workers. Playwright runs spec files in parallel by default, and fullyParallel spreads single files across workers too. A shared qa@company.com — or one inbox minted in globalSetup — is mutable state shared across all of them: worker 3 reads the code meant for worker 1, and the failure reproduces only under load, never locally with --workers=1. The suite looks flaky; it's actually racing.

3. expect.poll against the messages list. Playwright's own retry tool looks like the fix, so suites wrap a list call in expect.poll with a generous interval. That's a fresh HTTP request per tick eating your rate limit, a race in every gap between polls, and — without a cursor — a match against yesterday's message. The polling isn't the fix; it's the sleep from mistake #1 wearing a loop.

The fixture

The fix is the seam Playwright itself provides: test.extend. An inbox fixture mints a fresh address before each test that asks for one and deletes it afterwards — setup, isolation, and teardown in one place, written once. And because the server holds a long-poll open until mail actually arrives, "wait for the code" is one awaited call, not a polling loop:

fixtures.ts
import { test as base } from '@playwright/test';
import { MailFixture, Inbox } from 'mailfixture';

const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY — Node-side, specs run here anyway

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    const inbox = await mail.createInbox({ ttlSeconds: 900 });
    await use(inbox);
    await inbox.delete(); // teardown runs pass or fail; TTL covers aborted runs
  },
});
export { expect } from '@playwright/test';
signup.spec.ts
import { test, expect } from './fixtures';

test('signup sends a one-time code', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.fill('#email', inbox.address);
  await page.click('text=Send code');

  const otp = await inbox.waitForOtp({ timeout: 30_000 }); // server-held long-poll
  await page.fill('#otp', otp);
  await expect(page.locator('h1')).toHaveText('Welcome');
});

The timeout is now a ceiling, not a duration: waitForOtp resolves the moment the email lands — typically well under a second — and only spends the full 30 when something is genuinely broken. The spec body is the same code as the four-minute quickstart; what the quickstart doesn't have room for is the rules that keep the fixture honest at suite scale.

The rules the fixture imposes

NOTE Because every test owns its inbox, fullyParallel: true and --shard across CI machines have no shared state to fight over — and inboxes expire on their TTL, so a killed run cleans up after itself.

Magic links and resets ride the same fixture

Nothing about the pattern is OTP-specific. inbox.waitForLink({ kind: 'verify' }) returns the confirmation link already classified — never the logo link, never the tracking pixel — and page.goto(link.url) performs the click in the browser your assertions run in. Passwordless login and password reset each deserve their own edge-case matrix — single use, expiry, scanner prefetch, sessions after reset — and each has one: magic links, password reset. And everything else extraction produces is one more await on the same message: SPF/DKIM/DMARC verdicts, a SpamAssassin score, attachment metadata.

Local dev vs CI

None of this replaces watching emails render while you build — a local SMTP-capture tool like Mailpit does that job well, with zero setup. The split that works: capture locally, deliver in CI. The CI suite receives on real domains through your real provider, which is the only place a test can catch the staging config still pointing at the sandbox. The trade-offs are laid out in MailFixture vs. Mailpit.

If an AI agent is authoring or drive-by verifying the flow before the test lands in CI, OTP with Playwright + MCP covers which layer owns the inbox. And if your suite is Cypress, the browser/Node divide makes this a different article — the cy.task bridge has its own guide.

100 messages/mo free · the fixture is fifteen lines, written once
Start free Read the Playwright quickstart