@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'])