Мобильное, кроссбраузерное тестирование и доступность
Три темы в одной статье — не потому что они мелкие, а потому что у них общий корень. Все три отвечают на один вопрос: работает ли продукт не только там, где его писали.
Разработчик пишет код на MacBook, в Chrome, мышью, при отличном интернете, зрении и мелкой моторике. Пользователь открывает продукт на Android четырёхлетней давности в Samsung Internet, в метро, одной рукой, с включённым TalkBack, при 200% системного шрифта. Между этими двумя мирами лежит целый класс дефектов, которых нет ни в юнит-тестах, ни в интеграционных, ни даже в E2E, если E2E гоняется в единственном headless Chromium.
Главная сложность здесь не техническая, а экономическая: комбинаций окружений бесконечно много, а денег конечное количество. Поэтому статья начинается не с инструментов, а с того, как решить, что вообще тестировать.
Комбинаторный взрыв и почему его нельзя победить в лоб
Посчитаем честно. Даже скромный веб-продукт:
- 5 браузерных движков в обиходе (Blink, Gecko, WebKit, WebKit-на-iOS отдельно, старые WebView),
- × 4 версии каждого, которые ещё живы,
- × 4 ОС,
- × 5 разрешений экрана,
- × 2 ориентации,
- × светлая/тёмная тема,
- × 3 языка,
- × «обычный / скринридер / клавиатура».
Это порядка 50 000 конфигураций. Умножьте на 200 тест-кейсов регресса — и вы получите число прогонов, которое не поместится ни в одну ферму устройств мира.
Вывод, который надо принять сразу: полное покрытие окружений невозможно и не нужно. Задача — не покрыть всё, а покрыть то, где вероятность отказа, умноженная на цену отказа, максимальна. Это тот самый риск-ориентированный подход из тест-дизайна, только применённый не ко входным данным, а к средам исполнения.
Начните с данных, а не с интуиции
Единственный корректный источник списка окружений — аналитика вашего продукта, а не мировая статистика. У медицинского сервиса для пожилых доля iOS будет одна, у подросткового приложения — совсем другая; у корпоративного портала половина трафика может идти из Edge, потому что так настроена групповая политика.
Достаньте из аналитики срез за последние 90 дней и постройте распределение. Оно почти всегда выглядит так:
Картинка задаёт политику, которую можно защитить перед менеджментом:
| Уровень | Что входит | Что гоняем | Кто чинит баг |
|---|---|---|---|
| Tier 1 (≈70–80% трафика) | 3–5 конфигураций | весь автоматический регресс на каждом PR | блокирует релиз |
| Tier 2 (до 90–95%) | ещё 4–8 конфигураций | smoke ночью и перед релизом | чиним в текущем спринте |
| Хвост (5–10%) | всё остальное | не тестируем | реактивно, по жалобе и логам |
Важная деталь, которую часто упускают: доля трафика — не единственная ось. Конфигурация с 1% сессий, через которую проходит 30% выручки (например, iPad у менеджеров по продажам), обязана быть в Tier 1. Поэтому приоритизируйте по двум осям.
Расхождение взглядов. Разработчик обычно хочет сузить матрицу: «Baseline поддерживает, polyfill есть, вопрос закрыт». Тестировщик обычно хочет её расширить: «а вдруг». Правильный ответ посередине и он числовой: матрица окружений — это документ с датой, владельцем и процентами трафика, который пересматривают раз в квартал. Спор «нужен ли нам Firefox» без цифры перед глазами не решается никогда.
Кроссбраузерное тестирование
Хорошая новость: движков всего три
За фразой «поддержать 12 браузеров» на самом деле стоит три рендеринг-движка:
- Blink — Chrome, Edge, Opera, Yandex Browser, Samsung Internet, все Chromium-обёртки;
- Gecko — Firefox;
- WebKit — Safari на macOS и все браузеры на iOS.
Последний пункт — источник половины недоразумений. Исторически на iOS любой браузер обязан был использовать системный WebKit; «Chrome на iPhone» — это оболочка вокруг WebKit. С 2024 года в ЕС по требованиям DMA разрешены альтернативные движки, но на практике подавляющее большинство iOS-трафика по-прежнему WebKit. Практический вывод: баг, воспроизводящийся в Chrome на iPhone, надо проверять в Safari, а не в Chrome на десктопе.
Соответственно, разумный минимум автоматики — по одному представителю на движок,
плюс отдельно мобильный WebKit, потому что он отличается от десктопного (жесты, viewport,
100vh, автоплей видео, политика хранилищ).
Что реально ломается в 2020-х
Времена, когда полстраницы уезжало из-за box-model в IE, прошли. Современные различия конкретнее и коварнее — они не роняют вёрстку целиком, а ломают один сценарий:
- Даты.
new Date("2026-07-19 10:00")работает в Blink и падает в Safari; корректно — ISO-формат сT. Классика, которая до сих пор регулярно ломает бронирования. - Регулярные выражения. Lookbehind (
(?<=...)) появился в Safari сильно позже других; сломанныйRegExpв бандле роняет весь чанк JS, а не одну функцию. - Хранилища. Safari ITP ограничивает срок жизни
localStorageи cookie у сторонних доменов; сценарий «не разлогинивает через неделю» ведёт себя по-разному. 100vhна мобильных. Адресная строка Safari то появляется, то исчезает; фикс —100dvh.- Ввод и фокус. iOS зумит страницу при фокусе на
inputсfont-sizeменьше 16px. - Медиа и автоплей. Политики автовоспроизведения различаются; звук почти везде требует жеста.
- Форматы.
avif/webp, кодеки видео,Intlс разным набором локалей. - Печать и PDF.
@media printрендерится по-разному в каждом движке.
Не держите этот список в голове — держите два ресурса открытыми: caniuse.com для конкретной фичи и Baseline — свод «что уже безопасно во всех основных браузерах». Baseline снимает большую часть споров: фича в статусе Widely available обсуждению не подлежит, фича в Newly available требует запасного пути.
Стратегия кода: feature detection, а не user-agent sniffing
Самая частая ошибка совместимости живёт не в тестах, а в продакшн-коде: ветвление по строке User-Agent. Она врёт, её подделывают, она меняется (Chrome заморозил её и постепенно заменяет на Client Hints). Ветвиться нужно по наличию возможности.
// Плохо: сломается на первом же новом браузере или при подмене UA
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) { useFallbackShare(); }
// Хорошо: спрашиваем не «кто ты», а «умеешь ли ты»
if (typeof navigator.share === "function") {
await navigator.share({ title, url });
} else {
await navigator.clipboard.writeText(url);
showToast("Ссылка скопирована");
}
// Для CSS — @supports, тот же принцип на уровне стилей
// @supports (height: 100dvh) { .screen { height: 100dvh } }
// @supports not (height: 100dvh) { .screen { height: 100vh } }
Расхождение взглядов. Для разработчика это архитектурное правило. Для тестировщика — подсказка, где искать баги: везде, где в коде есть ветвление по окружению, есть минимум две ветки, и обычно протестирована одна. Грепните репозиторий по
userAgent,@supports,Platform.OS,isMobile— вы получите готовый список рискованных мест.
Как это запускается: матрица в Playwright
Playwright умеет описывать матрицу окружений декларативно через projects — включая
эмуляцию мобильных устройств (viewport, DPR, User-Agent, touch-события).
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
// Матрица уровней: на PR гоняем tier 1, ночью — всё.
const tier1 = [
{ name: "chromium-desktop", use: { ...devices["Desktop Chrome"] } },
{ name: "webkit-desktop", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 14"] } },
];
const tier2 = [
{ name: "firefox-desktop", use: { ...devices["Desktop Firefox"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
// «Планшет в альбомной» — отдельная раскладка, отдельный класс багов
{ name: "tablet-landscape", use: { ...devices["iPad (gen 7) landscape"] } },
// Пользователь с крупным шрифтом и уменьшенной анимацией
{
name: "a11y-preferences",
use: {
...devices["Desktop Chrome"],
deviceScaleFactor: 2,
reducedMotion: "reduce",
forcedColors: "none",
colorScheme: "dark",
},
},
];
export default defineConfig({
testDir: "./tests",
// Полная матрица только в ночном прогоне
projects: process.env.FULL_MATRIX ? [...tier1, ...tier2] : tier1,
use: { baseURL: process.env.BASE_URL ?? "http://localhost:3000", trace: "on-first-retry" },
});
Ключевая мысль: эмуляция устройства — это не устройство. devices["iPhone 14"]
в Chromium даёт правильный viewport и touch-события, но не даёт настоящий WebKit,
настоящую производительность и настоящую клавиатуру iOS. Поэтому мобильный проект
надо запускать именно на движке webkit, а «настоящий iPhone» — это уже ферма устройств.
Визуальное регрессионное тестирование и его цена
Соблазн очевиден: сделать скриншот каждой страницы в каждом браузере и сравнить с эталоном.
Инструменты есть — встроенный toHaveScreenshot() в Playwright, Percy, Applitools, BackstopJS.
Честно про боль: пиксельное сравнение — самый флаки-склонный вид проверок из существующих. Причины: сглаживание шрифтов различается между ОС; шрифт может не успеть загрузиться; курсор мигает; анимация не докрутилась; в блоке «Популярное» другой товар; в футере сегодняшняя дата. Один неаккуратно настроенный визуальный набор способен генерировать больше ложных срабатываний, чем весь остальной CI вместе взятый.
Что делает визуальные тесты жизнеспособными:
test("карточка товара выглядит стабильно", async ({ page }) => {
await page.goto("/product/42");
// 1) Замораживаем всё недетерминированное
await page.clock.setFixedTime(new Date("2026-01-15T12:00:00Z"));
await page.route("**/api/recommendations", (r) =>
r.fulfill({ path: "fixtures/recommendations.json" }));
await page.addStyleTag({ content: `*, *::before, *::after {
animation: none !important; transition: none !important; caret-color: transparent !important; }` });
await page.evaluate(() => document.fonts.ready);
// 2) Снимаем компонент, а не всю страницу: меньше площадь — меньше шума
await expect(page.getByTestId("product-card")).toHaveScreenshot("product-card.png", {
maxDiffPixelRatio: 0.01, // допуск на сглаживание
mask: [page.getByTestId("promo-banner")], // заведомо изменчивый блок
});
});
Практическое правило: визуальные тесты — для дизайн-системы и нескольких ключевых экранов, а не для всего приложения. Компонентные скриншоты в Storybook дают тот же сигнал в десять раз стабильнее и в сто раз быстрее, чем скриншоты полных страниц через E2E. И храните эталоны отдельно на каждую пару «браузер + ОС»: эталон, снятый на macOS, никогда не совпадёт с рендером в Linux-контейнере CI.
Фермы устройств и облачные гриды
Когда нужны реальные браузеры и устройства, есть три пути:
- Свой Selenium Grid в Docker (docker-selenium) — дёшево, полностью контролируемо, но только Chromium/Firefox под Linux. Ни Safari, ни iOS.
- Облако — BrowserStack, Sauce Labs, LambdaTest. Реальные Safari, реальные iPhone, ручные сессии для отладки. Дорого (обычно за параллельные сессии), медленно (сеть), и добавляет внешнюю зависимость: их сбой = ваш красный CI.
- Firebase Test Lab / AWS Device Farm — для нативных мобильных приложений, оплата по минутам устройства.
Совет по стоимости: не гоняйте в облаке весь регресс. Гоняйте там только те проверки, которые нельзя получить локально — то есть WebKit на реальной iOS и пару популярных Android. Остальное отлично живёт в контейнерах.
Мобильное тестирование
Три разных мира под одним словом «мобильное»
тестирование)) Мобильный веб Тот же код, что и десктоп Инструменты: Playwright WebKit, реальный Safari Риски: viewport, жесты, шрифты, ITP Гибрид и PWA WebView внутри нативной оболочки Cordova, Capacitor, React Native WebView Риски: версия WebView отстаёт от браузера Риски: мост JS и натив, права доступа Нативное приложение Swift/Kotlin, собственный UI Инструменты: XCUITest, Espresso, Appium Риски: жизненный цикл, релизный цикл сторов
Разница принципиальна не только в инструментах, но и в цене ошибки. Веб чинится выкаткой за 10 минут. Нативное приложение чинится сборкой, ревью в App Store (часы-дни) и — главное — обновлением у пользователя, которое может не произойти никогда. Отсюда правило: в мобильной разработке регресс перед релизом жёстче, а фичефлаги и удалённый конфиг — не роскошь, а способ починить продакшн без релиза.
Что ловится только на мобильном и нигде больше
Это ядро статьи: список классов дефектов, которых просто не существует на десктопе.
1. Жизненный цикл процесса. ОС может выгрузить ваше приложение из памяти в любой момент. Пользователь свернул приложение на этапе оплаты, ответил на сообщение, вернулся — а система пересоздала процесс. Восстановилось ли состояние? Не улетел ли повторный платёж?
2. Прерывания. Входящий звонок, пуш поверх экрана, разряженная батарея, режим энергосбережения (он душит фоновые задачи и анимации), уведомление банка с биометрией.
3. Сеть. Не «есть или нет», а весь спектр: 2G в электричке, переключение Wi-Fi → LTE на середине загрузки (у сокета меняется IP), captive portal в кафе, который отдаёт HTML вместо вашего JSON. Проверяйте не только offline, но и медленно и нестабильно: в Charles Proxy и в Android Emulator есть троттлинг, в Playwright — эмуляция через CDP.
4. Права и системные настройки. Отказ в доступе к камере/геолокации/уведомлениям; отзыв разрешения в настройках, пока приложение свёрнуто; «разрешить один раз». Ветка «пользователь нажал «Запретить»» — самая непротестированная ветка мобильных приложений.
5. Фрагментация и настройки экрана. Вырезы под камеру, жестовая навигация внизу, складные устройства, split-screen, системный шрифт на 200%, RTL-локали. Проверка «включите крупный шрифт в настройках ОС и пройдите ключевой сценарий» находит сломанную вёрстку за пять минут — и её почти никто не делает.
6. Ресурсы. Разряд батареи, перегрев (ОС начинает троттлить CPU), забитая память устройства, отсутствие места для кэша.
Эмулятор или реальное устройство
Вечный спор решается разделением задач.
| Проверяем | Эмулятор / симулятор | Реальное устройство |
|---|---|---|
| Логику и вёрстку | да, основной инструмент | избыточно |
| Разные разрешения и версии ОС | да, дёшево и быстро | дорого |
| Прогон автотестов в CI | да | только Tier 1 |
| Производительность, плавность скролла | нет, врёт | обязательно |
| Камера, NFC, биометрия, Bluetooth | нет | обязательно |
| Батарея, перегрев, реальная сеть | нет | обязательно |
| Push-уведомления, платежи в сторе | частично | обязательно |
| Ощущение продукта в руке | нет | обязательно |
Разумный баланс: автоматика — на эмуляторах, исследовательское тестирование и приёмка — на реальных устройствах. И минимум одно «плохое» устройство в парке: дешёвый Android на 3 ГБ памяти показывает больше проблем, чем весь флагманский зоопарк.
Автоматизация: Appium
Appium — реализация протокола W3C WebDriver для мобильных платформ: тот же API, что у Selenium, но драйверы за ним — XCUITest (iOS) и UiAutomator2 (Android). Главная ценность — единый код теста на две платформы; главная ловушка — попытка написать единый код на 100%, из-за чего тесты обрастают ветвлениями.
# tests/test_checkout_mobile.py
# pip install Appium-Python-Client pytest
import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
options = UiAutomator2Options().load_capabilities({
"platformName": "Android",
"appium:deviceName": "Pixel_7_API_34",
"appium:app": "/builds/app-release.apk",
"appium:automationName": "UiAutomator2",
# Не переустанавливать приложение между тестами — экономит минуты
"appium:noReset": True,
# Автоматически закрывать системные диалоги прав — если они не предмет теста
"appium:autoGrantPermissions": True,
})
drv = webdriver.Remote("http://127.0.0.1:4723", options=options)
drv.implicitly_wait(0) # никаких неявных ожиданий: только явные, иначе флаки
yield drv
drv.quit()
def test_cart_survives_process_death(driver):
"""Регрессия PROD-1841: корзина терялась, если ОС убивала процесс в фоне."""
wait = WebDriverWait(driver, 15)
# Локатор по accessibility id — он же content-desc на Android
# и accessibilityLabel на iOS: один локатор на две платформы.
wait.until(EC.presence_of_element_located(
(AppiumBy.ACCESSIBILITY_ID, "product-42-add-to-cart"))).click()
badge = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "cart-badge")
assert badge.text == "1"
# Симулируем убийство процесса ОС: сворачиваем и терминируем.
driver.background_app(-1) # свернуть без возврата
driver.terminate_app("com.example.shop") # ОС «забрала память»
driver.activate_app("com.example.shop") # пользователь открыл снова
badge = wait.until(EC.presence_of_element_located(
(AppiumBy.ACCESSIBILITY_ID, "cart-badge")))
assert badge.text == "1", "корзина не восстановилась после перезапуска процесса"
Обратите внимание на две вещи. Первая: ACCESSIBILITY_ID — лучший локатор в мобильной
автоматизации, потому что он же используется скринридером. Проставляя contentDescription
и accessibilityLabel, вы одновременно делаете тесты устойчивыми и приложение доступным —
редкий случай, когда две задачи решаются одним действием. Вторая: implicitly_wait(0).
Смешивание неявных и явных ожиданий в WebDriver даёт непредсказуемые тайминги —
источник флаки-тестов, разобранных в статье про E2E.
Чек-лист мобильного релиза
Не тест-кейсы, а именно чек-лист — то, что проходят руками перед выкаткой. Формат и логика чек-листов подробно разобраны в статье о документации.
## Mobile release checklist — v4.12
### Жизненный цикл
- [ ] Холодный старт менее 3 с на «плохом» устройстве (Redmi 9A)
- [ ] Сворачивание на шаге оплаты и возврат: заказ не задвоился
- [ ] Убийство процесса (adb shell am kill) на форме: черновик восстановлен
- [ ] Поворот экрана на каждом экране с формой: данные не потерялись
- [ ] Split-screen: вёрстка не рассыпалась
### Сеть
- [ ] Offline: понятная ошибка, а не бесконечный спиннер
- [ ] Wi-Fi → LTE на середине загрузки файла: докачка или внятный ретрай
- [ ] Троттлинг 2G: нет таймаутов на критичном сценарии
- [ ] Возврат в онлайн: очередь неотправленных действий доедет
### Права и настройки ОС
- [ ] Отказ в геолокации: сценарий продолжается вручную
- [ ] Отзыв разрешения камеры в фоне: возврат не крашится
- [ ] Системный шрифт 200%: текст не обрезан, кнопки нажимаемы
- [ ] Тёмная тема: нет чёрного текста на чёрном фоне
- [ ] Уменьшенная анимация: переходы не сломаны
### Устройство
- [ ] Вырез камеры и жестовая навигация не перекрывают элементы
- [ ] Клавиатура не закрывает поле ввода и кнопку отправки
- [ ] Свободное место на диске 0: понятная ошибка при сохранении
- [ ] Push приходит и открывает нужный экран (deep link) при закрытом приложении
Баг-репорт с мобильного: что обязано быть
Мобильный баг-репорт без окружения бесполезен: разработчик не воспроизведёт. Минимальный набор полей поверх обычного:
**Заголовок:** [Android 12 / Samsung A52] Корзина пустеет после возврата из фона на шаге оплаты
**Окружение**
- Устройство: Samsung Galaxy A52, Android 12 (One UI 4.1), 4 ГБ RAM
- Сборка: 4.12.0 (build 2841), release, установлена из internal track
- Сеть: LTE, ~4 Мбит/с | Локаль: ru-RU | Системный шрифт: 100%
- Настройки разработчика: «Не сохранять действия» = ВКЛ (эмуляция нехватки памяти)
**Шаги**
1. Добавить любой товар в корзину, перейти к оплате
2. Свернуть приложение кнопкой Home, открыть камеру, снять фото
3. Вернуться в приложение через недавние
**Фактически:** корзина пуста, пользователь на главном экране, деньги не списаны
**Ожидается:** возврат на шаг оплаты с сохранённой корзиной
**Воспроизводимость:** 5/5 при «Не сохранять действия» = ВКЛ; 0/5 при ВЫКЛ
**Вложения:** видео экрана, logcat с меткой времени 14:32:10, скриншот
Опция разработчика «Не сохранять действия» (Don’t keep activities) — самый дешёвый и самый недооценённый инструмент мобильного тестировщика. Включите её и пройдите основные сценарии: вы гарантированно найдёте пару багов восстановления состояния, которые в проде проявляются у пользователей со слабыми устройствами и выглядят как «приложение глючит», без внятного репорта.
Доступность
Зачем это на самом деле
Три причины, и мотивируют они разных людей.
Люди. По оценке ВОЗ, примерно 16% населения — каждый шестой — живёт с существенным нарушением здоровья. Это не только слепота: слабое зрение, дальтонизм, глухота, тремор, травма руки, когнитивные особенности, дислексия. Плюс ситуативные ограничения, которые касаются вообще всех: солнце на экране, гипс на правой руке, шумное метро, одна рука занята ребёнком. Доступный интерфейс — это интерфейс, который не ломается в неидеальных условиях.
Закон. WCAG — не «рекомендации для энтузиастов», а нормативная основа: в ЕС это EN 301 549 и European Accessibility Act, в США — раздел 508 и практика по ADA, в России — ГОСТ Р 52872-2019 для сайтов госорганов. Для B2B и госзаказа отсутствие доступности всё чаще означает недопуск к тендеру.
Инженерия. Доступный код лучше структурирован. Семантический <button> вместо
<div onclick> бесплатно даёт фокус, клавиатуру, роль для скринридера — и устойчивый
локатор getByRole('button') для автотестов.
Модель: POUR и уровни AA
WCAG 2.2 строится на четырёх принципах (полный текст на w3.org):
- Perceivable (воспринимаемость) — контент можно воспринять: альтернативы для картинок, субтитры, достаточный контраст, работоспособность без цвета как единственного признака.
- Operable (управляемость) — всем можно управлять с клавиатуры, есть время на реакцию, ничего не мигает опасно, есть понятная навигация, зоны нажатия достаточного размера.
- Understandable (понятность) — предсказуемое поведение, понятные ошибки форм, язык страницы.
- Robust (надёжность) — корректная разметка и ARIA, чтобы вспомогательные технологии могли её разобрать.
Практический ориентир — уровень AA: он требуется законами и достижим. Уровень AAA целиком берут единицы, и в качестве цели проекта он обычно нереалистичен.
Честно: сколько находит автоматика
Это главный пункт раздела и место, где обычно обманывают себя.
Разработчики axe-core сами оценивают автоматическое покрытие примерно в 30–40%
нарушений — и это оценка по числу находимых типов проблем, а не по их важности.
Автомат отлично видит отсутствующий alt и недостаточный контраст. Он принципиально
не может проверить, что alt="изображение123" бесполезен, что порядок табуляции
осмыслен, что модальное окно удерживает фокус, что ошибка формы объявляется скринридером.
Отсюда рабочая формула: автоматика — это линтер, а не приёмка. Она защищает от регресса в грубых вещах и освобождает время человека на то, что умеет только человек.
Автопроверки в CI
Самый практичный путь — axe-core в существующих E2E:
// tests/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const KEY_PAGES = ["/", "/catalog", "/product/42", "/cart", "/checkout"];
for (const path of KEY_PAGES) {
test(`нет критичных нарушений доступности: ${path}`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
// Известный долг: виджет чата от подрядчика. Тикет A11Y-233, срок — Q3.
// Исключение с тикетом и датой, а не молчаливое отключение правила.
.exclude("#vendor-chat-widget")
.analyze();
const critical = results.violations.filter(
(v) => v.impact === "critical" || v.impact === "serious",
);
// Понятное сообщение: без него отчёт axe читать больно
const report = critical.map((v) =>
`${v.id} (${v.impact}): ${v.help}\n узлы: ${v.nodes.map((n) => n.target).join(", ")}\n ${v.helpUrl}`,
).join("\n\n");
expect(critical, `Нарушения на ${path}:\n\n${report}`).toEqual([]);
});
}
Тот же движок работает на уровне компонентов — это быстрее и ловит проблему раньше:
// Component-level: jest-axe / vitest-axe поверх Testing Library
import { render } from "@testing-library/react";
import { axe } from "jest-axe";
it("модалка подтверждения доступна", async () => {
const { container } = render(<ConfirmDialog open title="Удалить заказ?" />);
expect(await axe(container)).toHaveNoViolations();
});
Для нативных мобильных приложений аналоги: Accessibility Scanner и AccessibilityChecks
в Espresso на Android, Accessibility Inspector и XCUIApplication.performAccessibilityAudit()
в Xcode на iOS.
Ручные проверки, которые дают 80% результата
Пять проверок, которым можно научить любого члена команды за полчаса:
- Отложите мышь. Пройдите ключевой сценарий целиком: Tab, Shift+Tab, Enter, Space, стрелки, Esc. Вопросы: видно ли фокус всегда? совпадает ли порядок с визуальным? можно ли закрыть модалку? не проваливается ли фокус в скрытый контент за ней? не появляется ли «ловушка фокуса», из которой не выйти?
- Включите скринридер на 10 минут. NVDA (Windows, бесплатный), VoiceOver (macOS/iOS, Cmd+F5), TalkBack (Android). Не нужно быть экспертом: закройте глаза и попробуйте добавить товар в корзину. Опыт отрезвляет сильнее любого отчёта.
- Зум 200% и ширина окна 320px. По WCAG контент должен оставаться доступным без горизонтальной прокрутки. Ломается почти всегда.
- Уберите цвет. Включите оттенки серого в настройках ОС. Если ошибка формы обозначена только красной рамкой — 8% мужчин с дальтонизмом её не увидят.
- Пройдите с крупным системным шрифтом и с
prefers-reduced-motion: reduce.
Чек-лист доступности для PR
Короткий, чтобы им реально пользовались:
- [ ] Новые интерактивные элементы — семантические теги (button/a/input), не div с onclick
- [ ] Всё, что делается мышью, делается с клавиатуры; фокус виден (не убран outline)
- [ ] У картинок осмысленный alt; декоративные — alt="" (пустой, но присутствует)
- [ ] У полей есть связанный label; ошибка формы связана через aria-describedby
- [ ] Контраст текста ≥ 4.5:1 (крупного ≥ 3:1), у границ контролов ≥ 3:1
- [ ] Модалка: фокус входит внутрь, удерживается, Esc закрывает, фокус возвращается
- [ ] Динамические изменения (тост, счётчик) объявляются через aria-live
- [ ] Зона нажатия ≥ 24×24 CSS-пикселя (WCAG 2.2), на мобильном лучше 44×44
- [ ] Прогон axe на затронутых страницах — чисто или с тикетом на исключение
Где доступность встречается с мобильным и кроссбраузерным
Три темы статьи смыкаются не случайно:
- Скринридеры сами по себе — окружения: VoiceOver + Safari ведёт себя иначе, чем NVDA + Firefox. Баг доступности часто существует только в одной паре.
- Мобильная доступность — это ещё и размер зон нажатия, работа с жестами TalkBack/VoiceOver (свайпы перехватывает скринридер), поддержка Dynamic Type.
- Локаторы по роли (
getByRole) работают через дерево доступности. Если оно сломано, автотесты вынуждены цепляться за хрупкие CSS-селекторы. Плохая доступность делает автоматизацию дороже — этот аргумент действует на разработчиков лучше морального.
Как всё это уложить в CI, не разорившись
Ключевая идея: разные уровни матрицы — на разных триггерах. Подробности про quality gates и параллелизацию — в статье о тестах в CI.
# .github/workflows/compatibility.yml
name: compatibility
on:
pull_request: # быстрый сигнал: только Tier 1
schedule:
- cron: "0 2 * * *" # ночью — полная матрица
workflow_dispatch:
jobs:
browsers:
runs-on: ubuntu-latest
strategy:
fail-fast: false # обязательно: падение WebKit не должно скрывать статус Firefox
matrix:
project: ${{ github.event_name == 'pull_request'
&& fromJSON('["chromium-desktop","webkit-desktop","mobile-safari"]')
|| fromJSON('["chromium-desktop","webkit-desktop","mobile-safari","firefox-desktop","mobile-chrome","tablet-landscape","a11y-preferences"]') }}
shard: [1, 2] # шардирование внутри проекта — линейное ускорение
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps ${{ contains(matrix.project, 'webkit') && 'webkit' || 'chromium firefox' }}
- run: npx playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/2
env:
FULL_MATRIX: ${{ github.event_name != 'pull_request' }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: trace-${{ matrix.project }}-${{ matrix.shard }}
path: test-results/
retention-days: 7
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci && npx playwright install --with-deps chromium
# Блокирующий гейт только по critical/serious: иначе команда научится игнорировать
- run: npx playwright test tests/a11y.spec.ts
Три решения в этом конфиге стоит выделить:
fail-fast: false— иначе первый упавший браузер отменит остальные, и вы не узнаете, сломано везде или только в WebKit. Это и есть главная информация.- Разные матрицы на PR и ночью. Полная матрица на каждый PR — самый быстрый способ сделать CI бесполезно медленным и вырастить культуру «перезапусти, оно моргает».
- Гейт по доступности только на critical/serious. Порог, который команда не может выдержать, отключают целиком; лучше жёсткий гейт на малом и растущее покрытие.
Типичные ошибки
- Матрица из головы. «Поддерживаем последние две версии всех браузеров» без единого взгляда в аналитику. Обычно оказывается, что реальный Tier 1 — три конфигурации, а половина усилий уходит в хвост.
- Эмуляция вместо движка. Мобильный проект в Chromium с
devices["iPhone 14"]создаёт иллюзию покрытия iOS. Настоящий WebKit ведёт себя иначе. - Скриншот-тесты на всё подряд. Через два месяца команда обновляет эталоны, не глядя на диффы, — и визуальные тесты перестают что-либо ловить.
- Отключённые правила axe. Ровно как с подавленными предупреждениями компилятора: исключение без тикета и даты — это навсегда.
- Доступность в конце проекта. Сделать доступным готовый интерфейс из
divдороже, чем сразу писать семантику: это архитектурная характеристика, как и безопасность. user-scalable=noв мета-viewport. Одна строка, запрещающая пользователю зумить, — прямое нарушение WCAG и до сих пор встречается в шаблонах.- Тестирование только на флагманах. Команда с новыми iPhone не увидит, как продукт живёт на трёхлетнем Android за 12 тысяч — где, вероятно, и находится массовый пользователь.
- Один прогон вместо процесса. Разовый аудит доступности перед сдачей даёт отчёт, который устаревает через спринт. Работает только гейт в CI плюс чек-лист в PR.
Мини-итог
- Совместимость — задача выбора, а не покрытия: комбинаций десятки тысяч, ресурсов нет. Стройте матрицу по двум осям — доля трафика и цена отказа — и фиксируйте её документом с владельцем и датой пересмотра.
- Браузерных движков всего три, и это упрощает жизнь. Запускайте по одному представителю на движок, отдельно мобильный WebKit; ветвитесь в коде по feature detection, а не по UA.
- Эмулятор не заменяет устройство для производительности, железа, сети и батареи. Автоматика — на эмуляторах, исследовательское тестирование и приёмка — на реальном железе, включая одно заведомо слабое.
- Мобильная специфика — это жизненный цикл, прерывания, сеть, права и настройки ОС. Опция «Не сохранять действия» находит эти баги за полчаса.
- Доступность — требование закона и качества, а не благотворительность. Автоматика ловит 30–40% и работает как линтер; клавиатура, скринридер, зум и контраст остаются за человеком.
- Хорошая доступность удешевляет автоматизацию: роли и accessibility id — самые устойчивые локаторы из существующих.
Источники
- WCAG 2.2 и How to Meet WCAG (Quick Reference)
- WAI-ARIA Authoring Practices — готовые паттерны доступных компонентов
- Deque: axe-core и правила проверок
- Web Baseline и caniuse.com
- MDN: Cross-browser testing
- Playwright: Emulation, Visual comparisons, Accessibility testing
- Appium documentation и W3C WebDriver
- Android: Processes and app lifecycle, Testing your app’s accessibility
- Apple: Accessibility on iOS и Human Interface Guidelines: Accessibility
- WHO: Disability fact sheet
- Laura Kalbag. Accessibility for Everyone, A Book Apart — короткое и практичное введение
Что дальше
Мы разобрали самое дорогое измерение тестирования — окружения — и научились ограничивать матрицу вместо того, чтобы бесконечно её расширять. Тот же вопрос «за что мы платим и что получаем» стоит задать и всей автоматизации целиком: какие тесты окупаются, какие только создают иллюзию защиты, а какие писать не надо вовсе.
Стратегия автоматизации: пирамида, ROI, что автоматизировать не надо