Всё, везде, но не сразу: как мы написали Test Impact Analysis и сократили время Android-сборки с 90 до 35 минут
Рассказывает Аида Сундетова, Senior Mobile Automation QA Engineer, Kolesa Group

Пять лет назад в Kolesa Group активно начала развиваться автоматизация тестирования приложений. Писались юниты, инструментальные(далее - UI) тесты. К этому времени команда автоматизаторов Android-приложений, состоящая из 4 человек, успела написать около 200 End-to-End UI тестов на каждом проекте.
Ежедневно эти тесты запускались на 50 разных сборках, около 20 андроид разработчиков с трех проектов(Kolesa.kz, Krisha.kz, Avtoelon.uz) работали над кодом, разрабатывая новый функционал, который обязательно надо было проверить автотестами. Помимо этого, в команде мобильной разработки было 15 продуктовых Mobile QA, которые могли запустить автотесты для прогона перед релизом или для проверки UI после изменений на бэкенде.
Не все запуски тестов были полезны - низкий процент покрытия автотестами нового функционала делал прогон всех тестов на каждой ветке дорогим и не всегда оправданным. Тем не менее, критичные кейсы из нового функционала все равно необходимо было покрыть и количество тестов стало больше. Но, следовательно, сборки стали дольше, time to market задачи увеличивался.
Для ускорения прогона автотестов существующими инструментами, использовалась вся мощь фильтрации, аналитики и перезапусков тестового раннера Marathon. Тесты запускались на 2 агентах - машинах, на которых выполняется сборка и запуск тестов в рамках CI/CD процессов. На каждом таком агенте было по 2 девайса и сборка всех тестов занимала 1 час. Увеличивать количество агентов и девайсов на тот момент было затруднительно.
Инфраструктура начала не выдерживать такой нагрузки: появились очереди, ферма девайсов периодически выходила из строя. Время ожидания сборки в очереди занимала от 1 до 1.5 часов, приходилось тормозить вручную не очень важные сборки, чтобы собрать приоритетную задачу для релиза.
Появилась острая необходимость ускорить сборку, уменьшить время ожидания и сделать это надо было быстро.
В этой статье расскажу, как мы пришли к идее и реализовали собственный Test Impact Analysis - запуск тестов по измененному функционалу, и какие конкретно сложности ждут, если решитесь на то же самое.
Первая попытка: smoke-аннотации
Сначала мы решили применить быстрое решение — разметить тесты аннотацией @SmokeTest и запускать только их. Набралось около 100 тестов, время сборки сократилось до 20 минут. Минус - упустили некритичный баг из-за теста, непомеченным этой аннотацией. Потом этот тест тоже был помечен, но это показало, что всегда есть риск пропустить какие то баги, так как запускались одни и те же тесты.
Test Impact Analysis: что это и зачем
Test Impact Analysis (TIA) — это методика в тестировании, используемая для определения какие тесты необходимо запускать при изменении кода. Это помогает сократить время на прогон тестов, особенно в больших проектах, где полный тест-ран может быть очень дорогим по времени. Самая известная статья по этой теме была написана Paul Hammant, называется The Rise of Test Impact Analysis. Он одним из первых описал, как строить связь между изменениями в коде и конкретными тестами, и ввёл сам термин TIA в широкий обиход.
Мы вдохновлялись и другими примерами реализаций TIA: поиск изменений по модулям, по макету экрана и его PageObject-у. В итоге, появилось понимание, как можно это использовать для нашего проекта.
Архитектура тестов: что нужно понять перед кодом
Прежде чем переходить к реализации TIA, важно понять структуру тестовой кодовой базы. На схеме ниже иллюстрирована структура одного package-а с тестами, далее просто - Пакета.

У каждого пакета с тестами есть уникальное имя(например, packageName). Основные директории Пакета:
- Rule(рулы) - это набор базовых правил для конкретного экрана, например, в рулах создается объявление, и открывается через intent с указанным в руле активити. Рулы вызываются из тестов.
- Lookup(лукап) - это хранилище элементов, содержащее в себе matcher(матчеры) для всех элементов экрана, который относится к данному пакету. Лукапы используются в Screens(скрины). Скрины содержат классы с методами-действиями: клики, скроллы,ассерты.
- Screens используются непосредственно в тестах.
В тестовом коде это выглядит следующим образом:

Запомним, что в данном пример название пакета - детали объявления.
Подготовка данных
Для реализации идеи TIA, связанная с концепцией поиска измененного View экрана, нужно было упорядочить тестовые данные: проверить все lookup-объекты и ID ресурсов в них на соответствие правильному пакету. Например, вью с ресурсом advert_details_layout должен создаваться в пакете детали объявления в коде приложения:

Для этого ищем по всему проекту, где используется этот ресурс, на скрине можно увидеть, что он используется в пакете детали_объявления. Значит, ресурс находится в верном лукапе нужного пакета.

Просмотрев все лукапы, мы расположили ID ресурсов в соответствующие пакеты. Некоторые папки пришлось переименовывать, либо создавать новые, таким образом, была подготовлена тестовая структура к работе со скриптом тест импакт анализа. Данный процесс занял около трех недель и участвовали все 4 автоматизатора.
Таким образом, данные и структура тестов была подготовлена к реализации основной идеи TIA: если изменен пакет в коде приложения, значит запускаются все тесты, которые используют Screen с Lookup из одноименного пакета в тестовом коде.
Реализация Kolesa TIA
Общий флоу

Начнем с описания общего флоу: в начале с помощью команды git-diff формируется список измененных файлов(diffFileContent) по сравнению с мастер веткой. Далее этот список попадает в TIA и в качестве параметра передается название проекта, для которого будут запускаться тесты(таких у нас 3). На выходе получается файл со списком тестов.
Измененные файлы условно делятся на следующие категории, это может быть:
- Код приложения
- Модуль с тестами - androidTest
- Библиотечные модули
- Gradle файл
- Библиотечный модуль для тестов
Шаг 1. Условия запуска всех тестов
Первым делом проверяется, затронут ли BaseTestData — от его классов наследуются все screens, rules и тесты. Если да — определить конкретные затронутые тесты невозможно и поэтому запускаются все тесты:

Шаг 2. Проверка изменения Gradle-файлов

Следующий шаг - в переменную isContainsGradleChanges записывается true, если изменено что-то в gradle файле. Так как при поднятии версии библиотек, в коде изменений не будет, в таком случае нужно запускать все тесты, чтобы убедиться, что поднятие версии ничего не сломало.
Шаг 3. Сборка имен изменённых пакетов

Для поиска измененных пакетов из кода приложения, просматривается содержимое файла diffFileContent. В условиях проверяется, есть ли в пути до измененного файла библиотечная папка modules либо папка кода приложения app_code_path. Если хотя бы одно условие выполнилось, то в список имен пакетов appPackages добавляется название пакета. По названию пакетов из appPackages, в дальнейшем можно найти screen-ы, по которым запустятся тесты. В этих пакетах находится только код приложения.
Шаг 4. Анализ изменений в тестах и данных с тестового модуля
В модуле с тестовыми данными и директорией androidTest есть следующие категории файлов: тестовые классы, константы, объекты для параметризированных тестов.
В условии поиска проверяется, тестовый ли это модуль (в этом случае путь должен содержать папку androidTest и путь до проекта). Если условие выполняется, то можно записывать имена пакетов в список testPackages. Этот список создан отдельно, чтобы понимать, что это пакеты, содержащие тесты.

Данный список нужен для анализа, если изменён или добавлен тест — то запускаем все тесты из этого пакета, не только изменённый. Это необходимо для проверки, что новый тест атомарен и не повлияет на соседние тесты.
Шаг 5. Анализ изменённых rules
Если в путях измененных файлов есть /rules/, то сохраняются они в соответствующие списки в формате импортов, чтобы потом было легче найти их в тестах. Например, определив, что в пути до CabinetRule есть папка rules, эту строку можно превратить в формат импорта, сделав несложные манипуляции со строкой.

Шаг 6. Поиск изменённых screens через lookups
Заключительный список - измененные Screens, где хранятся все действия в вью матчерами. Для его создания по diffFileContent происходит поиск матчеров по скринам либо лукапам, и проверяется, что путь должен содержать app_code_path либо testModule, в котором хранятся скрины/лукапы общепроектных модулей.

Если Lookup найден, то по нему можно найти путь до файла Screen, который его использует. Это просто, так как пути Lookup и Screen, отличаются только окончанием и названием пакета.

Далее по этому пути открывается файл и прочитывается до имени Screen.

Таким образом можно найти имя скрина и записать в список измененных скринов. Анализ файлов из git diff закончен. Теперь нужно сформировать список путей для дальнейшей обработки.
Шаг 7. Сопоставление изменённых пакетов с rules и screens
С помощью рекурсивного поиска по папкам и подпапкам проекта, создаются списки путей до всех тестов, rules, screens.

Шаги 5 и 6 были найдены rules и screens только там, где менялся сам файл rule или lookup. Но если поменялся файл в пакете кода приложения, а файлы rules и lookup в тестах не трогали — эти rules и screens пока не попали в списки на запуск тестов. Поэтому был добавлен шаг с анализом таких случаев:
Сначала в цикле происходит анализ списка измененных пакетов из кода приложения и библиотек appPackages. Проверяются условия: содержится ли измененный пакет appPackage в списках rules либо screens. Если да, то снова производя манипуляции со строкой, эти rules либо screens добавляются в соответствующие списки.

Шаг 8. Создание списка тестов на запуск
Это была самая простая часть во всей реализации Kolesa TIA:
- Проходя по списку всех тестов проекта projectTests и по списку testPackages, в содержимом файла каждого теста анализируется первая строка с package — в ней указано имя пакета, к которому принадлежит тест. Если есть совпадение в имени пакета, то тест будет запущен.

- Дальше построчно в содержимом тестов происходит поиск screens и rules по импорту. Если импорт найден — значит, он используется в этом тесте, и тест добавляется в прогон.

Шаг 9. Финальная проверка и запись
Последний шаг TIA — проверить, что список тестов не пустой, и записать его в файл. Этот файл со списком тестов передаётся в Marathon TestRunner.

Здесь и проверяется значение переменной isContainsGradleChanges, которая фиксирует, затронуты ли gradle-файлы.
Если список тестов на запуск пустой, но gradle-файлы были изменены — в консоль выводится RunAllTests, и запускаются все тесты проекта.

Запуск тестов в CI
Файл с тестами передается в Марафон с фильтром по SimpleClassName, который динамически включается через переменную среды MARATHON_SMOKE_TEST. Динамическая фильтрация позволила переключаться между запуском всех тестов и отфильтрованных без правки файла конфигурации Marathon.


Если файл с тестами пустой, то тесты не запускаются вообще. Такое бывает, если из трех проектов, измененную библиотеку используют только 2, то нет смысла прогонять тесты для третьего проекта. Если список с тестами не пустой, то он передается на запуск. В CI все эти условия разруливаются в bash скрипте, который добавлен в github пайплайне.
Проблемы, с которыми столкнулись
В ранних версиях Marathon не было фильтрации по имени класса и динамического включения фильтров. Чтобы решить этот вопрос, форкнули Marathon, написали свой фильтр и добавили проверку на пустой список — без неё Marathon при пустом списке запускал все тесты.
ID ресурсов лежали не в тех пакетах. TIA не находил соответствие между изменённым кодом и тестами. Решение — проверка расположения ресурсов в code review: смотрим каждый новый ViewMatcher и убеждаемся, что пакет правильный.
Баг в мастере из-за теста, которого TIA не включил. Тест проверял виджет на экране детали объявления, но импортировал только screen виджета — screen родительского экрана не использовал. При изменении пакета деталей TIA тест не нашёл. Решение: screen виджета теперь вызывается из screen родительского экрана. При изменении пакета деталей TIA автоматически захватывает тесты виджета.
Результаты
На графике показано время 20 уникальных сборок, собранных за неделю по проекту, где самое большое количество тестов - 473. Красной линией показано время сборки тестов без оптимизации - 90 минут, синими столбиками показано время тех же сборок, но с оптимизацией. В сборках с оптимизацией гораздо меньше тестов и по времени соответственно они собираются быстрее. Даже есть сборки, где запускались 8-9 тестов и сборка занимала 4-5 минут. Время прогона в среднем уменьшилось с 90 минут до 35 минут, и количество очередей тоже сократилось. Теперь запускаются тесты, которые связаны с измененным функционалом.

Сборка самого скрипта тест импакт анализа занимает от 7 до 15 сек, в зависимости от количества измененных файлов.
Заключение
От идеи до стабильной работы заняло около полугода. Большая часть времени ушла не на код, а на подготовку данных и проверку гипотез на реальных Pull Request(PR). Ниже подробная таблица со сроками и этапами разработки:
| Этап | Срок | Кто |
| Актуализация ID ресурсов по пакетам | ~3 недели | 4 автоматизатора |
| TIA для кода приложения + настройка Marathon | ~2 месяца | 1 человек |
| Раскатка, мониторинг PR, багфиксы | 2–3 месяца | 4 человека |
| Расширение TIA для кода тестов | ~1 месяц | 1 человек |
Если есть желание построить свой TIA, то могу дать следующие советы:
- Нужно учитывать архитектуру ваших тестов
- Искать зависимости и логику связи между кодом приложения и тестами
- Подобрать подходящий тестовый раннер для запуска отфильтрованных тестов
- Не забывать постоянно поддерживать и адаптировать ваш TIA