Stubbing Email and Notification Providers
Transactional messages — password resets, receipts, verification codes, shipping notices — are among the most user-visible things an application does and among the least tested. The reason is obvious: nobody wants a test suite that sends real email. The usual response, a global stub that swallows every send, removes the risk and every opportunity to test what was sent: the recipient, the template, the variables, the link in the password reset. This guide covers an outbox fake that records messages for precise assertions, testing templates as rendered output, handling provider errors and bounces, and using a local mail catcher for the end-to-end flows where a user must click a link in an email. It applies to SendGrid, Postmark, Resend, SES and SMS providers alike, and sits under third-party SDK isolation.
Root Cause Analysis
Email bugs are distinctive because they are invisible to the sender. A receipt with the wrong total, a password-reset link pointing at a staging domain, a verification code sent to the previous email address after a change — none of these throws an error, and none appears in logs. They are discovered by customers, usually after a support ticket, and often after many have already gone out.
The global stub is the reason. Swallowing every send makes tests safe and blind simultaneously: a test can assert that sign-up succeeded but not that the welcome email went to the right person with a working link. The information needed to catch every bug above is in the send call’s arguments, and the stub discards it.
The second gap is failure handling. Providers reject messages for invalid addresses, rate-limit bursts, and report bounces asynchronously hours later. Code that does not handle a rejected send may mark a user verified who never received the code, or retry a permanently invalid address forever. None of that can be tested against a stub that always succeeds.
Reproducible Setup
Define messages in your own terms — a template name and its variables — so the port is independent of the provider’s API.
// src/ports/mailer.ts
export type Message =
| { template: 'password-reset'; to: string; vars: { resetUrl: string; expiresInMinutes: number } }
| { template: 'receipt'; to: string; vars: { orderId: string; totalFormatted: string } }
| { template: 'verify-email'; to: string; vars: { code: string } };
export class PermanentDeliveryError extends Error {}
export interface Mailer {
send(message: Message, opts?: { idempotencyKey?: string }): Promise<{ messageId: string }>;
}
// test/fakes/outbox.ts
import type { Mailer, Message } from '../../src/ports/mailer';
import { PermanentDeliveryError } from '../../src/ports/mailer';
export function outbox() {
const sent: Array<Message & { idempotencyKey?: string }> = [];
const seenKeys = new Set<string>();
let failNext: Error | undefined;
const mailer: Mailer = {
async send(message, opts) {
if (failNext) { const e = failNext; failNext = undefined; throw e; }
if (opts?.idempotencyKey && seenKeys.has(opts.idempotencyKey)) return { messageId: 'dedup' };
if (opts?.idempotencyKey) seenKeys.add(opts.idempotencyKey);
sent.push({ ...message, idempotencyKey: opts?.idempotencyKey });
return { messageId: `msg_${sent.length}` };
},
};
return {
mailer, sent,
to: (address: string) => sent.filter((m) => m.to === address),
last: () => sent.at(-1),
rejectNextAsInvalid: () => { failNext = new PermanentDeliveryError('invalid recipient'); },
rejectNextAsTransient: () => { failNext = new Error('rate limited'); },
};
}
Implementation
Step 1 — Assert on recipient, template and variables. This is the assertion a swallowing stub cannot support, and it catches the bugs customers report.
// src/auth/request-password-reset.test.ts
import { test, expect } from 'vitest';
import { outbox } from '../../test/fakes/outbox';
import { requestPasswordReset } from './request-password-reset';
test('sends a reset link to the account’s current address', async () => {
const box = outbox();
await requestPasswordReset({ email: 'ada@example.test' }, { mailer: box.mailer, users: usersWith({ email: 'ada@example.test' }) });
expect(box.last()).toMatchObject({
template: 'password-reset',
to: 'ada@example.test',
vars: { resetUrl: expect.stringMatching(/^https:\/\/app\.example\.com\/reset\?token=[\w-]{32,}$/), expiresInMinutes: 30 },
});
});
Step 2 — Follow the link the email contains. Extracting the URL from the recorded message and using it proves the token in the email is the token the server will accept.
test('the link in the reset email completes the reset', async () => {
const box = outbox();
const deps = { mailer: box.mailer, users: usersWith({ email: 'ada@example.test' }) };
await requestPasswordReset({ email: 'ada@example.test' }, deps);
const token = new URL(box.last()!.vars.resetUrl as string).searchParams.get('token')!;
await expect(completeReset({ token, newPassword: 'correct horse battery staple' }, deps)).resolves.toEqual({ ok: true });
});
Step 3 — Test that nothing is sent when nothing should be. Account-enumeration protection means a reset request for an unknown address must succeed silently — and send nothing.
test('does not reveal whether an address exists', async () => {
const box = outbox();
const result = await requestPasswordReset({ email: 'nobody@example.test' }, { mailer: box.mailer, users: usersWith() });
expect(result).toEqual({ ok: true });
expect(box.sent).toEqual([]);
});
Step 4 — Distinguish permanent from transient provider failures. A permanently invalid address should be recorded and not retried; a rate limit should be retried later.
test('marks the address undeliverable on a permanent rejection', async () => {
const box = outbox();
box.rejectNextAsInvalid();
const users = usersWith({ email: 'typo@exmaple.test' });
await sendVerification({ userId: 'u1' }, { mailer: box.mailer, users });
expect(await users.get('u1')).toMatchObject({ emailStatus: 'undeliverable' });
});
test('schedules a retry on a transient failure', async () => {
const box = outbox();
box.rejectNextAsTransient();
const queue = memoryQueue();
await sendVerification({ userId: 'u1' }, { mailer: box.mailer, users: usersWith({ email: 'a@example.test' }), queue });
expect(queue.added).toContainEqual(expect.objectContaining({ name: 'retry-verification' }));
});
Step 5 — Render templates in their own tests. Whether the template produces correct HTML is a separate question from whether the right template was chosen, and it is best answered by rendering with fixed variables and checking the output.
// src/emails/receipt.test.ts
import { renderEmail } from './render';
test('the receipt shows the order and total, and has a plain-text part', async () => {
const { subject, html, text } = await renderEmail('receipt', { orderId: 'o1', totalFormatted: '£49.99' });
expect(subject).toBe('Your receipt for order o1');
expect(html).toContain('£49.99');
expect(text).toContain('Order o1');
expect(html).not.toMatch(/\{\{|undefined/); // no unrendered placeholders
});
Step 6 — Use a mail catcher for end-to-end flows. When a browser journey requires clicking a link in an email, point the real adapter at a local SMTP catcher and read the message through its API.
# docker-compose.test.yml
services:
mailpit:
image: axllent/mailpit:v1.20
ports: ["1025:1025", "8025:8025"]
// e2e/journeys/verify-email.spec.ts
test('a new user verifies their address from the email', async ({ page, request }) => {
await page.goto('/sign-up');
await page.getByLabel('Email').fill('new@example.test');
await page.getByRole('button', { name: 'Create account' }).click();
const list = await (await request.get('http://localhost:8025/api/v1/search?query=to:new@example.test')).json();
const msg = await (await request.get(`http://localhost:8025/api/v1/message/${list.messages[0].ID}`)).json();
const link = msg.Text.match(/https?:\/\/\S+verify\S+/)[0];
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});
Verification
Confirm no real message can leave a test run: the provider’s API key is absent from the test environment, and MSW errors on any request to the provider’s host.
env | grep -iE "sendgrid|postmark|resend" || echo "no provider keys in the test environment"
Then confirm the outbox assertions have teeth by changing the reset URL’s domain in the code to a staging host. The recipient-and-link test must fail — that is precisely the bug that otherwise reaches customers.
Troubleshooting
Symptom: tests assert on email HTML and break on every copy change. Diagnosis: behaviour tests are checking presentation. Fix: assert on template name and variables in behaviour tests, and keep HTML assertions in the dedicated rendering tests, where a copy change is expected to update them.
Symptom: the mail catcher sometimes has no message yet. Diagnosis: the application sends asynchronously, and the test reads the inbox before delivery. Fix: poll the catcher’s API until the message appears or a timeout passes, rather than reading once.
Symptom: duplicate emails appear after retries. Diagnosis: the send is retried without an idempotency key, or the key changes between attempts. Fix: derive the key from the business event — the order, the reset request — and assert in a test that a second send with the same key does not add a message.
Symptom: SMS tests need a real phone number format. Diagnosis: validation rejects obviously fake numbers. Fix: use the ranges regulators reserve for fiction and testing, and keep the SMS port’s fake as simple as the email outbox — the same recording pattern works for any notification channel.
FAQ
Should the outbox fake render templates?
Keep them separate. The fake records the template name and variables; rendering is tested on its own. Coupling them means every behaviour test pays for template rendering and breaks on copy changes, which makes them slower and noisier without catching anything extra.
Is a mail catcher enough on its own?
It is excellent for end-to-end flows and far too slow for the bulk of behaviour tests. Starting a container and polling an inbox to check which template a receipt used costs seconds per test; the outbox fake answers the same question in microseconds.
How do I test bounce handling?
Bounces arrive as webhooks from the provider, often long after the send. Test the webhook handler with a signed bounce payload, as in testing webhook handlers with signed payloads, and assert that the address status updates and future sends are suppressed.
What about preview or staging environments?
Point them at a mail catcher or at the provider’s sandbox mode, never at real delivery, and make the configuration refuse a production key outside production — the same guard used for payment keys in testing Stripe payment flows without live keys.
Related
- Back to Third-Party SDK Isolation
- Testing background jobs queued with BullMQ — where most transactional email is sent from.
- Mocking SQS and SNS clients in Vitest — the queue many notification pipelines sit behind.
- Reusing authenticated state across Playwright tests — the journeys that start after verification.