emailr_
All articles
usecase·10 min

Email testing in CI/CD: Automation patterns

testingcicdautomation

Automated email testing catches issues before they reach production. Here's how to integrate email testing into your CI/CD pipeline.

What to test

  • Template syntax and rendering
  • Variable substitution
  • Link validation
  • Spam score checking
  • Accessibility compliance
  • Cross-client rendering

Template validation

Syntax checking

// In your test suite
import { validateTemplate } from './email-utils';

describe('Email Templates', () => {
  const templates = glob.sync('templates/**/*.html');
  
  templates.forEach(templatePath => {
    it(`${templatePath} should have valid syntax`, () => {
      const content = fs.readFileSync(templatePath, 'utf-8');
      const result = validateTemplate(content);
      
      expect(result.valid).toBe(true);
      expect(result.errors).toEqual([]);
    });
  });
});

function validateTemplate(html: string): ValidationResult {
  const errors: string[] = [];
  
  // Check for unclosed tags
  const unclosedTags = findUnclosedTags(html);
  if (unclosedTags.length > 0) {
    errors.push(`Unclosed tags: ${unclosedTags.join(', ')}`);
  }
  
  // Check for invalid variables
  const invalidVars = findInvalidVariables(html);
  if (invalidVars.length > 0) {
    errors.push(`Invalid variables: ${invalidVars.join(', ')}`);
  }
  
  return { valid: errors.length === 0, errors };
}

Variable validation

it('should have all required variables', () => {
  const template = loadTemplate('order-confirmation');
  const requiredVars = ['orderNumber', 'items', 'total', 'customerName'];
  
  const templateVars = extractVariables(template);
  
  for (const required of requiredVars) {
    expect(templateVars).toContain(required);
  }
});

Rendering tests

Snapshot testing

import { render } from './email-renderer';

describe('Email Rendering', () => {
  it('renders order confirmation correctly', async () => {
    const html = await render('order-confirmation', {
      orderNumber: 'ORD-123',
      customerName: 'Test User',
      items: [{ name: 'Widget', quantity: 2, price: 29.99 }],
      total: 59.98
    });
    
    expect(html).toMatchSnapshot();
  });
});

Visual regression testing

import { takeScreenshot, compareImages } from './visual-testing';

it('should match visual baseline', async () => {
  const html = await render('welcome', testData);
  const screenshot = await takeScreenshot(html);
  
  const diff = await compareImages(screenshot, 'welcome-baseline.png');
  
  expect(diff.percentage).toBeLessThan(0.1); // 0.1% tolerance
});

Link validation

Check all links

import { extractLinks, validateUrl } from './link-utils';

describe('Email Links', () => {
  const templates = glob.sync('templates/**/*.html');
  
  templates.forEach(templatePath => {
    it(`${templatePath} should have valid links`, async () => {
      const html = fs.readFileSync(templatePath, 'utf-8');
      const links = extractLinks(html);
      
      for (const link of links) {
        // Skip template variables
        if (link.includes('{{')) continue;
        
        const isValid = await validateUrl(link);
        expect(isValid).toBe(true);
      }
    });
  });
});

Unsubscribe link check

it('marketing emails should have unsubscribe link', () => {
  const marketingTemplates = glob.sync('templates/marketing/**/*.html');
  
  marketingTemplates.forEach(template => {
    const html = fs.readFileSync(template, 'utf-8');
    
    expect(html).toMatch(/unsubscribe/i);
    expect(html).toContain('List-Unsubscribe');
  });
});

Spam score checking

SpamAssassin integration

import { checkSpamScore } from './spam-checker';

describe('Spam Score', () => {
  it('should have acceptable spam score', async () => {
    const html = await render('newsletter', testData);
    
    const result = await checkSpamScore({
      from: '[email protected]',
      subject: 'Your weekly update',
      html
    });
    
    expect(result.score).toBeLessThan(5.0);
    expect(result.flags).not.toContain('MISSING_HEADERS');
  });
});

Accessibility testing

import { checkAccessibility } from './a11y-checker';

describe('Email Accessibility', () => {
  it('should pass accessibility checks', async () => {
    const html = await render('welcome', testData);
    
    const results = await checkAccessibility(html);
    
    expect(results.violations).toHaveLength(0);
  });
  
  it('should have alt text on images', () => {
    const html = fs.readFileSync('templates/welcome.html', 'utf-8');
    const images = html.match(/<img[^>]*>/g) || [];
    
    images.forEach(img => {
      expect(img).toMatch(/alt=["'][^"']+["']/);
    });
  });
});

CI/CD pipeline integration

GitHub Actions example

name: Email Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Validate templates
        run: npm run test:templates
      
      - name: Check spam scores
        run: npm run test:spam
      
      - name: Visual regression tests
        run: npm run test:visual
      
      - name: Upload visual diffs
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: test-results/visual-diffs/

Pre-deployment checks

Staging environment testing

// Send test emails to staging inbox
async function testInStaging() {
  const testEmails = [
    { template: 'welcome', data: welcomeTestData },
    { template: 'order-confirmation', data: orderTestData },
    { template: 'password-reset', data: resetTestData }
  ];
  
  for (const { template, data } of testEmails) {
    await sendEmail({
      to: '[email protected]',
      template,
      data,
      tags: ['staging-test', `template:${template}`]
    });
  }
  
  // Verify delivery
  await sleep(5000);
  const delivered = await checkStagingInbox();
  
  expect(delivered).toHaveLength(testEmails.length);
}

Best practices

  1. Test early - Validate templates on every commit
  2. Snapshot carefully - Update snapshots intentionally
  3. Check spam scores - Catch issues before they affect deliverability
  4. Validate links - Broken links hurt user experience
  5. Test accessibility - Ensure emails work for everyone
  6. Use staging - Real delivery tests before production

Automated email testing catches issues before users see them. Invest in your test suite and deploy with confidence.

e_

Written by the emailr team

Building email infrastructure for developers

Ready to start sending?

Get your API key and send your first email in under 5 minutes. No credit card required.