Playwright Email Testing: End-to-End Tutorial
This tutorial shows how to test a real email flow in Playwright end to end — no mocking. We provision a disposable inbox per test, drive the signup, then read the verification email through an API and extract the code. It builds on the disposable email API and is part of our email testing for developers guide.
The approach
Create one inbox per test (not per suite — sharing reintroduces the stale-state problem). Trigger the flow in the browser, poll the inbox API for the message, extract the code with a simple match, and type it back into the page.
A reusable fixture
Wrap the inbox calls in a Playwright fixture so each test gets a fresh address automatically and the test body stays about behaviour, not transport:
// inbox.ts — talks to your MoeMail-style API
export async function createInbox() {
const res = await fetch(API + '/emails/generate', {
method: 'POST',
headers: { 'X-API-Key': process.env.MAIL_KEY },
})
return res.json() // { id, address }
}
export async function waitForMessage(id, timeoutMs) {
const deadline = Date.now() + (timeoutMs || 30000)
while (Date.now() < deadline) {
const res = await fetch(API + '/emails/' + id + '/messages', {
headers: { 'X-API-Key': process.env.MAIL_KEY },
})
const list = await res.json()
if (list.messages && list.messages.length) return list.messages[0]
await new Promise(r => setTimeout(r, 1500))
}
throw new Error('no email arrived in time')
}
The test
import { test, expect } from '@playwright/test'
import { createInbox, waitForMessage } from './inbox'
test('verifies signup email', async ({ page }) => {
const inbox = await createInbox()
await page.goto('/signup')
await page.getByLabel('Email').fill(inbox.address)
await page.getByRole('button', { name: 'Sign up' }).click()
const msg = await waitForMessage(inbox.id, 30000)
const code = msg.text.match(/[0-9]{6}/)[0]
await page.getByLabel('Verification code').fill(code)
await expect(page.getByText('Welcome')).toBeVisible()
})
Note Date.now() guards the poll loop and the match grabs a six-digit code; for a link instead, match the URL and call page.goto() on it.
Make it less flaky
Prefer a webhook over polling to cut latency, give every run a unique address so parallel tests never collide, and keep CI timeouts tight. The same recipe covers OTP and 2FA logins, and the same fixture works in Cypress and Selenium.
Get started
Grab an API key from your profile, read the OpenAPI docs, and drop the fixture above into your suite. New to the idea? Start with email testing for developers.