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

от автора

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

Шаг первый: получаем 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<File>()     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<File>()     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<Module> {     val modules = ArrayList<Module>()     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<Link>): Set<Module> {     val queue = LinkedList<Module>()     val visited = HashSet<Module>()     queue.add(origin)     val result = HashSet<Module>()     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<Module>): List<UiTestMetaData> {     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 🙂


ссылка на оригинал статьи https://habr.com/ru/company/citymobil/blog/647519/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *