Когда проект растёт, дольше всего начинают тянуться самые «тяжёлые» проверки – UI-тесты. Один-два модуля поменял, а CI всё равно гоняет сотни тестов на эмуляторах и ждёт их десятки, а то и сотни минут. Знакомо?
Это третья, финальная статья цикла о том, как мы в Циане перешли от полного прогона всех проверок к выборочному с помощью Impact-анализа. В первой части я показывал, как мы ускорили статические анализаторы, во второй — как в два раза сократили unit-тесты, научившись обходить граф Gradle-модулей. В заключительной части я расскажу, как у нас на проде реализован выборочный запуск UI-тестов.

Где мы остановились
Быстро напомню главное из прошлых частей.
UI- и unit-тесты нельзя выбирать просто по изменившимся файлам: важно не только то, какой файл поменялся, но и что от него зависит. Поэтому мы работаем на уровне Gradle-модулей и их графа зависимостей. Обходя граф, мы находим:
модули, которые изменили напрямую (
changed);модули, которые от них зависят — напрямую или транзитивно (
affected).
Объединение этих двух множеств мы называем modified — это всё, что могло сломаться. Сам граф мы собираем не через конфигурацию Gradle: она занимает 2–3 минуты, что забирает львиную долю выгоды. Собираем через парсинг build.gradle.kts и settings.gradle.kts.
Подробности реализации я описывал во второй части. По итогам на входе у нас получается список modified-модулей, и для unit-тестов мы регистрируем Gradle-таску, которая гоняет тесты только в этих модулях.
С UI-тестами так в лоб не получится, и скоро я расскажу почему.

Напоминаю схему нашего тестового приложения из пары фичей и базовых модулей. На нём мы и будем проводить эксперименты. Для простоты можете представить, что feature1 — список новостей, а feature2 — статичный экран «О приложении».
Так что с UI-тестами?
Под UI-тестами мы понимаем инструментальные (instrumentation) тесты, которые гоняют на реальном устройстве или эмуляторе. Пишем мы их на Kaspresso и Kakao – это Kotlin-DSL поверх Espresso, на котором сценарий читается почти как текст: «открой экран», «нажми кнопку», «проверь, что виден текст». А запускает их всех тест-раннер Marathon, раскидывая по ферме устройств.
Немного фактов про UI-тесты в нашем проекте:
Это самый долгий вид проверок.
Полный прогон примерно 800 тестов занимает около 50 минут.
Принцип оптимизации тот же, что и раньше: запускать только то, что затронуто изменениями.
Но есть проблема. В отличие от unit-тестов, которые лежат рядом с кодом в своих модулях, в нашей архитектуре все UI-тесты лежат в одном модуле. Когда я меняю код в :feature1, граф модулей честно подсказывает, что затронут :feature1. Но в самом :feature1 UI-тестов нет: все они в отдельном тестовом модуле, и связь между тестом и модулем, к которому он относится, отсутствует.
Получается, граф зависимостей, который так выручил нас с unit-тестами, для UI-тестов бесполезен. Нам нужен механизм, который вручную объявляет связь «UI-тест ↔ модуль».
Объявляем связь «UI-тест ↔ модуль»
Самое простое и явное решение – аннотация над тестом, где разработчик перечисляет модули, к которым этот тест относится:
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) annotation class AffectedModules( // Имена модулей, изменение которых должно приводить к прогону теста val modules: Array<String>, )
Теперь рядом с каждым тестом видно, какие модули он проверяет:
@Test @AffectedModules(modules = ["feature1"]) fun testNewsListScreen() { ... } @Test @AffectedModules(modules = ["feature1"]) fun testNewsDetailsScreen() { ... } @Test @AffectedModules(modules = ["feature2"]) fun testAboutAppScreen() { ... }
Дальше весь Impact-анализ UI-тестов раскладывается на четыре шага:
Получаем список изменённых модулей – этому мы уже научились во второй части.
Устанавливаем связь между модулем и тестом – через
@AffectedModules.Определяем, какие тесты надо запустить.
Формируем команду для тест-раннера.
Первые два шага мы уже рассмотрели. Разберём оставшиеся.
Определяем, какие тесты надо запустить
Допустим, разработчик поменял :base_network. Обходим граф зависимостей и получаем список модулей, затронутых (modified):
Изменился: base_network Затронутые модули: base_network, core, feature1, app
Теперь проходим по всем UI-тестам и смотрим: пересекается ли список из @AffectedModules
с затронутыми модулями?
Тест | @AffectedModules | Запускать? |
|
| ✅ да, |
|
| ✅ да, |
t |
| ❌ нет, |
Feature2 никак не зависит от base_network, поэтому testAboutAppScreen спокойно
пропускаем. Список тестов на запуск сохраняем в файл — его прочитает CI.
Формируем команду для тест-раннера
На CI шаг запуска тестов сначала проверяет, есть ли файл со списком выбранных тестов. Если есть – передаёт их в Marathon:
./marathon mock -m "testNewsListScreen|testNewsDetailScreen"
Чтобы такой подход не разваливался, мы добавили пару валидирующих тасок, которые
гоняются в проверках:
У каждого UI-теста должна быть
@AffectedModules, и списокmodulesне должен быть пустым. Иначе тест рискует никогда не запуститься и тихо «протухнуть». Также проверка гарантирует, что все элементы списка — реально существующие в проекте модули. Иначе gradle-таск упадёт.Имена тестов должны быть уникальны — мы фильтруем тесты по имени, и одинаковые имена сломали бы выборку.
Что в итоге
Медиана прогона UI-тестов с Impact-анализом упала примерно с 50 до 25 минут – то есть примерно вдвое. Для самого долгого вида проверок это ощутимо: при наших 200+ pull request'ах в месяц экономия времени и нагрузки на ферму устройств получается солидной.
Что мы сделали за весь цикл статей
На этом цикл про Impact-анализ можно закрывать. Давайте соберём вместе, что нам дала оптимизация каждого вида проверок:
Detekt (часть 1) — ускорили в 10+ раз, отказавшись от Gradle в пользу CLI (секунды вместо минут).
Unit-тесты (часть 2) — сократили примерно вдвое за счёт графа модулей.
UI-тесты — сократили примерно вдвое (c 50 до 25 минут) через
@AffectedModulesи
выборочный запуск в Marathon.
А вот как это выглядит на сводной метрике времени выполнения всех проверок на CI:
Перцентиль | До оптимизации | После оптимизации |
p99 | 77 мин | 48 мин (−37%) |
p90 | 60 мин | 40 мин (−33%) |
p75 | 55 мин | 37 мин (−33%) |
median | 47,5 мин | 27 мин (−42%) |
Главный вывод прост: время проверок должно зависеть от размера изменений, а не от размера проекта. Чем больше растёт кодовая база, тем сильнее окупается выборочный запуск — и тем легче и быстрее проходит pull request.
Спасибо, что дочитали цикл до конца! Если вы оптимизируете CI или пробовали что-то похожее — расскажите в комментариях, какие приёмы сработали у вас.
📺 Видеоверсию этого доклада можно посмотреть на RuTube.

