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.

Mailhooks TeamJanuary 27, 20257 min read
testinge2eplaywrightcypressemail

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

  1. Generate a unique test email address for each test
  2. Run your user flow (signup, purchase, etc.)
  3. Wait for the email to arrive
  4. 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 test

Parallel 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

  1. Sign up at mailhooks.dev
  2. Create a test inbox — you'll get an address like [email protected]
  3. Add credentials to CI — store API key and inbox ID as secrets
  4. Write your first test — start with a simple "email arrives" assertion
  5. 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.

Ready to receive emails in your app?

Get started with Mailhooks in minutes. No credit card required.