Skip to content
intermediate Phase 14 · Accessibility & Security Testing Basics

Automated Accessibility Audits

Run axe-core or Lighthouse in CI and manage accessibility debt with owners.

1h
0 problems
Topic Progress 0%

Wiring axe-core into the Pull Request: Playwright, jest-axe, and Lighthouse CI

qa-automated-a11y-audits-axe-ci -->

Manual keyboard passes do not run on every commit — that is the job for a scanner, and the scanner you want is axe-core, Deque's rule engine that powers most commercial audit tooling. The integration strategy is the same one you use for functional tests: run it where the page actually renders (a real browser), on every pull request, and fail the build on violations you have decided are unacceptable.

The strongest integration sits inside Playwright, because it scans the built, hydrated application — a critical point for single-page apps, where a static HTML lint sees an empty <div id="root"> and reports a perfectly accessible blank page:

import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';

test('shop page has no WCAG 2.1 AA violations', async ({ page }) => {
  await page.goto('/shop');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();
  expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});

Three options do most of the day-to-day work. include/exclude scopes the scan (new AxeBuilder({ page }).include('#checkout')) so a PR touching one flow does not fail on an unrelated legacy page. disableRules opts out of a single rule with a comment explaining why (disableRules: ['color-contrast']) — an explicit, reviewable exception, never a silent one. And withTags ties the run to a conformance target: wcag2a + wcag2aa + the wcag21* tags is the automated face of the "WCAG 2.1 AA" commitment from WCAG Accessibility Fundamentals.

Component-level scanning catches defects even earlier. For a React/Jest stack, jest-axe wraps the same engine around rendered markup:

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('renders an accessible login form', async () => {
  const { container } = render(<LoginForm />);
  expect(await axe(container)).toHaveNoViolations();
});

A failing component test names the file that broke, which beats a pipeline failure on a URL three components away. For whole-site coverage — marketing pages, docs, the flows no unit test touches — add Lighthouse CI and let its accessibility score act as a coarse gate:

# lighthouserc.yml
ci:
  assert:
    assertions:
      accessibility: ['error', { score: 0.95 }]
      'color-contrast': ['error', { score: 1 }]

Keep the score gate coarse (a single critical issue drops it hard) and let axe's per-rule output do the precise triage; teams that gate on 0.99 discover that a button-name violation and a minor region warning weigh the same to a percentage. Wire the run into CI with artifacts kept per PR — a JSON results file uploaded next to the Playwright report — so the fix loop starts from evidence, exactly as failures were handled in Test Reporting and Dashboards. Run the scan on PRs against changed routes, and schedule a nightly full crawl of authenticated flows (the axe DevTools extension is the manual cousin for exploring one page interactively).

Two determinism details decide whether the gate survives its first month. Freeze what varies: dismiss or stub the cookie banner, wait for the loading spinner to clear (await expect(page.getByRole('status')).toBeHidden()), and close auto-opening overlays — anything that mounts randomly produces violations that come and go, and flaky gates get disabled. And scan at a fixed viewport with animations disabled (reducedMotion: 'reduce' in Playwright), because a mid-transition text color reports a contrast violation that does not exist in the settled UI.

Then one fact keeps expectations honest, and it is the most important sentence in this topic: an all-green axe run does not mean the page is accessible.

Why Automated Audits Catch About a Third of the Issues

qa-automated-a11y-audits-coverage -->

Studies of real-world audits keep landing on the same ratio: automated tooling detects roughly 30–35 % of accessibility defects. That is not a flaw in axe — it is the boundary between what can be decided from rendered markup and what requires human judgment. Knowing the boundary precisely is what makes you credible when someone proposes replacing manual testing with a scanner.

What the engine checks well is structural: information present in the DOM can be verified mechanically.

axe rule Failure it detects Criterion
image-alt <img> with no alt at all 1.1.1 (A)
label / button-name / link-name Control with no accessible name 3.3.2, 4.1.2 (A)
color-contrast Text below 4.5:1 (or 3:1 large) against its background 1.4.3 (AA)
aria-valid-attr, aria-required-children Malformed or structurally illegal ARIA 4.1.2 (A)
document-title, html-has-lang Missing page title or lang attribute 2.4.2, 3.1.1 (A)
scrollable-region-focusable A scrollable div unreachable by keyboard 2.1.1 (A)
duplicate-id aria-labelledby pointing at a duplicated id 4.1.1/4.1.2 (A)

What no scanner can decide is everything about meaning and behavior. Alt text can be present and useless — alt="icon" passes the rule and communicates nothing; only a human knows the image's purpose. Focus order is a runtime sequence across the whole session, not a property of one element's markup. A custom date picker can carry a perfect role="button" and still be inoperable with a keyboard (2.1.1) — the attribute is right, the behavior is missing. Error messages, instructions, link text that makes sense out of context ("click here"), whether a heading hierarchy reflects the page's structure, whether a modal restores focus on close: all invisible to the engine.

The practical division of labor that results:

  • Every PR: axe scan in the browser (structural regressions, contrast, names).
  • Every new feature touching interaction: the keyboard-only pass from the previous topic — tab order, traps, Esc, focus visibility.
  • Before release: a short screen-reader session (NVDA/VoiceOver) on the changed flows — you verify what is announced, not what is present.
  • Periodically: a full manual audit against the criteria list, ideally by someone who has not built the feature.

Two technical caveats keep the automated half honest. First, scan the real rendered app with a real browser — hydration, lazy-loaded routes, and modal content that only mounts on open all defeat static analysis, so a scan of the initial HTML of a SPA is close to meaningless. Second, make the scan deterministic: animations mid-scan can flip computed contrast values, and dialogs or cookie banners that mount randomly produce violations that come and go — flaky accessibility gates get disabled within a month, which is worse than never having one.

With the coverage boundary drawn, the remaining question is organizational: what do you do about the hundreds of pre-existing violations the scan just surfaced? That is the last piece — the topic of the next chapter.

Failing Builds Without Blocking the Team: Baselines, Severity Policy, and Owned Debt

qa-automated-a11y-audits-debt -->

Turn the axe scan on for a mature product and the first run reports what looks like 400 violations. Ship a gate that fails on all of them and the team does one of two things: reverts the integration, or quietly stops running it. Neither outcome is better than no audit at all, so the policy needs as much design as the pipeline.

Start with impact, which axe assigns per violation: critical, serious, moderate, minor. The mapping to policy is a judgment call you should make explicitly and write down:

Impact Examples Policy
critical image-alt, button-name — content or control completely unusable Fail the build, no exceptions
serious color-contrast, aria-required-children — significant barrier Fail on changed pages (see baseline)
moderate / minor region, heading-order nits Report and track; never block

Then separate new breakage from existing debt with a ratchet baseline. Fingerprint each known violation (rule id + a stable selector path) and store the set in the repository; the gate fails only on fingerprints not in the file:

const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const current = results.violations
  .filter(v => ['critical', 'serious'].includes(v.impact))
  .flatMap(v => v.nodes.map(n => `${v.id}::${n.target.join(' ')}`));
const baseline = new Set(JSON.parse(fs.readFileSync('a11y-baseline.json', 'utf8')));

const regressions = current.filter(f => !baseline.has(f));
if (regressions.length) {
  throw new Error(`New accessibility violations:\n${regressions.join('\n')}`);
}
// The baseline may only shrink: deleting a fingerprint requires the fix, not a re-baseline

The ratchet has one essential property — the baseline can only shrink. Allow re-baselining on demand and the file becomes a dumping ground; require a linked fix ticket to remove an entry, and every sprint mechanically reduces the debt. Report the trend like any other quality metric (open violations by impact, top offending components) using the dashboard habits from Test Reporting and Dashboards, because a number nobody sees does not decay — it metastasizes.

Ownership turns the list into work. Each baseline entry carries a component owner, and the debt lives in the tracker with the same shape as any defect: rule id and criterion (color-contrast — fails 1.4.3), a screenshot or the rendered snippet, the failing page, and an impact. Severity decides the service level: critical debt fixed in the current sprint, serious debt scheduled within a quarter, moderate/minor parked deliberately. When a component has no owner, that is the finding — you cannot fix what nobody owns, and the audit's first deliverable is the org chart correction, not the CSS.

Interviewers ask how you introduce accessibility to a team that has none. The answer that lands: a scanning gate on new code from day one (no gate is too late to start), a ratchet so history never blocks a release, severity policy written down, and named owners — tooling creates the pressure, policy decides where it is applied. This whole workflow generalizes to the next class of quality gates, where the scans are looking for vulnerabilities instead of contrast ratios: OWASP Security Testing Basics.

Quiz

1. The first axe run reports 380 violations on a mature product. What keeps the CI gate alive instead of getting disabled?

Question 1 options

2. Why must axe scan a React SPA through a real browser rather than the built HTML file?

Question 2 options

3. Which defect can axe-core never detect, no matter how it is configured?

Question 3 options

4. How should a team treat axe violations classified as moderate or minor?

Question 4 options

Flashcards

Question

How axe-core is wired into PR checks, and the three scope options.

Answer

@axe-core/playwright in an E2E test on the rendered page, jest-axe in component tests, Lighthouse CI as a coarse score gate. include/exclude scopes the scan to a region or page, disableRules opts out of one rule with a written justification, withTags binds the run to a target (wcag2a + wcag2aa + wcag21*).

Question

Why scanning static HTML of a SPA is close to meaningless.

Answer

The initial HTML is an empty root; hydration, lazy routes, and dialog content mount after execution. Scan the built app in a real browser, drive the UI open before scanning, and make the run deterministic (animations and randomly mounting banners create flaky violations that get the gate disabled).

Question

What automated audits cover — and what they structurally cannot.

Answer

Covered: DOM-derivable facts — missing alt, missing names, computed contrast, invalid ARIA, missing title/lang, duplicate ids. Not covered: alt text meaning, focus order, keyboard operability, link text out of context, instruction and error clarity. Roughly a third of real-world issues fall to automation.

Question

The ratchet baseline in one sentence.

Answer

Fingerprint each known violation (rule id + stable selector), store the set in the repo, fail CI only on new fingerprints — and allow an entry to leave the baseline only with a linked fix, so the list shrinks mechanically instead of being re-baselined away.

Question

Severity-tiered gate policy.

Answer

critical (image-alt, button-name) always fails the build · serious (color-contrast, aria structure) fails on changed pages via the baseline · moderate/minor reported and tracked with owners, never blocking. The service level is written down, not improvised per PR.

Revision Notes

Key Takeaways

  • 1. Run axe where the app actually renders — a real browser on the hydrated SPA — or the scan audits an empty root element
  • 2. Automation covers structural facts (alt, names, contrast, ARIA validity) and roughly a third of real issues; operability and meaning stay manual
  • 3. withTags binds the scan to the AA commitment; disableRules is an explicit, commented, reviewable exception — never a silent one
  • 4. The ratchet baseline makes CI fail only on new critical/serious violations, and the baseline may shrink only through linked fixes
  • 5. Severity-tiered policy plus named component owners turns a 380-violation list into scheduled work instead of a reason to disable the gate

Interview Tips

  • • "How do you add accessibility testing to CI?" — axe-core in Playwright on the rendered page per PR, jest-axe for components, Lighthouse score as a coarse gate, artifacts uploaded; then mention the manual complement
  • • "First scan found 380 issues — what now?" — fingerprinted baseline so only regressions fail, severity policy, component owners, trend reporting; argue that the baseline may only shrink
  • • "What can't automated audits catch?" — alt-text meaning, focus order, keyboard operability, out-of-context link text; quote the roughly one-third coverage and name your manual checks
  • • "Our a11y gate keeps getting disabled — why?" — it blocked on moderate/minor noise or flaky scans; fix with impact-tiered policy, deterministic scans, and baseline-ratcheted failures

Cheat Sheet

Automated Accessibility Audits

Three integrations:

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
  .include('#checkout')
  .analyze();

jest-axe: expect(await axe(container)).toHaveNoViolations() · Lighthouse CI: accessibility: ['error', { score: 0.95 }] (coarse gate only).

Scan the rendered app — SPA HTML is an empty root; hydrate, open dialogs, freeze animations, otherwise violations come and go and the gate gets disabled.

Coverage: structural rules only (~1/3 of issues): image-alt (1.1.1), label/button-name (4.1.2), color-contrast (1.4.3), aria-* validity, document-title (2.4.2), html-has-lang (3.1.1). Meaning, focus order, and keyboard operability stay manual.

Gate policy: critical → fail always · serious → fail on new fingerprints · moderate/minor → report with owners.

Ratchet:

const current = violations.filter(v => ['critical','serious'].includes(v.impact))
  .flatMap(v => v.nodes.map(n => `${v.id}::${n.target.join(' ')}`));
const regressions = current.filter(f => !baseline.has(f));   // baseline may only shrink

Track debt: every baseline entry carries a component owner; entries leave only via linked fixes; trend open violations by impact like any quality metric.

Continue Learning