Одной из самых дорогих по времени операций на 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 :)
