Привет я Федотов Михаил, технический лидер по Android-разработке в Альфа-Банке. Сегодня хочу поговорить про performance (ускорение) unit-тестов.
Я работаю на проекте Android приложения Альфа-банка для физических лиц. Для нашего большого проекта это время — больная тема, так как у нас около 800 модулей и большая часть кода полностью покрыта unit тестами (за некоторыми исключениями, например, view классы). Сейчас у нас почти 6000 spec (тест-классов) в которых 37 000 тест-кейсов.
Общее время прогона всех тестов на CI превышало 3 часа. Локально все тесты прогнать вообще было утопией, так как вероятнее всего сборка просто падала от нехватки оперативной памяти.
Сейчас все тесты проекта мы научились прогонять за честные 12 минут и я расскажу что для этого потребовалось.
Конечно же мы изворачивались, чтобы не ждать по 3 часа на каждом пулл-реквесте, пока прогонится вся эта куча тестов, ведь таких ПР у нас десятки, а то и сотня в день. Для рядовых пулл-реквестов мы применяли impact-анализ (и продолжаем это делать). Эта техника позволяет найти затронутые вашими изменениями части кода и запустить тесты только для них. Так получается намного меньше тестов.
Но ПР-ы в основную master-ветку и перед релизами приложения всё равно страдали. Если на этом этапе тесты находили ошибку, то их перезапуск — это дополнительные 3 часа, что тоже крайне болезненно.
В результате мог уйти почти целый рабочий день.
Однако, как оказалось, главная проблема медленных тестов это далеко не их количество.
Поиск проблем
Мы начали анализировать, на что же уходит время при прогоне тестов. Обложили логами множество областей кода и вызовов методов, и после череды экспериментов, проб и ошибок сделали 2 интересных вывода:
Огромное кол-во времени уходит на инициализацию моков.
Каждый последующий тест выполнятся дольше предыдущего.
Уточним наш технический стек:
Kotlin — язык разработки.
MockK — библиотека для мокирования.
Gradle — система сборки приложения, она же запускае тесты.
Kotest — это Kotlin native фреймворк для прогона unit тестов. Сами тесты исполняются на JUnit платформе.
И будем разбираться по порядку.
Инициализация моков
Думаю, ни для кого не секрет, что это дорогая операция, но насколько?
Примечание. Я буду рассказывать в контексте MockK, но для других фреймфорков, например, Mockito, общая логика такая же.
Мок моку рознь.
Если мокируете interface, то под капотом MockK использует dynamic proxy механизм (встроен в JVM), он работает достаточно быстро.
Но что происходит, когда вы мокируете class? «Коробочного» решения от Java платформы не существует, и чтобы мокировать класс, да от которого ещё и наследоваться нельзя (Kotlin классы такие по умолчанию), MockK использует генерацию байт-кода на лету. За это отвечает библиотека byte-buddy (Mockito тоже её использует). Поэтому, когда мы написали mockk<Activity>(), то под капотом мы запустили генерацию байт-кода и его динамическое исполнение в рантайме.
Звучит тяжело. И это так и есть: замеры показали, что на создание мока Activity уходит больше 1 секунды. Это не просто дорогой вызов, а золотой.
Существует корреляция между размером класса, который мокируется, и временем, которое уходит на создание мока. Activity — это огромный класс с кучей методов, поэтому он мокируется долго. Более мелкие классы будут мокироваться быстрее.
Хорошая новость в том, что операция генерации байт-кода для класса кешируется. Долгая только первая попытка — все последующие выполняются за 1 мс, то есть дёшево.
Деградация производительности при выполнении тестов
Примечание. Эта проблема специфична для MockK. Так что если вы не используете эту библиотеку можете пропустить блок.
Замеры производительности помогли выяснить, что каждый вызов метода clearAllMocks() происходит все дольше и дольше. Давайте разбираться что это за метод и почему так происходит.
Мы вызываем
clearAllMocks(), чтобы очистить записи в моках.Каждый мок запоминает взаимодействия с ним, какие методы и с какими аргументами вызывались (это, собственно, и отличает его от обычного stub).
После того, как наш тест повзаимодействовал с моком, и мы проверили эти записи через verify блок, моки нужно почистить, чтобы приступить к следующему тесту с чистого листа.
Не сложно догадаться, что для того, чтобы
clearAllMocks()могла выполнять свою работу, библиотека MockK вынуждена хранить внутри список всех, когда-либо созданных моков.
Так она и делает: там есть глобальная map, которая пополняется каждый раз, когда вы вызываете mockk<Anything>()метод. Данная map по задумке авторов должна автоматически очищаться с помощью GC, но, видимо, что-то идёт не по плану, и очистки на практике мы так ни разу и не увидели. Зато размер этой map постоянно растёт, что приводит к тому, что операции очистки всех моков становятся очень долгими.
Короче говоря, течёт память.
Примечание. Если вас интересует детали по этой теме то вот issue на GitHub, в рамках него делаем полноценное решение.
Gradle параллелит тесты
Последний гвоздь в крышку гроба быстрых unit-тестов забивает наш всеми горячо любимый Gradle, который за последнее время попил немало моей крови.
Система сборки стремится всё ускорить и, конечно же, путём распараллеливания.
Тесты разных модулей он видит как отдельные таски.
Для каждой такой таски он заботливо создаёт свой JVM-процесс, запускает в нём все тесты модуля.
И делает это параллельно для нескольких тасок (смотрите своё значение
maxWorkers).
Концептуально это классный и правильный подход, но если учесть, что мы теперь знаем про создание моков и насколько дорога их первичная инициализация, то становится понятно, насколько это может быть не оптимально.
К сожалению, в Gradle не существует встроенной механики, чтобы заставить тесты разных модулей исполняться в одном единственном JVM-инстансе, чтобы использовать кеширование генерации байт-кода.
Если грубо прикинуть, около половины наших модулей точно используют мок Activity. Получаем 400 JVM-процессов, в каждом из которых инициализация мока Activity занимает 1 секунду. Получаем 1 сек * 400 = около 6 минут. Столько времени мы теряем на создания одного единственного мока при стандартной стратегии запуска тестов через Gradle.
Переходим к решению
Думаю, внимательный читатель уже догадался, что нужно использовать тот факт, что генерация байт-кода кэшируется. Чтобы это использовать, нам требуется, чтобы unit-тесты разных модулей исполнялись в одном JVM-процессе, а не пересоздавали его каждый раз заново.
Также нам требуется решить вторую проблему с деградацией производительности тестов, которая кроется в бесконечном росте внутренней map с моками в MockK.
Для начала мы написали кастомную gradle-таску, которая сканирует все наши модули с тестами, собирает из них classpath для каждого модуля в коллекцию и запускает все unit-тесты всех модулей разом в одном JVM-процессе. По сути, это был просто полный отказ от распараллеливания.
Также мы прикрутили костыль для MockK, который через рефлексию чистит внутреннюю map (пришлось повозиться с исключениями для глобальных моков). Эти две манипуляции позволили выполнить все тесты проекта за 1,5 часа.
Теперь осталось только разбить коллекцию classpath модулей на 10 (maxWorkers) чанков. В результате мы получили прогон всех тестов в проекте за 12 минут. И это время с учетом конфигурации проекта, которое занимает несколько минут, — сами тесты идут ещё быстрее. Фактически мы отказываемся от стандартной стратегии распараллеливания тестов Gradle (по модулям) и заменяем её на распараллеливание всех тестов на 10 (maxWorkers) чанков.
Напомню что на ПР-ах мы также применяем impact-анализ, который позволяет сократить количество прогоняемых тестов до необходимого минимума. Так что теперь прогоны тестов на рядовых ПР-ах могут укладываться даже в одну минуту.
Минусы
Негативной стороной такого подхода можно назвать то, что тесты становятся более чувствительными к ошибкам разработчиков, чем до этого — уменьшается изоляция тестов друг от друга на уровне JVM-процесса.
Утечки глобальных моков (
mockkStatic,mockkObject,mockkConstructor) — отсутствие вызова соответствующего unmock метода.Использование моков с вечным (глобальным) временем жизни.
Сложность реализации и поддержки. Мы сталкивались с не самыми очевидными проблемами, например, выбором неправильной версии зависимостей, которые попадают в classpath транзитивно.
Большинство этих проблем и раньше не соответствовало нашим соглашениям и являлись ошибками разработчиков, но они могли оставаться скрытыми и не отстреливали из-за того, что тесты были изолированы друг от друга в рамках модуля (процесса).
Выводы
Проверенные временем стандартные подходы, которые применяются нами ежедневно и повсеместно, даже такие привычные как запуск тестов через Gradle, могут оказаться крайне не оптимальными в вашем конкретном сценарии. Стоит критически относиться к производительности сборок и тестов, так как они занимают значительную часть времени для поставки приложения в продакшн.
На этом все, спасибо за внимание!
В будущем возможно выложим финальное решение в виде Gradle-плагина в open-source. А пока заходите ко мне на GitHub где есть репо с начальными черновиками Gradle-таски, описанной в статье.
Делитесь вашими подходами к решению проблем производительности в комментах)
Если вам откликается подход, описанный в статье и хочется поучаствовать в развитии нашей платформы, присоединяйтесь к команде — у нас как раз открыты вакансии:
• QA Automation на C# — будете развивать автотесты для наших backend‑сервисов и CI/CD-пайплайнов.
• Senior AQA (автотесты на Java) — роль с фокусом на архитектуру автотестов, выбор инструментов и менторство команды.
Подписывайтесь на Телеграм-канал Alfa Digital. Рассказываем о работе в IT и Digital, делимся полезными советами, новостями, видео с митапов, краткими выжимками из статей и многим другим, иногда шутим.
Подписывайтесь на блог Альфа-Банка на Хабре, впереди много хороших статей.
Читайте также:
