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.
// ❌ 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:
You're testing the mock, not the system.
The solution: send real emails to real addresses, then verify they arrived.
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`)
}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/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')
})
})
})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()
})
})
})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.
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)
})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}`)
})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)
})# .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 testEach 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.
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`)
}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('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)
})Local SMTP catchers like MailHog are fine for development, but:
Mailhooks tests the real path: your app → your email provider → actual delivery.
[email protected]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.