Running Accessibility Checks Against Preview Deployments
Accessibility regressions are cheap to fix in the pull request that introduced them and expensive to fix six months later, when the offending pattern has been copied into fourteen components. A scan against the preview deployment puts the feedback at the point of change β and a baseline keeps it from blocking every merge on pre-existing debt.
When to use this pattern
- You have preview deployments and no automated accessibility signal.
- The codebase has existing violations, so a strict gate would block everything.
- Contrast and labelling issues keep reaching production and being reported by users.
Prerequisites
Why the preview, and not a component test
Most accessibility violations are properties of a composed page, not of a component in isolation. Scanning the deployment is what makes them visible.
Complete working example
// tests/a11y.spec.ts β scan the deployed preview, diff against a baseline.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync, existsSync } from 'node:fs';
const ROUTES = ['/', '/products', '/products/example-item', '/cart', '/checkout', '/account'];
// A baseline is a set of violation fingerprints that already existed on main.
type Finding = { rule: string; selector: string; route: string; impact: string };
const key = (f: Finding) => `${f.route}::${f.rule}::${f.selector}`;
const baseline = new Set<string>(
existsSync('a11y-baseline.json')
? JSON.parse(readFileSync('a11y-baseline.json', 'utf8')).map(key)
: [],
);
for (const route of ROUTES) {
test(`accessibility: ${route}`, async ({ page }, testInfo) => {
await page.goto(route, { waitUntil: 'networkidle' });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) // the level you actually commit to
// Disable nothing globally; exclude only third-party embeds you cannot change.
.exclude('#intercom-container')
.analyze();
const found: Finding[] = results.violations.flatMap((v) =>
v.nodes.map((n) => ({ rule: v.id, selector: n.target.join(' '), route, impact: v.impact ?? 'minor' })),
);
// Only NEW findings fail. Pre-existing ones are tracked, not blocking.
const regressions = found.filter((f) => !baseline.has(key(f)));
await testInfo.attach('a11y-findings', {
body: JSON.stringify(found, null, 2), contentType: 'application/json',
});
expect(regressions, regressions.map((r) =>
`${r.impact.toUpperCase()} ${r.rule} at ${r.selector}`).join('\n')).toEqual([]);
});
}# .github/workflows/a11y.yml
name: Accessibility
on: [pull_request]
permissions: { contents: read, pull-requests: write }
jobs:
a11y:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
- run: npx playwright install --with-deps chromium
- run: npx playwright test tests/a11y.spec.ts
env:
BASE_URL: ${{ needs.deploy-preview.outputs.url }}
- if: always()
uses: actions/upload-artifact@v4
with: { name: a11y-findings, path: test-results/, retention-days: 14 }# scripts/refresh-a11y-baseline.sh β run on main, never on a pull request.
set -euo pipefail
BASE_URL="https://www.example.com" npx playwright test tests/a11y.spec.ts || true
jq -s 'add | unique_by(.route + .rule + .selector)' \
test-results/**/a11y-findings.json > a11y-baseline.json
echo "baseline now holds $(jq length a11y-baseline.json) known findings"Step-by-step walkthrough
Scan the deployment, waiting for the network to settle. A scan that runs before client-side rendering completes reports violations for a page that never existed and misses the ones in the rendered result. waitUntil: 'networkidle' is the blunt but reliable version; a wait for a known post-render element is better where one exists.
Choose the tag set deliberately. wcag2a, wcag2aa and wcag21aa correspond to a commitment you can state. Including best-practice adds advisory rules that are often right but are not a standard, and mixing them means a failure no longer tells you whether you broke a commitment.
The baseline is what makes this adoptable. A codebase with 60 existing violations cannot turn on a strict gate β the first pull request would fail for reasons unrelated to itself, and the gate would be disabled within a week. Fingerprinting existing findings and failing only on new ones lets the check ship today and the debt shrink deliberately.
Fingerprint on route, rule and selector. Coarser keys (rule only) let a genuine new violation hide behind an old one elsewhere. Finer keys (including text content) break on every copy change, filling the baseline with noise.
Refresh the baseline only from the main branch. Regenerating it during a pull request would silently absorb that pull requestβs own regressions β the baseline would grow to include exactly the violations the check exists to catch.
Verification
# 1. The check fails on a deliberate regression.
git apply <<'PATCH'
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
- <button aria-label="Close">Γ</button>
+ <button>Γ</button>
PATCH
BASE_URL="$PREVIEW_URL" npx playwright test tests/a11y.spec.ts; echo "exit=$?"Expected β a named rule, a selector, and a non-zero exit:
CRITICAL button-name at .modal > button
exit=1# 2. Pre-existing findings do not fail the run.
git checkout -- src/components/Button.tsx
BASE_URL="$PREVIEW_URL" npx playwright test tests/a11y.spec.ts; echo "exit=$?" # β exit=0
# 3. The baseline is shrinking over time, not growing.
git log --format='%ad %H' --date=short -- a11y-baseline.json | head -5 | while read -r d h; do
printf '%s %s\n' "$d" "$(git show "$h:a11y-baseline.json" | jq length)"
doneThe third command is the one to keep an eye on. A baseline that grows means findings are being absorbed rather than fixed β usually because someone regenerated it from a branch rather than from main.
Making the debt shrink instead of settle
A baseline solves adoption and creates a new risk: a permanent list nobody ever revisits. Two practices keep it moving.
The first is ordering the baseline by impact and treating the critical and serious entries as ordinary backlog items with owners, rather than as an undifferentiated blob. Axeβs impact rating is a reasonable proxy for user harm, and a list of nine critical findings is a plausible sprint; a list of sixty mixed findings is not, and reads as unfixable.
The second is a ratchet: when a pull request happens to fix a baselined finding, remove it from the baseline in the same change. That way the file only ever shrinks through normal work, and nobody has to schedule an accessibility project to make progress. Pairing this with the review-time signal β the findings comment on the pull request β means the remaining human effort goes to what automation genuinely cannot see: keyboard order, focus management, and whether the labelling makes sense to someone who cannot see the layout.
Common pitfalls
Failing on the whole violation list rather than on regressions. The gate gets disabled the first week. Baseline first, ratchet after.
Scanning only the homepage. It is usually the most polished page in the product. Scan the routes where the interesting composition happens β forms, checkout, account settings.
Treating a passing scan as accessibility. Automated rules cover roughly a third of WCAG criteria. The scan buys back review time for keyboard and screen-reader testing; it does not replace it.
Re-run the baseline generation after any design-system upgrade. A change to shared component styles can resolve dozens of baselined findings at once, and leaving them in the file makes the remaining debt look larger than it is while hiding the improvement the upgrade actually delivered.
Related
- Running E2E Tests Against Preview Environments β the parent guide the scan runs inside.
- Capturing Playwright Traces for Flaky Preview Failures β evidence capture for the same suite.
- Posting Preview URLs as Sticky Pull Request Comments β where the findings should surface.
- Preview Environments & Environment Parity β the section overview.
β Back to Running E2E Tests Against Preview Environments