IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Intermediate·25 / 27

Accessibility testing

axe-core integrated with Playwright runs automated WCAG checks in 3 lines of code. I add it to every page-level test — it catches missing labels, contrast issues, and duplicate IDs before they reach production.

Install axe-core

Playwright doesn't include accessibility scanning out of the box — you need the @axe-core/playwright package. axe-core is the most widely used automated accessibility engine. It catches ~57% of WCAG issues automatically — the rest require manual review.

bash
npm install --save-dev @axe-core/playwright
ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('dashboard has no accessibility violations', async ({ page }) => {
  await page.goto('/dashboard')

  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})

Scan the whole page or a specific component

By default AxeBuilder.analyze() scans the entire page. If you only care about a specific region — a modal, a form, a nav — use .include(). This is useful when you're adding accessibility tests incrementally and don't want violations from other parts of the page to block you.

Important: if you're scanning a UI state that appears after user interaction (a dropdown, a modal), trigger that state before calling analyze().

ts
test('create order form is accessible', async ({ page }) => {
  await page.goto('/orders')

  // Відкриваємо форму — вона з'явиться в DOM
  await page.getByRole('button', { name: 'Create order' }).click()
  await page.getByRole('dialog').waitFor()

  // Скануємо лише форму, не всю сторінку
  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze()

  expect(results.violations).toEqual([])
})

test('navigation is accessible', async ({ page }) => {
  await page.goto('/dashboard')

  const results = await new AxeBuilder({ page })
    .include('nav')
    .analyze()

  expect(results.violations).toEqual([])
})

Target specific WCAG criteria

By default axe runs all its rules — some are WCAG requirements, others are best practices. If your project has an accessibility conformance target (e.g. WCAG 2.1 AA), filter to those specific tags. This makes failures meaningful and actionable rather than "best practice" noise.

ts
test('meets WCAG 2.1 AA', async ({ page }) => {
  await page.goto('/orders')

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

  expect(results.violations).toEqual([])
})

Handle known violations

When adding accessibility tests to an existing app, you'll find violations you can't fix immediately. Two options: exclude the element, or disable the specific rule. Don't suppress blindly — document why each suppression exists and when it should be fixed.

ts
// Виключити проблемний елемент зі сканування
test('dashboard accessible except legacy widget', async ({ page }) => {
  await page.goto('/dashboard')

  const results = await new AxeBuilder({ page })
    .exclude('#legacy-chart-widget') // TODO: fix Q3 2026 — tracked in #1234
    .analyze()

  expect(results.violations).toEqual([])
})

// Вимкнути конкретне правило
test('orders page accessible', async ({ page }) => {
  await page.goto('/orders')

  const results = await new AxeBuilder({ page })
    .disableRules(['color-contrast']) // TODO: update design tokens
    .analyze()

  expect(results.violations).toEqual([])
})

Shared axe configuration via fixture

When the same axe configuration (same WCAG tags, same exclusions) repeats across many tests — extract it into a fixture. This keeps configuration in one place and makes tests cleaner.

ts
// fixtures/axe.ts
import { test as base, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

type AxeFixture = {
  makeAxeBuilder: () => AxeBuilder
}

export const test = base.extend<AxeFixture>({
  makeAxeBuilder: async ({ page }, use) => {
    const makeAxeBuilder = () =>
      new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
        .exclude('#legacy-chart-widget')

    await use(makeAxeBuilder)
  },
})

export { expect }

// В тестах
import { test, expect } from '../fixtures/axe'

test('login page is accessible', async ({ page, makeAxeBuilder }) => {
  await page.goto('/login')
  const results = await makeAxeBuilder().analyze()
  expect(results.violations).toEqual([])
})

test('dashboard accessible with form open', async ({ page, makeAxeBuilder }) => {
  await page.goto('/dashboard')
  await page.getByRole('button', { name: 'Create order' }).click()

  // Додаткова конфігурація поверх спільної
  const results = await makeAxeBuilder()
    .include('[role="dialog"]')
    .analyze()

  expect(results.violations).toEqual([])
})