Всем привет! Меня зовут Александр и в hh.ru я занимаюсь решением инфраструктурных (и не только) задач, касающихся автотестирования. Ниже я опишу один из подобных кейсов.

У нас в hh.ru более 370 микросвервисов. Классическая пирамида тестирования состоит из трех основных уровней: юнит-тесты, интеграционный слой, ui-тесты (e2e). Релизы сервисов проходят несколько раз в день. В вопросе интеграционных тестов было принято решение размещать их внутри сервиса отдельным модулем и запускать при очередных изменениях. При этом сами тесты проверяют эндпоинты тестируемого сервиса и иногда используют эндпоинты сервисов, которые с ним взаимодействуют. 

Для визуализации зависимостей между сервисами есть отдельная самописная утилита, показывающая связи и топологию, поэтому процесс определения смежных сервисов больших затруднений не вызывает. 

Но тут возникает следующий вопрос: как нам понять, все ли эндпоинты в сервисе проверяются и контролируют покрытие? 

Из этого вопроса выросла задача оценки тестового покрытия интеграционными тестами.

Выбор

После изучения вопроса в качестве инструмента оценки был выбран JaCoCo. Так как он единственный из всех вариантов более-менее поддерживается. Он позволяет оценить покрытие кода в двух режимах:

  • на соответствующей фазе сборки проекта

  • работая как агент

В случае оценки при сборке проекта JaCoCo необходимо подключить в pom-файл проекта. В нашем случае этот способ подходит только для unit-тестирования, так как для оценки не требуется наличие работающего приложения. Наши же интеграционные тесты запускаются как отдельное приложение и тестируют уже запущенный сервис.

И тут нам пригодится механизм джава агентов, любезно предоставленных самой джавой.

JaCoCo работает как агент Java. Он отвечает за инструментирование байт-кода во время выполнения тестов. JaCoCo углубляется в каждую инструкцию и показывает, какие строки выполняются во время каждого теста.

Для сбора данных о покрытии JaCoCo использует ASM для инструментирования кода на лету, получая в процессе события от интерфейса JVM Tool.

Если кратко, то суть следующая: при запуске jar нашего сервиса мы указываем jar агента, который встраивается в JVM и “слушает” код приложения.

JaCoCo поставляется с агентом и консольной утилитой позволяющей подключиться к удаленному агенту, получить с него данные и сформировать отчет. Сам же отчет можно получить в нескольких форматах (html, xml, csv). 

Инструкция по созданию отчета:

  1. Разворачиваем наш сервис, подключив агент JaCoCo:

java -JVM_OPTS -javaagent:/docker-java-home/lib/jacocoagent/org.jacoco.agent.jar=address=*,port=4408,destfile=jacoco-it.exec,output=tcpserver -cp our/service/path

  1.  Выкачиваем JaCoCo и берем оттуда консольный клиент:

wget https://search.maven.org/remotecontent?filepath=org/jacoco/jacoco/0.8.11/jacoco-0.8.11.zip -O jacoco-0.8.11.zip

unzip -qo jacoco-0.8.11.zip -d jacoco

cp jacoco/lib/jacococli.jar jacococli.jar

  1. Сбрасываем логи агента (так как при запуске сервиса осуществляется вызов кода сервиса, и он попадает в лог):

java -jar jacococli.jar dump --address <service_name>.<stand_name> --port 4408 --destfile <dump_name>.exec --reset

  1. Запускаем интеграционные тесты

  2. Получаем логи агента:

java -jar jacococli.jar dump --address <service_name>.<stand_name> --port 4408 --destfile <dump_name>.exec --reset

  1. Генерируем отчет по сервису:

java -jar jacococli.jar report <dump_name>.exec --classfiles <path_to_service_target> --html <report_directory_name> --sourcefiles <path_to_service_source>

Двигаемся дальше

Но что нам делать с отдельным отчетом по отдельному сервису? Хочется дальнейшей автоматизации. 

JaCoCo очень хорошо интегрируется с Sonar. Для этого достаточно настроить сканер и с его помощью отправлять xml-отчет JaCoCo в сам Sonar.

В результате у нас возник следующий процесс:

  1. Перед запуском интеграционных тестов мы запускаем тестируемый сервис с агентом JaCoCo

  2. Запускаем интеграционные тесты

  3. Получаем отчет в формате XML

  4. Отправляем отчет в Sonar

Для отправки отчета используется консольная утилита SonarScanner. На основе сгенерированного JaCoCo отчета статистика отправляется в Sonar. Давайте посмотрим на команду отправки:

sonar-scanner 

-Dsonar.projectKey=project_key 

-Dsonar.projectName=project_name 

-Dsonar.host.url=https://sonar_url 

-Dsonar.login=user_token 

-Dsonar.coverage.jacoco.xmlReportPaths=jacoco_XML_report_path 

-Dsonar.java.binaries=service_target_path 

-Dsonar.sources=service_source_path

Собственно, понятно, за что каждый параметр отвечает. Важное замечание: если в Sonar не существует указанного проекта, то он его создаст. 

Казалось бы, все автоматизировано и можно спокойно получить анализ покрытия. Но разбираться в Sonar с покрытием каждого сервиса — тоже весьма трудоемкий процесс, хочется и его как-то автоматизировать. 

Можно напрямую обратиться к базе Sonar, где хранится вся статистика, но есть API, позволяющее получить интересующую нас информацию. Его мы и будем использовать.

Давайте попробуем посчитать покрытие эндпоинтов сервиса нашими тестами. Для этого надо обратиться к четырем эндпоинтам Sonar:

  1. SONAR_URL/api/components/search?qualifiers=TRK — тут будем получать список всех проектов в Sonar

  2. SONAR_URL/api/measures/component?component=SERVICE_PROJECT_KEY&metricKeys=coverage — этим запросом получаем общее покрытие по сервису

  3. SONAR_URL/api/components/tree?component=SERVICE_PROJECT_KEY&qualifiers=FIL&q=Resource.java — здесь мы получаем список файлов-классов с описанием ресурсов нашего сервиса (у нас описание сервисов приведено к единому формату и все эндпоинты описываются в *Resource.java)

  4. SONAR_URL/api/measures/component?component=RESOURCE_FILE_KEY&metricKeys=coverage — этим запросом получаем покрытие по конкретному эндпоинту

Алгоритм следующий:

  1. Получаем список проектов-сервисов

  2. Для каждого проекта получаем общее покрытие

  3. Также для каждого проекта получаем список файлов с описанием эндпоинтов сервиса

  4. Получаем покрытие по каждому файлу

  5. Выводим среднюю арифметическую оценку по покрытию эндпоинтов сервиса по формуле: сумма покрытия по всем файлам/количество файлов. 

Реализуем алгоритм на python:

components_url = "SONAR_URL/api/components/search?qualifiers=TRK&ps=500"

measures_url = "SONAR_URL/api/measures/component?component={}&metricKeys=coverage"

resource_files_url = "SONAR_URL/api/components/tree?component={}&qualifiers=FIL&q=Resource.java"

resource_coverage_url = "SONAR_URL/api/measures/component?component={}&metricKeys=coverage"

measures_headers = {"Authorization": f"Basic {}"}

# get list of services

def get_coverage() -> dict:

    result: dict = {}

    for comp in get_services():
        response = requests.request("GET", measures_url.format(comp.key), headers=measures_headers, data=payload)
        data = response.json().get("component")
        measures = data.get("measures")
        total_coverage = 0.0

        if not measures:
            result[comp.name] = [-1, -1]

        elif float(measures[0].get("value")) > 0:
            total_coverage = float(measures[0].get("value"))

            # get resource files in service
            response = requests.request(
                "GET", resource_files_url.format(comp.key), headers=measures_headers, data=payload
            )

            resource_files_data = response.json().get("components")
            value = 0.0
            avg_coverage_value = 0.0

            for resource_file in resource_files_data:

                # get coverage for every resource file in service
                response = requests.request(
                    "GET", resource_coverage_url.format(resource_file["key"]), headers=measures_headers, data=payload
                )
                resource_coverage_data = response.json().get("component")

                if resource_coverage_data:
                    resource_file_measures = resource_coverage_data["measures"]

                    if not resource_file_measures:
                        result[f"{comp.name}: resource without coverage"] = [resource_file["path"]]
                        value = value + 0.0

                    else:
                        value = value + float(resource_file_measures[0].get("value"))

            if len(resource_files_data) > 0:
                avg_coverage_value = value / len(resource_files_data)

            result[comp.name] = [round(total_coverage, 2), round(avg_coverage_value, 2)]

        else:
            result[comp.name] = [-1, -1]

    return result

В результате реализации мы получаем следующую статистику по каждому сервису:

service1: общее покрытие: 11.4, покрытие по эндпоинтам: 32.7

service2: 25.4, покрытие по эндпоинтам: 42.9

service3: общее покрытие: 48.1, покрытие по эндпоинтам: 39.03

service4: общее покрытие: 36.9, покрытие по эндпоинтам: 59.89

service5: общее покрытие: 27.8, покрытие по эндпоинтам: 33.73

service6: общее покрытие: 72.1, покрытие по эндпоинтам: 82.75

service7: общее покрытие: 31.0, покрытие по эндпоинтам: 50.57

service8: общее покрытие: 17.7, покрытие по эндпоинтам: 15.5

service9: 66.1, покрытие по эндпоинтам: 39.7

service10: общее покрытие: 50.3, покрытие по эндпоинтам: 100.0

service11: общее покрытие: не покрыто тестами, покрытие по эндпоинтам: не покрыто тестами

service12: общее покрытие: не покрыто тестами, покрытие по эндпоинтам: не покрыто тестами

Вывод

Обновление статистики мы сделали по расписанию — один раз в неделю. В результате проведенной работы получили следующее:

  • регулярно получаем информацию о наличии интеграционных тестов по новым сервисам в нашей микросервисной архитектуре

  • получили инструмент мониторинга покрытия как эндпоинтов, так и общего покрытия тестами логики сервиса

Стоит отметить, что произвести оценку тестового покрытия таким образом можно не только на уровне интеграционных тестов, но также и UI. Для этого надо также запустить сервис с агентом JaCoCo и вместо интеграционных — запустить UI тесты. При этом отчеты агента можно рассматривать как по отдельности, так и свести их в один с помощью команды merge.

Комментарии (2)


  1. DarkenRaven
    29.11.2023 09:13
    +1

    А все операции с jacoco через java -jar обусловлены тем, что код тестов java приложений на разных уровнях написан на разных языках (видимо интеграционные на python)?
    В обратном случае все можно уложить в фазы самих тестов:

    ...
    import org.jacoco.core.tools.ExecDumpClient;
    import org.jacoco.core.tools.ExecFileLoader;
    ...
    import org.junit.jupiter.api.AfterAll;
    
    import java.io.File;
    import java.io.IOException;
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class IntegrationTestBase {
        private static final AtomicInteger coveragePart = new AtomicInteger(0);
    ...
        @AfterAll
        public static void tearDown() throws IOException {
            readJacocoDump("./build/jacoco/integrationTest." + coveragePart.get() + ".exec",
                getDockerServiceHost(...),
                getDockerServicePort(..., JACOCO_PORT));
            ...
        }
    
        private static void readJacocoDump(String destPath, String host, Integer port) throws IOException {
            final ExecFileLoader loader = new ExecDumpClient().dump(host, port);
            loader.save(new File(destPath), true);
        }
    ...
    


    1. AlexDronin Автор
      29.11.2023 09:13
      +1

      Нет, интеграционные на java, как и все тесты, которые пишут QA на всех уровнях. Так было проще было встроить в нашу инф-ру, но совет дельный, спасибо!