---
title: "How to Test Email Validation Without Live DNS"
description: "Build deterministic signup tests with injected checks, frozen verdict fixtures, and explicit timeout cases instead of depending on live DNS or production API keys in CI."
slug: "test-email-validation-without-live-dns"
date: 2026-10-06
updated: 2026-10-06
last_tested: 2026-10-06
summary: "Test your signup policy locally, audit the public fixture contract separately, and keep live DNS checks out of the assertions that decide whether a merge is safe."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Node.js test runner"
    url: "https://nodejs.org/api/test.html"
  - title: "Node.js assert — strict assertion mode"
    url: "https://nodejs.org/api/assert.html"
  - title: "nobounce.dev agent reference"
    url: "https://nobounce.dev/llms.txt"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy and fixture list"
    url: "https://nobounce.dev/v1/config"
---

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.

```js
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](https://nobounce.dev/blog/suggest-the-correction-not-rejection/) 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.

```js
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](https://nobounce.dev/blog/handle-email-validation-timeout/) 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](https://nobounce.dev/blog/null-mx-rfc-7505-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](https://nobounce.dev/integrate.md).
