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.

Volatility in MJML compiler output Generated class names, whitespace and the compiler version comment vary between runs and versions, while the table structure and inline styles must stay stable. What Changes Between Two Identical Builds Volatile — normalise generated class names indentation and line breaks the compiler version comment a version bump would rewrite every snapshot Stable — assert table nesting depth and cell order inline style declarations MSO conditional blocks this is the output clients actually consume
Normalisation is what keeps a compiler upgrade from producing hundreds of meaningless snapshot diffs.
// 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:

Where normalisation runs A transformer normalises before the assertion so the test sees clean output, while a serializer normalises at write time so the stored snapshot is clean. Normalise Before the Assertion, or at Write Time Transformer runs inside the test, before expect() Explicit and easy to debug; every test must remember to call it. best when only some tests need it Serializer registered globally, runs on write Applies everywhere automatically; the transformation is invisible in the test file. best when the whole suite needs it
Pick one and apply it consistently — mixing both makes a failing diff hard to attribute to either layer.
  • 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

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.

Frequently Asked Questions

Should the snapshot capture MJML source or compiled HTML?

Compiled HTML. The source is what you wrote and cannot regress on its own; the compiled output is what a client receives and is what changes when the compiler is upgraded or an attribute is dropped. Snapshotting the source produces a test that passes through the exact upgrade you wanted to catch.

A compiler upgrade rewrote every snapshot. Now what?

Read one diff carefully before updating any of them. If the change is confined to generated class names and whitespace, extend the normaliser and re-run — the snapshots should then be stable again. If table structure or inline styles moved, that is a rendering change and deserves a client check before it is accepted.

How many templates should have snapshots?

All of them, because the cost per template is close to zero once the harness exists. The judgement is in the assertions beyond the snapshot: the client workarounds worth guarding explicitly are the ones a refactor would remove without anyone noticing.

Do snapshots replace visual regression testing?

No, and the distinction matters. A snapshot compares markup; two documents with identical markup render identically only within one engine. Layout breakage, dark-mode contrast and Word-engine spacing are invisible to a text diff, which is why the visual stage exists alongside it.

Where should snapshot files live?

Beside the templates, committed. They are review artefacts: the point of the test is that a change to the output shows up in a pull request as a readable diff, which only works if the previous state is in the repository.


← Back to Automated Snapshot Testing