Импакт-анализ на примере Android-проекта

Одной из самых дорогих по времени операций на CI-сервере является прогон автотестов. Есть множество способов их ускорения, например, распараллеливание выполнения по нескольким CI-агентам и/или эмуляторам, полная эмуляция внешнего окружения (backend/сервисы Google/вебсокеты), тонкая настройка эмуляторов (Отключение анимации/ Headless-сборки / отключение снепшотов) и так далее. Сегодня поговорим про импакт-анализ или запуск только тех тестов, которые связаны с последними изменениями в коде. Расскажу какие шаги нужны для импакт-анализа и как мы реализовали это в нашем проекте.

image-loader.svg

Шаг первый: получаем diff изменений.

Проще всего достигается встроенными средствами Git. Мы обернули работу импакт-анализа в Gradle-плагин и используем Java-обертку над Git — JGit. Для merge request мы используем premerge-сборки (это когда сначала выполняется объединение с целевой веткой, используется для оперативного выявления конфликтов), поэтому достаточно получить diff последнего коммита:

    val objectReader = git.repository.newObjectReader()
    val oldTreeIterator = CanonicalTreeParser()
    val oldTree = git.repository.resolve("HEAD^^{tree}")
    oldTreeIterator.reset(objectReader, oldTree)
    val newTreeIterator = CanonicalTreeParser()
    val newTree = git.repository.resolve("HEAD^{tree}")
    newTreeIterator.reset(objectReader, newTree)

    val formatter = DiffFormatter(DisabledOutputStream.INSTANCE)
    formatter.setRepository(git.repository)
    val diffEntries = formatter.scan(oldTree, newTree)
    val files = HashSet()
    diffEntries.forEach { diff ->
        files.add(git.repository.directory.parentFile.resolve(diff.oldPath))
        files.add(git.repository.directory.parentFile.resolve(diff.newPath))
    }
    return files

Но ничто не мешает собрать все коммиты между двумя ветками:

    val oldTree = treeParser(git.repository, previousBranchRef)
    val newTree = treeParser(git.repository, branchRef)
    val diffEntries = git.diff().setOldTree(oldTree).setNewTree(newTree).call()
    val files = HashSet()
    diffEntries.forEach { diff ->
        files.add(git.repository.directory.parentFile.resolve(diff.oldPath))
        files.add(git.repository.directory.parentFile.resolve(diff.newPath))
    }
    return files
private fun treeParser(repository: Repository, ref: String): AbstractTreeIterator {
    val head = repository.exactRef(ref)
    RevWalk(repository).use { walk ->
        val commit = walk.parseCommit(head.objectId)
        val tree = walk.parseTree(commit.tree.id)
        val treeParser = CanonicalTreeParser()
        repository.newObjectReader().use { reader ->
            treeParser.reset(reader, tree.id)
        }
        walk.dispose()
        return treeParser
    }
}

Шаг второй: собираем дерево зависимостей исходного кода.

Детализация дерева зависит от количества кода и автотестов. Чем больше детализация тем выше точность изоляции только нужных тестов, но медленнее отрабатывает сборка дерева. Сейчас мы собираем дерево зависимостей на уровне модулей, и присматриваемся к уровню отдельных классов.
Список модулей в проекте:

private fun findModules(projectRootDirectory: File): List {
    val modules = ArrayList()
    projectRootDirectory.traverse { file ->
        if (file.list()?.contains("build.gradle") == true) {
            val name = file.path
                .removePrefix(projectRootDirectory.absolutePath)
                .replace("/", ":")
            val pathToBuildGradle = "${file.path}/build.gradle"
            val manifestFile = File("${file.path}/$ANDROID_MANIFEST_PATH")
            if (manifestFile.exists()) {
                if (modulePackage != null) {
                    modules.add(Module(name))
                }
            }
        }
    }

    return modules
}

Ноды мы связываем парсингом файла build.gradle. Также дерево зависимостей можно генерировать не автоматически, а собрать один раз руками и переиспользовать. Преимущество — детализация любого уровня без влияния на время работы, недостаток — кому-то придется вручную поддерживать граф по мере развития проекта.

Шаг третий: выделяем все затронутые ноды дерева зависимостей.

Берем изменения из первого шага, сопоставляем с нодами из второго, и простым обходом в ширину находим все затронутые ноды.

private fun findAllDependentModules(origin: Module, links: Set): Set {
    val queue = LinkedList()
    val visited = HashSet()
    queue.add(origin)
    val result = HashSet()
    while (queue.isNotEmpty()) {
        val module = queue.poll()
        if (visited.contains(module)) {
            continue
        }
        visited.add(module)
        result.add(module)
        queue.addAll(links.filter { it.to == module }.map { it.from })
    }
    return result
}

Шаг четвертый: собираем список тестов, связанных с затронутыми нодами дерева зависимостей.

На этом этапе нам надо как то связать автотесты с нодами дерева зависимостей из второго шага. Путей для этого есть много (например связь через кастомные аннотации), но для надежного и всегда актуального состояния лучше парсить исходный код самих автотестов. Мы используем фреймворк Kaspresso, и для связки тестов с деревом зависимостей парсим тесты компилятором самого Kotlin. Собираем дерево зависимостей вида тесткейсы → сценарии → описания страниц (Page Object)→ ноды зависимостей из второго шага, потом обратным проходом получаем список всех нужных тестов.

Дерево зависимостейДерево зависимостей

implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.5.10")
private fun readUiTestsMetaData(modules: List): List {
    val testRootDirectory = rootDirectory.get().resolve(TEST_ROOT_PATH)
    val ktFiles = kotlinFiles(testRootDirectory)
    val pageObjects = ktFiles.mapNotNull { parsePageObjectMetaData(it, modules) }
        .sortedBy { it.name }
    val scenarioObjects = ktFiles.map { parseScenarioObjects(it, pageObjects) }.flatten()
    val scenarios = buildScenarioMetaData(scenarioObjects, pageObjects)
    return ktFiles.map { parseUiTestMetaData(it, scenarios, pageObjects) }
        .flatten()
        .sortedBy { it.name }
}

Шаг пятый: запускаем нужные тесты.

Штатное средство запуска тестов в Android позволяет фильтровать тесты по названию, пакету или привязанным аннотациям. Мы для запуска автотестов используем Marathon, у которого более широкая функциональность по фильтрации. В Teamcity на этапе импакт-анализ, наш Gradle-плагин собирает все автотесты из четвертого шага, выдирает из них идентификатор теста и пишет в файл. После этого при подготовке Marathon мы скармливаем ему все эти идентификаторы и получаем запуск только нужных тестов из всех существующих.

Сейчас полный прогон всех тестов занимает около 30 минут, и импакт анализ экономит нам минут 10. С дальнейшим развитием проекта и добавлением новых модулей/автотестов сэкономленное время будет только увеличиваться. Надеюсь статья оказалась вам полезной, and stay tuned folks:)

© Habrahabr.ru