Протестируй это: как мы определяем, какие тесты запускать на пулл-реквест-чеках
Привет, Хабр! Меня зовут Егор Даниленко. Я занимаюсь разработкой цифровой платформы корпоративного интернет-банка Сбербанк Бизнес Онлайн, и сегодня я хочу рассказать вам о процедуре CI разработки, принятой у нас.
Как изменения разработчика доходят до вливания в релизную ветку? Разработчик делает изменения локально и пушит в нашу систему управления версиями. Мы используем Bitbucket с авторским плагином (об этом плагине мы писали ранее здесь). На этих изменениях запускается сборка и гоняются тесты (юнит, интеграционные, функциональные). Если сборка не завалилась и все тесты пройдены успешно, а также после успешного ревью, пулл-реквест вливается в основную ветку.
Но со временем количество команд выросло. Пропорционально выросло и количество тестов. Мы понимали, что такое количество команд ускорит наступление проблемы «медленного пулл-реквест-чека», и разрабатывать продукт станет невозможно. На текущий момент у нас порядка 40 команд. Вместе с новыми фичами они приносят и новые тесты, которые также нужно запускать на пулл-реквестах.
Мы подумали, что было бы круто, если бы мы знали, какие тесты нужно запустить под изменение конкретного куска кода.
И вот как мы решили эту задачу.
Постановка задачи
Имеется проект с тестами, и мы хотим определять какие тесты нужно запускать при «затрагивании» определенного файла.
Все мы знаем о библиотеке для покрытия кода JaCoCo от EclEmma. Ее мы и взяли за основу.
Немного о JaCoCo
JaCoCo — библиотека для измерения покрытия кода тестами. Работа основана на анализе байт кода. Агент собирает информацию о выполнении и выгружает ее по запросу или остановке JVM.
Существует три режима сбора данных:
- Файловая система: после остановки JVM данные запишутся в файл.
- TCP Socket Server: можно подключиться внешними инструментами к JVM и получить данные через сокет.
- TCP Socket Client: при запуске агент JaCoCo подключается определенному TCP endpoint.
Мы выбрали второй вариант.
Решение
Необходимо реализовать возможность запускать приложения и сами тесты с агентом JaCoCo.
Первым делом добавляем в gradle возможность запуска тестов c агентом JaCoCo.
Java агент может быть запущен:
-javaagent:[yourpath/]jacocoagent.jar=[option1]=[value1],[option2]=[value2]
Добавляем в наш проект зависимость:
dependencies {
compile ‘org.jacoco:org.jacoco.agent:0.8.0’
}
Запуск с агентом нужен нам только для сбора стастики, поэтому добавляем в gradle.properties признак withJacoco с дефолтовым значением false. Также прописываем директорию, в которую будет собираться статистика, адрес и порт.
Добавляем в таску запуска тестов формирование jvm аргумента с агентом:
if (withJacoco.toBoolean()) {
…
jvmArgs "-javaagent:${tempPath}=${jacocoArgs.join(',')}".toString()
}
Теперь нам нужно после каждого успешного завершения теста собрать статистику с JaCoCo. Для этого пишем TestNG listener.
public class JacocoCoverageTestNGListener implements ITestListener {
private static final IntegrationTestsCoverageReporter reporter = new IntegrationTestsCoverageReporter();
private static final String TEST_NAME_PATTERN = "%s.%s";
@Override
public void onTestStart(ITestResult result) {
reporter.resetCoverageDumpers(String.format(TEST_NAME_PATTERN, result.getInstanceName(), result.getMethod().getMethodName()));
}
@Override
public void onTestSuccess(ITestResult result) {
reporter.report(String.format(TEST_NAME_PATTERN, result.getInstanceName(), result.getMethod().getMethodName()));
}
}
Добавляем listener в testng.xml и комментарим его, так как при обычном запуске тестов он нам не нужен.
Теперь у нас есть возможность запускать тесты с агентом JaCoCo, при каждом успешном тесте будет собираться статистика.
Немного подробнее о том, как реализован reporter для сбора статистики.
Во время инициализации reporter происходит подключение к агентам, создание директории, где будет храниться статистика и собственно сбор статистики.
Добавим метод report:
public void report(String test) {
reportClassFiles(test);
reportResources(test);
}
Метод reportClassFile создает в директории статистики папку jvm, в которой хранится статистика собранная по class файлам.
Метод reportResources создает папку resources, в которой хранится собранная статистика по ресурсам (по всем не class файлам).
В report находится вся логика по подключению к агенту, чтению данных из сокета и запись в файл. Реализовано средствами, которые предоставляет JaCoCo, такие как org.jacoco.core.runtime.RemoteControlReader/RemoteControlWriter.
Функции reportClassFiles и reportResources используют общую функцию dumpToFile.
public void dumpToFile(File file) {
try (Writer fileWriter = new BufferedWriter(new FileWriter(file))) {
for (RemoteControlReader remoteControlReader : remoteControlReaders) {
remoteControleReader.setExecutionDataVisitor(new IExecutionDataVisitor() {
@Override
public void visitClassExecution(ExecutionData data) {
if (data.hasHits()) {
String name = data.getName();
try {
fileWriter.write(name);
fileWriter.write('\n');
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
});
}
}
}
Результатом функции будет файл с набором классов/ресурсов, которые данный тест затрагивает.
И так, после запуска всех тестов у нас есть директория со статистикой по class файлам и ресурсам.
Осталось написать пайплайн для ежедневного сбора статистики и добавить в пайплайн запуска пулл-реквест-чеков.
Стейджи сборки проектов нам не интересны, а вот стейдж для публикации статистики рассмотрим подробнее.
stage('Agregate and parse result') {
def inverterInJenkins = downloadMavenDependency(
url: NEXUS_RELEASE_REPOSITORY,
group: 'ХХХ',
name: 'coverage-inverter',
version: '0',
type: 'jar',
mavenHome: wsp
)
dir('coverage-mapping') {
gitFullCheckoutRef 'ХХХ', 'ХХХ', 'coverage-mapping', "refs/heads/${params.targetBranch}-integration-tests"
sh 'rm -rf *'
}
sh "ls -lRa ../ХХХ/out/coverage/"
def inverter = wsp + inverterInJenkins.substring(wsp.length())
sh "java -jar ${inverter} " +
"-d ../ХХХ/out/coverage/jvm " +
"-o coverage-mapping/ХХХ/jvm " +
"-i coverage-config/jvm-include " +
"-e coverage-config/jvm-exclude"
sh "java -jar ${inverter} " +
"-d ../ХХХ/out/coverage/resources " +
"-o coverage-mapping/ХХХ/resources " +
"-i coverage-config/resources-include " +
"-e coverage-config/resources-exclude"
gitPush 'ХХХ', 'ХХХ', 'coverage-mapping', "${params.targetBranch}-integration-tests"
}
В coverage-mapping нам нужно хранить имя файла и внутри него список тестов, которые необходимо запустить. Так как результат работы сбора статистики — это имя теста, в котором хранится набор классов и ресурсов, нам нужно инвертировать все это дело и исключить лишние данные (классы из third-party libraries).
Инвертируем нашу статистику и пушим в наш репозиторий.
Статистика собирается каждую ночь. Она хранится в отдельном репозитории для каждой релизной ветки.
Бинго!
Теперь при прогоне тестов нам остаётся найти измененный файл и определить тесты, которые нужно запустить.
Проблемы, с которыми мы столкнулись:
- Так как JaCoCo работает только с байткодом, собрать статистику по таким файлам как .xml, .gradle, .sql из коробки невозможно. Поэтому нам пришлось «прикручивать» свои решения.
- Постоянный мониторинг актуальности статистики и частота сборки, если ночная сборка завалилась по какой-то причине, то на проверке в пулл-реквестах будет использоваться «вчерашняя» статистика.