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.
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:
- Run
npx jestlocally to identify snapshot drift. - Inspect diffs using
git diff __snapshots__/. - Verify that CSS inlining changes do not break Outlook conditional comments or table nesting.
- 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
mjmlis pinned to an exact version withminify: trueandvalidationLevel: 'strict'- The transformer throws on
mjml2htmlerrors 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=UTCand runs the suite with--cito fail on drift - Changed
.snapfiles 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.
Related
- Automated Snapshot Testing — normalization protocols and CI orchestration for snapshots
- Visual regression testing of emails with Playwright — add pixel-level diffs on top of structural assertions
- Litmus & Email on Acid Workflows — extend code-level checks with cross-client rendering
- Email Testing & QA Workflows — the wider QA discipline these tests fit into
← Back to Automated Snapshot Testing