axe-core Accessibility Testing for Form Validation
axe-core is the accessibility rules engine that turns “is this form accessible?” into a deterministic, automatable check. This guide covers its axe.run API, the framework bindings (@axe-core/playwright, jest-axe, vitest-axe), how to scope a scan to a single form, how to read a violation object, which rules actually matter for validation, and — crucially — the limits of what rules-based scanning can prove.
The problem axe-core solves is that accessibility regressions are silent. A refactor that strips a <label>, lowers error-text contrast below 4.5:1, or points aria-describedby at a removed node throws no exception and passes every functional test. axe-core encodes WCAG expertise as executable rules so these defects fail a test instead of reaching a user. It pairs naturally with the native Constraint Validation API Deep Dive: the native API produces the validity state and error DOM, and axe-core audits whether that rendered DOM conforms.
Prerequisites
| Requirement | Why it matters |
|---|---|
| Rendered DOM (real or jsdom) | axe-core inspects live nodes and computed styles, not source |
| Form in a representative state | Audit the error state, not just the pristine empty form |
| A test runner | @axe-core/playwright (E2E) or vitest-axe / jest-axe (component) |
| WCAG target level agreed | Choose tags: wcag2a, wcag2aa, wcag22aa |
axe-core needs a rendered tree. In Playwright that is a real browser; in component tests it is jsdom. Contrast rules require computed styles, so jsdom-based runs cannot evaluate color contrast — that check is meaningful only in a real browser, which is one reason the Playwright Form Validation Testing approach pairs so well with axe.
API Reference
| API | Binding | Use |
|---|---|---|
axe.run(context, options) |
axe-core core |
Returns a Promise<AxeResults> |
new AxeBuilder({ page }) |
@axe-core/playwright |
Fluent scan inside a Playwright test |
.include(sel) / .exclude(sel) |
@axe-core/playwright |
Scope the scan to a subtree |
.withTags([...]) |
@axe-core/playwright |
Limit rules to WCAG tags |
.disableRules([...]) |
@axe-core/playwright |
Skip a known-noisy rule |
toHaveNoViolations() |
jest-axe / vitest-axe |
Custom matcher for component tests |
context accepts a CSS selector, an element, or an include/exclude object. options carries runOnly (tag or rule filtering), rules (per-rule toggles), and resultTypes. The result object always exposes four arrays: violations, passes, incomplete, and inapplicable.
Step-by-Step Implementation
1. Audit a form in error state with @axe-core/playwright
Trigger validation first so the audit sees the real error DOM, then scope the scan to the form so unrelated page issues do not fail your form test.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('signup form has no a11y violations in error state', async ({ page }) => {
await page.goto('/signup');
// Force the error state — this is where most violations live.
await page.getByRole('button', { name: 'Create Account' }).click();
const results = await new AxeBuilder({ page })
.include('#signup-form') // scope to the form subtree
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
2. Audit a component in isolation with vitest-axe
For framework component tests, render the component, drive it into an error state, and assert no violations against the resulting HTML. Note that jsdom cannot evaluate contrast, so disable that rule here and let Playwright own it.
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'vitest-axe';
import { expect, test } from 'vitest';
import { SignupForm } from './SignupForm';
expect.extend(toHaveNoViolations);
test('SignupForm error state is accessible', async () => {
const { container, getByRole } = render(<SignupForm />);
getByRole('button', { name: /create account/i }).click();
// color-contrast is not evaluable in jsdom; defer it to Playwright.
const results = await axe(container, {
rules: { 'color-contrast': { enabled: false } },
});
expect(results).toHaveNoViolations();
});
3. Read a violation object
When a scan fails, the violation object tells you exactly which node, which rule, and how to fix it. Logging it well turns a red build into an actionable ticket.
import type { Result } from 'axe-core';
function reportViolations(violations: Result[]): void {
for (const v of violations) {
console.error(`[${v.impact}] ${v.id}: ${v.help}`);
console.error(` ${v.helpUrl}`); // axe docs page for the rule
for (const node of v.nodes) {
console.error(` selector: ${node.target.join(' ')}`);
console.error(` fix: ${node.failureSummary}`);
}
}
}
Each Result carries an id (the rule), impact (minor to critical), help text, a helpUrl, and a nodes array. Each node’s target is a CSS selector locating the offending element and failureSummary states what must change.
Rules Relevant to Form Validation
| Rule id | WCAG SC | What it checks | Common form failure |
|---|---|---|---|
label |
3.3.2 / 1.3.1 | Every control has a programmatic label | Placeholder used instead of <label> |
aria-valid-attr-value |
4.1.2 | ARIA values are valid and refs resolve | aria-describedby points to a removed node |
aria-input-field-name |
4.1.2 | Custom widgets expose an accessible name | Styled div control with no name |
color-contrast |
1.4.3 | Text meets 4.5:1 (3:1 large) | Red error text on light background fails |
aria-required-attr |
4.1.2 | Required widgets expose the state | Custom control missing aria-required |
form-field-multiple-labels |
1.3.1 | A field is not labelled ambiguously | Duplicate <label for> targets |
The aria-valid-attr-value rule is the one that most often catches validation-specific bugs: when an error message node is removed on correction but aria-describedby is not cleared, the reference dangles and the rule fails. This maps directly to the accessible-error wiring documented in Inline Error Messaging Strategies, where the describedby linkage must be added and removed in lockstep with the message node.
State Management & Edge Cases
Validation UIs are dynamic, so when you scan matters as much as whether you scan. Three edge cases dominate.
Scanning before the error renders. If an aria-live region is populated asynchronously (after a debounced server check), the scan may run against an empty container and pass falsely. Await the visible error before analyzing:
await page.getByLabel('Email').fill('taken@example.com');
await page.getByLabel('Email').blur();
// Wait for the async error to render before auditing.
await expect(page.getByText(/already registered/i)).toBeVisible();
const results = await new AxeBuilder({ page }).include('#signup-form').analyze();
expect(results.violations).toEqual([]);
Triggering native validation without server cancellation races. When async checks are cancelled via AbortController, the DOM can momentarily hold a stale message. Scan after the settled state, not mid-flight, to avoid auditing a transient node.
Duplicate ids from re-rendered error containers. Frameworks that re-mount error nodes can briefly emit duplicate ids, failing duplicate-id-aria. Key error nodes stably so the id is unique across renders.
Common Gotchas
Auditing the pristine form only. An empty form passes axe trivially; the violations appear in the error state.
// Before — passes but proves nothing about error accessibility:
const results = await new AxeBuilder({ page }).analyze();
// After — drive the form into error first:
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page.getByText(/required/i)).toBeVisible();
const results = await new AxeBuilder({ page }).include('#signup-form').analyze();
Scanning the whole page and drowning in unrelated noise. Without .include(), a header contrast issue fails your form test.
// Before — fails on issues you don't own:
const results = await new AxeBuilder({ page }).analyze();
// After — scope to the form:
const results = await new AxeBuilder({ page }).include('#signup-form').analyze();
Disabling color-contrast globally to silence one failure. Disable it only where it cannot run (jsdom), never in the Playwright run where it is the whole point of the check.
Browser Compatibility
| Environment | Contrast rule | ARIA/label rules | Notes |
|---|---|---|---|
| Chromium (Playwright) | Evaluated | Evaluated | Full coverage |
| Firefox / WebKit (Playwright) | Evaluated | Evaluated | Use for cross-engine confidence |
| jsdom (vitest/jest) | Not evaluable | Evaluated | Disable color-contrast |
axe-core itself runs identically across engines; the difference is what the host environment can compute. Contrast needs real layout and computed styles, so it is meaningful only in a real browser.
Gating on impact with resultTypes and a Custom Reporter
Failing on violations.length > 0 is the right default, but large legacy forms often carry a backlog you cannot fix in one pull request. The honest way to make progress is to gate on impact — block critical and serious regressions immediately while tracking moderate and minor items as a debt list — rather than disabling rules wholesale, which hides future critical failures under the same switch.
The impact field on each result is populated from axe-core’s rule metadata and takes one of four values: minor, moderate, serious, critical. For form validation, the label and ARIA-reference rules almost always report as critical or serious because a missing accessible name or a dangling aria-describedby makes the control unusable with assistive technology, whereas some redundant-role checks report as minor. A severity-aware reporter lets you encode that distinction in one place.
import type { AxeResults, Result, ImpactValue } from 'axe-core';
// Ordered so we can compare severities numerically.
const IMPACT_ORDER: ImpactValue[] = ['minor', 'moderate', 'serious', 'critical'];
function atOrAbove(impact: ImpactValue | null, floor: ImpactValue): boolean {
if (impact === null) return false;
return IMPACT_ORDER.indexOf(impact) >= IMPACT_ORDER.indexOf(floor);
}
interface GateResult {
blocking: Result[];
tracked: Result[];
}
// Split a scan into build-breaking failures and a tracked debt list.
export function gateByImpact(results: AxeResults, floor: ImpactValue = 'serious'): GateResult {
const blocking: Result[] = [];
const tracked: Result[] = [];
for (const violation of results.violations) {
(atOrAbove(violation.impact, floor) ? blocking : tracked).push(violation);
}
return { blocking, tracked };
}
Wired into a Playwright test, this keeps the build green on pre-existing minor debt while still failing the moment a refactor drops a <label>:
test('signup form blocks on serious+ a11y regressions', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page.getByText(/required/i)).toBeVisible();
const results = await new AxeBuilder({ page })
.include('#signup-form')
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
const { blocking, tracked } = gateByImpact(results, 'serious');
if (tracked.length) {
console.warn(`${tracked.length} tracked (minor/moderate) a11y items`);
}
expect(blocking, blocking.map((v) => v.id).join(', ')).toEqual([]);
});
The expect message argument surfaces the failing rule ids directly in the test report, so a red run names label, aria-valid-attr-value instead of just “array not empty”. One caveat: an impact gate is a migration tool, not a destination. A moderate contrast miss on error text is still a real barrier for low-vision users, so the tracked list must be a burn-down queue with an agreed deadline, not a permanent parking lot.
Understanding the Four Result Arrays
AxeResults always returns four arrays, and treating them as a two-way pass/fail split is the most common misreading. violations and passes are the obvious pair, but incomplete and inapplicable carry information you need to interpret a scan honestly.
inapplicable lists every rule that found no matching nodes in the scanned subtree. When you scope a scan to #signup-form, page-level rules like document-title land here — that is expected and healthy. What is not healthy is finding a form-specific rule such as label in inapplicable when you expected inputs to be present: it means your .include() selector missed the controls, and the scan proved nothing. A quick assertion that the rules you care about actually ran guards against a scope typo silently turning your audit into a no-op.
import type { AxeResults } from 'axe-core';
// Confirm the rules we rely on were actually applicable to the subtree.
function assertRulesRan(results: AxeResults, mustApply: string[]): void {
const ran = new Set([
...results.violations,
...results.passes,
...results.incomplete,
].map((r) => r.id));
const skipped = mustApply.filter((id) => !ran.has(id));
if (skipped.length) {
throw new Error(
`Expected rules did not apply — check your include() scope: ${skipped.join(', ')}`,
);
}
}
// Usage: if `label` never ran, the scan found no inputs and is meaningless.
assertRulesRan(results, ['label', 'aria-valid-attr-value']);
incomplete deserves its own discipline. axe-core parks a rule here when it cannot reach a deterministic verdict — most often color-contrast over a gradient or background image, where the effective background colour is ambiguous. The engine is telling you it needs a human, and swallowing incomplete as an implicit pass is how contrast bugs on error banners slip through. Assert that the array is empty for a clean run, or route each entry into a manual-review checklist keyed by node.target so a person confirms the real contrast against the rendered pixels.
Sharing an axe Configuration Across a Suite
Once more than a handful of tests call axe.run or AxeBuilder, copy-pasting .withTags([...]) and per-rule toggles drifts out of sync. Centralise the policy in one factory so every test audits against the same WCAG target and the same documented exceptions. This also gives you a single, reviewable place where each disabled rule is justified — a disabled rule with no comment is a future accessibility hole.
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';
const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'];
// One source of truth for the project's accessibility policy.
export function formAxe(page: Page, formSelector: string): AxeBuilder {
return new AxeBuilder({ page })
.include(formSelector)
.withTags(WCAG_TAGS)
// 'region' is a page-landmark rule, irrelevant to an isolated form subtree.
.disableRules(['region']);
}
Every form test then reads as a single expressive line — const results = await formAxe(page, '#signup-form').analyze() — and raising the WCAG target for the whole suite is a one-line edit. The same principle applies to component tests: export a wrapped axeComponent(container) that disables color-contrast for jsdom in exactly one location, so the reason the rule is off lives next to the code instead of being re-derived in every file. When you do disable a rule, prefer disableRules (which still lets other rules in the same category run) over dropping a whole WCAG tag, and never reach for resultTypes: ['violations'] as a performance shortcut in these suites — you lose the incomplete and inapplicable signals that the checks above depend on.
Frequently Asked Questions
Why does color-contrast never fail in my Vitest tests?
jsdom does not compute layout or resolved colors, so axe-core cannot evaluate contrast there and marks it inapplicable. Run the contrast check in a real browser via @axe-core/playwright, and explicitly disable the rule in jsdom-based component tests to avoid a false sense of coverage.
What is the difference between violations and incomplete?
violations are confirmed failures you should fail the build on. incomplete are cases axe-core could not decide automatically — often contrast over a background image — that need human review. Treat incomplete results as a manual-check queue, not as automatic passes or failures.
How do I stop one header issue from failing my form test?
Scope the scan with .include('#signup-form') so axe-core only walks the form subtree. This keeps the form test focused on the form's own accessibility and lets unrelated page-level issues be owned by their own tests.
Can axe-core confirm my error messages are well-worded?
No. axe-core verifies structure — that a label exists, that aria-describedby resolves, that contrast passes — but cannot judge whether the message suggests a useful correction (WCAG SC 3.3.3). That requires reading the text and ideally hearing it through a screen reader.
Should I gate on impact or fail on every violation?
On a greenfield form, fail on every violation — an empty violations array is the cleanest contract. On a legacy form with existing debt, gate on serious and critical so new regressions block immediately while known minor items are tracked to a deadline. The impact gate is a migration ramp, not a permanent exemption: a moderate contrast miss is still a real barrier and must have an owner and a due date.
Why is the label rule showing up in inapplicable?
A rule lands in inapplicable when axe-core finds no matching nodes in the scanned subtree. If label is inapplicable, your .include() selector almost certainly excluded the inputs, so the scan proved nothing about them. Assert that the rules you depend on appear in violations, passes, or incomplete before trusting a green result — a scope typo otherwise turns the audit into a silent no-op.
Related Guides
- Automating axe-core Form Audits in CI — wire scans into GitHub Actions and fail on new violations
- Playwright Form Validation Testing — drive the form into the error state axe should audit
- WCAG 2.2 Form Compliance Checklists — the criteria axe rules map to
- Constraint Validation API Deep Dive — the native API that produces the DOM axe audits
- Inline Error Messaging Strategies — the describedby wiring the
aria-valid-attr-valuerule checks
← Back to Testing & Accessibility