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

Комментарии (0)