Automating axe-core Form Audits in CI

This recipe wires axe-core form-accessibility audits into a GitHub Actions pipeline so that any new WCAG violation in a form’s error state fails the build, with baseline management to avoid blocking on pre-existing debt.

When to Use This Recipe

Reach for CI-enforced audits once your form validation has accessible-error wiring worth protecting — aria-invalid, aria-describedby, and live regions, as produced by the native Constraint Validation API Deep Dive. It is the right tool when you want regressions caught at the pull request, not in QA. If you only need to write the scans themselves, start with axe-core Accessibility Testing first; this recipe assumes those scans exist and focuses on automation, build-gating, and baselines.

CI audit gate A pull request triggers GitHub Actions, which runs Playwright with axe-core. New violations fail the merge; results matching the baseline pass. pull request Playwright + axe-core scan diff vs baseline new → fail known → pass
The pipeline scans the form's error state and gates the merge on violations not present in the committed baseline.

Minimal Working Implementation

The scan itself lives in a Playwright test that drives the form into its error state and audits only the form subtree. Failing on new violations — rather than all violations — is what makes the gate adoptable on an existing codebase.

// tests/a11y/signup.a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync } from 'node:fs';

// Rule ids known to fail today, accepted as debt until fixed.
const baseline: string[] = JSON.parse(
  readFileSync(new URL('./axe-baseline.json', import.meta.url), 'utf8'),
);

test('signup form error state introduces no new a11y violations', async ({ page }) => {
  await page.goto('/signup');

  // Trigger validation so axe audits the real error DOM.
  await page.getByRole('button', { name: 'Create Account' }).click();
  await expect(page.getByText(/required/i).first()).toBeVisible();

  const { violations } = await new AxeBuilder({ page })
    .include('#signup-form')                       // scope to the form
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze();

  // Only fail on violations not already accepted in the baseline.
  const regressions = violations.filter((v) => !baseline.includes(v.id));

  if (regressions.length) {
    console.error(
      regressions.map((v) => `${v.id} (${v.impact}): ${v.help}`).join('\n'),
    );
  }
  expect(regressions, 'new accessibility violations').toEqual([]);
});

The companion axe-baseline.json is just an array of rule ids you have consciously deferred:

// tests/a11y/axe-baseline.json
["color-contrast"]

The GitHub Actions workflow runs the suite and fails the job on any non-zero Playwright exit. Caching the browser binaries keeps the accessibility job under a minute.

# .github/workflows/a11y.yml
name: a11y-audit
on: [pull_request]
jobs:
  axe-form-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npx playwright install --with-deps chromium
      # Non-zero exit on any regression fails the merge.
      - run: npx playwright test tests/a11y --reporter=line
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Parameter Reference

Option Where Purpose
.include('#signup-form') scan Scope audit to the form subtree
.withTags([...]) scan Limit to WCAG levels you commit to (wcag22aa)
baseline[] test Rule ids accepted as known debt
if: failure() upload workflow Capture the report only when the gate fails
actions/cache (ms-playwright) workflow Skip re-downloading browsers each run
--reporter=line command Compact CI log; trace/HTML on failure

Verification Steps

Confirm the gate actually blocks regressions before trusting it. Introduce a deliberate violation — remove a <label> or lower error contrast — push the branch, and watch the axe-form-audit job turn red with the offending rule id in the log. Then revert and confirm green. Locally, reproduce CI with:

npx playwright test tests/a11y --reporter=line

Inspect the uploaded playwright-report artifact on a failed run to see each violation’s selector and failureSummary.

Edge Cases & Failure Modes

The scan runs before an async error renders. A debounced server check populates the live region after the scan, so the audit passes against an empty container. Always await expect(...).toBeVisible() on the error before calling .analyze(), as shown above. This is the same race that motivates cancelling stale requests in async validation flows.

The baseline silently hides a real regression. Baselining by rule id accepts every instance of that rule, so a newly broken field under an already-baselined rule slips through. Keep the baseline minimal, add a dated comment for each entry, and schedule its removal rather than letting it grow.

Flaky failures from un-awaited focus or animation. If the error appears with a transition, the scan can catch a mid-animation node. Wait for the settled, visible state and prefer role/label locators over brittle CSS selectors, consistent with the focus-recovery behavior in Focus Management & Keyboard Navigation.

Frequently Asked Questions

How do I adopt this on a codebase that already has violations?

Commit the currently-failing rule ids to axe-baseline.json and fail only on violations not in that list. This stops new regressions immediately while letting you burn down existing debt on your own schedule. Keep the file small and dated so it does not become a permanent excuse.

Should the audit run on every pull request or only on main?

Run the scoped, single-browser form audit on every pull request — it is fast and catches regressions before merge. Reserve the full multi-engine matrix for the main branch or a nightly run, where the extra coverage justifies the longer runtime.

Why scope the scan instead of auditing the whole page?

Scoping with .include('#signup-form') keeps the form gate from failing on unrelated page-level issues like a header contrast bug. It makes failures actionable for the team that owns the form and keeps the baseline focused on form concerns.

← Back to axe-core Accessibility Testing