Тестируем: клавиатура, скринридер, автопроверка
Три проверки, которые нужно уметь делать самому: пятиминутный проход клавиатурой, десять команд скринридера и автопрогон axe — плюс честный список того, что автотест не увидит никогда.
Тестирование доступности — это не одна проверка, а три разные: автоматическая ловит нарушения контракта разметки, клавиатурная — недостижимые элементы и ловушки фокуса, а прослушивание скринридером — бессмыслицу, которую произносит интерфейс. Ни одна из трёх не заменяет две другие.
Зачем разработчику вообще это трогать
Есть соблазн сказать: «доступность проверит QA» или «закажем аудит перед релизом». На практике это работает плохо. Внешний аудит приходит раз в год, приносит сто пунктов, и половина из них — про компоненты, которые вы уже переписали. Баг, найденный через полгода после мержа, стоит в разы дороже, чем баг, пойманный за две минуты в своей же ветке.
Хорошая новость: базовая проверка доступности — это навык на один вечер. Вам не нужно быть незрячим пользователем, чтобы заметить, что модалка не закрывается по Esc, а кнопка «Удалить» произносится как «кнопка» — без единого слова о том, что именно она удалит. Такие дефекты видны сразу, если знать, куда смотреть и что нажимать.
Дальше — рабочий порядок проверки, ровно в том виде, в каком его стоит применять к каждой своей задаче.
Шаг 1. Пятиминутный проход клавиатурой
Уберите руку с мыши. Буквально: отодвиньте её, чтобы не было соблазна. Откройте страницу, поставьте курсор в адресную строку и жмите Tab, пока не пройдёте всю страницу до футера. Это самая дешёвая проверка на свете и она ловит больше всего.
| Что нажимаем | Что должно произойти | Красный флаг |
Tab с самого верха | первым появляется skip-link «К основному содержимому» | первые 15 нажатий уходят в меню-гармошку |
Tab дальше | фокус видно всегда: рамка, обводка, подсветка | фокус «пропал» — элемент активен, но невидим |
Tab по порядку | фокус идёт сверху вниз, как читается страница | фокус прыгает из шапки в футер и обратно |
Enter / Space | кнопка нажимается, ссылка переходит | элемент реагирует только на клик мышью |
Esc в модалке | окно закрывается, фокус возвращается на кнопку, которая его открыла | фокус улетает в начало страницы или остаётся внутри закрытой модалки |
Tab внутри модалки | фокус ходит по кругу внутри окна | фокус уходит на ссылки под оверлеем |
Shift + Tab | тот же путь в обратную сторону | назад пройти нельзя — ловушка фокуса |
Отдельно проверьте всё, что появляется по наведению: выпадающие меню, тултипы, кнопки, которые показываются при hover на карточке. Классический баг — кнопка «В избранное», которая существует только под курсором мыши: с клавиатуры до неё не добраться в принципе, потому что она либо display: none, либо появляется только по :hover. Пользователь клавиатуры такую функцию просто не увидит.
Второй частый улов — «мышиные» кнопки из div. Они выглядят как кнопки, кликаются мышью, но фокус на них не встаёт: у элемента нет ни tabindex, ни роли, ни обработчика клавиш. Клавиатурный проход находит их за секунды.
Шаг 2. Слушаем страницу скринридером
Скринридер — программа, которая читает вслух то, что видит в дереве доступности браузера. Разработчику не нужно осваивать её целиком, как ежедневный инструмент. Нужно уметь включить, пройти свой компонент и услышать, что он объявляет.
Два бесплатных варианта покрывают почти всё: NVDA на Windows (скачивается с сайта NV Access) и VoiceOver на macOS (уже встроен). Работать с ними стоит в паре с «родным» браузером: NVDA — с Firefox или Chrome, VoiceOver — с Safari.
NVDA: минимальный набор
Клавиша NVDA в командах ниже — это Insert (или CapsLock, если включить режим ноутбука при установке).
| Команда | Что делает |
Ctrl + Alt + N | запустить NVDA |
Ctrl | заткнуть речь немедленно (самая нужная команда в начале) |
NVDA + Q | выйти |
Стрелка вниз | читать следующую строку — так «читают» страницу подряд |
H / Shift + H | прыгать по заголовкам вперёд и назад |
D | прыгать по ориентирам-лендмаркам (header, nav, main, footer) |
B | прыгать по кнопкам |
F | прыгать по полям формы |
NVDA + F7 | список всех ссылок, заголовков и кнопок страницы |
VoiceOver: минимальный набор
Модификатор VO — это Control + Option, нажатые вместе.
| Команда | Что делает |
Cmd + F5 | включить и выключить VoiceOver |
Control | прервать речь |
VO + Стрелка вправо | перейти к следующему элементу |
VO + Space | «нажать» текущий элемент |
VO + U | ротор: списки заголовков, ссылок, лендмарков, полей формы |
VO + Cmd + H | следующий заголовок |
На iPhone VoiceOver включается в «Настройки → Универсальный доступ», и полчаса, проведённые с ним на своём же мобильном сайте, обычно производят больше впечатления, чем любой отчёт.
Что именно слушать
Не пытайтесь оценить «удобно ли». Слушайте три конкретные вещи: имя (как элемент называется), роль (кнопка? ссылка? поле?) и состояние (нажата? раскрыта? выбрана?). Вот типичный компонент до и после.
<!-- Плохо: скринридер произнесёт «кнопка» — и всё -->
<div class="icon-btn" onclick="removeItem(12)">
<svg><use href="#trash"></use></svg>
</div>
<!-- Плохо: имя есть, но состояние не объявляется -->
<button class="accordion" onclick="toggle()">Доставка</button>
<!-- Хорошо: имя, роль и состояние на месте -->
<button type="button" aria-label="Удалить «Кофемолка Wilfa»" onclick="removeItem(12)">
<svg aria-hidden="true" focusable="false"><use href="#trash"></use></svg>
</button>
<button type="button" class="accordion" aria-expanded="false" aria-controls="panel-delivery">
Доставка
</button>
<div id="panel-delivery" hidden>Курьером за 2 дня, самовывоз сегодня.</div>
Второй вариант NVDA прочитает как «Удалить «Кофемолка Wilfa», кнопка» и «Доставка, кнопка, свёрнуто». Первый — как безымянную «кнопку», по которой невозможно понять, что произойдёт. Разница видна только на слух: глазами оба варианта выглядят одинаково.
Шаг 3. Автопроверка: axe DevTools и Lighthouse
axe-core — движок правил доступности от Deque, на котором построены и расширение axe DevTools, и раздел Accessibility в Lighthouse, и десятки CI-плагинов. То есть, гоняя Lighthouse, вы гоняете тот же axe, просто с урезанным набором правил и другой обёрткой.
Начать проще всего с расширения axe DevTools для Chrome или Firefox: вкладка в DevTools, кнопка Scan, список нарушений с точным селектором, объяснением и ссылкой на критерий WCAG. Для одноразовой проверки чужого сайта хватит и командной строки.
# Проверить страницу без установки расширений
npx @axe-core/cli https://example.com
# Проверить несколько страниц и сохранить отчёт
npx @axe-core/cli https://example.com https://example.com/cart --save report.json
# Только критичные и серьёзные нарушения
npx @axe-core/cli https://example.com --tags wcag2a,wcag2aa
Результат:
Testing https://example.com ... please wait, this may take a minute.
Violations:
1. image-alt (critical) — Images must have alternative text
Elements: img.promo-banner
2. color-contrast (serious) — Elements must meet minimum color contrast ratio
Elements: a.footer-link, span.price-old
3. button-name (critical) — Buttons must have discernible text
Elements: button.js-close
3 violations found. 42 checks passed.
Lighthouse живёт прямо во вкладке DevTools и даёт число: «Accessibility 87». Это удобная метрика для дашборда и совершенно бесполезная как цель. Балл считается по фиксированному списку автоматических правил и молчит обо всём, что нельзя проверить кодом.
Тот же axe можно запустить из скрипта — например, чтобы проверить не всю страницу, а один конкретный компонент. Такой код требует DOM и сборки, поэтому в песочнице учебника он не выполняется, но в вашем проекте отработает как есть.
import axe from 'axe-core';
// Проверяем только диалог, а не всю страницу целиком
const dialog = document.querySelector('#checkout-dialog');
const results = await axe.run(dialog, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] }
});
results.violations.forEach((v) => {
console.log(`[${v.impact}] ${v.id}: ${v.help}`);
v.nodes.forEach((n) => console.log(' ', n.target.join(' ')));
});
Что автотест НЕ поймает
Это главное, что стоит унести из урока. По оценкам самой Deque, автоматические правила покрывают примерно треть проблем доступности — и это оптимистичная оценка, потому что она считает нарушения, а не пользователей, которые из-за них ушли. Всё остальное требует человека.
| Автотест видит | Автотест слеп |
у <img> нет атрибута alt | alt="изображение" формально есть, но смысла не несёт |
| контраст текста ниже 4.5:1 | смысл передан одним цветом: «поля с ошибкой подсвечены красным» |
у поля нет связанного <label> | подпись есть, но называется «Введите значение» |
| у кнопки нет доступного имени | имя есть, но не совпадает с видимым текстом кнопки |
заголовки идут h2 → h4, уровень пропущен | заголовки на месте, но текст под ними не про то |
| указана роль, недопустимая для этого элемента | роль tablist корректна, но стрелки внутри неё не работают |
дублируются id | после отправки формы фокус улетел в никуда |
у страницы нет атрибута lang | ошибку валидации показали, но не объявили — она молча появилась внизу |
Обратите внимание на правую колонку: там сплошь вещи, которые ломают сценарий целиком. Пользователь скринридера не «немного пострадает» от того, что ошибка формы не объявлена, — он просто не сможет отправить заявку. А для axe эта страница будет полностью зелёной.
Как это работает
Все три инструмента смотрят на одну и ту же структуру — дерево доступности (accessibility tree). Браузер строит его параллельно с DOM: берёт каждый узел, вычисляет его роль, доступное имя, описание и состояния, выбрасывает то, что скрыто, и отдаёт результат вспомогательным технологиям через платформенный API (UIA в Windows, NSAccessibility в macOS).
Скринридер читает вслух ровно то, что лежит в этом дереве. axe обходит то же дерево по своим правилам («у элемента с ролью button должно быть непустое доступное имя»). Посмотреть дерево можно и глазами: в Chrome DevTools вкладка Elements → панель Accessibility, там же вычисленное имя элемента и то, откуда оно взялось — из текста, из aria-label или из <label>.
Отсюда простое следствие: если в дереве доступности имени нет — его не придумает ни один инструмент и ни один пользователь. И наоборот, если axe молчит, это значит лишь одно: формальные правила не нарушены. Осмысленность имён, логичность порядка и работоспособность сценария в дереве не хранятся.
Частые ошибки
- Считать балл Lighthouse целью. «Accessibility 100» достигается на странице, где единственная кнопка —
divбез роли: axe про него просто ничего не знает и не проверяет. Балл говорит об отсутствии известных нарушений, а не о наличии доступности. - Проверять axe только главную страницу. Нарушения живут в формах, модалках, выпадающих списках и на шаге оплаты. Сканируйте состояния, а не URL: откройте модалку — и запустите скан.
- Тестировать скринридером «мышью». Если водить курсором и слушать, что произносится под ним, вы не увидите порядка чтения и не поймаете ловушку фокуса. Скринридером ходят с клавиатуры.
- Проверять только в одной связке. NVDA + Firefox, VoiceOver + Safari, JAWS + Chrome ведут себя по-разному. Ориентируйтесь на стандарт, а не на поведение конкретной пары, иначе начнёте писать хаки под скринридер.
- Правки «чтобы axe замолчал». Классика: axe ругается «у кнопки нет имени» — разработчик дописывает
aria-label="button". Нарушение исчезло, пользователь по-прежнему слышит бессмыслицу. - Откладывать проверку до релиза. Переписать компонент за час в своей ветке — норма. Переписать его за неделю до релиза, когда он уже используется в двенадцати местах, — совсем другая история.
Итоги
- Проверка доступности состоит из трёх частей: клавиатура, скринридер, автотест — и они ловят разные классы дефектов.
- Проход
Tabпо странице занимает пять минут и находит недостижимые элементы, невидимый фокус, сломанный порядок и ловушки в модалках. - Разработчику достаточно десятка команд NVDA или VoiceOver: слушайте имя, роль, состояние — а не «удобно ли».
- axe DevTools,
npx @axe-core/cliи Lighthouse построены на одном движке axe-core; расширение точнее всего показывает, что и где чинить. - Автотест закрывает около трети проблем и никогда не оценит осмысленность alt-текста, подписей и объявлений об ошибках.
- Все инструменты читают одно и то же дерево доступности — его же можно посмотреть во вкладке Accessibility в DevTools.