Test email-validation handling with local verdict stubs, not live DNS. Inject the check function into your signup policy, exercise every action offline, and audit the public response contract separately. A resolver outage should not decide whether your merge passes, and a production key should not be necessary to prove that a typo suggestion renders correctly.
The distinction is simple: your tests must prove what your application does with an answer. They do not need to prove that Gmail's DNS records are unchanged today.
Separate three kinds of test
| Layer | Dependency | What it proves |
|---|---|---|
| Policy unit test | Local objects | Suggest, reject, accept, and unchecked branches |
| Form or handler test | Stubbed application check | The account and UI follow that policy |
| Contract audit | Public fixtures and schema | Your client still understands the documented response |
Keep the first two in ordinary CI. Run the contract audit as a separate, bounded integration task. An unavailable audit is an infrastructure failure, not evidence that the submitted address is bad.
Do not treat /demo/check as your general-purpose test validator. It needs no key, but it resolves only the frozen corpus listed by /v1/config. It cannot validate arbitrary real addresses, including whatever randomly generated address your test factory produces.
Put the policy behind an injected check
Here is a small server-side module, email-policy.mjs. Its check argument is your production HTTP helper in the application and a local function in tests. The helper's contract is a validated verdict object or null when no usable answer arrives.
export async function emailAction(email, check) {
let result;
try {
result = await check(email);
} catch {
result = null;
}
if (result === null) {
return { action: "accept", verdict: "unchecked", mxChecked: false };
}
if (result.suggestion !== null) {
return { action: "suggest", suggestion: result.suggestion };
}
if (result.verdict === "undeliverable") {
return { action: "reject", reason: result.reason };
}
return {
action: "accept",
verdict: result.verdict,
reason: result.reason,
mxChecked: result.checked.mx,
};
}
Suggestion comes before rejection. It also comes before accepting risky or unknown: both can carry corrections. Suggest the Correction Instead of Rejecting the Signup explains why this is the valuable branch.
unchecked is your application's marker, not a fifth API verdict. The four public verdicts remain deliverable, undeliverable, risky, and unknown.
Make the important cases executable
Save this as email-policy.test.mjs alongside the module. These objects are deliberately local policy inputs, not claims about current DNS. The complete response fields match the published verdict shape.
import test from "node:test";
import assert from "node:assert/strict";
import { emailAction } from "./email-policy.mjs";
function verdict(value, reason, suggestion = null, mx = true) {
return {
verdict: value, reason, suggestion, confidence: 0.9,
checked: { syntax: true, mx, typo: true, disposable: true },
cached: false,
};
}
const cases = [
[verdict("deliverable", "ok"), "accept"],
[verdict("risky", "role_account"), "accept"],
[verdict("risky", "disposable"), "accept"],
[verdict("unknown", "dns_error", null, false), "accept"],
[verdict("undeliverable", "syntax_invalid", null, false), "reject"],
[verdict("undeliverable", "typosquat_mx", "user@gmail.com"), "suggest"],
[verdict("risky", "likely_typo", "user@yahoo.com"), "suggest"],
[verdict("unknown", "dns_error", "user@gmail.com", false), "suggest"],
];
for (const [result, expected] of cases) {
test(`${result.verdict}/${result.reason}/${expected}`, async () => {
const action = await emailAction("user@gmail.com", async () => result);
assert.equal(action.action, expected);
if (expected === "suggest") {
assert.equal(action.suggestion, result.suggestion);
}
if (expected === "accept") {
assert.equal(action.mxChecked, result.checked.mx);
}
});
}
test("unavailable checks do not reject signup", async () => {
for (const check of [
async () => null,
async () => { throw new Error("synthetic timeout"); },
]) {
assert.deepEqual(await emailAction("user@gmail.com", check), {
action: "accept", verdict: "unchecked", mxChecked: false,
});
}
});
Run node --test email-policy.test.mjs. No network, account, or key is involved. The synthetic confidence value is intentionally irrelevant: this policy offers suggestions rather than using confidence as a delivery probability.
Add a handler test that asserts account creation still happens after an unavailable check. Add a browser test that accepting a suggestion changes the field only after the user's click. An action-object test alone cannot prove either side effect.
Test the HTTP boundary separately
Stub your HTTP client's transport to return a non-2xx response, malformed JSON, an incomplete verdict, and a rejected request. Your production helper should turn each into null before invoking the policy. Do not pass {} through and accidentally accept it because verdict is missing.
Also test the deadline mechanism itself with a controlled stalled transport. The thrown-error test above proves policy handling, not that an actual request stops waiting. How to Handle an Email Validation Timeout at Signup covers that separate boundary.
For a public contract audit, fetch /openapi.json and /v1/config, then exercise named demo fixtures. On October 6, 2026, the frozen user@yahooo.com fixture returned risky/likely_typo with user@yahoo.com; user@gmial.com returned unknown/dns_error, checked.mx: false, and still suggested user@gmail.com. These cases catch clients that incorrectly restrict suggestions to undeliverable.
Do not assert a fixed cached value in your production smoke check. Cache warmth is operational state, not a signup action.
Keep reserved fixtures and secrets in their lanes
Reserved names such as qa@example.com produce reserved_domain, not an ordinary mailbox failure. Use local stubs for successful test signups rather than weakening production rejection rules to accommodate your factory. Null MX and Reserved Domains explains that trap.
A live smoke test, if you choose to run one, uses /v1/check with the key in an Authorization header. There is no free tier: hobby is $1/mo for 1,000 checks/mo, pro $19/mo for 100,000, and scale $99/mo for 1,500,000. Keys come from operator-minted access codes or paid self-serve Stripe card and usevig stablecoin Checkout; both rails are live.
Keep those secrets out of ordinary pull-request tests. Log actions and reasons, not request bodies or suggestions. nobounce persists no plaintext address; anything persisted is SHA-256 hashed, with domains in clear. That does not protect your own test traces automatically.
For the production helper, key handling, and complete signup mapping, give your coding agent /integrate.md.