Тестирование ПО Мобильное, кроссбраузерное тестирование и доступность
0%

Мобильное, кроссбраузерное тестирование и доступность

Мобильное, кроссбраузерное тестирование и доступность

Три темы в одной статье — не потому что они мелкие, а потому что у них общий корень. Все три отвечают на один вопрос: работает ли продукт не только там, где его писали.

Разработчик пишет код на 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 дней и постройте распределение. Оно почти всегда выглядит так:

Длинный хвост окружений: три конфигурации дают 72% трафика

Картинка задаёт политику, которую можно защитить перед менеджментом:

Уровень Что входит Что гоняем Кто чинит баг
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.

Фермы устройств и облачные гриды

Когда нужны реальные браузеры и устройства, есть три пути:

  1. Свой Selenium Grid в Docker (docker-selenium) — дёшево, полностью контролируемо, но только Chromium/Firefox под Linux. Ни Safari, ни iOS.
  2. Облако — BrowserStack, Sauce Labs, LambdaTest. Реальные Safari, реальные iPhone, ручные сессии для отладки. Дорого (обычно за параллельные сессии), медленно (сеть), и добавляет внешнюю зависимость: их сбой = ваш красный CI.
  3. Firebase Test Lab / AWS Device Farm — для нативных мобильных приложений, оплата по минутам устройства.

Совет по стоимости: не гоняйте в облаке весь регресс. Гоняйте там только те проверки, которые нельзя получить локально — то есть WebKit на реальной iOS и пару популярных Android. Остальное отлично живёт в контейнерах.

Мобильное тестирование

Три разных мира под одним словом «мобильное»

Разница принципиальна не только в инструментах, но и в цене ошибки. Веб чинится выкаткой за 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% результата

Пять проверок, которым можно научить любого члена команды за полчаса:

  1. Отложите мышь. Пройдите ключевой сценарий целиком: Tab, Shift+Tab, Enter, Space, стрелки, Esc. Вопросы: видно ли фокус всегда? совпадает ли порядок с визуальным? можно ли закрыть модалку? не проваливается ли фокус в скрытый контент за ней? не появляется ли «ловушка фокуса», из которой не выйти?
  2. Включите скринридер на 10 минут. NVDA (Windows, бесплатный), VoiceOver (macOS/iOS, Cmd+F5), TalkBack (Android). Не нужно быть экспертом: закройте глаза и попробуйте добавить товар в корзину. Опыт отрезвляет сильнее любого отчёта.
  3. Зум 200% и ширина окна 320px. По WCAG контент должен оставаться доступным без горизонтальной прокрутки. Ломается почти всегда.
  4. Уберите цвет. Включите оттенки серого в настройках ОС. Если ошибка формы обозначена только красной рамкой — 8% мужчин с дальтонизмом её не увидят.
  5. Пройдите с крупным системным шрифтом и с 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 — самые устойчивые локаторы из существующих.

Источники

Что дальше

Мы разобрали самое дорогое измерение тестирования — окружения — и научились ограничивать матрицу вместо того, чтобы бесконечно её расширять. Тот же вопрос «за что мы платим и что получаем» стоит задать и всей автоматизации целиком: какие тесты окупаются, какие только создают иллюзию защиты, а какие писать не надо вовсе.

Стратегия автоматизации: пирамида, ROI, что автоматизировать не надо

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов