Когда проект растёт, дольше всего начинают тянуться самые «тяжёлые» проверки – 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-тестов раскладывается на четыре шага:

  1. Получаем список изменённых модулей – этому мы уже научились во второй части.

  2. Устанавливаем связь между модулем и тестом – через @AffectedModules.

  3. Определяем, какие тесты надо запустить.

  4. Формируем команду для тест-раннера.

Первые два шага мы уже рассмотрели. Разберём оставшиеся.

Определяем, какие тесты надо запустить

Допустим, разработчик поменял :base_network. Обходим граф зависимостей и получаем список модулей, затронутых (modified):

Изменился: base_network
Затронутые модули: base_network, core, feature1, app

Теперь проходим по всем UI-тестам и смотрим: пересекается ли список из @AffectedModules
с затронутыми модулями?

Тест

@AffectedModules

Запускать?

testNewsListScreen()

feature1

✅ да, feature1 затронут

testNewsDetailsScreen()

feature1

✅ да, feature1 затронут

testAboutAppScreen()

feature2

❌ нет, feature2 не задет

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.