Email Testing Best Practices: Rendering, Deliverability, Spam Scoring, and Template Regression
Email testing is more than "did it send?" Good email testing covers HTML rendering across clients, spam score validation, deliverability checks, template regression, and the full user journey from trigger to action. This guide covers the complete email testing pyramid with practical tooling and CI strategies.
Key Takeaways
Email rendering is the hardest part to test. HTML emails are rendered by 100+ different email clients, each with different CSS support. Use Litmus or Email on Acid for cross-client rendering — or test a subset of high-traffic clients.
Test deliverability indicators, not just content. SPF, DKIM, DMARC, spam score, and sender reputation affect whether your email reaches the inbox. Test them before deployment.
Spam score regression is a CI job. SpamAssassin scores change when you change copy, links, or HTML structure. Build score assertions into your CI pipeline.
Template regression tests prevent visual drift. Email templates change frequently. Screenshot-based regression catches CSS and layout regressions before they reach users.
The full email test pyramid: unit → integration → rendering → deliverability → E2E. Each layer catches different bugs. Don't skip any layer for critical transactional emails.
The Email Testing Pyramid
Email testing has more layers than standard web testing because email is delivered, not served — you can't reload the page if something is wrong.
Layer 5: E2E User Journey → Email arrives, user clicks CTA, flow completes
Layer 4: Deliverability → Spam score, SPF/DKIM/DMARC, reputation
Layer 3: Rendering → HTML renders correctly in Gmail, Outlook, Apple Mail
Layer 2: Integration → Email is sent, attachments included, headers correct
Layer 1: Unit → Email construction logic, template data, recipientsLayer 1: Unit Tests
Unit tests cover email construction logic — the code that builds the message before it's handed to an SMTP client.
What to Test at the Unit Level
- Recipients:
to,cc,bccare correct and validated - Subject: correct string, no encoding issues with special characters
- Template data: all required variables are provided, correct types
- From address: branded format (
Name <email>) - Headers: Reply-To, List-Unsubscribe, Precedence
// Tests pass correct template data to the email service
it('sends correct variables to welcome template', async () => {
await emailService.sendWelcomeEmail({
email: 'user@example.com',
name: 'Full Name',
unsubscribeToken: 'token-abc',
});
const templateData = mailer.send.mock.calls[0][0].dynamicTemplateData;
// Must match template variable names exactly
expect(templateData.userName).toBe('Full Name');
expect(templateData.unsubscribeUrl).toContain('token-abc');
expect(templateData.ctaUrl).toMatch(/^https:\/\//);
});Common Unit Test Mistakes
Testing the library, not your code:
// WRONG: testing that Nodemailer can send an email (it can)
it('sends email', async () => {
expect(mailer.send).toHaveBeenCalled();
});
// RIGHT: testing that your code builds the correct message
it('includes correct recipient and subject', async () => {
expect(mailer.send).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
subject: 'Welcome to HelpMeTest!',
})
);
});Layer 2: Integration Tests
Integration tests verify that your application actually sends emails when the right events occur — and doesn't send when it shouldn't.
Tools
| Tool | Approach | Best for |
|---|---|---|
| MailHog | Local Docker SMTP | Dev + CI, no internet |
| Mailtrap | Cloud SMTP capture | CI, spam scoring included |
| Ethereal | Temporary test accounts | Node.js + quick setup |
| Mailpit | Local Docker (MailHog fork) | Modern MailHog alternative |
Key Integration Assertions
// After triggering the event:
const email = await waitForEmail('user@example.com');
// 1. Correct recipient
expect(email.to).toContain('user@example.com');
// 2. Correct sender
expect(email.from).toMatch(/helpmetest\.com/);
// 3. Subject
expect(email.subject).toBe('Welcome to HelpMeTest!');
// 4. Body contains key content
const html = await getEmailBody(email.id);
expect(html).toContain('user@example.com');
expect(html).toContain('Get Started');
// 5. Unsubscribe link present (legal requirement in many jurisdictions)
expect(html).toMatch(/unsubscribe/i);
// 6. Attachment
expect(email.attachmentsCount).toBe(1);Testing Negative Cases
// Email should NOT be sent for unsubscribed users
it('does not send to unsubscribed users', async () => {
await unsubscribeUser('opted-out@example.com');
await triggerNotification('opted-out@example.com');
await sleep(3000);
const messages = await getMessages();
expect(messages.filter(m => m.to === 'opted-out@example.com')).toHaveLength(0);
});
// Email should NOT be sent twice for the same event
it('sends welcome email only once per user', async () => {
await registerUser({ email: 'once@example.com', name: 'Once' });
await registerUser({ email: 'once@example.com', name: 'Once' }); // duplicate
const messages = await getMessages();
const toOnce = messages.filter(m => m.to === 'once@example.com');
expect(toOnce).toHaveLength(1);
});Layer 3: Rendering Tests
Email clients support different CSS subsets. An email that looks perfect in Gmail may be broken in Outlook. Rendering tests catch these issues before they reach users.
Manual Rendering Check Tools
- Litmus — previews in 100+ clients, accessibility checks, spam testing
- Email on Acid — similar to Litmus, strong Outlook coverage
- Mailtrap's Email Previews — free tier includes popular clients
Automated HTML Validation
Before paying for Litmus, run basic HTML structure checks:
// tests/email/rendering.test.js
import { JSDOM } from 'jsdom';
import { renderTemplate } from '../src/email/templates.js';
describe('Email HTML structure', () => {
let html;
beforeAll(async () => {
html = await renderTemplate('welcome', {
name: 'Test User',
appUrl: 'https://helpmetest.com',
unsubscribeUrl: 'https://helpmetest.com/unsubscribe/token',
});
});
it('has valid HTML structure', () => {
const dom = new JSDOM(html);
const doc = dom.window.document;
expect(doc.querySelector('html')).toBeTruthy();
expect(doc.querySelector('body')).toBeTruthy();
expect(doc.querySelector('head')).toBeTruthy();
});
it('uses inline CSS (required for Outlook)', () => {
// External stylesheets are stripped by many email clients
const dom = new JSDOM(html);
const links = dom.window.document.querySelectorAll('link[rel="stylesheet"]');
expect(links.length).toBe(0, 'Email should not have external stylesheet links');
});
it('all images have alt attributes', () => {
const dom = new JSDOM(html);
const images = dom.window.document.querySelectorAll('img');
images.forEach((img, i) => {
expect(img.hasAttribute('alt')).toBe(true,
`Image ${i} is missing alt attribute: ${img.src}`);
});
});
it('all links are absolute URLs', () => {
const dom = new JSDOM(html);
const links = dom.window.document.querySelectorAll('a[href]');
links.forEach((link, i) => {
const href = link.getAttribute('href');
if (!href.startsWith('mailto:') && !href.startsWith('#')) {
expect(href).toMatch(/^https?:\/\//, `Link ${i} is relative: ${href}`);
}
});
});
it('has unsubscribe link', () => {
const dom = new JSDOM(html);
const links = Array.from(dom.window.document.querySelectorAll('a'));
const unsubLink = links.find(l =>
l.textContent.toLowerCase().includes('unsubscribe') ||
(l.getAttribute('href') || '').includes('unsubscribe')
);
expect(unsubLink).toBeTruthy();
});
it('does not exceed 600px width (email standard)', () => {
const dom = new JSDOM(html);
const tables = dom.window.document.querySelectorAll('table');
tables.forEach(table => {
const width = parseInt(table.getAttribute('width') || '0');
if (width > 0) {
expect(width).toBeLessThanOrEqual(600);
}
});
});
});Screenshot Regression for Email Templates
Render templates to screenshots and compare against a baseline:
import puppeteer from 'puppeteer';
async function screenshotEmailHtml(html) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setViewport({ width: 600, height: 800 });
await page.setContent(html);
const screenshot = await page.screenshot({ fullPage: true, type: 'png' });
await browser.close();
return screenshot;
}
it('welcome email matches visual baseline', async () => {
const html = await renderTemplate('welcome', defaultTemplateData);
const screenshot = await screenshotEmailHtml(html);
expect(screenshot).toMatchImageSnapshot({
customSnapshotsDir: 'tests/snapshots/email',
failureThreshold: 0.01,
failureThresholdType: 'percent',
});
});Layer 4: Deliverability Tests
Deliverability determines whether your email lands in the inbox or the spam folder.
What Affects Deliverability
- SPF record — authorizes which servers can send for your domain
- DKIM signature — cryptographic signature verifying the email wasn't tampered with
- DMARC policy — instructs receivers what to do with SPF/DKIM failures
- Spam score — SpamAssassin or similar, based on content and headers
- Sender reputation — IP and domain reputation at major ISPs
Automated Deliverability Checks
# Check MX, SPF, DKIM via DNS
import dns.resolver
def test_spf_record_exists():
"""Verify SPF TXT record is published for the sender domain."""
answers = dns.resolver.resolve('helpmetest.com', 'TXT')
spf_records = [str(r) for r in answers if 'v=spf1' in str(r)]
assert len(spf_records) >= 1, "No SPF record found"
assert 'include:sendgrid.net' in spf_records[0] or \
'include:smtp.postmarkapp.com' in spf_records[0], \
"SPF record doesn't authorize the email provider"
def test_dmarc_policy_exists():
"""Verify DMARC policy is published."""
try:
answers = dns.resolver.resolve('_dmarc.helpmetest.com', 'TXT')
dmarc = str(list(answers)[0])
assert 'v=DMARC1' in dmarc
# Policy should be at least quarantine, not 'none' (monitoring only)
assert 'p=reject' in dmarc or 'p=quarantine' in dmarc
except dns.resolver.NXDOMAIN:
pytest.fail("No DMARC record found at _dmarc.helpmetest.com")Spam Score Testing in CI
Use Mailtrap's spam analysis API or a local SpamAssassin instance:
// Mailtrap spam report API
it('welcome email spam score is acceptable', async () => {
const email = await waitForEmail('test@example.com');
const report = await getSpamReport(email.id);
// Score < 3.0 is safe; > 5.0 is likely spam
expect(report.result.score).toBeLessThan(3.0);
console.log(`Spam score: ${report.result.score}`);
console.log('Triggered rules:', report.result.rules.map(r => r.name).join(', '));
});Common spam triggers to avoid in your email copy:
- ALL CAPS subject lines
- Excessive exclamation marks!!!
- Words: "FREE", "URGENT", "ACT NOW", "CLICK HERE"
- Image-to-text ratio too high (all images, no text)
- Missing unsubscribe link
- HTML with errors or script tags
Layer 5: E2E User Journey Tests
Unit and integration tests verify the email is sent correctly. E2E tests verify the user experience:
- User signs up → Welcome email arrives
- User clicks "Activate Account" link
- Browser opens activation page
- Account is activated, user is redirected to onboarding
This requires a test environment where:
- Your application is running
- An email trap (MailHog/Mailtrap) is capturing emails
- A browser automation tool reads the link from the captured email and follows it
*** Test Cases ***
User Activates Account Via Email Link
Register New User email=test@example.com name=Test User
${email}= Wait For Email In MailHog to=test@example.com
${activate_url}= Extract Link From Email ${email} text=Activate
Go To ${activate_url}
Wait For Text Account activated successfully
Location Should Contain /dashboardTemplate Regression Testing Strategy
Email templates change frequently — marketing copy, new product announcements, rebrand. Establish a regression workflow:
1. Snapshot on First Render
# Generate baseline screenshots for all templates
npm run snapshot:email-templates
git add tests/snapshots/email/
git commit -m <span class="hljs-string">"chore: update email template baselines"2. Assert on Every CI Run
- name: Email template regression
run: npm run test:email-rendering
# Fails if any template changed without updating snapshots3. Update Intentionally
When intentionally changing a template:
npm run snapshot:email-templates -- --update
git diff tests/snapshots/ # Review the visual changes
git add tests/snapshots/
git commit -m <span class="hljs-string">"chore: update email template — new CTA copy"CI Pipeline Structure
name: Email Test Pipeline
on: [push, pull_request]
jobs:
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test -- --testPathPattern='email/unit'
rendering-tests:
name: HTML rendering tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:email-rendering
- name: Upload snapshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: email-snapshots
path: tests/snapshots/email/
integration-tests:
name: Integration tests (MailHog)
runs-on: ubuntu-latest
services:
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025
- 8025:8025
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm start &
- run: sleep 5
- run: npm run test:email-integration
env:
MAILHOG_URL: http://localhost:8025
SMTP_HOST: localhost
SMTP_PORT: 1025
deliverability-tests:
name: DNS/deliverability checks
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: pip install dnspython
- run: pytest tests/deliverability/ -vQuick Reference: Email Testing Checklist
Before shipping a new email or major template change:
Unit tests (run every commit)
- Correct
to,from,subject - All template variables provided
- Reply-To header set
- Error handling covers SMTP failures
Integration tests (run every commit)
- Email sends on correct trigger
- Email does NOT send when it shouldn't
- Attachments included and correctly typed
- Unsubscribe link present
Rendering tests (run every commit)
- Valid HTML structure
- No external CSS links
- All images have
altattributes - All links are absolute URLs
- Width ≤ 600px
- Matches visual baseline
Deliverability (run on main branch)
- SPF record authorizes sender
- DMARC policy is
quarantineorreject - Spam score < 3.0
E2E (run on staging)
- Email arrives in inbox within SLA
- CTA link opens correct page
- Action completes (activation, password reset, etc.)
For the E2E layer — browser automation that follows email links and verifies the complete user journey — HelpMeTest provides plain-English test scenarios that run on a schedule and alert you when an email flow breaks in staging or production.