Когда проект растёт, а вместе с ним — количество проверок, 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 или пробовали что-то подобное — обязательно напишите в комментариях, какие приёмы сработали у вас. Делитесь опытом — так мы все экономим время.