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.

axe-core scan pipeline A form DOM subtree is passed to axe.run with include and tag options. The rules engine evaluates label, aria, and contrast rules, producing a results object split into violations, passes, and incomplete arrays. Form DOM in error state axe.run(ctx, opts) include / exclude withTags(wcag22aa) violations[] fail the test passes[] rules satisfied incomplete[] needs review
axe.run evaluates the scoped subtree against tagged rules and returns violations, passes, and incomplete results.

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.

← Back to Testing & Accessibility

Explore This Section