Email Testing for Developers: The Complete Guide
Test real email flows end to end — verification codes, password resets, OTP and magic links — with a disposable inbox and an API.
Email is the step most test suites give up on. Everything up to it is in your control — fill the form, click the button, assert the redirect — and then the flow leaves your application entirely, travels through infrastructure you do not own, and lands somewhere your test cannot see. So the suite stubs the send call, asserts that the app tried, and calls it covered.
What that leaves untested is the part that actually breaks: whether the message left, whether the template rendered, and whether the code inside it matches the one your backend will accept.
This is the map for testing it properly.
First, the distinction that eliminates half the tools
Before choosing anything, know which of two problems you have. Tools in this space split cleanly, and the split is not obvious from their marketing.
Real inbound inboxes give you a routable address that receives mail from anywhere on the internet, readable over an API or pushed to a webhook. Only these can test an email your own code did not send — a signup confirmation, a password reset, a 2FA code from a third-party provider.
SMTP catchers are a fake SMTP server you point your app at. They capture what your app sends and show it in a UI. Excellent for the local dev loop and for asserting on your own outgoing templates. They cannot receive external mail, because nothing on the internet can route to them.
Mailpit, MailHog, MailCatcher, MailDev and Mailtrap's Email Testing sandbox are all the second kind. They appear on every "email testing tools" list alongside the first kind, with no indication that they answer a different question. Picking one to do the other's job is the single most common mistake in this area, and it is usually discovered three days into an integration.
Most teams end up wanting both: a catcher in the dev loop, a real inbox in end-to-end CI.
The pattern: one inbox per test
For the inbound half, the whole discipline reduces to one rule. Provision a fresh inbox per test, not per suite.
Sharing one mailbox across a suite reintroduces exactly the flakiness you are trying to remove: the moment two tests run in parallel, one reads the other's mail, and you get a failure that reproduces only under concurrency. A test that passes alone and fails in a suite costs more to debug than the feature it covers.
Four moving parts, in order:
- an address unique to this test run,
- the browser or API flow that triggers the send,
- a read of the message that arrives,
- extraction of the code or link, then the assertion.
Steps 1 and 3 are where the tooling choice matters. Everything else is ordinary test code.
The smallest thing that works
Two endpoints do the entire job — create an address, then poll it until something lands:
const API = 'https://moemail.app/api'
const H = { 'X-API-Key': process.env.MAIL_KEY, 'Content-Type': 'application/json' }
// expiryTime is in milliseconds. Omit `name` and a random local part is
// generated, which is what you want so parallel tests cannot collide.
export async function createInbox() {
const res = await fetch(`${API}/emails/generate`, {
method: 'POST',
headers: H,
body: JSON.stringify({ expiryTime: 3_600_000, domain: 'moemail.app' }),
})
if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
return res.json() // { id, email }
}
export async function waitForMessage(inboxId, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`${API}/emails/${inboxId}`, { headers: H })
const { messages } = await res.json()
if (messages?.length) return messages[0]
await new Promise(r => setTimeout(r, 1500))
}
throw new Error(`no email arrived within ${timeoutMs}ms`)
}
A message comes back as { id, from_address, to_address, subject, content, html, received_at } — content is the plain-text body, html the HTML part.
Bound the wait by a deadline, not a retry count. A fixed count silently becomes a different timeout the moment someone tunes the interval.
And assert on the extraction itself:
const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()
match(...)[0] throws Cannot read properties of null when the regex misses, which points the stack trace at your regex instead of at the real problem — that the email said something you did not expect. Putting the subject line in the failure message turns ten minutes of confusion into one glance at the report.
Polling or webhooks
Polling is simpler and fine for a handful of tests. A webhook removes the latency and the wasted requests, and matters once the suite is large enough that a 1.5s interval times a hundred tests is real wall-clock.
Either way, keep the timeout tight but honest. Transactional mail in a test environment should arrive in a few seconds; 30s covers real delivery with margin. If a suite needs 90s, that is a delivery problem being hidden rather than fixed.
Reading the HTML part
One failure worth pre-empting: if your template only puts the verification link in the HTML body, reading content finds nothing. Plain-text alternatives drift out of sync with the HTML more often than anyone expects, and the part users actually click is the HTML. Read msg.html when the link is not in content, and prefer testing whichever part your users receive.
Choosing a tool
If you are evaluating paid services, the comparison that matters is not feature count — it is whether the tool receives real inbound mail, what the free tier really allows, and whether you can self-host to escape per-inbox quotas.
Two write-ups go through that in detail, including where the incumbents are honestly the better buy: Mailosaur alternatives, which sorts the whole field by the inbound/catcher split above, and MailSlurp alternatives, which works through the arithmetic of paying per inbox when the correct pattern creates one per test.
Where MoeMail fits
MoeMail is a real inbound inbox: routable addresses, an OpenAPI with key auth, and webhooks. It is open source and self-hostable on Cloudflare if you want custom domains and no external quota.
Plainly, what it does not do: no SMS/phone OTP testing, no cross-client rendering previews, and fewer turnkey framework integrations than the paid incumbents — you wire the HTTP calls yourself, which is the twenty lines above. If your tests need SMS in the same tool, buy Mailosaur instead; that is a real gap, not a rough edge.
Grab an API key from your profile and read the OpenAPI docs, or create a mailbox to see the shape of the data.
The guides below apply this pattern to specific frameworks, flows, languages and CI setups.