Playwright Email Testing: Wait for a Verification Email and Extract the Code
Playwright is great at driving a browser, but it can’t read your email — so the verification step in a signup flow usually gets stubbed or skipped. Here’s a complete, real Playwright test that signs a user up, waits for the verification email through a disposable inbox API, extracts the code, and finishes the flow. No polling boilerplate.
The approach
We’ll give each test run its own disposable inbox, drive the signup form with that address, then pull the code (or link) out of the email that arrives. Two ingredients: a tiny Devmailr client, and a Playwright fixture that creates an inbox before the test and deletes it after.
A small Devmailr client
const BASE = 'https://api.devmailr.app/api'
const headers = { Authorization: `Bearer ${process.env.DEVMAILR_API_KEY}` }
export async function createInbox(prefix: string) {
const res = await fetch(`${BASE}/mailboxes`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix }),
})
if (!res.ok) throw new Error(`createInbox failed: ${res.status}`)
return res.json() as Promise<{ _id: string; address: string }>
}
export async function waitForEmail(mailboxId: string, query = '') {
const res = await fetch(`${BASE}/mailboxes/${mailboxId}/wait?timeout=60&${query}`, { headers })
if (res.status === 404) throw new Error('No matching email arrived in time')
if (!res.ok) throw new Error(`waitForEmail failed: ${res.status}`)
return res.json()
}
export async function deleteInbox(mailboxId: string) {
await fetch(`${BASE}/mailboxes/${mailboxId}`, { method: 'DELETE', headers })
}A Playwright fixture for a per-test inbox
Extend Playwright’s test with an inboxfixture, so every test gets a clean address and it’s torn down automatically — even if the test fails:
import { test as base } from '@playwright/test'
import { createInbox, deleteInbox } from './devmailr'
export const test = base.extend<{ inbox: { _id: string; address: string } }>({
inbox: async ({}, use) => {
const inbox = await createInbox(`pw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
await use(inbox)
await deleteInbox(inbox._id)
},
})
export { expect } from '@playwright/test'The test: signup + email verification
import { test, expect } from './fixtures'
import { waitForEmail } from './devmailr'
test('user can sign up and verify their email', async ({ page, inbox }) => {
// 1. Drive the signup form with the disposable address
await page.goto('https://staging.myapp.com/signup')
await page.getByLabel('Email').fill(inbox.address)
await page.getByLabel('Password').fill('correct-horse-battery-staple')
await page.getByRole('button', { name: 'Create account' }).click()
// 2. Block until the verification email arrives (no sleeps, no polling)
const email = await waitForEmail(inbox._id, 'has_code=true')
// 3. Enter the code your app emailed
await page.getByLabel('Verification code').fill(email.extractedCodes[0])
await page.getByRole('button', { name: 'Verify' }).click()
await expect(page.getByText('Welcome')).toBeVisible()
})Magic links instead of codes
If your app sends a click-to-verify link rather than a code, use primaryLink — the best-guess call-to-action URL — and navigate straight to it:
const email = await waitForEmail(inbox._id, 'subject=' + encodeURIComponent('Confirm your email'))
await page.goto(email.primaryLink)
await expect(page.getByText('Email confirmed')).toBeVisible()Tips for reliable runs
- Unique inbox per test.The fixture keys on a timestamp + random suffix, so parallel tests never read each other’s mail.
- Reusing one inbox across steps? Capture a timestamp before the action and pass
received_after=<ms>so/waitignores older emails. - Keep the key secret. Set
DEVMAILR_API_KEYas a CI secret, never in the repo. - On Cypress? The same client works from a
cy.taskorcy.request— the API is just HTTP.
Why /wait and not polling: A single /waitrequest holds the connection open until the email lands, so your test reads as a straight line instead of a retry loop — and it doesn’t burn ~30 list calls against your rate limit.
Related reading
Try it in two minutes
Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.