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.
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.
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