Most "email testing" advice ends at stubbing the send call. You assert that your
app tried to send a message, and the test goes green. That leaves the
interesting half untested: whether the message actually left your infrastructure,
whether the template rendered, and whether the six-digit code inside it matches
the one your backend is willing to accept.
This walks through the other approach — driving a real signup flow in
Playwright, letting a real email get delivered to a
real inbox, then reading it back over an API and typing the code into the page.
No mail server to run, no shared QA mailbox to clean up.
The shape of the problem
A verification-email test has four moving parts:
- an address that is unique to this test run,
- the browser flow that triggers the send,
- a way to read the message that arrives,
- code extraction and the assertion.
Steps 1 and 3 are the ones people get wrong, and they get them wrong in the same
way: by sharing one mailbox across the suite. The moment two tests run in
parallel, one of them reads the other's email. So the rule is one inbox per
test, provisioned on the fly and thrown away afterwards.
The inbox helper
Any disposable-inbox API with a REST interface works here. I'll use
MoeMail's because it's open source and the free tier is
enough for a CI suite — the shape is the same anywhere, so swap the base URL and
the auth header if you use something else.
// inbox.ts
const API = 'https://moemail.app/api'
const KEY = process.env.MAIL_KEY!
export type Inbox = { id: string; email: string }
export async function createInbox(ttlMs = 3_600_000): Promise<Inbox> {
const res = await fetch(`${API}/emails/generate`, {
method: 'POST',
headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
// Omit `name` and a random local part is generated for you — which is
// exactly what you want, so parallel tests can never collide.
body: JSON.stringify({ expiryTime: ttlMs, domain: 'moemail.app' }),
})
if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
return res.json() // { id, email }
}
export type Message = {
id: string
from_address: string
subject: string
content: string
html: string | null
received_at: number
}
export async function waitForMessage(
inboxId: string,
{ timeoutMs = 30_000, intervalMs = 1_500 } = {},
): Promise<Message> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`${API}/emails/${inboxId}`, {
headers: { 'X-API-Key': KEY },
})
if (!res.ok) throw new Error(`poll failed: ${res.status}`)
const { messages } = await res.json()
if (messages?.length) return messages[0]
await new Promise(r => setTimeout(r, intervalMs))
}
throw new Error(`no email arrived within ${timeoutMs}ms`)
}
Two details worth keeping. expiryTime is in milliseconds, and the poll loop
is bounded by a deadline rather than a fixed retry count — a fixed count silently
becomes a different timeout the moment you change the interval.
Wiring it into a Playwright fixture
Doing this in the test body works, but it puts transport concerns in front of
behaviour. A fixture hides it, and gives every test a fresh address without
anyone having to remember:
// fixtures.ts
import { test as base } from '@playwright/test'
import { createInbox, type Inbox } from './inbox'
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
await use(await createInbox())
},
})
export { expect } from '@playwright/test'
Now the test reads like the user story it represents:
import { test, expect } from './fixtures'
import { waitForMessage } from './inbox'
test('a new user can verify their email', async ({ page, inbox }) => {
await page.goto('/signup')
await page.getByLabel('Email').fill(inbox.email)
await page.getByRole('button', { name: 'Sign up' }).click()
const msg = await waitForMessage(inbox.id)
const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()
await page.getByLabel('Verification code').fill(code!)
await expect(page.getByText('Welcome')).toBeVisible()
})
Note the expect on the extraction itself. match(...)[0] throws
Cannot read properties of null when the regex misses, and you get a stack trace
pointing at the regex instead of the actual failure, which is that the email said
something you didn't expect. Asserting with the subject line in the message turns
ten minutes of confusion into one glance at the report.
Magic links instead of codes
Same fixture, different extraction — pull the URL out and navigate straight to it:
const link = msg.content.match(/https:\/\/[^\s"<>]+\/verify\?[^\s"<>]+/)?.[0]
expect(link).toBeDefined()
await page.goto(link!)
If your template only puts the link in the HTML part, read msg.html instead of
msg.content — plain-text alternatives get out of sync with the HTML more often
than anyone expects, and testing the part users actually click is the point.
Making it survive CI
A few things that turned flaky runs into boring ones for me:
- Never share an address across tests. The fixture already handles it; the failure mode when you don't is a test that passes alone and fails in a suite.
- Prefer a webhook over polling if latency matters. Polling is simpler and fine for a handful of tests; a push means you react the instant mail lands instead of waiting out an interval.
- Keep the timeout tight but honest. 30s covers real delivery. If you need 90s, you have a delivery problem that a longer timeout is hiding, not fixing.
-
Put the API key in CI secrets, not in
playwright.config.ts. Obvious, and still the most common thing I see committed.
The same recipe elsewhere
Nothing above is Playwright-specific except the fixture wiring. The two helpers
drop into Cypress or Selenium unchanged — only the way you register them differs.
If you're currently paying for a hosted inbox service, this is also roughly the
whole migration: the fixture is the seam, and only the transport behind it
changes. I wrote up how the options compare —
Mailosaur and MailSlurp alternatives that receive real email
— including which of them can't actually accept mail from the public internet,
which is the distinction most comparison posts skip.
Full API reference is in the OpenAPI docs,
and the server itself is on
GitHub under MIT if you'd rather
self-host it and point the same tests at your own domain.
Top comments (0)