Testing Email Validation: Unit Tests, Integration Tests, and Mock Strategies
Your Validation Code Works. Can You Prove It?
Every tutorial on email validation shows you how to call the API. Wire up the HTTP request, parse the response, handle the statuses. Ship it.
Nobody shows you how to test it.
So your validation logic lives behind one integration test that hits the live API, runs in 3 seconds, and breaks whenever the CI server can’t reach the external service. Or worse, you’ve got zero tests and a silent prayer that the regex you copied from Stack Overflow handles [email protected].
About 22% of email addresses in B2B databases are invalid according to ZeroBounce’s annual report. Your validation code is the gate. If the gate has a bug, bad data floods in and you won’t know until bounces spike. Testing that gate properly takes three layers: unit tests for the pure logic, integration tests for the API contract, and mocks that let you run both in CI without burning API credits.
Unit Testing the Pure Logic
Start with what doesn’t need a network call. Format validation, domain extraction, and response classification are all pure functions. Test them in isolation.
Regex and Format Checks
Your email validation API handles the heavy lifting, but you’ve probably got client-side checks too. A Zod schema, a Rails validator, a regex in a Django form. Those need their own tests.
// email-format.test.js (Vitest)
import { describe, it, expect } from "vitest";
import { isValidFormat } from "./email-format";
describe("isValidFormat", () => {
it("accepts standard addresses", () => {
expect(isValidFormat("[email protected]")).toBe(true);
});
it("accepts plus-addressing", () => {
expect(isValidFormat("[email protected]")).toBe(true);
});
it("accepts unicode local parts", () => {
expect(isValidFormat("用户@example.com")).toBe(true);
});
it("rejects missing @ sign", () => {
expect(isValidFormat("userexample.com")).toBe(false);
});
it("rejects empty local part", () => {
expect(isValidFormat("@example.com")).toBe(false);
});
it("handles 64-character local parts", () => {
const long = "a".repeat(64) + "@example.com";
expect(isValidFormat(long)).toBe(true);
});
it("rejects local parts over 64 characters", () => {
const tooLong = "a".repeat(65) + "@example.com";
expect(isValidFormat(tooLong)).toBe(false);
});
});
Notice the edge cases. Plus-addressing (user+tag@) is valid per RFC 5233 but plenty of homegrown regexes reject it. Unicode local parts are legal per RFC 6531. Punycode domains like xn--nxasmq6b.com are valid too. If your format check rejects any of these, real users get blocked.
Response Classification Logic
After the API returns a result, your app makes decisions. “Deliverable” gets saved. “Undeliverable” gets rejected. What about “risky”? What about catch-all domains?
# test_classifier.py (pytest)
from classifier import classify_email_result
def test_deliverable_accepted():
result = {"status": "deliverable", "disposable": False, "catch_all": False}
assert classify_email_result(result) == "accept"
def test_disposable_rejected():
result = {"status": "deliverable", "disposable": True, "catch_all": False}
assert classify_email_result(result) == "reject"
def test_catch_all_flagged():
result = {"status": "deliverable", "disposable": False, "catch_all": True}
assert classify_email_result(result) == "review"
def test_unknown_deferred():
result = {"status": "unknown", "disposable": False, "catch_all": False}
assert classify_email_result(result) == "defer"
def test_role_account_rejected_for_outreach():
result = {"status": "deliverable", "role_account": True, "disposable": False}
assert classify_email_result(result, context="outreach") == "reject"
These tests run in milliseconds. No network. No API key. No flakiness. They document every business rule your validation enforces. When someone changes the classification logic six months from now, these tests catch it before it ships.
Mocking the Validation API
Your integration with the validation API makes HTTP requests. Testing that integration without hitting the real API requires mocks. Two approaches work well depending on your stack.
MSW for Node.js and Browser Tests
Mock Service Worker (MSW) intercepts HTTP requests at the network level. Your code doesn’t know it’s talking to a mock. That’s the point. The mock lives outside your application code, so you’re testing the real HTTP client, real headers, real parsing.
// mocks/handlers.js
import { http, HttpResponse } from "msw";
export const handlers = [
http.post("https://api.mailcop.net/v1/verify", async ({ request }) => {
const { email } = await request.json();
if (email === "[email protected]") {
return HttpResponse.json({
email,
result: "deliverable",
is_disposable: false,
is_catch_all: false,
});
}
if (email.endsWith("@tempmail.ninja")) {
return HttpResponse.json({
email,
result: "deliverable",
is_disposable: true,
is_catch_all: false,
});
}
return HttpResponse.json({
email,
result: "undeliverable",
is_disposable: false,
is_catch_all: false,
});
}),
];
// mocks/server.js
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// setup-tests.js (Vitest global setup)
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
The onUnhandledRequest: "error" flag is critical. Without it, MSW’s default behavior is to print a warning but still pass the request through to the real API. In CI, that means you’re burning credits and introducing network-dependent flakiness. With the flag, unhandled requests throw immediately. You’ll know.
Mocking in Ruby with WebMock
For Rails applications, WebMock does the same job.
# spec/services/email_validator_spec.rb
require "rails_helper"
require "webmock/rspec"
RSpec.describe EmailValidator do
before { WebMock.disable_net_connect! }
it "returns deliverable for valid addresses" do
stub_request(:post, "https://api.mailcop.net/v1/verify")
.with(body: { email: "[email protected]" }.to_json)
.to_return(
status: 200,
body: { email: "[email protected]", result: "deliverable" }.to_json,
headers: { "Content-Type" => "application/json" }
)
result = described_class.validate("[email protected]")
expect(result.status).to eq("deliverable")
end
it "handles API timeouts gracefully" do
stub_request(:post, "https://api.mailcop.net/v1/verify")
.to_timeout
result = described_class.validate("[email protected]")
expect(result.status).to eq("unknown")
end
end
WebMock.disable_net_connect! blocks all outbound HTTP. If your code tries to reach an un-stubbed endpoint, the test fails immediately. Same principle as MSW’s onUnhandledRequest: "error".
Fixture Patterns That Scale
When you’ve got 15 test files all mocking the same API responses, copy-pasting response objects gets painful fast. Build a fixture factory instead.
// tests/fixtures/validation-responses.js
export function validResponse(overrides = {}) {
return {
email: "[email protected]",
result: "deliverable",
is_disposable: false,
is_catch_all: false,
is_role_account: false,
mx_found: true,
smtp_provider: "google",
validation_time_ms: 245,
...overrides,
};
}
export function disposableResponse(email = "[email protected]") {
return validResponse({ email, is_disposable: true });
}
export function catchAllResponse(email = "[email protected]") {
return validResponse({ email, is_catch_all: true, result: "risky" });
}
export function undeliverableResponse(email = "[email protected]") {
return {
...validResponse({ email }),
result: "undeliverable",
mx_found: false,
};
}
export function roleAccountResponse(email = "[email protected]") {
return validResponse({ email, is_role_account: true });
}
Now your tests read like specifications.
it("blocks disposable emails at signup", async () => {
server.use(
http.post("https://api.mailcop.net/v1/verify", () =>
HttpResponse.json(disposableResponse())
)
);
const result = await validateSignupEmail("[email protected]");
expect(result.allowed).toBe(false);
});
Five fixtures cover 90% of test scenarios: valid, invalid, disposable, catch-all, and role-based. Add more as your business logic grows. The overrides parameter means you never need a sixth fixture for “valid but with a slow response time.” Just pass validResponse({ validation_time_ms: 3200 }).
Integration Tests Against the Real API
Mocks prove your code handles responses correctly. Integration tests prove the real API returns what you expect. You need both.
Sandbox/Test Mode
Run integration tests against the actual email validation API using a test API key with rate-limited access. Keep these tests in a separate suite that only runs on-demand or in nightly CI, not on every push.
// tests/integration/validation-api.test.js
import { describe, it, expect } from "vitest";
const API_KEY = process.env.TRUEMAIL_TEST_API_KEY;
describe.skipIf(!API_KEY)("MailCop API integration", () => {
it("validates a known-good address", async () => {
const res = await fetch("https://api.mailcop.net/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "[email protected]" }),
});
const data = await res.json();
expect(data.result).toBe("deliverable");
expect(data).toHaveProperty("is_disposable");
expect(data).toHaveProperty("is_catch_all");
});
it("rejects a nonexistent domain", async () => {
const res = await fetch("https://api.mailcop.net/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "[email protected]" }),
});
const data = await res.json();
expect(data.result).toBe("undeliverable");
});
});
The describe.skipIf(!API_KEY) pattern means local developers without the API key skip these tests automatically. CI gets the key from a secret, runs the full suite. No one has to remember to set environment variables before pushing.
Property-Based Testing with fast-check
Hard-coded test cases cover known scenarios. But what about the weird addresses you haven’t thought of? Property-based testing generates random inputs and verifies invariants hold across all of them.
// tests/property/email-format.test.js
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { isValidFormat } from "./email-format";
describe("email format properties", () => {
it("never crashes on arbitrary strings", () => {
fc.assert(
fc.property(fc.string(), (input) => {
const result = isValidFormat(input);
expect(typeof result).toBe("boolean");
})
);
});
it("accepts all well-formed emails", () => {
const emailArb = fc
.tuple(
fc.stringMatching(/^[a-zA-Z0-9._%+-]{1,64}$/),
fc.stringMatching(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
)
.map(([local, domain]) => `${local}@${domain}`);
fc.assert(
fc.property(emailArb, (email) => {
expect(isValidFormat(email)).toBe(true);
})
);
});
it("rejects strings without @ symbol", () => {
const noAtArb = fc.string().filter((s) => !s.includes("@"));
fc.assert(
fc.property(noAtArb, (input) => {
expect(isValidFormat(input)).toBe(false);
})
);
});
});
fast-check ran 100 random strings through your format checker in the first test. If any input causes a crash, an exception, or a non-boolean return, the test fails and fast-check shrinks the input to the smallest reproducing case. That’s how you find the null and undefined edge cases that hard-coded tests miss.
CI Pipeline Integration
Your test suite runs locally. Great. Now make it run on every push without leaking API keys or hitting rate limits.
Environment Variable Strategy
Split your test suites by what they need.
Unit tests and mock-based tests need nothing. They run everywhere, every time. Integration tests need TRUEMAIL_TEST_API_KEY. Set it as a CI secret and only run those tests on the main branch or nightly.
# .github/workflows/test.yml
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:unit
integration:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
env:
TRUEMAIL_TEST_API_KEY: ${{ secrets.TRUEMAIL_TEST_API_KEY }}
Unit tests block the PR. Integration tests run post-merge. That way a flaky network connection or a rate limit on the test key never blocks a developer from shipping code.
Test Scripts in package.json
{
"scripts": {
"test:unit": "vitest run tests/unit tests/property",
"test:integration": "vitest run tests/integration",
"test": "vitest run"
}
}
Keep the split explicit. When someone joins the team, they run npm run test:unit and everything passes without any setup. Integration tests are opt-in.
Edge Cases Worth a Dedicated Test
Every Node.js email validation setup hits the same edge cases eventually. Write the tests before they bite you.
Unicode emails (用户@example.com) trip up regex patterns that only expect ASCII. Plus-addressing ([email protected]) gets rejected by overly strict format checks. Very long local parts (64 characters is the RFC 5321 limit, but 65 should fail) break assumptions about string length.
Punycode domains (xn--nxasmq6b.com) are the internationalized version of non-ASCII domain names. Your validation should resolve them, not reject them.
What about the empty string? null? undefined? A number where a string should be? These aren’t email edge cases. They’re programming edge cases. But they’ll reach your validation function if you don’t guard the entry point. Test them.
And test your error paths. What happens when the validation API returns a 500? A 429 with a rate limit header? A 200 with malformed JSON? A 200 with an unexpected status value? Each of those is a distinct code path that needs coverage.
Structuring Your Test Directory
After building validation for a few projects, this layout works consistently.
tests/
unit/
email-format.test.js
classifier.test.js
response-parser.test.js
integration/
validation-api.test.js
property/
email-format.test.js
mocks/
handlers.js
server.js
fixtures/
validation-responses.js
Unit tests in unit/. Integration tests in integration/. Property-based tests in property/. Mocks and fixtures get their own directories because they’re shared across test files.
The email validation microservice post uses this same structure. It scales from five tests to five hundred without reorganization.
The Testing Pyramid for Validation Code
Most of your tests should be unit tests. Fast, deterministic, no network. They cover format checking, response parsing, and classification logic. Aim for 70% of your test count here.
Mock-based tests come next. They verify your HTTP client code, header handling, timeout behavior, and error recovery. Maybe 25% of your tests. Still fast (MSW adds ~50ms of overhead), still deterministic.
Integration tests sit at the top. Two or three tests that prove the real API contract hasn’t changed. They run slowly, they need credentials, and they can flake. Keep them small and run them separately.
Does this ratio feel familiar? It’s the same testing pyramid every engineering team talks about. The difference with validation code is that the temptation to skip mocks and test everything against the live API is strong. Resist it. A test suite that takes 45 seconds and never flakes beats one that takes 12 seconds but fails every third run because the API was slow.
Build the Zod tRPC validation layer with type safety, test it with mocks, and verify the contract with a handful of integration tests. That’s the whole strategy.