E2E Email Testing with Playwright & Cypress
How to test that your app actually sends emails. Verify customer invoices, welcome emails, and password resets arrive correctly in your CI/CD pipeline.
Your app sends emails — welcome messages, invoices, password resets, order confirmations. But how do you test that they actually arrive?
Most teams don't. They mock the email service, cross their fingers, and hope production works. Then a customer complains they never got their invoice, and you realize the email template has been broken for two weeks.
Here's how to properly test email delivery in your E2E suite.
The Problem with Mocking
// ❌ What most teams do
jest.mock('./emailService', () => ({
sendEmail: jest.fn().mockResolvedValue({ success: true })
}))
test('sends welcome email', async () => {
await registerUser({ email: '[email protected]' })
expect(emailService.sendEmail).toHaveBeenCalled()
})This tests that your code calls the email function. It doesn't test:
- Whether the email actually sends through your provider
- Whether the template renders correctly
- Whether links in the email work
- Whether attachments are included
- Whether the email lands in spam
You're testing the mock, not the system.
Real Email Testing
The solution: send real emails to real addresses, then verify they arrived.
The Architecture
- Generate a unique test email address for each test
- Run your user flow (signup, purchase, etc.)
- Wait for the email to arrive
- Assert on the email content
With Mailhooks
Mailhooks gives you instant access to incoming emails via API or real-time push. Perfect for E2E tests.
// test-helpers/email.js
const MAILHOOKS_API = 'https://api.mailhooks.dev'
export async function waitForEmail(toAddress, { timeout = 30000, subject } = {}) {
const start = Date.now()
while (Date.now() - start < timeout) {
const response = await fetch(
`${MAILHOOKS_API}/inboxes/${INBOX_ID}/messages?to=${toAddress}`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
)
const messages = await response.json()
const match = messages.find(m =>
!subject || m.subject.includes(subject)
)
if (match) return match
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Email not received within ${timeout}ms`)
}Playwright Example: Testing Invoice Emails
Let's test that customers receive their invoice after a purchase:
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test'
import { waitForEmail } from '../test-helpers/email'
test('customer receives invoice after purchase', async ({ page }) => {
// Generate unique email for this test
const testEmail = `test-${Date.now()}@inbox.mailhooks.email`
// Complete purchase flow
await page.goto('/products/widget')
await page.click('button:has-text("Add to Cart")')
await page.goto('/checkout')
await page.fill('[name="email"]', testEmail)
await page.fill('[name="card"]', '4242424242424242')
await page.fill('[name="expiry"]', '12/25')
await page.fill('[name="cvc"]', '123')
await page.click('button:has-text("Complete Purchase")')
await expect(page.locator('.order-confirmation')).toBeVisible()
// Wait for invoice email
const invoice = await waitForEmail(testEmail, {
subject: 'Your Invoice',
timeout: 30000
})
// Verify email content
expect(invoice.subject).toContain('Invoice')
expect(invoice.html).toContain('Order #')
expect(invoice.html).toContain('Widget')
expect(invoice.html).toContain('$49.99')
// Verify PDF attachment
expect(invoice.attachments).toHaveLength(1)
expect(invoice.attachments[0].filename).toMatch(/invoice.*\.pdf/i)
})Cypress Example: Testing Password Reset
// cypress/e2e/password-reset.cy.js
describe('Password Reset', () => {
it('sends reset email with valid link', () => {
const testEmail = `reset-${Date.now()}@inbox.mailhooks.email`
// Request password reset
cy.visit('/forgot-password')
cy.get('[name="email"]').type(testEmail)
cy.get('button[type="submit"]').click()
cy.contains('Check your email').should('be.visible')
// Wait for email
cy.waitForEmail(testEmail, { subject: 'Reset your password' })
.then((email) => {
// Extract reset link from email
const resetLink = email.html.match(/href="([^"]*reset[^"]*)"/)[1]
// Visit reset link
cy.visit(resetLink)
cy.url().should('include', '/reset-password')
// Complete reset
cy.get('[name="password"]').type('newSecurePassword123!')
cy.get('[name="confirmPassword"]').type('newSecurePassword123!')
cy.get('button[type="submit"]').click()
cy.contains('Password updated').should('be.visible')
})
})
})Cypress Custom Command
Add this to cypress/support/commands.js:
Cypress.Commands.add('waitForEmail', (toAddress, options = {}) => {
const { timeout = 30000, subject } = options
return cy.wrap(null, { timeout }).then(() => {
return new Cypress.Promise((resolve, reject) => {
const startTime = Date.now()
const poll = async () => {
const response = await fetch(
`${Cypress.env('MAILHOOKS_API')}/inboxes/${Cypress.env('INBOX_ID')}/messages?to=${toAddress}`,
{ headers: { 'Authorization': `Bearer ${Cypress.env('MAILHOOKS_API_KEY')}` } }
)
const messages = await response.json()
const match = messages.find(m => !subject || m.subject.includes(subject))
if (match) {
resolve(match)
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Email to ${toAddress} not received within ${timeout}ms`))
} else {
setTimeout(poll, 1000)
}
}
poll()
})
})
})Real-Time with WebSockets
For faster tests, use Mailhooks' real-time push instead of polling:
// test-helpers/email-realtime.js
import { WebSocket } from 'ws'
export function waitForEmailRealtime(toAddress, { timeout = 30000 } = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`wss://realtime.mailhooks.dev?token=${API_KEY}`)
const timer = setTimeout(() => {
ws.close()
reject(new Error('Timeout waiting for email'))
}, timeout)
ws.on('message', (data) => {
const email = JSON.parse(data)
if (email.to === toAddress) {
clearTimeout(timer)
ws.close()
resolve(email)
}
})
})
}This eliminates polling delay — your test continues the instant the email arrives.
What to Test
Welcome Emails
test('welcome email has correct content', async () => {
const email = await waitForEmail(testEmail, { subject: 'Welcome' })
expect(email.from).toBe('[email protected]')
expect(email.html).toContain(userName)
expect(email.html).toContain('Get Started')
// Verify CTA link works
const ctaLink = extractLink(email.html, 'Get Started')
const response = await fetch(ctaLink)
expect(response.ok).toBe(true)
})Transactional Emails
test('order confirmation includes all items', async () => {
const email = await waitForEmail(testEmail, { subject: 'Order Confirmed' })
// Verify each item appears
for (const item of cartItems) {
expect(email.html).toContain(item.name)
expect(email.html).toContain(item.price)
}
// Verify totals
expect(email.html).toContain(`Total: $${expectedTotal}`)
})Email Attachments
test('invoice PDF is attached and valid', async () => {
const email = await waitForEmail(testEmail, { subject: 'Invoice' })
expect(email.attachments).toHaveLength(1)
const pdf = email.attachments[0]
expect(pdf.contentType).toBe('application/pdf')
expect(pdf.filename).toMatch(/invoice-\d+\.pdf/)
// Download and verify PDF
const pdfContent = await fetch(pdf.url).then(r => r.arrayBuffer())
expect(pdfContent.byteLength).toBeGreaterThan(1000)
})CI/CD Integration
GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run E2E tests
env:
MAILHOOKS_API_KEY: ${{ secrets.MAILHOOKS_API_KEY }}
MAILHOOKS_INBOX_ID: ${{ secrets.MAILHOOKS_INBOX_ID }}
run: |
npm ci
npx playwright testParallel Test Isolation
Each test should use a unique email address to avoid conflicts:
// Generate unique address per test
const testEmail = `test-${testInfo.testId}-${Date.now()}@inbox.mailhooks.email`With Mailhooks, all addresses at your domain are automatically routed to your inbox, so you don't need to create them in advance.
Common Patterns
Wait with Retry
async function waitForEmail(address, options) {
const { timeout = 30000, interval = 1000, subject } = options
for (let elapsed = 0; elapsed < timeout; elapsed += interval) {
const emails = await fetchEmails(address)
const match = emails.find(e => !subject || e.subject.includes(subject))
if (match) return match
await sleep(interval)
}
throw new Error(`No email received at ${address} within ${timeout}ms`)
}Clean Up Test Emails
afterEach(async () => {
// Delete test emails to keep inbox clean
await fetch(`${API}/inboxes/${INBOX_ID}/messages`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({ olderThan: '1h' })
})
})Test Email Timing
test('password reset email arrives within 5 seconds', async () => {
const start = Date.now()
await requestPasswordReset(testEmail)
const email = await waitForEmail(testEmail, { timeout: 10000 })
const deliveryTime = Date.now() - start
expect(deliveryTime).toBeLessThan(5000)
})Why Not Use MailHog/MailCatcher?
Local SMTP catchers like MailHog are fine for development, but:
- No real delivery testing — you're not testing your actual email provider
- CI complexity — need to run another service in your pipeline
- No production parity — different behavior than real email delivery
- No attachment handling — limited parsing capabilities
Mailhooks tests the real path: your app → your email provider → actual delivery.
Getting Started
- Sign up at mailhooks.dev
- Create a test inbox — you'll get an address like
[email protected] - Add credentials to CI — store API key and inbox ID as secrets
- Write your first test — start with a simple "email arrives" assertion
- Expand coverage — test content, links, attachments, timing
Total setup time: ~10 minutes. Your email testing goes from "hope it works" to "know it works."
Questions about email testing? Check our documentation or reach out — we help teams set up reliable email testing every day.