Когда проект растёт, а вместе с ним — количество проверок, CI превращается в марафон. Мы в Циан через это прошли: кодовая база растёт, тестов становится всё больше, и каждое изменение начинает тормозить весь пайплайн.
В этой статье расскажу, как мы сократили время выполнения unit-тестов с помощью Impact-анализа — метода, который позволяет запускать только те тесты, которые действительно нужны. Это продолжение моего подхода к оптимизации проверок в Android — в первой статье я показывал, как ускорить статические анализаторы. Теперь — про unit-тесты.
Примеры кода будут на JUnit, но подход подходит ко всем проверкам, которые гоняются через Gradle. В конце статьи — рабочий пример на GitHub, который можно адаптировать под себя.
В чём вообще проблема
В прошлой статье я показывал, как с помощью git diff можно понять, какие файлы изменились, и запустить на них статические проверки. Для detekt это отлично работает — берём изменившийся файл и проверяем его.
А вот с unit-тестами так просто не получится. Здесь важно не только то, какой файл поменялся, но и кто от него зависит. Если ты изменил какой-то базовый класс, тестировать нужно не только его, но и все модули, которые от него зависят — напрямую или транзитивно.
Разберём на примере:
У нас есть модуль :feature, который тянет к себе :core как implementation. :core, в свою очередь, — это фасад, через который фича подключает всё базовое: например, модуль :base-network, в котором лежит логика походов в сеть. :core подключает :base-network как api, чтобы классы из него были доступны в :feature.

Допустим, в :feature мы делаем свою реализацию NetworkClient, которая наследуется от базовой. То есть :feature зависит от :base-network — пусть и не напрямую, а через :core.
Теперь представьте: мы поменяли что-то в BaseNetworkClient в :base-network. Очевидно, что нужно прогнать тесты и для :base-network, и для :feature, даже если в :feature сам код не трогали.
Вот тут и появляется проблема: анализ по изменившимся файлам нам не поможет, потому что он не учитывает зависимости между модулями.
Чтобы учесть транзитивные связи, нужно копать глубже — в граф зависимостей Gradle. То есть теперь мы будем анализировать изменения на уровне модулей, а не отдельных файлов. И запускать тесты там, где действительно может что-то сломаться.
Как нам поможет граф Gradle-модулей
Gradle-модули в многомодульном проекте образуют ациклический направленный граф: один модуль зависит от другого, но не может зависеть от себя обратно. Это удобно — по такому графу можно точно понять, какие части проекта затронуты изменениями.
Чтобы не гонять все тесты подряд, мы проверяем только изменившиеся модули и всё, что от них зависит. Такой подход мы называем optimized-проверками. Покажу, как это работает, на примере.
Допустим, у нас два экрана:
:feature1— основная функциональность, ходит в сеть, сохраняет данные в базу и отображает их пользователю.:feature2— простой экран «О приложении», почти без логики.
Обе фичи используют компоненты из модуля :ui-kit, чтобы приложение выглядело одинаково. А :feature1, помимо этого, зависит ещё от :base-network (через :core) и от :base-database. Получается вот такая картина:

Теперь предположим, что кто-то внёс изменения в :base-network. Что нам нужно проверить?
Тесты самого
:base-network;Тесты
:core, который на него ссылается;Тесты
:feature1, которая зависит от:core;Тесты
:app, если он объединяет всё в один APK.

То есть нам нужно обойти граф зависимостей и отметить все модули, которые:
были изменены напрямую (
changed);зависят от изменённых (
affected).
Мы объединяем их под общим понятием modified.
Если же кто-то затронул :ui-kit, то запускать надо тесты всех модулей, которые его используют: :feature1, :feature2, :app — вне зависимости от того, трогали ли их напрямую.
Принцип простой: обходим граф, находим все изменённые и затронутые модули, и... всё. Регистрируем Gradle-таску, которая запускает проверки только для этих модулей.

Подытожим: обходим граф зависимостей, находим все изменённые и затронутые модули — и регистрируем Gradle-таску, которая запускает проверки только для них. Разработчику остаётся просто её вызвать.


Ну а теперь — по шагам, как мы всё это реализовали у себя.
Получаем список измененных файлов
В прошлой статье мы уже говорили, что список изменившихся файлов относительно целевой ветки (в нашем случае — origin/develop) можно получить с помощью двух команд:
git diff --name-status origin/develop... git diff --name-status HEAD
При разработке первой версии нашего плагина мы вдохновлялись open-source решением от Avito, так что часть кода по поиску изменений в Git может показаться знакомой.
За определение изменений отвечает интерфейс ChangesSearcher:
internal interface ChangesSearcher { /* Общий интерфейс для получения измененных файлов из разных источников (git/report) */ fun computeChanges( targetDirectory: File, ): List<ChangedFile> } internal data class ChangedFile( val rootDir: File, val file: File, val changeType: ChangeType )
Одна из реализаций — GitChangesSearcher. Она использует ProviderFactory, чтобы выполнить git diff и вернуть результат как текст:
private fun getRawGitDiff(targetBranch: String): String { val command = arrayOf("git", "diff", "--name-status", "$targetBranch") println(command.joinToString(separator = " ")) val result = providerFactory.exec { commandLine(*command) } .standardOutput .asText .get() return result }
Выход выглядит примерно так:
A modules/common/base-database/src/main/java/com/dkonopelkin/NewFile.kt A modules/feature/feature2/api/src/main/java/com/dkonopelkin/feature2/api/AnotherNewFile.kt M modules/feature/feature2/api/src/main/java/com/dkonopelkin/feature2/api/ChangedFile.kt D modules/feature/feature2/api/src/main/java/com/dkonopelkin/feature2/api/DeletedFile.kt
Первая буква обозначает тип изменения:
internal enum class ChangeType(val code: Char) { ADDED('A'), COPIED('C'), DELETED('D'), MODIFIED('M'), RENAMED('R'); }
Напомню, что команду git diff мы выполняем дважды — чтобы получить как закоммиченные изменения (targetBranch), так и те, что находятся под индексом (HEAD). Дальше склеиваем результаты и превращаем их в список объектов ChangedFile — тех самых, что описаны выше в internal data class.
private fun gitDiffWith(targetBranch: String): Set<ChangedFile> { val committedChanges = getRawGitDiff(targetBranch) val workTreeChanges = getRawGitDiff("HEAD") val diffResult = committedChanges.plus(workTreeChanges) println("git diff result:") println(diffResult) return diffResult .lineSequence() .filterNot { it.isBlank() } .map { line -> parseGitDiffLine(line).asChangedFile(gitRootDir) } .toSet() }
Чтобы не гонять git diff каждый раз заново, результат вычисляем один раз и кешируем — через by lazy. Это и быстрее, и чище:
private val gitDiff by lazy { gitDiffWith(targetBranch) }
На выходе получаем список всех файлов, которые были изменены в проекте, вместе с путями. Отлично — можно переходить к следующему шагу.
Превращаем файлы в модули
Следующий шаг — понять, в каких Gradle-модулях были изменения. Нам нужен способ сопоставить путь изменённого файла и директорию конкретного модуля. Самый прямой путь: сравнить project.projectDir с путём к файлу.

Для этого мы используем метод computeChanges, который берёт директорию модуля и отфильтровывает из списка ChangedFile только те файлы, что к ней относятся:
private fun computeChanges( targetDirectory: File ): List<ChangedFile> { if (!targetDirectory.toPath().startsWith(gitRootDir.toPath())) { throw IllegalArgumentException("$targetDirectory must be inside $gitRootDir") } val targetPath = targetDirectory.toPath() return gitDiff .filter { changedFile -> changedFile.file.toPath().startsWith(targetPath) } .toList() }
Чтобы обойти все модули проекта, нам нужно сначала собрать их список. Получаем его через rootProject.subprojects и сохраняем как Map<ModuleData, File> — где ключ содержит информацию о модуле, а значение — путь к его директории:
moduleDataMap: Map<ModuleData, File> = project.rootProject.subprojects.associate { val moduleData = ModuleData( name = it.name, relativePath = it.projectDir.absolutePath.replace(it.rootProject.projectDir.absolutePath, "") ) moduleData to it.projectDir } internal data class ModuleData( val name: String, val relativePath: String )
Теперь проходимся по этой мапе и для каждого модуля вычисляем изменения через changesSearcher.computeChanges(...). А те модули, где ничего не поменялось, отбрасываем:
fun getChanges( changesSearcher: ChangesSearcher ): Map<ModuleData, List<ChangedFile>> { return moduleDataMap .mapValues { changesSearcher.computeChanges(it.value) } .filterValues { it.isNotEmpty() } }
На выходе получаем карту: в каких модулях что-то изменилось, и какие конкретно файлы это были. Если интересует только список затронутых модулей — берём map.keys. Всё, теперь у нас есть стартовая точка для следующего шага — анализа графа зависимостей.
Находим зависящие модули
Первый вопрос, который встаёт — где взять тот самый граф модулей? Здесь стоит вспомнить, что у Gradle есть свой жизненный цикл: инициализация, конфигурация и выполнение.

Когда я только начал реализовывать Impact-анализ для проверок, запускаемых через Gradle, мне показалось хорошей идеей анализировать граф модулей, создаваемый на этапе конфигурации.
Но на практике такое решение не прошло проверку боем. Проблема в том, что граф становится доступен только на завершающей стадии фазы конфигурации, чтобы зарегистрировать новый таск на этой стадии приходится использовать непопулярные callback’и Gradle, что создаёт запутанный и непонятный код. На execution регистрировать таск вообще запрещено.
Получилось замкнутое: вроде бы граф есть, а использовать его неудобно. И, что важнее, полная конфигурация проекта всё равно выполняется — а это 2–3 минуты, даже если потом мы запускаем всего пару тестов. Ценность такого ускорения резко падает.
В итоге мы пошли другим путём. Поскольку структура проекта у нас стандартизирована, и зависимости модулей явно прописаны в build.gradle.kts и settings.gradle.kts, мы начали собирать граф модулей, парся эти файлы. Такой подход полностью снимает ограничения, связанные с жизненным циклом Gradle.
Подробнее об этом подходе рассказывал мой коллега Данил Перевалов @princeparadoxes на Mobius 2024 — рекомендую, если хочется сократить не только время проверок, но и сборки.
Теперь, когда у нас есть граф и список изменённых модулей с предыдущего шага, можно найти все затронутые:
Достаём сет модулей, код которых изменен:
val changedModules = allModulesInfoList .associateWith { changesSearcher.computeChanges(File(it.absolutePath)) } .filterValues { it.isNotEmpty() }
Получаем карту модулей, где
@key - информация о модуле
@value - список модулей, которые используют в своём build.gradle.kts модуль ключа (зависят от него).
Исключаем модули, которые не должны учитываться как affected. Например sample-приложения:
val moduleDependantMap: Map<ModuleInfo, Set<ModuleInfo>> = moduleGraphBuilder.buildModuleDependantGraph(allModulesInfoList) .mapValues { (_, dependents) -> dependents.filterNot { it.name in excludeAffectedModules }.toSet() }
Находим модули которые зависят от измененных модулей напрямую и транзитивно и возвращаем результат:
val affectedModules = moduleGraphAnalyser.findAffectedModules( moduleDependantMap = moduleDependantMap, changedModules = changedModules.keys ) return ProjectChanges( changedModuleList = changedModules.keys.map { it.name }.toSet(), affectedModuleList = affectedModules.affectedModuleList.map { it.name }.toSet(), transitivelyAffectedModuleList = affectedModules.transitivelyAffectedModuleList.map { it.name }.toSet() )
Если хочется провалиться в реализацию — в sample-проекте на GitHub всё открыто.
Теперь у нас есть список всех модифицированных модулей. Осталось немного — зарегистрировать новую Gradle-тас��у и настроить запуск проверок.
Регистрируем Gradle-таски для запусков только на нужных модулях
Чтобы подключить выборочные проверки, мы написали Gradle-плагин, который применяется к rootProject. В нём задаётся конфигурация — какие проверки запускать и на каких модулях: только на изменённых или ещё и на зависимых от них.
enum class OptimizeStrategy( val taskList: Set<String>, val targetProjects: (ProjectChanges) -> Set<String>, ) { ONLY_CHANGED_MODULES( taskList = setOf("detekt",), targetProjects = { it.changedModuleList } ), CHANGED_MODULES_WITH_DEPENDENCIES( taskList = setOf("testDebugUnitTest"), targetProjects = { it.changedModuleList.plus(it.affectedModuleList)} ) }
Дальше — три шага.
1. Создаём optional-обёртку для таски
На каждый модульный таск создаём таску-обёртку с префиксом optional. Это защита от случаев, когда у модуля нет нужной таски — например, testDebugUnitTest.
val optionalTaskProvider = tasks.register(getOptionalTaskName(checkName)) { group = IMPACT_ANALYSIS description = "Вызывает таску '$checkName', если она есть в модуле" } tasks.named { it == checkName }.configureEach { optionalTaskProvider.get().dependsOn(this) }

2. Создаём optimized-таску на уровне root-проекта
Это основная точка входа для запуска оптимизированных проверок. Например:
./gradlew :optimizedTestDebugUnitTest
3. Связываем optimized и optional таски
Находим изменённые и затронутые модули, пробегаем по ним и подключаем соответствующие optionalTask в dependsOn.
val optimizedTaskName = getOptimizedTaskName(taskName) root.tasks.register(optimizedTaskName) { group = IMPACT_ANALYSIS description = "Вызывает таску '$taskName' только для изменённых модулей" mustRunAfter(ImpactAnalysisChangedFileReportTask.NAME) val changes = getChanges(root) for (changedModule in strategy.targetProjects(changes)) { val optionalTaskName = getOptionalTaskName(taskName) dependsOn(":$changedModule:$optionalTaskName") } }

Теперь всё просто: если вызвать
./gradlew :optimizedTestDebugUnitTest
Gradle запустит проверки только на нужных модулях, а не на всём проекте. Это и есть вся магия. Дальше — обсудим, как оно работает в реальности.
Что в итоге
Собрав статистику за месяц после внедрения, мы зафиксировали заметное улучшение: медианное время выполнения unit-тестов сократилось примерно вдвое относительно полного прогона всех тестов.

Напомню, у нас около 110 pull request'ов в месяц, каждый из которых прогоняет тесты — и плюс столько же локальных запусков перед ними. Только за счёт этого подхода мы экономим порядка 22 часов ожидания тестов в месяц. Это меньше времени на кофе и больше времени на разработку.
Решение мы уже активно используем, и оно показало себя отлично. Ниже — то, что у нас уже есть, и что планируем добавить в ближайшее время.

На этом у меня всё. Надеюсь, подход окажется полезным и для вашей команды. Если вы уже оптимизируете CI или пробовали что-то подобное — обязатель��о напишите в комментариях, какие приёмы сработали у вас. Делитесь опытом — так мы все экономим время.
