Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 037 / 100

Testing emails in E2E tests: what developers should know

Verify what users receive.

Expona Admin9 min read

Introduction

Email verification is one of the trickiest parts of end-to-end testing. When users sign up, confirm their account, or reset their password, they receive emails—and your tests need to verify that those emails arrive with the correct content, links, and codes. Testing emails in E2E tests: what developers should know starts with a simple principle: capture what users receive without sending real messages to real mailboxes.

In this guide, we'll walk through setting up a local mail server, querying messages via API, extracting dynamic links and codes, and building reliable email verification into your Playwright test suite.

Why email testing matters in E2E workflows

Email is a critical user journey in most applications. Sign-up confirmation, password resets, two-factor authentication codes, and transactional receipts all depend on email delivery. If your tests skip email verification, you're leaving a significant gap in your coverage.

The challenge is that traditional approaches either:

  • Send real emails to test accounts (slow, unreliable, pollutes real inboxes)
  • Mock the email service entirely (tests pass but you don't verify the actual email was composed correctly)
  • Use external services like Mailgun or SendGrid test modes (adds complexity and external dependencies)

A local mail server running in Docker solves all three problems. You get real emails in a controlled, fast, inspectable environment—no external services required.

Use a test mail server like Mailpit

Mailpit is a lightweight, open-source mail server designed for testing. It accepts SMTP connections, stores emails in memory, and exposes them via a REST API and web UI. It's perfect for local development and CI/CD pipelines.

Setting up Mailpit with Docker:

typescript
// docker-compose.yml for your test environment
version: '3.8'
services:
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "1025:1025"  // SMTP port
      - "8025:8025"  // Web UI and API port
    environment:
      MP_MAX_WORKERS: 4

Then update your application's email configuration to point to Mailpit during testing:

typescript
// config.ts (test environment)
export const emailConfig = {
  host: process.env.MAIL_HOST || 'localhost',
  port: process.env.MAIL_PORT || 1025,
  secure: false,
  auth: undefined, // Mailpit doesn't require auth in test mode
};

// In your NestJS mailer setup or similar
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport(emailConfig);

Start the container before your test suite runs, and all emails sent by your application will be captured by Mailpit instead of trying to reach real SMTP servers.

Query messages via API

Once Mailpit is running and capturing emails, you can fetch them through its HTTP API. This is where your Playwright tests connect the dots.

Basic email retrieval:

typescript
// mailpit.ts - helper utilities
async function getLatestEmail(recipientEmail: string) {
  const response = await fetch('http://localhost:8025/api/v1/messages');
  const data = await response.json();
  
  // Filter for the specific recipient
  const email = data.messages.find((msg: any) =>
    msg.To.some((to: any) => to.Address === recipientEmail)
  );
  
  if (!email) {
    throw new Error(`No email found for ${recipientEmail}`);
  }
  
  return email;
}

async function getAllEmailsForRecipient(recipientEmail: string) {
  const response = await fetch('http://localhost:8025/api/v1/messages');
  const data = await response.json();
  
  return data.messages.filter((msg: any) =>
    msg.To.some((to: any) => to.Address === recipientEmail)
  );
}

The API returns full email metadata including sender, subject, timestamp, and both HTML and plain-text bodies. You can then assert on any of these fields:

typescript
// test.spec.ts - verification example
import { test, expect } from '@playwright/test';
import { getLatestEmail } from './mailpit';

test('user receives confirmation email on signup', async ({ page }) => {
  const testEmail = 'user@example.com';
  
  // Navigate and sign up
  await page.goto('http://localhost:3000/signup');
  await page.fill('input[name="email"]', testEmail);
  await page.fill('input[name="password"]', 'SecurePass123!');
  await page.click('button:has-text("Sign Up")');
  
  // Give the email server time to process
  await page.waitForTimeout(1000);
  
  // Fetch and verify the email
  const email = await getLatestEmail(testEmail);
  expect(email.Subject).toContain('Confirm your email');
  expect(email.From[0].Address).toBe('noreply@myapp.com');
});

The real power of email testing emerges when you extract dynamic content—confirmation links, reset tokens, OTP codes—and use them in your tests.

Parsing confirmation links from HTML:

typescript
// mailpit.ts - link extraction
async function extractConfirmationLink(recipientEmail: string): Promise {
  const email = await getLatestEmail(recipientEmail);
  
  // Email body is in email.HTML
  const htmlBody = email.HTML;
  
  // Look for href in confirmation button or link
  const match = htmlBody.match(/href="([^"]*confirm[^"]*)"/i);
  
  if (!match || !match[1]) {
    throw new Error('No confirmation link found in email');
  }
  
  return match[1];
}

async function extractOTPCode(recipientEmail: string): Promise {
  const email = await getLatestEmail(recipientEmail);
  const textBody = email.Text;
  
  // Common OTP patterns: 6-digit code, alphanumeric token, etc.
  const match = textBody.match(/code[:\s]+(\d{6})/i);
  
  if (!match || !match[1]) {
    throw new Error('No OTP code found in email');
  }
  
  return match[1];
}

Using extracted links in tests:

typescript
test('user can confirm email via link from confirmation email', async ({ page }) => {
  const testEmail = 'user@example.com';
  
  // Sign up
  await page.goto('http://localhost:3000/signup');
  await page.fill('input[name="email"]', testEmail);
  await page.fill('input[name="password"]', 'SecurePass123!');
  await page.click('button:has-text("Sign Up")');
  
  await page.waitForTimeout(1000);
  
  // Extract confirmation link
  const confirmLink = await extractConfirmationLink(testEmail);
  
  // Navigate to confirmation URL
  await page.goto(confirmLink);
  
  // Verify account is now confirmed
  await expect(page.locator('text=Email confirmed successfully')).toBeVisible();
});

For password reset flows, extract the token and construct the reset URL. For multi-factor authentication, extract the code and enter it in the form. The pattern is identical: parse, extract, use.

Never send real emails from tests

This is the non-negotiable rule. Your tests must always use a local mail server or mock. Sending real emails from automated tests causes multiple problems:

  • Spam complaints: Test runs generate dozens or hundreds of emails to real addresses, which get marked as spam and damage your sender reputation.
  • Rate limiting: Real SMTP services throttle high-volume sends and may block your test infrastructure.
  • Test isolation: Tests become flaky if they depend on external services or if multiple test runs interfere with each other.
  • Slow execution: Real email delivery takes seconds; local mail servers are instantaneous.

Enforce this at the config level. If your application has an environment variable controlling the mail host, make sure your test configuration explicitly overrides it:

typescript
// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run dev',
    env: {
      NODE_ENV: 'test',
      MAIL_HOST: 'localhost',
      MAIL_PORT: '1025',
      MAIL_FROM: 'test@myapp.com',
      // Explicitly prevent any real SMTP credentials in test mode
      SMTP_USER: '',
      SMTP_PASS: '',
    },
  },
});

Better yet, add a guard in your application startup that logs a warning or errors out if real SMTP credentials are detected in test mode:

typescript
// app.ts or main.ts
if (process.env.NODE_ENV === 'test' && process.env.SMTP_PASS) {
  throw new Error(
    'SMTP credentials detected in test mode. This will send real emails. ' +
    'Clear SMTP_USER and SMTP_PASS when NODE_ENV=test'
  );
}

Common mistakes

1. Not waiting for email delivery: Email processing is asynchronous. Your test might query the API before the message is stored. Always add a reasonable delay or poll the API with retry logic:

typescript
async function waitForEmail(
  recipientEmail: string,
  subject: string,
  maxWaitMs: number = 5000
): Promise {
  const startTime = Date.now();
  
  while (Date.now() - startTime < maxWaitMs) {
    const emails = await getAllEmailsForRecipient(recipientEmail);
    const email = emails.find((e: any) => e.Subject.includes(subject));
    
    if (email) return email;
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  
  throw new Error(`Email with subject "${subject}" not found after ${maxWaitMs}ms`);
}

2. Fragile regex patterns for link/code extraction: If your confirmation links change format, your regex breaks. Use HTML parsing libraries like cheerio or jsdom for robustness:

typescript
import { JSDOM } from 'jsdom';

async function extractLinkByTestId(recipientEmail: string, testId: string): Promise {
  const email = await getLatestEmail(recipientEmail);
  const dom = new JSDOM(email.HTML);
  const link = dom.window.document.querySelector(`[data-testid="${testId}"]`);
  
  if (!link || !link.href) {
    throw new Error(`Link with data-testid="${testId}" not found`);
  }
  
  return link.href;
}

3. Not cleaning up old emails between tests: If Mailpit accumulates thousands of emails, queries slow down and tests may grab the wrong message. Use the Mailpit API to delete messages:

typescript
async function clearAllEmails() {
  await fetch('http://localhost:8025/api/v1/messages', { method: 'DELETE' });
}

// In your test setup
test.beforeEach(async () => {
  await clearAllEmails();
});

4. Hardcoding email addresses: Tests should use unique, deterministic email addresses (e.g., with a timestamp or test ID). This prevents collision when tests run in parallel:

typescript
function generateTestEmail(testName: string): string {
  const timestamp = Date.now();
  return `test-${testName}-${timestamp}@local.test`;
}

Key takeaways

  • Use a containerized mail server like Mailpit to capture test emails locally without external dependencies.
  • Query the Mailpit API to fetch email metadata, bodies, and verify content in your Playwright tests.
  • Extract dynamic links and codes from email HTML or text using robust parsing libraries, not fragile regex.
  • Never send real emails from test environments; enforce this with configuration guards and CI/CD environment variables.
  • Poll the email API with retry logic to handle asynchronous email delivery; don't rely on fixed delays.
  • Clean up old emails between test runs to keep API queries fast and prevent cross-test contamination.
  • Use unique, deterministic email addresses for each test to support parallel execution.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles