Оценка тестового покрытия интеграционных тестов с помощью JaCoCo. Инструкция к применению
Всем привет! Меня зовут Александр и в 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).
Инструкция по созданию отчета:
Разворачиваем наш сервис, подключив агент 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
Выкачиваем 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
Сбрасываем логи агента (так как при запуске сервиса осуществляется вызов кода сервиса, и он попадает в лог):
java -jar jacococli.jar dump --address
Запускаем интеграционные тесты:
Получаем логи агента:
java -jar jacococli.jar dump --address
Генерируем отчет по сервису:
java -jar jacococli.jar report
Двигаемся дальше
Но что нам делать с отдельным отчетом по отдельному сервису? Хочется дальнейшей автоматизации.
JaCoCo очень хорошо интегрируется с Sonar. Для этого достаточно настроить сканер и с его помощью отправлять xml-отчет JaCoCo в сам Sonar.
В результате у нас возник следующий процесс:
Перед запуском интеграционных тестов мы запускаем тестируемый сервис с агентом JaCoCo
Запускаем интеграционные тесты
Получаем отчет в формате XML
Отправляем отчет в 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:
SONAR_URL/api/components/search? qualifiers=TRK — тут будем получать список всех проектов в Sonar
SONAR_URL/api/measures/component? component=SERVICE_PROJECT_KEY&metricKeys=coverage — этим запросом получаем общее покрытие по сервису
SONAR_URL/api/components/tree? component=SERVICE_PROJECT_KEY&qualifiers=FIL&q=Resource.java — здесь мы получаем список файлов-классов с описанием ресурсов нашего сервиса (у нас описание сервисов приведено к единому формату и все эндпоинты описываются в *Resource.java).
SONAR_URL/api/measures/component? component=RESOURCE_FILE_KEY&metricKeys=coverage
Алгоритм следующий:
Получаем список проектов-сервисов
Для каждого проекта получаем общее покрытие
Также для каждого проекта получаем список файлов с описанием эндпоинтов сервиса
Получаем покрытие по каждому файлу
Выводим среднюю арифметическую оценку по покрытию эндпоинтов сервиса по формуле: сумма покрытия по всем файлам/количество файлов.
Реализуем алгоритм на 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.