Testing Email Verification Flows in Development
Email verification is a surprisingly tricky part of the sign-up flow to test correctly. Shared test inboxes cause race conditions, mocked services miss real-world edge cases, and personal inboxes are unprofessional. Here is a better approach.
The problem with shared test inboxes
A shared address like test@yourcompany.com that all developers and CI jobs read causes race conditions. Parallel test runs pick up each other's emails. Test A triggers a sign-up and waits for the OTP. Test B triggers a different sign-up while A is waiting. One of them gets the other's OTP. The failure is non-deterministic and debugging takes hours.
The problem with mock email services
Services like Mailtrap and Mailhog capture outbound email in a fake SMTP server. This works for local development but has gaps:
- Email templates only render correctly in real mail clients, not preview panes
- Deliverability edge cases (SPF/DKIM, greylisting, spam scoring) are invisible
- CI environments require managing a running SMTP service alongside your test runner
The right approach: real isolated inboxes via API
Create a unique, real inbox per test run via the TempMailGrab API. The inbox receives actual email through real SMTP — so deliverability and template rendering are tested alongside your logic.
const API_BASE = 'https://tempmailgrab.com/api/v1';
const headers = { Authorization: 'Bearer ' + process.env.TMG_API_KEY };
export async function createTestInbox() {
const res = await fetch(API_BASE + '/inbox', { method: 'POST', headers });
return res.json();
}
export async function waitForMessage(inboxId, matcher, opts = { timeoutMs: 30000, intervalMs: 2000 }) {
const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(API_BASE + '/inbox/' + inboxId + '/messages', { headers });
const { messages } = await res.json();
const match = messages.find(matcher);
if (match) return match;
await new Promise(r => setTimeout(r, opts.intervalMs));
}
throw new Error('Message not received within ' + opts.timeoutMs + 'ms');
}
it('sends an OTP and completes verification', async () => {
const { id, address } = await createTestInbox();
await signUpWithEmail(address);
const msg = await waitForMessage(id, m => m.extracted_otp !== null);
await submitOtpOnVerificationPage(msg.extracted_otp);
});
Advantages over mocking
- Real email delivery — tests fail if your email sending is broken
- Real template rendering — email client bugs surface in tests
- No infrastructure to run — no SMTP service in CI
- Parallel-safe — each test gets its own isolated inbox
- OTP already extracted — no regex parsing in test code
Related articles
- Testing Email Flows in Your CI/CD Pipeline
- Playwright Email Testing: A Tutorial with Real Inboxes
- TempMailGrab API Tutorial
See also: API documentation · Email flows in CI/CD