
Accessibility Testing with Playwright and axe-core
Accessibility bugs often survive code review because a page can look correct while still being unusable with a keyboard, screen reader, or high-contrast settings. Playwright and axe-core provide a practical first line of defense: they run real browser flows, inspect the rendered DOM, and fail CI when detectable accessibility rules are violated.
This tutorial adds repeatable accessibility checks to a Playwright project, covers dynamic pages and authenticated flows, and shows how to triage results without turning the test suite into noise. Automated checks do not prove WCAG conformance, but they catch many common regressions early and make manual testing more focused.
What automated accessibility tests can catch
axe-core can detect issues such as missing form labels, invalid ARIA attributes, insufficient color contrast, duplicate IDs, and controls without accessible names. It cannot reliably judge whether alternative text is meaningful, whether focus order makes sense, or whether an interaction is understandable. Keep keyboard and assistive-technology review in the release process.
Install Playwright and axe-core
In an existing Node.js project, install the test runner and Playwright integration:
npm install --save-dev @playwright/test @axe-core/playwright
npx playwright install --with-deps
Keep both packages in devDependencies. Commit the lockfile so CI installs the same dependency graph. A minimal configuration can start the app automatically:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['html'], ['line']] : 'list',
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry'
},
webServer: {
command: 'npm run preview',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
})
Write the first accessibility scan
Navigate to a stable state, construct AxeBuilder with the current Playwright page, then assert that the violation list is empty:
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('home page has no automatically detectable violations', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze()
expect(results.violations).toEqual([])
})
Waiting for a meaningful UI element is safer than an arbitrary timeout. It proves that the page reached the state you intend to inspect. The selected tags provide a clear baseline; align the exact standard and level with your organization's accessibility target.
Make failures useful to developers
A raw deep-equality failure can be difficult to read. Attach a focused JSON report so CI preserves rule IDs, impact, help URLs, and affected nodes:
test('checkout accessibility', async ({ page }, testInfo) => {
await page.goto('/checkout')
await expect(page.getByRole('button', { name: 'Place order' })).toBeVisible()
const results = await new AxeBuilder({ page }).analyze()
await testInfo.attach('axe-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json'
})
expect(results.violations, 'Review the attached axe report').toEqual([])
})
Fix violations by rule and shared component rather than page by page. One correction to a design-system button or field can remove dozens of failures.
Test components in the state users actually see
Scanning only the initial page misses dialogs, validation errors, menus, and content loaded after user input. Drive the interaction before analyzing:
test('signup validation remains accessible', async ({ page }) => {
await page.goto('/signup')
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page.getByText('Email is required')).toBeVisible()
const results = await new AxeBuilder({ page })
.include('main')
.analyze()
expect(results.violations).toEqual([])
})
include() is helpful for testing an independently owned region, but do not permanently exclude navigation, cookie dialogs, or third-party widgets merely to make CI green. Document any exception with an owner and expiration date.
Add keyboard assertions that axe cannot infer
Combine the scan with behavioral checks. Verify that a user can reach controls, see focus, open a dialog, and return focus when it closes:
test('account dialog works from the keyboard', async ({ page }) => {
await page.goto('/account')
await page.keyboard.press('Tab')
const trigger = page.getByRole('button', { name: 'Edit profile' })
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog', { name: 'Edit profile' })
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(trigger).toBeFocused()
})
Prefer role and accessible-name locators. They are not a complete accessibility audit, but they encourage tests to use the same semantic surface exposed to assistive technology.
Reuse one scan helper carefully
// tests/helpers/check-a11y.ts
import { expect, Page, TestInfo } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
export async function checkA11y(page: Page, testInfo: TestInfo) {
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze()
await testInfo.attach('accessibility-violations', {
body: JSON.stringify(violations, null, 2),
contentType: 'application/json'
})
expect(violations).toEqual([])
}
A helper keeps policy consistent. Avoid a single giant test that visits every URL: smaller tests identify ownership, retry independently, and make failures easier to diagnose.
Run the suite in GitHub Actions
name: accessibility
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test tests/accessibility.spec.ts
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Pin the Node.js major version your application supports, set a job timeout, and upload reports even on failure. For sensitive authenticated tests, do not run deployment credentials on untrusted forked pull requests.
Troubleshooting common failures
Color contrast results change between environments
Ensure fonts are installed and loaded before scanning, and keep viewport, browser, theme, and reduced-motion settings deterministic. A missing webfont can change layout and computed colors.
Dynamic content is missing from the scan
Wait for a user-visible condition, not networkidle alone. Modern applications may keep analytics or streaming requests open while the important panel is already ready.
A third-party widget causes violations
First ask the vendor for a fix or use an accessible alternative. If exclusion is temporarily necessary, scope it to the exact selector, record the issue, assign an owner, and add a removal deadline.
The test passes but keyboard use is broken
This is expected when the problem requires human judgment. Add explicit keyboard scenarios and perform periodic manual checks with browser zoom, a screen reader, and operating-system accessibility settings.
Production checklist
- Scan critical public pages and every important interactive state.
- Use stable role-based locators and wait for meaningful UI conditions.
- Attach violations and Playwright traces to failed CI runs.
- Fix shared components before suppressing individual failures.
- Document every exclusion with an owner and expiration date.
- Add keyboard, focus-management, zoom, and screen-reader review.
- Run checks on pull requests and repeat a broader audit before releases.