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