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.

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.

← Back to Testing & Accessibility

Explore This Section