Learn how to build email validation test fixtures that cover typos, dead domains, catch-all inboxes, and disposable emails, and automate them in CI.

Most email validation test suites look solid and still fail in production because real addresses are messy. People paste whitespace, use plus tags, mix uppercase, add trailing dots, or type fast on mobile and miss a character. If your fixtures only cover clean, textbook emails, you end up testing a world your users do not live in.
Another common issue is testing only the easiest layer: syntax. Syntax checks catch obvious mistakes, but many bad signups use valid-looking addresses on domains that do not exist, domains with no mail setup, or disposable providers that change constantly. A test suite that stops at a regex creates a false sense of safety.
The goal is not to block everything suspicious. The goal is to stop bad signups without blocking real people. That means testing both sides: you should reject what truly needs rejecting, and accept common, legitimate patterns (like plus addressing and subdomains).
These failure patterns show up again and again:
Good tests cover layers that match how validation works in real signups: syntax (RFC-style rules), domain signals (domain exists, MX records), mailbox signals (catch-all behavior or acceptance patterns), and risk signals (blocklists and disposable detection).
Set expectations early: some outcomes are probabilistic. Catch-all domains can accept any address without proving a mailbox exists. Disposable lists evolve daily. Even enterprise-grade tools like Verimail can return risky signals that are best handled with product rules (allow, block, or require extra verification), instead of pretending every case has a perfect answer.
Email validation feels like one check, but it is really a chain of smaller checks. Split that chain into layers and your tests get clearer and easier to maintain. It also helps you build fixtures that match real user signups instead of random strings.
A practical layer model looks like this:
Not every layer should be treated the same in tests. Some layers are pure logic and can run fast and offline (syntax, most risk rules). Others depend on the network (DNS, MX lookup, real-time blocklists). Treat those differently so your test suite stays stable.
Next, decide what your app does with each layer result. Many teams only use pass or fail, then end up with confusing edge cases. A graded outcome is usually easier to work with:
A practical example: a user enters [email protected]. Syntax passes, but your typo rules mark it as risky. You might allow signup but ask them to confirm the address. If they enter [email protected], syntax passes but domain or MX fails, so you block.
When you write tests, map each fixture to the layer that should catch it and to the expected outcome. For network layers, prefer boundary-style tests (for example, how you handle a DNS failure vs a true no-MX result) and keep the rest as fast unit tests. If you use an API like Verimail, you can mirror its multi-stage results (syntax, domain, MX, disposable checks) in your own layer expectations.
Good email validation test fixtures look like what real users type, not like random strings. If you build fixtures around a few clear scenario categories, tests stay readable and gaps are obvious.
Keep these core categories in every suite:
@, double @@, leading/trailing spaces, double dots, a dot right before @, and swapped characters in common domains. These should fail fast at the syntax layer before any network checks.admin@, support@, info@ and suspicious patterns like long numeric strings. Test your policy decision (allow, warn, or block), rather than assuming they are always bad.To keep categories useful, tie each fixture to an expected decision, not just valid/invalid. Labels like syntax_invalid, domain_invalid, disposable_block, risky_allow_with_warning, unknown_catch_all make failures easier to interpret.
If you use a validator API such as Verimail, map these categories to the pipeline stages you expect to trigger (syntax, domain checks, MX lookup, disposable blocklist match). That way a failing test tells you what kind of failure it was, not just that something broke.
Good fixtures feel like real signups, not keyboard mash. When a test fails, you should immediately understand what happened and why it matters.
Treat fixtures as small records with a consistent shape: the input email, the expected outcome (accept, reject, or require extra checks), and a short reason. Add a notes field for anything that future you will forget (why the domain is used, or what behavior you want if the provider changes).
Here’s a compact example you can copy into your repo:
[
{
"name": "typo_gmail_missing_dot",
"input": "alex@gmailcom",
"expected": "reject",
"reason": "typo",
"tags": ["typo"],
"notes": "Missing dot in domain; should fail domain validation."
},
{
"name": "disposable_known_provider",
"input": "[email protected]",
"expected": "reject",
"reason": "disposable",
"tags": ["disposable"],
"notes": "Should be blocked by disposable provider list."
}
]
Tags make the suite easier to grow. Instead of one giant file, keep small named sets per category (typos, dead domains, catch-all, disposable, edge cases). That way you can run only what you need, like just disposable cases when you update classification rules.
Normalization rules belong in fixtures too, because real users paste messy data. Include cases that prove you handle:
Version fixtures like code. Every new case should answer one question: what bug did this prevent? Add a short note, require review on changes, and remove duplicates. Over time, your fixtures become a living map of the mistakes and attacks your signup actually sees.
If you rely on an API response (for example, a service like Verimail), store a pinned expected result per fixture and update it only when you intentionally change policy.
Email checks touch things that change without warning: DNS records, mail server settings, and the disposable-provider ecosystem. If your fixtures depend on the live internet, tests will fail on a random Tuesday for reasons that have nothing to do with your code.
Start by separating what you can make deterministic from what you cannot. Syntax cases are easy: they should never require a network call. The tricky part is anything that involves domain or mailbox reachability.
A stable fixture set usually comes from a few sources:
Avoid using real people’s addresses in fixtures, even if they are public. Use synthetic local parts (like "user" or "test") and clearly fake names. This reduces privacy risk and prevents accidental email sends if test data leaks into logs or downstream systems.
For dead-domain scenarios, the most stable approach is to treat the DNS failure as test data, not as live reality. Record or mock the resolver result (for example, NXDOMAIN or no MX records) and assert your logic handles it correctly. Live dead domains are unstable because domains can be purchased and configured later.
Disposable detection needs special care. Providers appear, disappear, and change domains often. Keep a versioned snapshot of your disposable list (or your validation vendor’s classification responses) and decide how drift is handled. For example: unit tests run against the snapshot, while a separate scheduled job checks for changes and opens a review task.
Write down which fixtures are time-sensitive and what you will do when they change: update the snapshot, loosen the assertion, or move the case into a contract test. If you use Verimail in production, treat its real-time classification as something you test with pinned recordings, then verify periodically with a small, controlled live suite.
Fast, reliable tests start with the parts of validation that do not touch the network. Treat your validator as a set of pure functions (input in, output out), then lock down the contract for what the rest of your app can expect.
Unit tests should cover syntax rules and normalization, because these are easy to break with small refactors. Examples include trimming spaces, lowercasing the domain, rejecting double @ signs, and handling obvious typos like missing dots in the domain.
When you build fixtures, make each row explain itself: input, expected normalized value (or empty), and a short reason code. Reason codes are easier to assert than full sentences.
A simple pattern is table-driven tests: loop through a fixture table and assert both status and reason for each case. This keeps the test file short while still covering many scenarios.
Contract tests answer a different question: does your validation module always return the same structure? If one team expects { status, reason, normalized } and another release silently changes it, you get bugs that look random.
Contract tests should verify things like:
status, reason, normalized_email)valid, invalid, risky)Property-based tests can help here too. Instead of hand-writing every typo, generate near-misses: extra spaces, swapped characters around the @, repeated dots, or mixed-case domains. The goal is to catch parser bugs and edge cases you did not think to include.
Snapshot testing can help for UI-facing messages, but use it carefully. Prefer snapshotting stable error codes, not full text. If you must lock down messages, keep them short and consistent so small copy edits do not break half your test suite.
Integration tests are where email validation logic often turns unreliable. The moment a test depends on real DNS, MX lookups, or a third-party service being reachable, you can get random failures that have nothing to do with your code.
For everyday CI runs, aim for tests that are fast and repeatable. Treat the network as an input you control.
A practical approach is to mock the network boundary and focus on what your app does with each result. If your signup service calls an email validation API, replace that call with a stub that returns known responses for your fixtures. You still test the full flow (controller, service, policy decision, error message), but you do not test the internet.
Patterns that tend to work well:
Timeouts deserve special attention. Decide your policy and test it explicitly: when validation times out, do you treat the email as invalid, or as unknown and let the user proceed with extra verification? Either can be correct, but only if it is consistent.
Mocked tests can drift from reality. A domain that used to have MX records can go dead. A disposable classification can change. To catch drift, keep a separate job that does real network calls, but do not run it on every pull request.
A typical setup is a nightly run or a manual live checks workflow. Keep this lane small: a handful of representative emails per category (typos, dead domains, catch-all, disposable) is enough to detect drift without creating noise.
If you use Verimail in production, your CI tests can stub its response to disposable: true for a fixture like [email protected], and you assert your UI blocks signup and shows the right message. Then your nightly job can hit the real API with a controlled set of addresses and alert you if outcomes change, so you can update fixtures or adjust policy before users notice.
A test suite can look busy and still miss the failures that hurt you in production. The biggest risk is false confidence: tests pass, but real users get blocked, or fake signups slip through.
One common trap is treating catch-all domains as proof that the mailbox exists. Catch-all only means the domain accepts mail for any address. If your logic auto-approves everything on a catch-all, your fixtures quietly train you to accept junk addresses.
Another trap is using only a regex and calling it validation. Regex can catch obvious formatting problems, but it cannot tell you whether the domain exists, whether it has MX records, or whether the address is from a disposable provider. If your tests only cover string patterns, you are testing your regex, not email behavior.
Hard-failing signups on temporary DNS issues is also a frequent mistake. Real networks have timeouts and intermittent failures. If your tests only cover DNS works and DNS fails, you may end up rejecting good users during a brief outage. A better approach is to treat some errors as unknown and retry, or allow the signup but flag the address for later verification.
Internationalized domains and modern mailbox rules are easy to forget. Plus-addressing (like [email protected]) is valid and widely used. Some users also have non-ASCII domains. If your fixtures never include these, you will ship a validator that breaks on normal input.
A few ways teams fool themselves:
+ is invalid or stripping it incorrectly.A practical safeguard is to separate results into clear buckets (invalid, risky, unknown, valid) and test the transitions between them. Tools like Verimail help by returning structured signals (syntax, domain, MX, disposable), which makes it easier to assert behavior without guessing from a single pass/fail.
Before you merge, do a quick pass for coverage and confidence. The goal is not to test every email on earth, but to make sure fixtures behave like real signups and fail in predictable ways.
Scan your fixture set by category. If a category only has one or two examples, a tiny code change can break real users and your tests can still stay green. Aim for a small cluster of cases per category so you catch edge cases (like a typo that still looks valid).
Use this short checklist:
Do not ignore stability. Tests that depend on real domains can rot over time, and catch-all behavior can change without warning. For unit tests, prefer fixtures that do not require the network and keep domain behavior as mocked responses.
For integration tests, limit scope and make them intentional. A simple pattern is a small nightly job that exercises a few known cases against your validation service (or provider), while CI stays focused on deterministic unit tests.
A common SaaS problem: your signup form looks fine in manual testing, but once you launch you start seeing waves of spam signups. Many of them use disposable email providers, which means bad leads, higher bounce rates, and a messier user database.
A practical approach is to define clear behavior and lock it in with fixtures. For example: block disposable addresses, reject dead domains, and allow (but warn on) catch-all domains.
Here is a small fixture set that covers realistic signup inputs without relying on random strings:
[email protected] (looks real, but the domain is misspelled)[email protected] (use .invalid to represent a domain that should never resolve)[email protected] (treat this as catch-all in mocks, not as a real DNS fact)[email protected] (treat this as disposable in mocks, not as a real provider)[email protected] (a normal-looking address for a happy path)The key is that your app should not try to prove catch-all or disposable status using the public internet during unit tests. Instead, your validation layer should return a normalized result that your signup logic can act on.
A simple rule set for the signup endpoint might be:
is_disposable = true: block signup with a clear errordomain_status = dead: reject and ask for a different emailis_catch_all = true: allow signup, but show a warning (and consider extra verification)To keep CI fast and predictable, split tests into two speeds: fast unit tests for your decision logic, and mocked integration tests for the validator boundary.
// Example: decision logic unit tests (no network)
const cases = [
{ email: "[email protected]", result: { is_disposable: true }, expect: "BLOCK" },
{ email: "[email protected]", result: { domain_status: "dead" }, expect: "REJECT" },
{ email: "[email protected]", result: { is_catch_all: true }, expect: "WARN" },
{ email: "[email protected]", result: { suggestion: "gmail.com" }, expect: "REJECT" },
{ email: "[email protected]", result: { ok: true }, expect: "ALLOW" },
];
for (const c of cases) {
const decision = decideSignup(c.email, c.result);
expect(decision).toBe(c.expect);
}
In CI, the mocked integration test can verify that your signup code calls the validator once and handles timeouts and error responses, without actually doing DNS or MX lookups.
If you want real checks (RFC-compliant syntax, domain verification, MX lookup, and disposable/blocklist matching) without building and maintaining that logic yourself, an email validation API can be a good fit. For teams that already use Verimail, a simple pattern is to keep most tests mocked and deterministic, then run a small set of contract checks to make sure your integration with verimail.co still matches the response structure your app expects.
Start by normalizing input and testing the messy stuff users actually submit: leading/trailing spaces, uppercase domains, trailing dots, and plus tags. Then add fixtures that cover domain existence, MX presence, catch-all behavior, and disposable detection so your tests reflect real signup risks.
A regex only tells you the string looks like an email. It can’t confirm the domain exists, whether it can receive mail (MX records), or whether it’s a disposable provider, so you end up approving addresses that will bounce or attract spam signups.
Split validation into layers you can test separately: syntax and normalization (offline), domain and MX signals (network boundary), provider type (disposable vs normal), and your business risk rules. This makes failures easier to diagnose because each fixture is meant to be caught by one specific layer.
Use graded results like valid, risky, invalid, and unknown instead of a single pass/fail. Catch-all domains and timeouts are the big reasons: they often can’t be proven deliverable, so a clear “unknown or risky” outcome lets your product decide whether to allow, warn, or require verification.
Include typos and formatting mistakes, dead domains (NXDOMAIN or no MX), catch-all cases, disposable providers and lookalikes, and role-based or suspicious patterns like admin@ or long numeric strings. Keep each fixture tied to an expected decision so you’re testing behavior, not just string parsing.
Treat them as structured records: input email, expected outcome, reason code, and notes. Add tags like typo, dead_domain, disposable, or catch_all so you can run only the relevant subset when you change a rule.
Make unit tests fully offline by mocking DNS/MX/API results, and record or replay responses for contract tests. Live internet dependencies change over time, so if you rely on them in CI you’ll get random failures unrelated to your code.
Mock the validator boundary so the signup flow is tested end-to-end with fixed responses, including timeouts and error cases. Then keep a small separate live-check job (nightly or manual) that hits real DNS or a real API to detect drift without breaking every pull request.
Treat it as a signal, not proof, and assert an ambiguous result like “domain OK, mailbox unknown” instead of auto-approving. In product logic, a good default is to allow signup but require confirmation or add limits, and your tests should lock in that policy.
Pin expected structured results per fixture and update them only when you intentionally change policy. If you use Verimail, you can map fixtures to its pipeline signals (syntax, domain, MX, disposable/blocklist) and keep CI deterministic by stubbing responses, while periodically running a small live suite to spot classification changes.