You're building test coverage for an email-heavy product — e-commerce order confirmations, fintech transaction alerts, B2B onboarding sequences. The test plan says "verify email is sent." That's not a test. That's a wish.
Here's a structured approach to email assertions, from cheapest to most thorough, with code you can drop into your test suite today.
The Assertion Hierarchy
Not every email test needs the heaviest level of verification. Here's the hierarchy, ordered from fastest/cheapest to slowest/most thorough:
Level 1: "An Email Was Sent"
Mock the mailer and assert it was called. This is a unit test — fast, no external dependencies, good for testing business logic.
// Node.js / Jest
jest.mock("../lib/mailer");
const { sendEmail } = require("../lib/mailer");
test("order confirmation triggers email", async () => {
await placeOrder({ productId: "abc", userId: "123" });
expect(sendEmail).toHaveBeenCalledTimes(1);
});
# Python / pytest
def test_order_sends_email(mocker):
mock_send = mocker.patch("app.services.mailer.send_email")
place_order(product_id="abc", user_id="123")
assert mock_send.call_count == 1
Level 2: "The Right Email Was Sent to the Right Person"
Still a mock — but now you inspect the arguments.
test("sends to the order owner with correct subject", async () => {
await placeOrder({ productId: "abc", userId: "123" });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "user123@example.com",
subject: expect.stringContaining("Order Confirmation"),
})
);
});
Level 3: "The Email Body Contains Expected Content"
This is where you cross from unit tests to integration tests. Send through a real SMTP server, capture the email, and inspect the body.
test("order email contains the product name", async () => {
await placeOrder({ productId: "abc", userId: "123" });
const email = await getLatestEmail("user123@example.com");
expect(email.subject).toContain("Order Confirmation");
expect(email.html_body).toContain("Widget Pro X");
expect(email.html_body).toContain("$49.99");
});
Level 4: "The Email Renders Correctly"
Capture the email and run it through an HTML compatibility checker. This catches Outlook-breaking CSS, missing alt tags, and other rendering issues.
Level 5: "Clicking the Link Completes the Flow"
The full integration test: capture the email, extract a link (reset URL, magic link, verification link), navigate to it, and assert on the outcome.
test("verification link activates account", async () => {
await registerUser({ email: "new@test.com" });
const email = await getLatestEmail("new@test.com");
const link = email.html_body.match(/href="([^"]*verify[^"]*)"/)[1];
const res = await fetch(link, { redirect: "manual" });
expect(res.status).toBe(302);
// Verify account is now activated
const user = await getUser("new@test.com");
expect(user.emailVerified).toBe(true);
});
Reusable Email Helper — Node.js
// test/helpers/email.js
const MAILHOG_API = process.env.MAILHOG_API || "https://mailhog.site/api/v1";
const API_KEY = process.env.MAILHOG_API_KEY;
/**
* Wait for an email to arrive for the given recipient.
* Polls every 300ms with a configurable timeout.
*/
async function waitForEmail(to, { timeout = 5000, subject } = {}) {
const start = Date.now();
while (Date.now() - start < timeout) {
const params = new URLSearchParams({ to, limit: "5" });
const res = await fetch(`${MAILHOG_API}/emails?${params}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const emails = await res.json();
const match = subject
? emails.find(e => e.subject.includes(subject))
: emails[0];
if (match) return match;
await new Promise(r => setTimeout(r, 300));
}
throw new Error(`No email for ${to} within ${timeout}ms`);
}
/**
* Extract all URLs from an email body.
*/
function extractLinks(htmlBody) {
const regex = /href="(https?://[^"]+)"/g;
const links = [];
let match;
while ((match = regex.exec(htmlBody)) !== null) {
links.push(match[1]);
}
return links;
}
/**
* Extract OTP/verification codes (4-8 digit numbers).
*/
function extractOTP(body) {
const match = body.match(/(d{4,8})/);
return match ? match[1] : null;
}
module.exports = { waitForEmail, extractLinks, extractOTP };
Reusable Email Helper — Python
# tests/helpers/email.py
import httpx, re, time, os
MAILHOG_API = os.environ.get("MAILHOG_API", "https://mailhog.site/api/v1")
API_KEY = os.environ.get("MAILHOG_API_KEY", "")
def wait_for_email(to: str, timeout: int = 5, subject: str = None):
"""Poll for an email to arrive. Returns the email dict."""
for _ in range(timeout * 3):
r = httpx.get(f"{MAILHOG_API}/emails",
params={"to": to, "limit": 5},
headers={"Authorization": f"Bearer {API_KEY}"})
emails = r.json()
if subject:
match = next((e for e in emails if subject in e["subject"]), None)
else:
match = emails[0] if emails else None
if match:
return match
time.sleep(0.3)
raise TimeoutError(f"No email for {to} within {timeout}s")
def extract_links(html_body: str) -> list[str]:
return re.findall(r'href="(https?://[^"]+)"', html_body)
def extract_otp(body: str) -> str | None:
m = re.search(r"\b(\d{4,8})\b", body)
return m.group(1) if m else None
Anti-Patterns to Avoid
- Shared inbox pollution. If parallel tests send to the same inbox, you'll get the wrong email. Use separate inboxes per test suite or unique recipient addresses (e.g.,
test-{uuid}@example.com). - Sleep-based waits.
sleep(3)is brittle. Always poll with a timeout. Email capture is fast but not instant. - Asserting on exact HTML. Don't assert
expect(html).toBe(expectedHtml). HTML can have whitespace differences, attribute reordering, and minification. Assert on content:expect(html).toContain("Order #123"). - Not cleaning between tests. Delete or filter emails between test runs. A leftover email from a previous test can cause false passes.
For CI pipeline setup, see testing email in GitHub Actions. For testing specific auth flows, see our guide on magic link testing.
FAQ
How do I handle email tests in parallel CI runs?
Use unique recipient addresses per test run (append a UUID to the local part) or use separate MailHog inboxes per CI job. This prevents cross-run interference.
Should I test email content at the unit or integration level?
Test the template rendering at the unit level (mock the data, render the template, assert on the HTML output). Test the full send-and-capture flow at the integration level. This gives you fast feedback on template changes and thorough coverage on the delivery pipeline.
How do I extract OTP codes from email for testing?
Capture the email body and use a regex to find the code. Most OTPs are 4-8 digit numbers. The helper functions above include an extractOTP utility that handles this. For more complex patterns (alphanumeric codes, codes in specific HTML elements), adjust the regex to match your template structure.