Testing Required-Field Validation with Playwright
Required-field validation looks trivial until you automate it, because a required attribute produces no visible DOM change on submit — the signal lives in the Constraint Validation API state, so this page shows how to drive and assert that state deterministically in Playwright without racing the browser.
When to Use This Native-Constraint Assertion Approach
Reach for this approach when your form leans on the browser’s own required gating rather than a bespoke validation library. It asserts against validationMessage, :invalid, and aria-invalid — the exact surface a screen-reader user perceives.
- Use it when validity is driven by
requiredplus a single manualreportValidity()call on<form novalidate>. - Prefer Testing Form Error Messages with Playwright instead when you assert the wording of visible inline text nodes rather than the empty/non-empty state of a field.
- Skip it if validity comes from Zod schema validation or asynchronous server checks — those need network stubbing, not constraint reads.
Minimal reportValidity() Baseline Under Test
The page-under-test ships the canonical baseline: <form novalidate> with a single manual constraint check. The novalidate attribute suppresses the browser’s own bubble so submission always reaches your handler, and one reportValidity() call flips validity state for every control at once.
// signup.ts — the module the page loads
export function wireSignupForm(form: HTMLFormElement): void {
form.addEventListener("submit", (event: SubmitEvent) => {
// novalidate means we, not the browser, decide when to check.
if (!form.checkValidity()) {
event.preventDefault();
// Single manual call: paints native messages + sets :invalid on empties.
form.reportValidity();
// Reflect state where automated + assistive tools can read it.
for (const el of form.elements) {
if (el instanceof HTMLInputElement) {
el.setAttribute("aria-invalid", String(!el.validity.valid));
}
}
return;
}
// ...proceed with a valid submission
});
}
<!-- The markup the test drives -->
<form id="signup" novalidate>
<label for="email">Work email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Create account</button>
</form>
The Playwright spec drives that form and reads the valueMissing flag straight off the element — the authoritative signal that a required field is empty.
import { test, expect } from "@playwright/test";
test("empty required email blocks submit and reports valueMissing", async ({ page }) => {
await page.goto("/signup");
const email = page.locator("#email");
await page.getByRole("button", { name: "Create account" }).click();
// Read native constraint state — no dependence on visible message text.
const valueMissing = await email.evaluate(
(el: HTMLInputElement) => el.validity.valueMissing,
);
expect(valueMissing).toBe(true);
// Assert the accessibility-facing reflection of that state.
await expect(email).toHaveJSProperty("validity.valid", false);
await expect(email).toHaveAttribute("aria-invalid", "true");
});
validity Read Points: Assertion Reference
These are the constraint-state values worth asserting for required-field coverage. Read them with toHaveJSProperty, evaluate, or attribute matchers rather than scraping rendered text.
| Option | Type | Default | Purpose |
|---|---|---|---|
validity.valueMissing |
boolean |
false |
true when a required control is empty — the core assertion. |
validity.valid |
boolean |
true |
Aggregate validity of the control; false if any constraint fails. |
validationMessage |
string |
"" |
Browser-supplied or custom validity message text. |
aria-invalid |
"true" | "false" |
absent | Accessibility reflection you set in the handler. |
:invalid (CSS) |
pseudo-class | n/a | Matches styled empty fields; assert via toHaveClass proxies or computed style. |
required |
boolean attr |
absent | Presence gate; assert it exists before testing its effect. |
Verification Steps
Confirm the behaviour interactively, then lock it into a spec so a regression fails CI rather than shipping.
test("filling the required field clears valueMissing", async ({ page }) => {
await page.goto("/signup");
const email = page.locator("#email");
await email.fill("dev@example.com");
await page.getByRole("button", { name: "Create account" }).click();
await expect(email).toHaveJSProperty("validity.valueMissing", false);
});
Edge Cases & Failure Modes
Whitespace-only input still reads as present. A field containing only spaces has a non-empty value, so valueMissing is false even though the input is semantically blank. Trim in the handler and set a custom error, or add a pattern="\S+.*" so the constraint itself rejects blank strings — otherwise the test passes while real users submit whitespace.
Asserting message text couples the test to the browser locale. validationMessage wording differs across Chromium, Firefox, and WebKit, and across languages. Assert valueMissing === true and aria-invalid, not the exact string; if you must check copy, drive it through your own accessible error messaging node whose text you control.
Hidden or disabled required fields never report missing. A disabled control is barred from constraint validation entirely, and a display:none field is skipped by reportValidity() focus. If a conditionally shown required field must validate, confirm it is enabled and in the layout before asserting, and pair this with focus management after a failed submit so the first invalid control receives focus.
Covering Every Required Field with a Data-Driven Loop
Real forms rarely have one required control. Asserting each field in its own hand-written test invites copy-paste drift, and a field silently losing its required attribute during a refactor is exactly the regression this page exists to catch. Drive the whole set from a single parameterised spec so adding a field to the form means adding one row, not one test.
import { test, expect } from "@playwright/test";
// One row per control the form marks as required. Keeping this list
// beside the spec makes an accidentally-dropped `required` obvious in review.
const requiredFields: ReadonlyArray<{ id: string; label: string }> = [
{ id: "email", label: "Work email" },
{ id: "password", label: "Password" },
{ id: "org", label: "Organisation" },
];
for (const field of requiredFields) {
test(`empty ${field.label} reports valueMissing`, async ({ page }) => {
await page.goto("/signup");
const control = page.locator(`#${field.id}`);
// Guard the premise: assert the attribute exists before testing its effect.
// If a refactor removed `required`, this line fails loudly instead of the
// downstream assertion passing for the wrong reason.
await expect(control).toHaveAttribute("required", "");
await page.getByRole("button", { name: "Create account" }).click();
await expect(control).toHaveJSProperty("validity.valueMissing", true);
await expect(control).toHaveAttribute("aria-invalid", "true");
});
}
The toHaveAttribute("required", "") guard matters more than it looks. A boolean attribute reflects as the empty string in the DOM, so this both proves presence and documents the reflected value. Without it, a spec that only checks valueMissing would keep passing if the attribute were removed and the field left empty for an unrelated reason — a false green.
Why toHaveJSProperty Beats a Manual evaluate
Both toHaveJSProperty and locator.evaluate read the same live property, but they differ in one respect that decides most flaky-test outcomes: retry. toHaveJSProperty is a web-first assertion, so Playwright re-reads the property under its default timeout until it matches or the deadline passes. A bare evaluate samples the value exactly once, at the instant it runs.
That distinction is invisible on the synchronous reportValidity() baseline above, where validity flips in the same task as the click. It becomes decisive the moment anything asynchronous sits between the click and the state change — a microtask-deferred handler, a framework re-render, or a requestSubmit() that schedules validation. In those cases a one-shot evaluate races the state transition and fails intermittently, while the retrying assertion simply waits.
// Prefer this: retries until validity settles, immune to a deferred handler.
await expect(email).toHaveJSProperty("validity.valueMissing", true);
// Reserve evaluate for reading a value you then compute on, not for the wait:
const message = await email.evaluate(
(el: HTMLInputElement) => el.validationMessage,
);
expect(message.length).toBeGreaterThan(0);
Use evaluate when you need the raw value for further logic — its length, a substring, a comparison against a sibling field — and reach for the matcher whenever the assertion is the wait. Mixing the two knowingly, rather than by habit, is what keeps a required-field suite deterministic across engines.
Asserting the :invalid Pseudo-Class Without Scraping Styles
The reference table lists :invalid as a read point, but Playwright has no direct pseudo-class matcher. Rather than diffing computed colours — which couples the test to your stylesheet — assert the state the pseudo-class reflects by calling matches(":invalid") on the element itself. This reads the browser’s own selector engine, so it stays true even if you restyle the error state entirely.
test("empty required field matches :invalid for styling hooks", async ({ page }) => {
await page.goto("/signup");
const email = page.locator("#email");
await page.getByRole("button", { name: "Create account" }).click();
const matchesInvalid = await email.evaluate((el) => el.matches(":invalid"));
expect(matchesInvalid).toBe(true);
});
This is the honest way to prove your input:invalid { border-color: … } rule will actually engage, decoupled from the specific declaration. It confirms the selector matches; a visual-regression snapshot, if you need one, then confirms the paint.
Frequently Asked Questions
Why does my Playwright click submit the form even though the field is empty?
Because the form uses novalidate, the browser will not block submission on its own — your handler must call event.preventDefault() after checkValidity() returns false. Assert on validity.valueMissing rather than expecting a navigation to be prevented for you.
Should I assert the native validation bubble text?
No. The bubble text comes from the browser and varies by engine and locale, making the assertion brittle. Read validity.valueMissing and aria-invalid, or assert against your own visible error node whose copy you control.
How do I read validity state on a locator in Playwright?
Use toHaveJSProperty("validity.valueMissing", true) for a retrying assertion, or locator.evaluate((el: HTMLInputElement) => el.validity.valueMissing) when you need the raw value. Both read the live constraint state directly from the element.
Does an empty required select or checkbox also set valueMissing?
Yes, but the definition of "empty" differs. A required <select> reports valueMissing only when its selected option has an empty value, so give your placeholder option value="". A single required checkbox reports missing until it is checked. The same toHaveJSProperty("validity.valueMissing", true) assertion covers all three control types.
How do I test a required field that is inside a shadow DOM or web component?
Playwright locators pierce open shadow roots automatically, so page.locator("#email") still resolves. The caveat is form-associated custom elements: their validity lives on the host element's ElementInternals, not on a native input, so read it with toHaveJSProperty("validity.valueMissing", true) against the host and confirm the component forwards its aria-invalid reflection to the accessibility tree.
Related Guides
- Testing Form Error Messages with Playwright — assert visible message copy once you have proven the field is invalid.
- Playwright Form Validation Testing — the broader set of form-driving patterns this recipe belongs to.
- WCAG 3.3.1 Error Identification Checklist — verify your required-field errors are programmatically identified.
- axe-core Accessibility Testing — pair constraint checks with an automated a11y audit of the invalid state.