Skip to main content

Implementing Jest Snapshot Testing for MJML Templates

Step-by-step guide to configuring Jest snapshot tests for MJML email templates in Node.js projects with CI/CD integration.

Integrating MJML into your Email Testing & QA Workflows requires moving beyond manual preview tools. As transactional email systems scale, maintaining template consistency across deployments becomes a critical engineering challenge. This guide provides a precise implementation strategy for Jest snapshot testing for MJML templates, focusing on deterministic HTML compilation, custom Jest transformers, and CI/CD pipeline integration.

MJML compile to snapshot assertion A dot-mjml source compiles to HTML through the Jest transformer, gets normalized by a serializer, and is asserted with toMatchSnapshot. MJML to Snapshot Assertion welcome.mjml source Transformer mjml2html Serializer Normalize toMatchSnapshot .snap baseline Strict validation fails the build on MJML compile errors.
How a .mjml file becomes a Jest assertion: transform, normalize, then match against the stored .snap baseline.

Environment & MJML Compilation Setup

Install required dependencies as development packages:

npm i -D mjml jest @babel/core @babel/preset-env babel-jest

Configure jest.config.js to intercept .mjml extensions. MJML's CSS inlining and whitespace normalization produce non-deterministic output by default when minify is disabled. Force deterministic compilation using mjml2html with strict validation.

// jest.config.js
module.exports = {
  transform: {
    '\\.mjml$': '<rootDir>/mjml-transformer.js'
  },
  testEnvironment: 'node',
  testPathIgnorePatterns: ['/node_modules/', '/dist/']
};
// mjml-transformer.js
const mjml2html = require('mjml');
const path = require('path');

module.exports = {
  process(src, filename) {
    const { html, errors } = mjml2html(src, {
      minify: true,
      validationLevel: 'strict',
      filePath: path.resolve(filename)
    });

    if (errors.length > 0) {
      throw new Error(`MJML Compilation Failed (${filename}):\n${errors.map(e => e.formattedMessage).join('\n')}`);
    }

    return { code: `module.exports = ${JSON.stringify(html)};` };
  }
};

Note: mjml2html is the default export of the mjml package. The error objects expose formattedMessage for readable output.

Implementing the Snapshot Assertion

The foundation of Automated Snapshot Testing relies on capturing the exact DOM structure after compilation. Create a __tests__/ directory adjacent to your templates. Import the .mjml file directly (handled by the transformer) and pass the compiled string to Jest's snapshot matcher.

// __tests__/welcome-email.test.js
const welcomeHtml = require('../templates/welcome.mjml');

describe('Welcome Email Template', () => {
  it('renders deterministic HTML structure', () => {
    expect(welcomeHtml).toMatchSnapshot();
  });
});

Execute npx jest to generate __snapshots__/welcome-email.test.js.snap. This baseline locks structural integrity and immediately flags unintended DOM mutations during refactoring or dependency updates.

Handling Dynamic Data & Volatile Attributes

Transactional templates inject volatile strings (user names, tracking IDs, timestamps) that trigger false-positive snapshot failures. Do not bypass snapshots. Implement a custom serializer that normalizes ephemeral data before comparison.

// jest.config.js (append to existing config)
module.exports = {
  // ...previous config
  snapshotSerializers: ['<rootDir>/email-serializer.js']
};
// email-serializer.js
module.exports = {
  test: (val) => typeof val === 'string' && val.includes('<!DOCTYPE'),
  serialize: (val, config, indentation, depth, refs, printer) => {
    const normalized = val
      .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z/g, '[TIMESTAMP]')
      .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '[UUID]')
      .replace(/utm_source=[^&"]+/g, 'utm_source=[TRACKING]');

    return printer(normalized, config, indentation, depth, refs);
  }
};

For inline assertions on specific dynamic blocks, use expect.stringContaining() or expect.stringMatching() to validate structural placeholders without freezing payload data.

CI/CD Integration & Workflow Maintenance

Embed the test suite into your deployment pipeline. Run npm test -- --ci to enforce strict snapshot matching and prevent interactive updates in headless environments. Configure your version control workflow to require explicit PR approvals when .snap files change.

# .github/workflows/email-snapshots.yml
name: Email Snapshot Tests
on: [pull_request]

jobs:
  snapshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --ci

When upgrading MJML minor versions:

  1. Run npx jest locally to identify snapshot drift.
  2. Inspect diffs using git diff __snapshots__/.
  3. Verify that CSS inlining changes do not break Outlook conditional comments or table nesting.
  4. Commit updated snapshots alongside the dependency bump in a dedicated PR for traceability.

Normalizing MJML Compile Output for Stable Snapshots

Even with minify: true and strict validation, raw MJML output is not safe to snapshot directly. MJML injects a generated <style> block whose media-query ordering can shift between minor versions, and the juice-style inlining it performs internally may emit declarations in a non-stable order. Add a normalization pass between the transformer and the matcher so the baseline reflects structure, not incidental formatting.

// normalize-mjml-html.js — applied before toMatchSnapshot for deterministic diffs
const cheerio = require('cheerio');

function normalizeMjmlHtml(html) {
  const $ = cheerio.load(html, { xmlMode: false, decodeEntities: false });

  // Alphabetize inline styles: MJML's inliner is not order-stable across versions,
  // which otherwise produces noisy diffs that look like regressions but are not.
  $('[style]').each((_, el) => {
    const sorted = ($(el).attr('style') || '')
      .split(';').map(s => s.trim()).filter(Boolean).sort().join('; ');
    $(el).attr('style', sorted);
  });

  return $.html()
    // Gmail strips most head <style>; the MSO-only block must survive verbatim,
    // so collapse whitespace but never touch <!--[if mso]> conditional comments.
    .replace(/>\s+</g, '><')
    .trim();
}

module.exports = { normalizeMjmlHtml };
// __tests__/welcome-email.test.js (updated to normalize before asserting)
const welcomeHtml = require('../templates/welcome.mjml');
const { normalizeMjmlHtml } = require('../normalize-mjml-html');

describe('Welcome Email Template', () => {
  it('renders deterministic HTML structure', () => {
    expect(normalizeMjmlHtml(welcomeHtml)).toMatchSnapshot();
  });
});

The normalizer must preserve <!--[if mso]> blocks untouched: those conditional comments are the only way <mj-button> and <mj-section> deliver working fallbacks to Outlook 2016-2021, whose Word engine ignores the modern markup MJML emits for Gmail and Apple Mail. A whitespace collapse that swallows them would pass the snapshot while shipping a broken layout to Outlook desktop. The same discipline underpins the broader Automated Snapshot Testing practice.

Transformer vs. Serializer: Choosing the Normalization Point

You can normalize in two places — inside the Jest transformer (so the imported module is already clean) or inside a snapshotSerializers entry (so normalization happens at assertion time). Each has trade-offs:

  • Transformer normalization runs once at compile, is cached by Jest, and keeps test files terse (expect(html).toMatchSnapshot()). The downside: the cached transform hides the raw output, making it harder to debug what MJML actually emitted.
  • Serializer normalization keeps the raw compiled HTML available in the module and applies cleanup only when serializing for the diff. This is the better default for email work because you can console.log(welcomeHtml) to see exactly what MJML produced before any cleanup.

Combine them deliberately: let the transformer enforce compile correctness (throw on errors, pin minify/validationLevel), and let the serializer enforce diff stability (token and style normalization). The serializer pattern from the parent Automated Snapshot Testing guide drops in unchanged.

Variant Cases: Conditionals, Loops, and Localized Templates

Real transactional templates are rarely static. Three variant shapes need explicit snapshot coverage:

Conditional blocks (mj-raw with handlebars/liquid). When a template wraps content in {{#if hasInvoice}}, snapshot each branch by compiling with both states. Do not snapshot the un-rendered conditional — that locks in template syntax, not rendered output.

// __tests__/receipt-variants.test.js — assert each rendered branch, not the raw conditional
const Handlebars = require('handlebars');
const mjml2html = require('mjml');
const fs = require('fs');

function renderReceipt(context) {
  const tmpl = Handlebars.compile(fs.readFileSync('./templates/receipt.mjml.hbs', 'utf8'));
  const { html, errors } = mjml2html(tmpl(context), { minify: true, validationLevel: 'strict' });
  if (errors.length) throw new Error(errors.map(e => e.formattedMessage).join('\n'));
  return html;
}

describe('Receipt variants', () => {
  it('renders the invoice branch', () => {
    expect(renderReceipt({ hasInvoice: true, total: '49.00' })).toMatchSnapshot();
  });
  it('renders the no-invoice branch', () => {
    expect(renderReceipt({ hasInvoice: false })).toMatchSnapshot();
  });
});

Repeated rows (loops). A line-item table built with {{#each items}} must be snapshotted with a fixed, representative fixture (e.g. exactly two items) so the row count is deterministic. A dropped <tr> then surfaces as a precise diff — exactly the kind of table-nesting regression that breaks Outlook.

Localized copy. Snapshot one canonical locale (typically en) for structure, and assert non-en locales with expect.stringContaining() on the translated strings rather than full snapshots, so a copy update in one language does not invalidate every baseline.

Provider and Client Constraint Table

The compiled MJML must survive both the sending provider's rewriting and the destination client's rendering engine. The constraints below shape what your snapshots should — and should not — assert.

Provider / Client Constraint affecting MJML output Snapshot implication
Gmail (web/app) Strips <head> <style>; ~102KB clip; ignores some inlined shorthand Assert that critical CSS is inlined, not in <head>
Outlook 2016/2019 Word engine needs <!--[if mso]> VML/table fallbacks; ignores max-width on div Verify conditional comments survive normalization
Outlook 365 (Windows) Same Word engine; 120 DPI scaling on dimensions Snapshot explicit pixel widths MJML emits
Apple Mail Honors modern CSS but applies its own font smoothing Structure-only; pixel checks belong in Playwright
iOS Mail Auto-scales columns below 320px Assert MJML's responsive @media block is present
Samsung Email Forces dark-mode color inversion on some backgrounds Snapshot cannot catch this; defer to visual testing
Amazon SES Open-tracking pixel + per-send messageId in URLs Normalize amazonses.com/... to a placeholder
SendGrid Rewrites every href through ct.sendgrid.net click tracking Normalize the click wrapper to [SENDGRID_CLICK]
Postmark Adds pm_source query params on links Strip pm_source in the serializer

Pipeline Integration

These tests belong in the same merge gate as the rest of your transactional build. The earlier GitHub Actions job runs npm test -- --ci on every pull request; extend it with two safeguards specific to MJML. First, set TZ=UTC so any date helper in the template renders identically to developer machines. Second, fail the build on any MJML compile error — the strict transformer already throws, so a compile regression surfaces as a hard failure rather than an empty snapshot. After structural snapshots pass, hand off to visual regression testing of emails with Playwright for pixel-level coverage that string comparison cannot provide.

Validation Checklist

  • mjml is pinned to an exact version with minify: true and validationLevel: 'strict'
  • The transformer throws on mjml2html errors so a compile failure cannot pass silently
  • Output is normalized (styles alphabetized, whitespace collapsed) before toMatchSnapshot
  • <!--[if mso]> conditional comments survive normalization untouched for Outlook
  • Each conditional and loop variant has its own deterministic fixture and baseline
  • Provider tracking URLs (SES, SendGrid, Postmark) are normalized to placeholders
  • CI sets TZ=UTC and runs the suite with --ci to fail on drift
  • Changed .snap files require explicit review before merge

Conclusion

By standardizing template compilation and enforcing strict snapshot assertions, engineering teams can eliminate visual regressions before they reach production. This configuration bridges the gap between component-based MJML development and enterprise-grade email QA, ensuring reliable transactional delivery at scale.


← Back to Automated Snapshot Testing