@axe-core/playwright запускает проверки доступности рядом с обычными Playwright-тестами, находя отсутствующий alt-текст, непомеченные поля форм, недостаточный цветовой контраст и некорректное использование ARIA при каждом коммите в CI. Автоматическое сканирование находит примерно 30–40% нарушений WCAG; остальное требует ручного тестирования с клавиатурой и скринридером. Статья охватывает полную настройку: сканирование целых страниц, отдельных компонентов, фильтрацию по уровню WCAG и тестирование потоков клавиатурной навигации.

Зачем автоматизировать тестирование доступности

Ручные аудиты доступности медленны и дороги. Автоматические проверки моментально находят самые распространённые проблемы:

  • Отсутствие alt-текста у изображений
  • Поля форм без меток
  • Недостаточный цветовой контраст
  • Отсутствующие ARIA-роли и атрибуты
  • Проблемы клавиатурной навигации
  • Проблемы управления фокусом

Автоматические инструменты находят около 30–40% проблем доступности. Остальное требует ручного тестирования с реальными вспомогательными технологиями. Но находить 30–40% автоматически при каждом коммите — это уже гораздо лучше чем ничего.

Установка

npm install --save-dev @axe-core/playwright

Базовое сканирование доступности

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

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

  const results = await new AxeBuilder({ page }).analyze();

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

При наличии нарушений тест падает с деталями:

● home page has no accessibility violations

  expect(received).toEqual(expected)

  Expected: []
  Received: [
    {
      id: 'color-contrast',
      description: 'Elements must have sufficient color contrast',
      nodes: [{ target: ['#nav-link'], ... }]
    }
  ]

Сканирование нескольких страниц

test.describe('Accessibility checks', () => {
  const pages = [
    { name: 'Home', url: '/' },
    { name: 'Login', url: '/login' },
    { name: 'Products', url: '/products' },
    { name: 'Contact', url: '/contact' },
  ];

  for (const { name, url } of pages) {
    test(`${name} page is accessible`, async ({ page }) => {
      await page.goto(url);
      await page.waitForLoadState('networkidle');

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

Фильтрация правил

Проверяй только конкретные критерии WCAG или исключай известные проблемы:

test('login page WCAG AA compliant', async ({ page }) => {
  await page.goto('/login');

  const results = await new AxeBuilder({ page })
    // Только критерии WCAG 2.1 AA
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();

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

test('products page with known issues excluded', async ({ page }) => {
  await page.goto('/products');

  const results = await new AxeBuilder({ page })
    // Временно исключаем известную проблему пока она исправляется
    .disableRules(['color-contrast'])
    .analyze();

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

Сканирование части страницы

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

  const results = await new AxeBuilder({ page })
    .include('#main-navigation')  // Только навигация
    .analyze();

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

test('modal dialog is accessible', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="add-to-cart"]');

  await page.waitForSelector('[role="dialog"]');

  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();

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

Тестирование клавиатурной навигации

axe находит отсутствующие ARIA-атрибуты. Проблемы навигационного потока требуют ручного клавиатурного тестирования:

test('login form is keyboard navigable', async ({ page }) => {
  await page.goto('/login');

  // Проходим по полям формы через Tab
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'email-input');

  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'password-input');

  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'submit-btn');

  // Отправляем через Enter
  await page.keyboard.press('Enter');
  await page.waitForURL('/dashboard');
});

test('modal can be closed with Escape', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="filter-btn"]');

  await expect(page.getByRole('dialog')).toBeVisible();

  await page.keyboard.press('Escape');

  await expect(page.getByRole('dialog')).not.toBeVisible();
});

Управление фокусом

Когда открывается модальное окно, фокус должен переместиться внутрь него. Когда закрывается, фокус возвращается к триггеру:

test('modal traps focus correctly', async ({ page }) => {
  await page.goto('/');

  // Открываем модальное окно
  const triggerButton = page.getByTestId('open-modal');
  await triggerButton.click();

  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();

  // Фокус должен быть внутри модального окна
  const focusedElement = page.locator(':focus');
  await expect(modal).toContainElement(focusedElement);

  // Tab внутри модального окна: фокус не должен уходить наружу
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');

  // Всё ещё внутри модального окна
  await expect(modal).toContainElement(page.locator(':focus'));

  // Закрываем модальное окно
  await page.keyboard.press('Escape');

  // Фокус возвращается на кнопку-триггер
  await expect(triggerButton).toBeFocused();
});

Проверка alt-текста изображений

test('all product images have alt text', async ({ page }) => {
  await page.goto('/products');

  const images = page.locator('img');
  const count = await images.count();

  for (let i = 0; i < count; i++) {
    const img = images.nth(i);
    const alt = await img.getAttribute('alt');
    const src = await img.getAttribute('src');

    // Alt должен существовать и не быть пустым
    // (если только это не декоративное изображение с role="presentation")
    const role = await img.getAttribute('role');
    if (role !== 'presentation') {
      expect(alt, `Image ${src} is missing alt text`).not.toBeNull();
      expect(alt, `Image ${src} has empty alt text`).not.toBe('');
    }
  }
});

ARIA-роли и метки

test('form inputs have labels', async ({ page }) => {
  await page.goto('/contact');

  const inputs = page.locator('input, textarea, select');
  const count = await inputs.count();

  for (let i = 0; i < count; i++) {
    const input = inputs.nth(i);
    const type = await input.getAttribute('type');

    // Пропускаем скрытые поля
    if (type === 'hidden') continue;

    const id = await input.getAttribute('id');
    const ariaLabel = await input.getAttribute('aria-label');
    const ariaLabelledBy = await input.getAttribute('aria-labelledby');

    // У поля должна быть метка: через id+label, aria-label или aria-labelledby
    const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;

    const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
    expect(isLabelled, `Input without label: ${id || 'unnamed'}`).toBeTruthy();
  }
});

Читаемые отчёты о нарушениях

import AxeBuilder from '@axe-core/playwright';

// Вспомогательная функция с понятным форматированием нарушений
async function checkAccessibility(page, selector?: string) {
  const builder = new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa']);

  if (selector) builder.include(selector);

  const results = await builder.analyze();

  if (results.violations.length > 0) {
    const report = results.violations.map(v =>
      `\n[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n` +
      v.nodes.map(n => `  - ${n.target.join(', ')}: ${n.failureSummary}`).join('\n')
    ).join('\n');

    throw new Error(`Accessibility violations found:\n${report}`);
  }
}

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

Распространённые проблемы доступности

| Проблема | Правило axe | Исправление |

|----------|------------|-------------|

| Нет alt у изображения | image-alt | Добавить alt="описание" |

| Низкий контраст | color-contrast | Контрастность не менее 4.5:1 |

| Поле без метки | label | Добавить или aria-label |

| Кнопка без текста | button-name | Добавить текст или aria-label |

| Порядок заголовков | heading-order | Не пропускать уровни h1→h3 |

| Нет атрибута lang | html-has-lang | Добавить |

| Ссылка без текста | link-name | Добавить описательный текст ссылки |

Итог

# Установка
npm install --save-dev @axe-core/playwright

# Базовое использование
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

# Фильтр по уровню WCAG
.withTags(['wcag2a', 'wcag2aa'])

# Ограничение области сканирования
.include('#main-nav')

# Исключение известной проблемы
.disableRules(['color-contrast'])

→ See also: Тестирование доступности для QA-инженеров: инструменты, техники и дедлайн EAA 2025 | События клавиатуры и мыши в Playwright | AI визуальное регрессионное тестирование: за пределами попиксельного сравнения