Протестируй это: как мы определяем, какие тесты запускать на пулл-реквест-чеках

habr.png

Привет, Хабр! Меня зовут Егор Даниленко. Я занимаюсь разработкой цифровой платформы корпоративного интернет-банка Сбербанк Бизнес Онлайн, и сегодня я хочу рассказать вам о процедуре CI разработки, принятой у нас.
Как изменения разработчика доходят до вливания в релизную ветку? Разработчик делает изменения локально и пушит в нашу систему управления версиями. Мы используем Bitbucket с авторским плагином (об этом плагине мы писали ранее здесь). На этих изменениях запускается сборка и гоняются тесты (юнит, интеграционные, функциональные). Если сборка не завалилась и все тесты пройдены успешно, а также после успешного ревью, пулл-реквест вливается в основную ветку.

Но со временем количество команд выросло. Пропорционально выросло и количество тестов. Мы понимали, что такое количество команд ускорит наступление проблемы «медленного пулл-реквест-чека», и разрабатывать продукт станет невозможно. На текущий момент у нас порядка 40 команд. Вместе с новыми фичами они приносят и новые тесты, которые также нужно запускать на пулл-реквестах.

Мы подумали, что было бы круто, если бы мы знали, какие тесты нужно запустить под изменение конкретного куска кода.

И вот как мы решили эту задачу.

Постановка задачи


Имеется проект с тестами, и мы хотим определять какие тесты нужно запускать при «затрагивании» определенного файла.

Все мы знаем о библиотеке для покрытия кода JaCoCo от EclEmma. Ее мы и взяли за основу.

Немного о JaCoCo


JaCoCo — библиотека для измерения покрытия кода тестами. Работа основана на анализе байт кода. Агент собирает информацию о выполнении и выгружает ее по запросу или остановке JVM.

Существует три режима сбора данных:

  1. Файловая система: после остановки JVM данные запишутся в файл.
  2. TCP Socket Server: можно подключиться внешними инструментами к JVM и получить данные через сокет.
  3. 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 из коробки невозможно. Поэтому нам пришлось «прикручивать» свои решения.
  • Постоянный мониторинг актуальности статистики и частота сборки, если ночная сборка завалилась по какой-то причине, то на проверке в пулл-реквестах будет использоваться «вчерашняя» статистика.

© Habrahabr.ru