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.
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.
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.
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.
Related Guides
- Unit Testing Validation Logic — the overall strategy.
- Testing Zod Schemas with Vitest — schema tests.
- Constraint Validation API Deep Dive — the flags themselves.
- Playwright Form Validation Testing — real typing and focus.