Testing React Email Components With Vitest
Set up Vitest for email components: snapshot the compiled HTML, assert the client workarounds that must survive refactors, and normalise the output that changes per run.
Email components have a property most React components do not: their value is in specific, fragile markup details that a well-meaning refactor will remove. This guide sets up Vitest to snapshot the compiled output and, more importantly, to assert the client workarounds that must survive.
Root Cause: The Important Details Look Like Mistakes
A Button component carries a border: 14px solid that duplicates its background colour, a width attribute alongside a CSS width, and an mso-line-height-rule property no browser has ever implemented. Each of those looks redundant, and each exists because a specific client breaks without it.
Component tests that assert rendered text — the kind that suit product UI — pass regardless of whether those attributes survive. A developer tidying "duplicate" styling gets a green suite and ships a button that collapses in Outlook, and nothing in the test output suggests otherwise.
The tests that catch this assert the compiled HTML string rather than a component tree. That is unusual for React testing and it is correct here, because the compiled string is what a mail client parses. A component tree that renders correctly in a testing library's virtual DOM tells you nothing about what the Word engine will do with it.
The Setup
Vitest needs a JSX transform and a node environment; email rendering is synchronous and needs no DOM.
// vitest.config.mjs
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
// The renderer produces a string — no jsdom is needed, and using one only
// slows the suite and invites assertions against a DOM no client has.
environment: "node",
include: ["emails/**/*.test.{js,jsx,ts,tsx}"],
snapshotFormat: { escapeString: false, printBasicPrototype: false },
},
});
// emails/Button.test.jsx
import { render } from "@react-email/render";
import { describe, expect, test } from "vitest";
import { Button } from "./components/Button.jsx";
const html = (node) => render(node, { pretty: false });
describe("Button", () => {
test("compiled markup is stable", async () => {
// The snapshot is the regression net: any change to the output is shown
// as a diff and requires a deliberate version decision.
expect(await html(<Button href="https://example.com/c">Confirm</Button>))
.toMatchSnapshot();
});
test("carries the client workarounds it exists for", async () => {
const out = await html(<Button href="https://example.com/c">Confirm</Button>);
// Outlook 2016-2021: padding is dropped on an inline anchor, so the inset
// is expressed as a border of the same colour. Removing it collapses the
// button to its text box.
expect(out).toMatch(/border:\s*14px solid #a53860/);
// Outlook 2016-2021: leading is derived from font metrics unless pinned.
expect(out).toMatch(/mso-line-height-rule:\s*exactly/);
// The rendered height must clear the 44px iOS minimum touch target:
// 24px line-height + 14px border top and bottom = 52px.
expect(out).toMatch(/line-height:\s*24px/);
// display:inline-block is what makes the border render as an inset at all.
expect(out).toMatch(/display:\s*inline-block/);
});
});
Each assertion carries a comment naming the client and the failure it prevents. That is not documentation for its own sake — a failing assertion whose message explains why the property matters is the difference between a developer restoring it and a developer deleting the test.
Normalising What Legitimately Varies
Some output differs between runs for reasons that carry no meaning, and snapshotting it produces failures that train people to update snapshots without reading them.
// test/normalise.js — strip only what genuinely varies. Over-normalising is
// worse than not normalising, because it hides real changes.
export function normalise(html) {
return html
// Generated ids from the renderer: differ per run, mean nothing.
.replace(/data-id="[^"]+"/g, 'data-id="[id]"')
// Cache-busting query strings on asset URLs.
.replace(/(\?|&)v=\d+/g, "$1v=[ver]")
// Timestamps injected by the layout.
.replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/g, "[timestamp]");
}
What must not be normalised is anything that could be wrong: prices, link targets, recipient data, the presence of conditional blocks. The failure mode of over-normalisation is a suite that stays green while a receipt ships with the wrong total — which is worse than having no test, because it carries false assurance.
Testing Props as a Contract
Typed props are one of the main reasons to use JSX for email, and the tests should exercise the contract rather than only the happy path.
Three cases are worth covering for any component taking data. A missing optional value should render a sensible fallback rather than the string undefined — which is a real and common defect, since a template rendering "Hello undefined" passes any test that only checks the happy path. An unexpectedly long value should not break the layout: a 60-character product name in a table cell either wraps or is truncated deliberately, and asserting which keeps a refactor from changing it. A value containing markup must be escaped, which the renderer does by default and which a test should confirm, because a component that opts out of escaping for one field is the kind of change that passes review unnoticed.
These are cheap tests and they map directly to production incidents. The undefined case in particular appears in nearly every email codebase that has been running for more than a year.
Keeping the Suite Fast Enough to Run
An email test suite that takes minutes gets skipped locally and eventually gets skipped in CI too, so speed is a correctness property rather than a nicety.
Two things make these suites slow, and both are avoidable. Configuring jsdom when nothing needs a DOM adds a per-file environment setup that can double the runtime of a suite doing nothing but string comparison; the node environment is both faster and more honest about what is being tested. And rendering the same component repeatedly across many small tests re-runs the renderer each time, which for a large layout component is the dominant cost.
The fix for the second is to render once per describe block and assert many times against the result. That is unusual advice for unit testing, where isolation between assertions is normally worth the cost, and it is right here because the render is a pure function of its props — there is no state to leak between assertions and nothing an earlier expectation can do to affect a later one.
describe("OrderReceipt", () => {
// Rendered once. Pure function of props, so no isolation is lost.
let out;
beforeAll(async () => {
out = await html(<OrderReceipt {...fixture} />);
});
test("includes the order total", () => expect(out).toContain("£42.00"));
test("includes every line item", () => expect(out.match(/data-item/g)).toHaveLength(3));
test("keeps the Outlook conditional", () => expect(out).toContain("<!--[if mso]>"));
test("has an unsubscribe link", () => expect(out).toMatch(/href="[^"]*\/unsubscribe/));
});
A suite structured this way typically runs the full set of email components in a couple of seconds, which is fast enough to run on save — and a suite that runs on save is one that catches a removed workaround at the moment it is removed rather than in review.
Validation and Deployment Checklist
Frequently Asked Questions
Should we use React Testing Library for email components?
No. It queries a virtual DOM, which is a fundamentally different representation from the HTML string a mail client parses — a component can pass every RTL query and produce markup that Outlook cannot render. The string is the artefact under test.
How do we stop snapshots being updated without review?
Run the suite with the update flag disabled in CI, so a snapshot change must be committed deliberately from a developer's machine. Pair that with a code-owner rule on the snapshot directory: a diff there is a rendered change, and per the versioning policy that is a major version decision rather than an incidental one.
Are snapshots enough on their own?
No — they detect change but do not encode intent. A snapshot updated after a refactor that removed the Outlook border records the broken output as the new expectation. The explicit workaround assertions are what stop that, because they fail with a message explaining what was lost rather than showing a diff someone might accept.
What about testing the plain-text part?
Worth doing, and easy to forget. Assert that the text part is non-empty, that it contains the message's key facts, and that links appear as full URLs rather than as bare anchor text. A text part generated from a component tree frequently loses link targets entirely, and nobody notices because nobody reads it.
Related
- React Email Development — the render pipeline these tests exercise
- Automated Snapshot Testing — the broader snapshot discipline and its limits
- Versioning Shared Email Components — why a snapshot diff is a version decision
- Bulletproof Email Buttons — the workarounds the assertions protect
← Back to React Email Development