Skip to content

Why Mocking Emails in Tests Is a Bad Idea

Your email tests pass. Your users don’t get their emails. Sound familiar?

The most common approach to email testing — mocking the email service — gives you fast, green tests that tell you nothing about whether emails actually work.

When you mock your email service in tests, you’re testing that your code calls the email function. You’re not testing:

  • SMTP configuration — wrong credentials, blocked ports, rate limits
  • Email content — broken templates, missing variables, encoding issues
  • Delivery — spam filters, bounces, DNS problems
  • End-to-end flow — the email arrives, the OTP works, the link is valid
// This test passes even if emails are completely broken
test('sends welcome email', async () => {
const mockSend = jest.fn().mockResolvedValue({ messageId: 'fake' })
emailService.send = mockSend
await registerUser('[email protected]')
expect(mockSend).toHaveBeenCalledWith({
subject: 'Welcome!',
})
})

This test tells you that registerUser calls emailService.send with the right arguments. It doesn’t tell you:

  • Does the email actually arrive?
  • Is the subject line correct in the rendered template?
  • Does the OTP in the email match what’s in the database?
  • Can the user click the verification link and complete signup?

Teams that mock emails often discover problems in production:

  • “Users can’t register” — the SMTP provider changed their API
  • “OTPs don’t work” — the template renders the wrong variable
  • “Password reset is broken” — the reset link uses the wrong domain
  • “Emails go to spam” — a template change triggered spam filters

Each of these is a support ticket, a lost user, and a fire drill — all preventable with real email tests.

Instead of mocking, send real emails to test inboxes and verify they arrive:

import { test, expect } from '@playwright/test'
import { inboxical } from '@inboxical/playwright'
test('user receives working OTP email', async ({ page }) => {
const inbox = await inboxical.createInbox()
// Actually trigger the email through your real stack
await page.goto('/register')
await page.fill('[name=email]', inbox.emailAddress)
await page.click('[type=submit]')
// Verify the email actually arrived
const messages = await inboxical.waitForMessages(inbox.id)
expect(messages[0].subject).toBe('Verify your email')
// Verify the OTP actually works
const otp = messages[0].extractedOtp
await page.fill('[name=otp]', otp)
await page.click('[type=submit]')
await expect(page.locator('h1')).toContainText('Dashboard')
})

This test verifies the entire chain: your app sends the email, the email arrives, the content is correct, and the OTP works end-to-end.

”But real email tests are slow and flaky”

Section titled “”But real email tests are slow and flaky””

This used to be true. The traditional approach — polling a shared inbox with sleep(5000) — was slow and unreliable. Modern email testing APIs solve this:

Old approachInboxical
Shared inboxIsolated inbox per test
Polling with sleepLong-polling (resolves instantly)
Regex to find OTPAutomatic OTP extraction
Manual SMTP setupCloud API, no infrastructure
Breaks in parallelFully parallel-safe

Mocking isn’t always wrong. It makes sense for:

  • Unit tests — testing that business logic triggers the right email at the right time
  • Development speed — fast feedback loop during active development
  • Offline testing — when you genuinely can’t make network calls

But for your E2E and integration test suite — the tests that tell you “this works in production” — mock emails give you false confidence.

  1. Unit tests (mocked) — verify email is triggered with correct parameters
  2. Integration tests (real) — verify email arrives with correct content
  3. E2E tests (real) — verify the full user flow including email verification

Layers 2 and 3 need real email delivery. That’s what Inboxical is for.