
Почта — массовый продукт, которым пользуются самые разные люди, и поэтому он должен быть максимально доступен. Нам важно, чтобы приложение Яндекс Почты было удобно каждому, в том числе людям с особенностями зрения.
На телефонах с Android за доступность для людей с особенностями зрения отвечает TalkBack — функция озвучивания экрана, которая зачитывает текст, роль и состояние всех размеченных элементов на экране и позволяет с ними взаимодействовать. Если разметка сломается, приложением будет пользоваться сильно сложнее или вообще невозможно.
Команда невизуального тестирования Яндекса ежегодно составляет рейтинг доступности сервисов, и в лигу I на всех платформах регулярно попадает Яндекс Почта. Меня зовут Владислав Шиляев, я разрабатываю Android-приложение Яндекс Почты и отвечаю за то, чтобы Почта продолжала входить в топ рейтинга доступности. В статье я расскажу, как мы решали проблему регрессий доступности с помощью снапшот-тестов в приложении и как уже поймали два бага до мёржа PR.
Что такое доступность приложения в понимании разработчика
В Android-приложении Почты мы пишем все новые экраны на Jetpack Compose, поэтому дальше в статье будем рассматривать только его. Сейчас экранов на Jetpack Compose достаточно мало и дефектов, соответственно, тоже, но с увеличением экранов на Compose дефектов станет намного больше.
За доступность в Compose отвечает семантика — информация о смысле и роли каждого компонента. Например, у компонента Text в семантическом свойстве Text находится текст, а у кнопки прописана роль Role = Button. Эти свойства компонентов собираются в семантическое дерево, которое передаётся сервисам доступности, например тому же TalkBack.
TalkBack интерпретирует семантическое дерево по своим правилам и эвристикам, зачитывает элементы пользователям и позволяет взаимодействовать с ними. Встроенные компоненты вроде Text, Icon или Button поддерживают доступность из коробки, но, чтобы создать кастомный компонент, семантику придётся строить с нуля: например, чтобы нарисовать текст, нужно не забыть установить семантическое свойство Text.
@Composable fun CustomText(text: AnnotatedString, modifier: Modifier = Modifier) { Canvas( modifier = modifier .semantics { this.text = text }, ) { ... } }
Нюансов и тонкостей семантических свойств в Compose немало, поэтому мы не будем останавливаться на этом в статье — они хорошо описаны в документации Android. Из-за того, что деталей так много, в работе легко ошибиться, и нужна система проверок, чтобы не допускать регрессии доступности.
Почему ручных проверок доступности недостаточно
Случайно сломать доступность очень легко: обновили дизайн-систему, сделали рефакторинг компонента или перенесли логику в общий компонент — и семантическое дерево сломалось. На код-ревью такие изменения могут быть не видны, баг попадёт сразу в продакшен и может заблокировать сценарий некоторым пользователям, использующим скринридеры, а для нас это недопустимо.
Чтобы мы могли поддерживать высокую планку лиги I в рейтинге доступности, коллеги из команды невизуальной доступности регулярно проводят ручной регресс приложения и собирают список дефектов, например:
у кнопки нет подписи;
у элемента нет роли, хотя семантически это кнопка, переключатель или вкладка;
состояния «выбрано» или «включено» не зачитываются;
переключатель и текст к нему зачитываются как разные элементы, хотя логически это один элемент управления;
декоративные элементы зачитываются, хотя не должны.
Такие ручные проверки нам очень помогают, но поскольку они дорогие, часто запускать их не получается. Нам нужен был инструмент, который автоматически ловил бы скрытые регрессии до того, как код попадал в продакшен.
Наш первый подход: сохраняем семантику в файл
Самая очевидная защита от регрессий доступности — сохранить идеальное семантическое дерево в файл и каждый раз сравнивать с ним текущий результат. Если есть различия, смотреть, случайные они или намеренные.
Это и было нашим первым решением. Давайте посмотрим, как оно работало на одном из наших самых простых экранов «О приложении».

В библиотеке Compose UI Test уже есть функция SemanticsNodeInteraction.printToString, которая умеет выводить семантическое дерево в строку. Давайте посмотрим, как выглядит дерево для компонента MailAboutPreview (это превью для MailAbout со скриншота выше, динамические данные типа версии будут различаться). Для этого напишем простой тест:
class MailAboutPreviewSemanticsTreeTest { @get:Rule val composeRule = createComposeRule() @Test fun printA11ySemanticsTree() { composeRule.setContent { MailAboutPreview() } val semanticsTree = composeRule .onRoot() .printToString() println(semanticsTree) } }
Текстовый снапшот семантического дерева MailAboutSnapshot
Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=0.0, r=20.0, b=1280.0)px |-Node #2 at (l=0.0, t=0.0, r=720.0, b=1280.0)px IsContainer = 'true' |-Node #16 at (l=0.0, t=0.0, r=720.0, b=1280.0)px | IsTraversalGroup = 'true' | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)' | Actions = [ScrollBy, ScrollByOffset] | |-Node #17 at (l=0.0, t=170.0, r=720.0, b=488.0)px | | ContentDescription = '[Yandex Mail logo]' | | Actions = [OnLongClick] | | MergeDescendants = 'true' | | |-Node #19 at (l=27.0, t=346.0, r=693.0, b=474.0)px | | Text = '[Yandex Mail (beta), Version 10.0.0 dated Sep 1, 2025 Build 123]' | | [Heading] | | Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution] | | MergeDescendants = 'true' | |-Node #25 at (l=0.0, t=563.0, r=720.0, b=740.0)px | | |-Node #27 at (l=0.0, t=563.0, r=720.0, b=658.0)px | | | |-Node #28 at (l=0.0, t=563.0, r=720.0, b=658.0)px | | | Focused = 'false' | | | Actions = [RequestFocus] | | | |-Node #30 at (l=0.0, t=563.0, r=720.0, b=658.0)px | | | ContentDescription = '[UUID. 00000000000000000000000000000000.]' | | | Text = '[UUID, 00000000000000000000000000000000]' | | | Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution] | | | MergeDescendants = 'true' | | |-Node #36 at (l=0.0, t=658.0, r=720.0, b=740.0)px | | ContentDescription = '[Button.]' | | Focused = 'false' | | Role = 'Button' | | Text = '[Button]' | | Actions = [ClearTextSubstitution, GetTextLayoutResult, OnClick, RequestFocus, SetTextSubstitution, ShowTextSubstitution] | | MergeDescendants = 'true' | |-Node #42 at (l=0.0, t=740.0, r=720.0, b=1146.0)px | |-Node #43 at (l=0.0, t=1166.0, r=720.0, b=1198.0)px | Text = '[(c) 2010-2025 «Yandex»]' | Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution] |-Node #6 at (l=27.0, t=41.0, r=706.0, b=116.0)px |-Node #7 at (l=27.0, t=58.0, r=68.0, b=99.0)px, Tag: 'TOP_BAR_SLOT_ICON' ContentDescription = '[Back]' Focused = 'false' Role = 'Button' Actions = [OnClick, RequestFocus] MergeDescendants = 'true'
Отлично, мы получили снапшот, теперь осталось сравнить значение строки semanticsTree с идеальным снапшотом и фейлить тест, если найдутся различия. Казалось бы, мы только начали — и вот уже на финишной прямой, однако у такого решения есть некоторые проблемы:
printToStringпечатает много информации без полезных свойств. Такие элементы TalkBack всё равно будет пропускать, а вот тесты из-за них падают. Эта проблема не слишком критична, её можно решить форкомprintToStringи отфильтровать лишнее.Сам текстовый формат неудачный, и его тяжело ревьюить. Высока вероятность, что ревьюер увидит огромный дифф снапшота, не захочет разбираться, в чём изменения и важны ли они, просто скажет: «LGTM!» и апрувнет PR. Все наши старания в этот момент потеряют смысл.
И вторая проблема для нас уже критична: мы не хотели мириться с тем, что никто не будет ревьюить снапшот-тесты.
Выход из тупика: подключаем скриншот-тесты
Нам нужен был формат, который легко и приятно визуально оценивать. И мы поняли, что наш подход со снапшотами очень похож на скриншот-тесты, которые мы делаем на других проектах с помощью Paparazzi. Оказывается, они могут спасти нас и тут.
В библиотеке есть расширение AccessibilityRenderExtension, которое накладывает поверх скриншота важные для доступности семантические свойства. Напишем тест:
class MailAboutSnapshotTest { @get:Rule val paparazzi = Paparazzi(renderExtensions = setOf(AccessibilityRenderExtension())) @Test fun test() = paparazzi.snapshot { MailAboutPreview() } }

Результат выглядит следующим образом: на сгенерированной Paparazzi картинке слева находится скриншот, а справа важные для доступности свойства. Например, кнопка «Назад» подписана Back, Button. Если у этой кнопки поменяются какие-то свойства, мы сразу это увидим.
Интеграция: включаем AccessibilityRenderExtension
Если Paparazzi уже внедрён в проект, то достаточно передать renderExtensions = setOf(AccessibilityRenderExtension()) в конструктор Paparazzi. Практически бесплатно можно превратить свои скриншот-тесты в снапшот-тесты доступности. В Почте мы не пользовались Paparazzi — разберём, как мы его внедряли.
Добавляем
Paparazziв version catalog
В gradle/libs.versions.toml добавляем плагин и библиотеку Paparazzi. [versions] paparazzi = "2.0.0-alpha02" # https://github.com/cashapp/paparazzi/releases [libraries] paparazzi = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazzi" } [plugins] paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } mail-screenshotTest = { id = "screenshot-test" } # Для нашего внутреннего convention-плагина
2. Регистрируем внутренний Gradle-convention-плагин
В файле gradle-convention/kotlin-plugins/build.gradle.kts регистрируем convention-плагин, который позволит подключать Paparazzi одной строкой в каждом модуле вместо того, чтобы прописывать вручную параметры и зависимости на каждом новом модуле:
gradlePlugin { plugins { register("screenshot-test") { id = "screenshot-test" implementationClass = "com.yandex.mail.gradle.plugins.ScreenshotTestPlugin" } } }
3. Реализуем convention-плагин
Нужно, чтобы в каждый модуль, где применяется convention-плагин, автоматически подтягивались Gradle-плагин Paparazzi и внутренние функции из модуля :core:screenshot-test, который мы создадим позже:
class ScreenshotTestPlugin : Plugin<Project> { override fun apply(target: Project) { with(target) { applyPlugins() applyDependencies() } } private fun Project.applyPlugins() { with(pluginManager) { apply(libs.plugins.paparazzi) } } private fun Project.applyDependencies() { dependencies { testImplementation(project(":core:screenshot-test")) } } }
4. Создаём модуль :core:screenshot-test
Модуль будет содержать установки Paparazzi, специфичные для Почты:
fun accessibilityPaparazzi( environment: Environment = detectEnvironment(), deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5, theme: String = "android:Theme.Material.NoActionBar.Fullscreen", renderingMode: RenderingMode = RenderingMode.NORMAL, appCompatEnabled: Boolean = true, supportsRtl: Boolean = false, showSystemUi: Boolean = false, validateAccessibility: Boolean = false, useDeviceResolution: Boolean = false, ) = mailPaparazzi( renderExtensions = setOf(AccessibilityRenderExtension()), // Сразу устанавливаем AccessibilityRenderExtension environment = environment, deviceConfig = deviceConfig, theme = theme, renderingMode = renderingMode, appCompatEnabled = appCompatEnabled, supportsRtl = supportsRtl, showSystemUi = showSystemUi, validateAccessibility = validateAccessibility, useDeviceResolution = useDeviceResolution, ) fun mailPaparazzi(...) = Paparazzi( environment = environment, deviceConfig = deviceConfig, theme = theme, renderingMode = renderingMode, appCompatEnabled = appCompatEnabled, snapshotHandler = SnapshotVerifier(maxPercentDifference = 0.0), // По умолчанию не разрешаем никакие различия на снапшоте renderExtensions = renderExtensions, supportsRtl = supportsRtl, showSystemUi = showSystemUi, validateAccessibility = validateAccessibility, useDeviceResolution = useDeviceResolution, )
Также сделаем интерфейс-маркер, которым будем помечать все скриншот-тесты, чтобы прогонять только их или, наоборот, исключать при необходимости:
interface ScreenshotTests
5. Применяем convention-плагин в модуле с Compose UI
Когда инфраструктура готова, остаётся подключить convention-плагин в модуль с нужным UI — в нашем примере это модуль с экраном «О приложении» :feature:about:presentation
plugins { ... alias(libs.plugins.mail.screenshotTest) }
6. Пишем тест
Теперь пишем тест для компонента MailAboutPreview. Для этого воспользуемся функцией accessibilityPaparazzi из нашего core-модуля и пометим класс маркером ScreenshotTest:
@Category(ScreenshotTests::class) class MailAboutSnapshotTest { @get:Rule val paparazzi = accessibilityPaparazzi() @Test fun test() = paparazzi.snapshot { MailAboutPreview() } }
7. Записываем baseline скриншотов
Для записи скриншотов используется Gradle-задача recordPaparazzi<variant>. Например, для нашего случая это будет ./gradlew :feature:about:presentation:recordPaparazziBetaGooglePlayDebug.
На этом базовая интеграция закончена. Тесты можно запускать, как минимум локально. Могут возникнуть проблемы с CI, но с ними мы разберёмся чуть позже.
8. Пишем много-много тестов
Когда мы настроили инфраструктуру полностью, мы перенесли этот подход практически на все экраны, написанные на Jetpack Compose. Предлагаю посмотреть на некоторые из них.
Примеры скриншотов




Интеграция в CI: работаем с разницей рендеринга на macOS и Linux
Если создать PR с текущими тестами и baseline, записанными локально, можно обнаружить неприятный сюрприз: локально все тесты проходят, а проверка на CI красная из-за минимальных различий в несколько пикселей. Дело в том, что разные ОС и окружения по-разному рендерят шрифты, сглаживание и тени, поэтому локальные скриншоты, сделанные, например, на macOS, будут отличаться от скриншотов, которые получаются на Linux CI.
Чтобы исключить эту разницу в рендере, baseline-скриншоты должны рисоваться только на CI. То есть флоу должен быть таким:
тесты как обычно запускаются на CI;
те скриншоты, в которых найдены расхождения, после CI-прогона перекладываются в нужные папки репозитория;
CI предлагает сделать коммит прямо в PR.
К сожалению, Paparazzi не предлагает одновременно функции проверки и, если не совпало, перезаписи. Обычный запуск тестов кладёт несовпавшие скриншоты в папку build, а задача recordPaparazzi не проверяет скриншоты. Поэтому мы сделали свою Gradle-задачу, которая перекладывает несовпавшие скриншоты из папки build в нужные папки в репозитории, и вшили её в convention-плагин:
class ScreenshotTestPlugin : Plugin<Project> { override fun apply(target: Project) { with(target) { ... configureMoveTask() } } private fun Project.configureMoveTask() { val isEnabled = providers.gradleProperty("recordFailedScreenshotTests") val recordFailedScreenshotTests = tasks.register<RecordFailedScreenshotTestsTask>("recordFailedScreenshotTests") { onlyIf { isEnabled.orNull?.toBoolean() ?: false } failuresDir.set(layout.buildDirectory.dir("paparazzi/failures")) snapshotsDir.set(layout.projectDirectory.dir("src/test/snapshots/images")) } tasks.withType<Test>().configureEach { finalizedBy(recordFailedScreenshotTests) } } } @DisableCachingByDefault(because = "Moves failed Paparazzi snapshots into source snapshots directory") abstract class RecordFailedScreenshotTestsTask : DefaultTask() { @get:Internal abstract val failuresDir: DirectoryProperty @get:Internal abstract val snapshotsDir: DirectoryProperty @TaskAction fun record() { val failuresDir = failuresDir.get().asFile val snapshotsDir = snapshotsDir.get().asFile if (!failuresDir.exists()) return failuresDir .walkTopDown() .filter { it.isFile && !it.name.startsWith("delta-") } .forEach { src -> val relativePath = src.relativeTo(failuresDir).path val dest = snapshotsDir.resolve(relativePath) dest.parentFile.mkdirs() Files.move(src.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING) } } }
Теперь на CI мы можем запускать тесты с переданным ключом -PrecordFailedScreenshotTests = true, а потом предлагать коммит в PR прямо из этого же прогона.
Сколько времени занимает интеграция и поддержка
Интеграцию в CI по инструкции, описанной выше, можно выполнить за пару дней или быстрее, если не возникнет никаких блокеров.
Скорее всего, если вы пишете на Jetpack Compose, у вас уже есть набор Preview для своих компонентов в разных состояниях. Они часто подходят и для скриншот-тестов, поэтому написать их можно довольно дёшево, тем более современные LLM-агенты отлично справляются и с реализацией Preview, и с созданием тестов на их основе.
История успеха № 1. Сломалась доступность таба «Профиль»
После обновления версии дизайн-системы таб «Профиль» перестал зачитываться ридером как единый элемент, и стало невозможно взаимодействовать с ним через TalkBack. Визуально интерфейс никак не изменился: скорее всего, без снапшот-тестов доступности мы бы долго не замечали этот баг, и он мог попасть в прод. А так — поймали баг на этапе код-ревью и починили до вливания PR в основную ветку.

Диф скриншотов

История успеха № 2. Сломался текст версии
Этот баг отловили бы и обычные скриншот-тесты, что только подчёркивает двойную полезность тестов доступности. После рутинного обновления версии внутренней дизайн-системы на экране «О приложении» пропало слово Version. Благодаря тестам мы заметили и исправили это до того, как PR ушёл в основную ветку.

Дифф скриншотов

Выводы
Доступность может восприниматься на проекте как что-то факультативное — мол, сделаем потом, когда разберёмся с остальными фичами. Но на практике это такая же необходимость, как тесты на бизнес-логику. Ручные проверки здесь незаменимы, но обходятся слишком дорого по ресурсам QA-отдела.
Снапшот-тесты доступности отлично помогают решить эту проблему: они позволяют автоматически фиксировать изменения в семантическом дереве и делают их наглядными для разработчика и ревьюера. Если доступность изменилась, разработчик видит это до мёржа и может либо исправить регрессию, либо осознанно принять изменения.
Для Почты внедрение снапшот-тестов уже окупилось пойманными до попадания в прод багами и двойным профитом: мы получаем и защиту от визуальных регрессий, и автоматический контроль доступности.
Если вы сомневаетесь, стоит ли заморачиваться с accessibility-тестами, — на наш взгляд, определённо стоит. Порог входа низкий, польза реальная и проверенная на живых багах, а ещё это сэкономленные нервы тестировщиков и разработчиков.