Your transactional email is a feature.
Test it like one.
Password resets, receipts, welcome emails, invoices — transactional email is a screen every user eventually sees, built from a code path almost no suite exercises. The template lives in an ESP dashboard, the copy gets edited by marketing, the DNS belongs to infra — and the first integration test most of it ever gets is a customer replying "this link doesn't work."
Quick answer. To test transactional email, receive it into a programmatic inbox from your app's real sending path, then assert on the parsed message: sender and subject, the content of both body parts, the links it carries, the SPF/DKIM/DMARC verdicts, and the spam score. One received message carries all of it — the whole bundle is a handful of assertions in the E2E suite you already run.
The most-used screen nobody tests
A transactional email fails differently from a web page, because more teams can break it and fewer tests watch it:
- Template debris ships silently. A renamed variable turns "Hi Priya" into "Hi undefined" — or
Hi {{first_name}}, verbatim. The send succeeds, the ESP reports delivered, and no assertion anywhere reads the body. - Links rot per environment. The reset button builds its URL from a config value; staging config leaks into prod (or vice versa) and the most important link in the email points at the wrong host — or at
localhost. - The plain-text part disappears. A redesign through the ESP's visual editor quietly drops the
text/plainleg of the multipart. Nothing visible changes in Gmail; spam filters notice immediately. - Authentication breaks from a distance. A DNS cleanup drops one SPF
include:, an ESP migration changes the envelope sender, a DKIM key rotates in a dashboard. The email still sends, still arrives in test tools — and real providers start junking it. - Copy drifts spammy. Subject-line A/B tests and urgent-sounding rewrites accumulate content-filter points nobody is counting.
None of these break the send. All of them break the feature. That's why "the email arrived" — the only thing most suites assert, when they assert anything — is the weakest claim you can make about a message you're already holding.
The checks that don't catch it
- Sending yourself a test email. A manual look at one rendering of one template, on the day someone remembered. Every regression after that is invisible.
- Asserting on a mocked mailer.
expect(sendMail).toHaveBeenCalled()proves your code called a function with the arguments you predicted. It proves nothing about what the template renders, what the ESP does to it, or what actually crosses the wire. (Where mocks do belong is covered in the layer-choice guide — the point is what each layer can't see.) - Trusting the ESP's "delivered" status. Delivery status tracks the handoff, not the content. A delivered email with a broken reset link is a delivered bug.
One message, five assertions
The fix is structural: point the flow under test at a real inbox you can query, and assert on the message as parsed data. Everything below rides on one received message — no second fetch, no scraping:
undefined, unrendered placeholders); the text part silently droppedthis guideHere's the bundle in a Playwright suite with the JS SDK (mailfixture on npm, zero runtime deps; timeouts are milliseconds):
import { test, expect } from "@playwright/test"; import { MailFixture } from "mailfixture"; const mfx = new MailFixture(); // reads MAILFIXTURE_API_KEY from the environment test("reset email is correct, whole, and deliverable", async () => { const inbox = await mfx.createInbox({ ttlSeconds: 900 }); await requestPasswordReset({ email: inbox.emailAddress }); const msg = await inbox.waitForMessage({ match: "subject:reset", timeout: 30_000, }); // 1 — envelope: the identity users (and filters) see expect(msg.fromAddr).toBe("no-reply@yourapp.example"); expect(msg.subject).toBe("Reset your password"); // 2 — content: both parts exist, neither carries template debris expect(msg.textBody).toBeTruthy(); // HTML-only mail scores spam points for (const body of [msg.textBody!, msg.htmlBody!]) { expect(body).toMatch(/Reset your password/); expect(body).not.toMatch(/undefined|\{\{|\bNone\b/); } // 3 — the link: classified as a reset link, and it actually answers expect(msg.links.some((l) => l.kind === "reset")).toBe(true); const followed = await mfx.followLink(msg.id, { kind: "reset" }); expect(followed.ok).toBe(true); // server-side GET, redirects followed // 4 + 5 — deliverability: authenticates, and doesn't look like spam expect(msg.auth?.dmarc.result).toBe("pass"); expect(msg.spam?.verdict).toBe("ham"); await inbox.delete(); // teardown: messages go with the inbox });
The wait is a ceiling, not a sleep: the server holds the request open and answers the moment the message arrives, so the suite pays real latency, not worst-case latency. The inbox is created inside the test and deleted in teardown, which is what makes every assertion above race-free at any worker count — the parallel guide covers why.
The same bundle in pytest with the Python SDK (mailfixture on PyPI, stdlib-only; timeouts are seconds), using the conftest fixture from the pytest guide:
def test_receipt_email_bundle(inbox, app): app.place_order(email=inbox.email_address, amount_cents=4900) msg = inbox.wait_for_message(match="subject:receipt", timeout=30) assert msg.from_addr == "billing@yourapp.example" assert msg.text_body and msg.html_body # both parts, always assert "Order #" in msg.text_body assert "$49.00" in msg.text_body # the number, not just the shape kinds = {link.kind for link in msg.links} assert "unsubscribe" not in kinds # a receipt is not a campaign assert msg.auth and msg.auth.dmarc.result == "pass" assert msg.spam and msg.spam.verdict == "ham"
Assert real values, not shapes: the receipt test checks $49.00, not "contains a dollar sign." Personalization bugs almost always render something — the fallback greeting, last month's total, the right template with the wrong data. Only an assertion pinned to the data this test created can tell the difference.
The deliverability legs need the real sending path
none across the auth verdicts (there's no DNS trail to verify — that's the honest answer, not a bug), and its spam score reflects your fixture, not your product. auth and spam are null when verification or scoring didn't run at all — treat that as "unknown", never as a pass.That constraint decides where the bundle runs. The envelope, content, and link assertions work anywhere your app can send from — local, staging, CI. The authentication and spam legs earn their keep in the E2E suite that exercises staging end-to-end, because that's the configuration that breaks silently from a DNS edit and the one your users receive from. Splitting it that way is normal; both halves are the same handful of lines.
Failure modes
match filter excludes the messageListing the inbox without match shows nothing, or shows an unexpected subjectList unfiltered to see what arrived; fix the filter or the send pathtext_body is null; MIME_HTML_ONLY appears in spam.rulesRestore the plain-text leg; keep the assertion so it can't regress againfollowed.ok is false in staging — link URLs built from environment config point at an internal hostrequested_url shows a host the follower can't reach (it requires public HTTPS targets)Build email URLs from the environment's public origin, not service-internal confignone everywhere — the message didn't travel the real sending pathspf.result == "none", empty dkim, no DMARC record foundRun the deliverability legs against staging's real ESP path (see above)Edge cases worth building in
- One inbox per test, deleted in teardown. Every count and content assertion above assumes the inbox holds exactly this test's mail. Shared inboxes make the bundle racy under parallelism and stale under retries.
- Reruns re-trigger the send. If your framework retries failures, scope the inbox per attempt (Playwright fixtures and pytest function-scoped fixtures both do this for free), or pass a
sincecursor so the retry can't assert against the first attempt's message. - Timeout units differ by SDK. JS takes milliseconds, Python takes seconds.
timeout=30_000in Python is an eight-hour hang waiting to happen. - Assert inside the test, not a later stage. Messages expire with plan retention, and teardown deletes them immediately — a pipeline stage that fetches the message an hour later is racing both.
What this doesn't prove
- Content assertions aren't rendering assertions. The bundle proves the message's data — parts, copy, links — not how Outlook or Gmail paints it. We don't sell client-rendering screenshots, and a data-level test shouldn't claim to be one.
- Passing auth + a ham verdict is not inbox placement. Real providers layer reputation and engagement signals no offline check can see. The bundle pins the parts that are binary and under your control — which is exactly what a test suite is for.
- Link classification is a heuristic.
kindis the server's guess from URL path and anchor text. Assert the link works (follow it); use the class to select, not as the assertion itself. - MailFixture is receive-only. Your app sends through your real provider; the test inbox is the destination. Nothing here exercises your sending code except your sending code.
Next steps
The bundle is the hub; each leg has a deep-dive guide when a layer needs more than one assertion:
- Deliverability legs in depth: SPF/DKIM/DMARC verdicts and spam scores in CI.
- When the email carries a file: attachment testing — metadata first, then the decoded bytes.
- The fixture architecture to hang the bundle on: Playwright, Cypress, pytest.
- Where this layer sits in the wider strategy: the automated email testing guide.