Testing Email Flows with Vitest
Vitest runs test files in parallel by default, which is exactly where naive email testing falls apart. This covers a parallel-safe fixture, correct teardown, and a clear line between what deserves a real inbox and what should stay mocked.
What to test with real email, and what not to
Real inboxes are slower than mocks — seconds rather than milliseconds. Spending that budget everywhere makes the suite miserable; spending it nowhere means shipping broken delivery. The useful split is by what can actually fail.
| Test | Approach | Reasoning |
|---|---|---|
| "Does signup enqueue a mail job?" | Mock | Pure logic, no I/O worth exercising |
| "Does the template render the right name?" | Mock / snapshot | String rendering, no delivery involved |
| "Does a verification email actually arrive?" | Real inbox | Exercises the provider, DNS, and the queue |
| "Is the reset link in the email clickable and valid?" | Real inbox | Catches encoding and template-variable bugs |
| "Is the OTP parseable by a client?" | Real inbox | Depends on the final rendered message |
A good rule: mock everything up to the point the message leaves your process, and use a real inbox for everything after. Roughly five to ten real-inbox tests covering the critical flows is usually the right size — enough to catch delivery breaking, few enough that the suite stays usable.
A parallel-safe fixture
Vitest runs files in parallel workers. Two workers requesting the same address prefix will collide, and a shared module-level inbox will have tests reading each other's mail. The fix is one inbox per test, created inside the test's own scope.
// tests/helpers/inbox.ts
import { afterEach } from 'vitest';
const BASE = 'https://tempmailgrab.com/api/v1';
const KEY = process.env.TMG_API_KEY;
if (!KEY) throw new Error('TMG_API_KEY is not set');
const headers = { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' };
export interface Inbox { id: string; address: string; expires_at: string }
export interface Message {
id: string; sender: string; subject: string;
snippet: string; extracted_otp: string | null; created_at: number;
}
// Registered for cleanup even if the test throws.
const open: string[] = [];
afterEach(async () => {
await Promise.allSettled(
open.splice(0).map((id) => fetch(BASE + '/inbox/' + id, { method: 'DELETE', headers })),
);
});
/** A fresh inbox, torn down automatically after the test that created it. */
export async function useInbox(): Promise<Inbox> {
const res = await fetch(BASE + '/inbox/generate', {
method: 'POST',
headers,
// No prefix: let the server pick. A prefix derived from the test name looks
// tidy right up until two parallel workers pick the same one and 409.
body: JSON.stringify({ ttl_seconds: 900 }),
});
if (!res.ok) throw new Error('inbox create failed: ' + res.status);
const inbox = (await res.json()) as Inbox;
open.push(inbox.id);
return inbox;
}
export async function waitForMessage(
inboxId: string,
match: (m: Message) => boolean = () => true,
{ timeoutMs = 45_000, intervalMs = 1_500 } = {},
): Promise<Message> {
const deadline = Date.now() + timeoutMs;
let subjects: string[] = [];
while (Date.now() < deadline) {
const res = await fetch(BASE + '/inbox/' + inboxId + '/messages', { headers });
if (res.ok) {
const { messages } = (await res.json()) as { messages: Message[] };
subjects = messages.map((m) => m.subject);
const hit = messages.find(match);
if (hit) return hit;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error('Timed out after ' + timeoutMs + 'ms. Seen: ' + (subjects.join(', ') || '(none)'));
}
export async function getMessage(inboxId: string, messageId: string) {
const res = await fetch(BASE + '/inbox/' + inboxId + '/messages/' + messageId, { headers });
if (!res.ok) throw new Error('getMessage failed: ' + res.status);
return res.json();
}
The tests
// tests/email-flows.test.ts
import { describe, it, expect } from 'vitest';
import { useInbox, waitForMessage, getMessage } from './helpers/inbox';
import { app } from '../src/app';
// Real network calls, so the default 5s timeout is far too short.
describe('email flows', { timeout: 60_000 }, () => {
it('sends a verification code on signup', async () => {
const inbox = await useInbox();
await app.request('/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox.address }),
});
const msg = await waitForMessage(inbox.id, (m) => /verify|confirm/i.test(m.subject));
expect(msg.extracted_otp).toMatch(/^\d{6}$/);
expect(msg.sender).toContain('@example.com');
});
it('sends a working password-reset link', async () => {
const inbox = await useInbox();
await app.request('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox.address, password: 'hunter2hunter2' }),
});
await waitForMessage(inbox.id, (m) => /verify|confirm/i.test(m.subject));
await app.request('/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox.address }),
});
const msg = await waitForMessage(inbox.id, (m) => /reset/i.test(m.subject));
const full = await getMessage(inbox.id, msg.id);
const link = full.extracted_links.find((u: string) => u.includes('/password/reset'));
expect(link, 'reset link missing from email').toBeTruthy();
// The bug this catches: a renamed template variable rendering the token
// as the literal string "undefined". The email still arrives and still
// contains a link, so anything short of inspecting the URL passes.
expect(link).not.toContain('undefined');
expect(new URL(link).searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{20,}$/);
});
});
Configuration
Keep the real-inbox tests in their own project so vitest stays fast for everything else and CI can run them on a different schedule:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['tests/**/*.test.ts'],
exclude: ['tests/email-flows.test.ts'],
},
},
{
test: {
name: 'email',
include: ['tests/email-flows.test.ts'],
testTimeout: 60_000,
// Delivery latency dominates, so these are I/O-bound, not CPU-bound.
// A couple of workers is plenty and keeps API usage predictable.
maxWorkers: 2,
},
},
],
},
});
Then vitest --project unit on every push, and vitest --project email nightly and before release.
Two things that will bite you
The default timeout
Vitest defaults to five seconds. Email delivery routinely takes longer. A test that fails with "Test timed out in 5000ms" looks like a delivery problem and is not — set testTimeout explicitly and save yourself the false lead.
Teardown that does not run
afterEach runs even when the test throws, which is exactly why the fixture above registers inboxes in an array rather than relying on a finally inside each test. Cleanup that only happens on the happy path is cleanup that stops happening the moment something breaks.
Related
The same helper shape works under Playwright and Cypress, and the GitHub Actions guide covers running these in CI on a schedule. Endpoints are documented in the API reference.