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.
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.
Related Guides
- axe-core Accessibility Testing — the scan API, scoping, and reading violations
- Testing & Accessibility — where automated audits sit in the testing pyramid
- Constraint Validation API Deep Dive — the native error DOM these audits inspect