Тестируем: клавиатура, скринридер, автопроверка

Три проверки, которые нужно уметь делать самому: пятиминутный проход клавиатурой, десять команд скринридера и автопрогон 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> нет атрибута altalt="изображение" формально есть, но смысла не несёт
контраст текста ниже 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.
Проверьте себя
1. Lighthouse показал Accessibility 100. Что это на самом деле означает?
AСайт полностью соответствует WCAG уровня AA
BНе нарушены автоматически проверяемые правила — примерно треть проблем доступности; ручную проверку это не отменяет
CСайт проверен пользователями скринридеров и признан удобным
DНа странице нет ни одного элемента, который скринридер не может прочитать
2. Какой дефект НЕ найдёт axe DevTools, но найдёт человек за пять минут?
AУ картинки отсутствует атрибут alt
BКонтраст текста ниже 4.5:1
CИз модалки нельзя выйти по Tab — фокус ходит по кругу и не возвращается на страницу
DНа странице два элемента с одинаковым id