Testing a signup email properly means letting your application send a real message to a real mailbox you control, then checking four separate things: that it arrived at all, how long it took, whether it passed the receiving server's authentication checks, and whether the link inside it still works when clicked from a browser that has never seen your app. Mocking the send and asserting that a function was called covers none of those. That is a useful test, but it tests your code, not your email.
The gap between the two is where most verification bugs live. A template renders perfectly in a unit test and arrives with a dead link, because the URL was built from the request host and staging sets that differently. Mail that sends in milliseconds locally takes minutes through a provider's queue, and the user has already clicked "resend" twice. A DKIM selector typo sits unnoticed for a month because nothing in the test suite ever looked at a header. None of these are code defects your assertions would catch, and all of them stop people signing up.
Three layers, and what each one can actually catch
Unit level: the message your code produces
Render the template with a fixture, assert on the result. This is where you check that the token is present, that the greeting handles a missing first name, that the unsubscribe block appears on marketing mail and not on transactional mail, and that generating a second code invalidates the first. These tests are fast and deterministic, so run them on every commit. They can tell you nothing whatsoever about delivery.
Integration level: the real MIME message, captured locally
Point your app at a local SMTP sink — Mailpit, MailHog, smtp4dev or your provider's sandbox mode — and it will accept and store the message without delivering it. You now have the actual bytes your mail library produced. This is the cheapest place to catch a missing text/plain alternative, a malformed multipart boundary, a subject line that breaks when it contains an accented character, or headers your framework quietly added.
End-to-end: over the public internet, into a real inbox
Only this layer exercises DNS, TLS negotiation, your sending reputation, SPF and DKIM verification, and the receiving spam filter. It is also slow, dependent on a third party, and occasionally flaky — so keep these tests few, explicit and separate from the suite developers run before pushing. A nightly job that covers signup, verification resend and password reset is worth more than fifty end-to-end tests nobody trusts.
Never put real addresses in test data
Before any of that, fix your test data. Seed fixtures and factories should produce addresses at a domain that cannot receive mail — example.com, example.org and example.net are reserved by RFC 2606 for exactly this purpose. Never a colleague's personal address, and never an address copied out of production.
The failure this prevents is specific and it happens regularly: a staging database gets refreshed from a production dump so the data looks realistic, a scheduled job notices a few thousand accounts with no welcome email logged, and it does its job. The defence is not developer discipline, it is a gate in the transport layer. In any non-production environment, make your mail transport check the recipient against an allowlist of domains you own or control, and hard-fail anything else. Put that check in one place, below every caller, so no new feature can route around it.
Where throwaway inboxes fit
For the end-to-end layer you need a destination with three properties: it accepts mail immediately with no account setup, you can read it programmatically, and you do not care that its address ends up in a CI log that half the company can see. A disposable inbox fits all three.
The detail that catches people out is uniqueness per run. If every nightly run sends to the same address, yesterday's message can satisfy today's assertion and a completely broken send will look green for weeks. Generate a fresh address per run — a random token in the local part against a domain that accepts anything, so that signup-test-a1b2c3@… arrives without being created first. That is a catch-all address doing the work, and it is the single most useful trick in this whole workflow.
Once the message lands, assert on more than its existence:
- Arrival inside a budget. Poll with a timeout you have chosen deliberately — if verification mail routinely takes two minutes, that is a product problem your test should be allowed to fail on.
- Exactly one message. Duplicate sends are among the most common email bugs, usually from a retried job or a double-submitted form. A test that stops at "at least one" will never see them.
- The envelope and the From header. Display name, address, and reply-to. These drift silently when someone edits a config.
- The token in the link matches the one in the database. Not just that a link is present, but that it is the right link.
Read the headers, not just the body
The headers are where the delivery answers live, and most test suites throw them away. Three are worth asserting on by name.
Authentication-Results is the receiving server telling you what it concluded: whether SPF passed, whether the DKIM signature verified, and whether DMARC was satisfied. Check the d= value on the DKIM result, not only that it passed — DMARC requires that domain to align with the one in your From header, and a signature from your provider's own domain will verify happily while still failing alignment.
The Received chain carries timestamps at each hop, which gives you real queue latency instead of the time your own code took to hand the message off.
Return-Path decides where bounces go, and if nothing reads that mailbox your bounce handling does not exist.
If you are not fluent in them yet, our walkthrough on how to read email headers covers the structure, and you can paste a raw message into the email header analyzer to see the authentication results and the hop timings broken out while you work out which fields your assertions should target.
SPF, DKIM and DMARC on a staging domain
Give staging its own sending subdomain with its own SPF record and its own DKIM key, published the same way production's are. Two bad outcomes come from skipping this. Either staging sends from the production domain, so test traffic and real traffic share one reputation and one set of DMARC reports. Or staging sends unauthenticated, everyone gets used to seeing failures that "don't matter", and a genuine production misconfiguration hides in the noise.
Keep staging's DMARC policy at p=none with a reporting address, so you receive the aggregate reports without anything being quarantined while you experiment. And always verify a DNS change by sending a real message and reading the Authentication-Results it produces: a wrong selector or a truncated public key is invisible in a zone file and obvious the moment mail flows.
Test the click, not just the email
The verification link is a second system and it deserves its own tests. Open it in a clean browser profile with no cookies, because a logged-in session can mask a flow that is broken for the person it was built for. Confirm the token is single-use, and test expiry by controlling the clock rather than sleeping through it.
One failure mode here is worth designing around specifically. Mail passes through things that follow links before any human does: click-tracking wrappers, corporate link scanners, and prefetchers that fetch a URL to check it is safe. If your verification endpoint consumes the token on a plain GET, one of those visits can burn it, and the user lands on "this link has already been used" without having clicked anything. Make the GET idempotent and have the landing page confirm with a POST. A test that fetches the link twice and expects the flow to still complete is the cheapest way to keep that fixed once you have fixed it.
The short version
- Fixtures use reserved domains; the transport blocks every non-allowlisted recipient outside production.
- Assert on the rendered template in unit tests, on the real MIME message in a local SMTP sink, and on real delivery in a small nightly end-to-end job.
- Use a fresh disposable address per run so a stale message can never pass for a new one.
- Assert exactly one message, inside a time budget you chose.
- Check Authentication-Results including DKIM alignment, and watch the Received timestamps for queue latency.
- Staging gets its own sending subdomain, its own keys, and
p=nonewith reports. - Click the link from a cold browser, twice, and make sure a prefetch cannot burn the token.
If you want the automation side in more depth — polling an inbox from a test runner, structuring the assertions, and keeping the whole thing out of your pre-commit hook — we go further into that in disposable email for developers.