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:
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';
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
- Test-scoped, not worker-scoped. The client can live at module level — it's stateless. The inbox can't: a worker-scoped fixture shares one inbox across every test that worker runs, which is the shared-mailbox disease from mistake #2, just sharded. If a test needs two inboxes (sender and recipient personas), extend the fixture; don't reuse one.
- Retries re-run fixtures — use that. With
retries: 2, each attempt sets up its fixtures from scratch, so attempt two gets a fresh inbox and can never re-read the stale code attempt one already consumed. This only holds if the inbox comes from the fixture: one minted inglobalSetupor a sharedbeforeAllis the same inbox on every attempt. - Give the test a longer leash than the SDK. Playwright's default test timeout is 30 seconds — the same as the
waitForOtpdeadline above. A slow email then dies as "Test timeout of 30000ms exceeded," pointing at the test instead of the wait. Settimeout: 60_000in the config (ortest.setTimeout) so the SDK's ownMailFixtureTimeoutgets to name the real failure. - Don't rebuild the wait with
expect.poll. It re-issues a request per interval and knows nothing about message cursors.waitForMessage/waitForOtphold one request open server-side and tracksinceinternally — for a resend flow, the second wait picks up where the first left off instead of re-matching the first email.
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.