Оценка тестового покрытия интеграционных тестов с помощью JaCoCo. Инструкция к применению

8492c332c35fbdd111aeb8e3ac4e688c.jpeg

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

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

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

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

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

Выбор

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

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

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

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

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

607c2a43f6339b4171b80b62d80cdcbb.png

Если кратко, то суть следующая: при запуске 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 . --port 4408 --destfile .exec --reset

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

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

java -jar jacococli.jar dump --address . --port 4408 --destfile .exec --reset

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

java -jar jacococli.jar report .exec --classfiles --html --sourcefiles

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

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

68a8e36a66c61156cc1fbb97859c8384.png

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 с покрытием каждого сервиса — тоже весьма трудоемкий процесс, хочется и его как-то автоматизировать. 

873a9b3cc09881d4b2ecea537c6c5407.png

Можно напрямую обратиться к базе 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.

© Habrahabr.ru