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.