Mocking ValidityState in jsdom

Code that turns native validity into custom messages — “if validity.typeMismatch, say ‘Enter an email address like name@example.com’” — is some of the most common validation code on the web, and one of the awkward bits to unit test. jsdom implements much of the Constraint Validation API, so many flags can be produced for real by setting attributes and values. A few cannot: badInput needs a browser’s parser to reject what the user typed, and tooShort and tooLong only apply after a real user edit. This guide shows how to get real validity from jsdom wherever possible, how to fake the flags it cannot produce without lying to the rest of your code, and where a mock gives false confidence. It is part of the strategy in unit testing validation logic.

The failure this guide prevents: tests that mock validity wholesale, pass, and never notice that the real element would have reported a different flag — or none.

Validity flags in jsdom A table listing each ValidityState flag and whether jsdom computes it from attributes and values, computes it only partly, or needs a mock. Real in jsdom Needs a mock valueMissing ✓ Yes ✗ No typeMismatch ✓ Yes ✗ No patternMismatch ✓ Yes ✗ No rangeUnderflow / rangeOverflow ✓ Yes ✗ No stepMismatch ✓ Yes ✗ No customError ✓ Yes ✗ No tooShort / tooLong user edit only often badInput ✗ No ✓ Yes
Most flags can be produced for real in jsdom by setting attributes and values; only a few need to be faked.

Prerequisites

Requirement Minimum version Notes
Vitest 2.x+ environment: "jsdom" per file or globally
jsdom 24+ Implements ValidityState and setCustomValidity
TypeScript 5.0+ Typed fakes via Partial<ValidityState>
Code under test A function that maps validity to a message

The Code Under Test

// messages.ts
export function messageFor(input: HTMLInputElement): string {
  const v = input.validity;
  const label = input.labels?.[0]?.textContent?.trim() ?? input.name;
  if (v.valid) return "";
  if (v.valueMissing) return `Enter your ${label.toLowerCase()}.`;
  if (v.badInput) return `Enter a number for ${label.toLowerCase()}.`;
  if (v.typeMismatch && input.type === "email") return "Enter an email address like name@example.com.";
  if (v.tooShort) return `Use at least ${input.minLength} characters.`;
  if (v.rangeUnderflow) return `Enter ${input.min} or more.`;
  if (v.rangeOverflow) return `Enter ${input.max} or less.`;
  if (v.customError) return input.validationMessage;
  return input.validationMessage;
}

The order of the checks matters, and it is the thing tests most need to pin down: badInput before valueMissing would tell someone who typed “1e” that the field is empty.

Step 1: Produce Real Validity Wherever You Can

// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from "vitest";
import { messageFor } from "../messages";

let input: HTMLInputElement;
beforeEach(() => {
  document.body.innerHTML = `<label for="q">Quantity</label><input id="q" name="quantity" type="number" min="1" max="20" required>`;
  input = document.querySelector("input")!;
});

describe("messageFor with real validity", () => {
  it.each([
    ["", "Enter your quantity."],
    ["0", "Enter 1 or more."],
    ["21", "Enter 20 or less."],
    ["5", ""],
  ])("value %j → %s", (value, expected) => {
    input.value = value;
    expect(messageFor(input)).toBe(expected);
  });
});

No mock is involved: jsdom computes valueMissing, rangeUnderflow and rangeOverflow from the attributes and value, exactly as a browser would. These tests stay honest because they exercise the element and the mapping together. Reach for a mock only when the flag cannot be produced this way.

Step 2: Fake Only the Flags jsdom Cannot Produce

badInput is set in browsers when the user types something the input’s parser rejects, such as 1e or -- in a number field. The browser then exposes value as "". jsdom has no user-facing parser, so it cannot produce this state. Fake it by overriding the validity getter on the one element under test, starting from the element’s real validity so the other flags stay truthful:

function withValidity(el: HTMLInputElement, overrides: Partial<ValidityState>): void {
  const real = el.validity;
  const flags = {
    valueMissing: real.valueMissing, typeMismatch: real.typeMismatch, patternMismatch: real.patternMismatch,
    tooLong: real.tooLong, tooShort: real.tooShort, rangeUnderflow: real.rangeUnderflow,
    rangeOverflow: real.rangeOverflow, stepMismatch: real.stepMismatch, badInput: real.badInput,
    customError: real.customError, ...overrides,
  };
  const valid = !Object.entries(flags).some(([, on]) => on);
  Object.defineProperty(el, "validity", { configurable: true, get: () => ({ ...flags, valid }) });
}

it("reports badInput before valueMissing", () => {
  input.value = "";                          // what a browser exposes for "1e"
  withValidity(input, { badInput: true, valueMissing: false });
  expect(messageFor(input)).toBe("Enter a number for quantity.");
});

Two details make this fake trustworthy. It derives valid from the flags, so the fake can never be in an impossible state such as valid: true with badInput: true. And it copies the real flags first, so the test changes only what it claims to change. Note the valueMissing: false override: in a real browser a required field with bad input reports badInput and not valueMissing, even though value is empty.

What the fake replaces and what it does not The test overrides the validity getter on one element; the message function reads the fake flags, but checkValidity still uses jsdom's internal state, which the fake does not touch. Test Element messageFor checkValidity define validity getter read validity fake flags internal state ignores the fake
A validity mock only affects code that reads input.validity; checkValidity and form submission still see jsdom's real state.

Step 3: Know What the Mock Does Not Change

Overriding validity affects only code that reads input.validity. jsdom’s checkValidity(), reportValidity(), form.checkValidity() and the :invalid matching all use the element’s internal state, not your getter. If code under test calls form.checkValidity() to decide whether to submit, a mocked badInput will not stop it. Either make that code path read through a function you can inject, or make the element genuinely invalid with setCustomValidity in the same test:

it("blocks submission on bad input", () => {
  withValidity(input, { badInput: true, valueMissing: false });
  input.setCustomValidity("Enter a number for quantity.");  // makes jsdom's own state invalid
  expect(input.form?.checkValidity() ?? input.checkValidity()).toBe(false);
});

This mismatch is the main way validity mocks mislead: the message logic is tested against a fake, and the submission logic silently against reality.

Step 4: tooShort and tooLong

In browsers, tooShort and tooLong apply only when the value was last changed by a user edit, so setting input.value from script never triggers them — a rule covered in minlength and maxlength character limits. Do not rely on jsdom’s result for programmatic values; it is not a faithful stand-in for typing. For the message mapping, fake the flag:

it("explains the minimum length", () => {
  document.body.innerHTML = `<label for="p">Password</label><input id="p" name="password" minlength="12">`;
  const pw = document.querySelector("input")!;
  pw.value = "short";
  withValidity(pw, { tooShort: true });
  expect(messageFor(pw)).toBe("Use at least 12 characters.");
});

Because browsers will not enforce the length for programmatic or autofilled values, the rule itself should also exist as a custom check; test that check as a pure function and cover real typing in a browser test.

Step 5: customError Comes for Free

setCustomValidity is fully implemented in jsdom: it sets customError, makes valid false and fills validationMessage. There is no reason to mock it.

it("passes custom messages through", () => {
  input.value = "5";
  input.setCustomValidity("Only 3 left in stock.");
  expect(input.validity.customError).toBe(true);
  expect(messageFor(input)).toBe("Only 3 left in stock.");
  input.setCustomValidity("");
  expect(messageFor(input)).toBe("");
});

Common Mistakes

Replacing validity with a bare object.

// Before: an impossible state, and every other flag is undefined
Object.defineProperty(input, "validity", { get: () => ({ badInput: true }) });
// After: real flags, one override, derived valid
withValidity(input, { badInput: true, valueMissing: false });

With the bare object, validity.valid is undefined, so if (v.valid) return "" falls through and the test passes for the wrong reason.

Mocking on the prototype. Object.defineProperty(HTMLInputElement.prototype, "validity", …) leaks into every other test in the file. Override on the instance, which is discarded with the DOM in beforeEach.

Asserting validationMessage text from jsdom. jsdom’s default messages differ from every browser’s. Test your own messages, never the built-in text.

Testing :user-invalid styling. jsdom does not implement it. Cover styling in a real browser.

Forgetting to restore after a spy. If you use vi.spyOn(input, "validity", "get") instead of defineProperty, call vi.restoreAllMocks() in afterEach. Instance overrides disappear with the element, but spies on shared objects do not, and a leftover spy makes later tests pass or fail depending on the order they run in.

Real validity versus a faked flag Two columns comparing tests that use jsdom's real validity with tests that fake a flag, listing when each is appropriate. Real validity ✓ set attributes and value ✓ required, type, pattern, min, max, step ✓ setCustomValidity ✓ checkValidity agrees with the test Faked flag • badInput, tooShort, tooLong • override one element only • derive valid from the flags ✗ checkValidity ignores the fake
Default to real validity; fake a flag only when no attribute or value can produce it in jsdom.

happy-dom and Browser Mode

happy-dom implements a smaller part of the Constraint Validation API than jsdom, so more flags may need faking there; check the specific flags your code reads before switching environments. Vitest’s browser mode runs tests in a real Chromium, Firefox or WebKit through Playwright, which removes the need for most mocks — but even there badInput requires simulated typing rather than setting value. A practical split is jsdom for the message mapping with a handful of faked flags, and browser-mode or Playwright tests for the typing-driven flags and focus behaviour.

A Checklist for Validity Tests

Before merging a test file that touches native validity, check that each faked flag is one jsdom genuinely cannot produce, that every fake starts from the element’s real flags and derives valid, that fakes are applied to instances and not prototypes, that no assertion depends on jsdom’s built-in message text, and that any code path which calls checkValidity() or submits the form is tested against real invalidity rather than a fake. These five checks catch nearly all of the misleading validity tests seen in practice, and they take a minute to apply in review.

Frequently Asked Questions

Does jsdom support the Constraint Validation API?

Largely. It computes valueMissing, typeMismatch, patternMismatch, range and step flags from attributes and values, and fully supports setCustomValidity. It cannot produce badInput, and tooShort and tooLong depend on real user edits.

How do I mock ValidityState in a test?

Override the validity getter on the one element with Object.defineProperty, copy the real flags, apply your override, and derive valid from the result so the fake cannot be in an impossible state.

Why does checkValidity ignore my mocked validity?

jsdom's checkValidity uses the element's internal state, not the getter you replaced. Use setCustomValidity to make the element really invalid when the code under test calls checkValidity.

Should I mock validity for required and pattern?

No. Set the attributes and value and let jsdom compute the flags for real, which tests your mapping and the element together.

← Back to Testing & Accessibility