Всем привет! Меня зовут Александр и в 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 <service_name>.<stand_name> --port 4408 --destfile <dump_name>.exec --reset
Запускаем интеграционные тесты
Получаем логи агента:
java -jar jacococli.jar dump --address <service_name>.<stand_name> --port 4408 --destfile <dump_name>.exec --reset
Генерируем отчет по сервису:
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.
В результате у нас возник следующий процесс:
Перед запуском интеграционных тестов мы запускаем тестируемый сервис с агентом 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.
DarkenRaven
А все операции с jacoco через java -jar обусловлены тем, что код тестов java приложений на разных уровнях написан на разных языках (видимо интеграционные на python)?
В обратном случае все можно уложить в фазы самих тестов:
AlexDronin Автор
Нет, интеграционные на java, как и все тесты, которые пишут QA на всех уровнях. Так было проще было встроить в нашу инф-ру, но совет дельный, спасибо!