Skip to main content

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.

What each kind of assertion detects A text-based assertion passes after a workaround is removed, while a compiled-output assertion fails and names the missing property. A Green Suite Is Not Evidence assert the rendered text "Confirm your order" is present passes with the border removed The button now collapses in Outlook and the suite is green. assert the compiled HTML border: 14px solid is present fails, naming the missing property The refactor is caught at the commit that introduced it.
The assertions worth writing are the ones that fail when a workaround is removed, which means asserting the markup rather than the meaning.

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.

Normalise volatility, assert meaning Generated ids, cache-busting parameters and timestamps are normalised, while prices, link targets and conditional blocks are asserted. Over-Normalising Is Worse Than Not Testing normalise generated ids cache-busting parameters injected timestamps different every run, mean nothing assert totals, currency, quantities link targets and unsubscribe URLs conditional blocks and workarounds normalising these hides real defects
The dividing line is whether a difference could indicate a defect; if it could, it belongs in the snapshot.

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

Runtime cost of re-rendering per test Rendering once per describe block and asserting many times is substantially faster than rendering inside every test. Render Once, Assert Many render per test 12 renders — the dominant cost ~9s render per block 3 renders ~2s — fast enough to run on save The render is a pure function of its props, so sharing it across assertions loses no isolation.
Speed matters because a suite fast enough to run on save catches a removed workaround at the moment it is removed.

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.


← Back to React Email Development