Всем привет! Меня зовут Александр, в hh.ru я занимаюсь автотестами. В статье про оценку тестового покрытия мы затронули тему интеграционных тестов. В этом материале я расскажу, как у нас обстоят дела с пирамидой тестирования в целом. В hh.ru более 200 микросервисов, которые тестируются на различных уровнях. У нас, как и в классической пирамиде, таких уровней три, а сейчас мы активно запускаем еще один — контрактные тесты.
Поехали!
Что за контрактное тестирование?
Тестирование контрактов гарантирует, что две стороны способны взаимодействовать друг с другом благодаря изолированной проверке взаимной поддержки сообщений обеими сторонами.
Одна сторона — потребитель — определяет взаимодействие с другой стороной — поставщиком, а затем создает контракт. Этот контракт представляет собой спецификацию запросов от потребителя и ответов от поставщика. Код приложения автоматически генерирует контракты, и в большинстве случаев это происходит на этапе модульного тестирования. Автоматическое создание гарантирует, что каждый контракт отражает актуальную реальность. Итак, кратко перечислим основные плюсы контрактного тестирования:
Такие тесты выполняются быстро и требуют минимального обслуживания;
Быстрое определение ошибок на принимающей стороне, при изменениях в API со стороны поставщика;
Они быстрые: им не нужно связываться с другими многочисленными системами;
Они просты в поддержке и обслуживании. Знать систему не обязательно;
Масштабируемы, поскольку каждый компонент может быть тестирован отдельно;
Они позволяют вскрыть баги локально на машине разработчика или тестировщика.
Проблемы UI-тестов
Так как приложение у нас большое, то и тестов у нас тоже немало. Например, одних только UI-тестов насчитывается более 9200. При этом количество сервисных тестов значительно ниже. Вообще, у нас достаточно тестов на всех уровнях, но вот их покрытие и избыточность до определенного времени оставались неизвестными. К этому добавлялось относительно длительное время прогона UI-тестов для выходящих по несколько раз в день релизов.
Рис. статистика за месяц
Чтобы ускорить ТТМ, мы решили увеличить количество тестов на сервисном слое. В нашем случае это, по сути, доработанные интеграционные тесты. Они значительно быстрее е2е тестов и предъявляют более низкие требования к тестовой инфраструктуре. По сути нам нужны только тестируемые микросервисы, тесты быстрее освобождают ресурсы и не требуют дополнительного ПО, например, браузеров и всего, что они за собой тянут.
В ходе обсуждений тесты мы решили писать внутри сервисов отдельным модулем. Основная причина — особенности релизного флоу: наши сервисы выпускаются независимо друг от друга и монорепа для таких тестов создавала бы трудности по контролю за версиями используемых сервисов. Еще это позволило бы нам упростить поддержку тестов: разработчики также вовлекаются в разработку сервисных тестов. Для этого в отдельном репозитории сделали либу-раннер сервисных тестов, в которой зашиты базовые моменты и фреймворки для всех сервисных тестов. Еще подготовили пару примеров по использованию библиотеки и написанию сервисных тестов.
Проблемы с сервисными тестами
Казалось бы, все готово и можно ожидать потока новых тестов. Но реальность оказалась прозаичной — тестов особо не было. Начали разбираться и выяснилось: тестировщикам было непонятно, с какой стороны начинать эти тесты, как их подключать в сервисы, как искать эндпоинты для покрытия, и вообще — это в самом микросервисе надо разбираться, сложно! Стали искать решение.
И решили вот что: для старта в написании тестов достаточно иметь типовую тестовую обвязку, универсальную для любого сервиса, и в каждом из сервисов можно эту обвязку генерировать по необходимости.
Рис. Тестовая обвязка сервисных тестов в одном из сервисов
Обвязка включала основной класс-раннер наших тестов, базовые утилитарные классы — описание нашего сервиса и фабричный маппер, базовый тестовый класс, класс с примером теста, проверяющего стандартную для каждого сервиса ручку на наличие ответа 200, различные конфиги: логгера, настройки тестов и так далее. А поскольку у нас уже имелся инструмент для генерации сервисов, мы решили расширить функциональность этого инструмента и добавить туда генерацию тестов. Инструмент написан на Python и использует Jinja для шаблонизации.
Алгоритм шаблонизации генерации интеграционных тестов
Для шаблонизации мы разработали собственный алгоритм. Для начала создаем шаблон Jinja, в котором описываем все файлы, необходимые нам в наших тестах:
основной класс-раннер, в котором в main методе запускаем наши тесты;
базовый тестовый файл, в котором подключаем основные библиотеки, фикстуры и т.п., использующиеся во всех сервисных тестах;
класс описания сервиса, в котором описываются все ручки, используемые при тестировании сервиса;
фабричный маппер;
работающий с сервисом тестовый класс для примера (проверка стандартной ручки на 200).
При запуске генерации указываем имя сервиса, по этому имении происходит поиск репозитория сервиса, и оно же подставляется в переменные шаблона, а также ветку для пуша нового кода в репозиторий.
Использование генерации позволило значительно увеличить количество сервисов, для которых были написаны сервисные тесты. Однако, на повестке дня оставались вопросы качества и применимости этих тестов. Этот вопрос мы решили с помощью оценки тестового покрытия. Статистика по покрытию прояснила картину количества и качества сервисных тестов. И картина нам не понравилась.
Мы вновь стали изучать проблематику вопроса и пришли к выводу, что имеющейся генерируемой обвязки недостаточно для облегчения старта. Основные затруднения вызывала трудоемкость описания эндпоинтов, которые мы будем проверять — наполнение того самого ServiceNameApi класса. Проанализировав сервисы и оттолкнувшись от алгоритмов, примененных при подсчете покрытия сервиса, мы доработали генерацию обвязки для сервисных сервисов.
Краткое описание алгоритма генерации
Так как разработка сервисов у нас во многом стандартизирована, то все эндпоинты описываются в *Resource.java. Соответственно их и будем анализировать.
@PUT
@Path("/endpoint_path")
public void getSomething(@PathParam("pathParam") String pathParam, @QueryParam("queryParam") String queryParam) {}
Так выглядит абстрактное описание любого из эндпоинтов. Нас здесь интересует:
тип эндпоинта, обозначаемый соответствующей аннотацией над методом;
адрес эднпоинта, идущий в качестве параметра к аннотации @Path;
параметры метода, помеченные соответствующими аннотациями;
возвращаемое методом значение (обычно какая либо DTO или void).
Теперь нам надо найти в проекте все классы с описанием эндпоинтов, получить информацию по предоставляемым методам, и на основе всего этого в наших тестах сгенерировать методы для работы с этими эндпоинтами.
С помощью библиотеки javalang распарсим файлы с эндпоинтами и сгенерируем код:
def search_endpoint(file):
endpoint_type = 'PUT|POST|GET|DELETE'
endpoint_api_methods = ''
result_code = {}
with open(file, "r", encoding='utf-8') as f:
tree = javalang.parse.parse(f.read())
service_path = ''
clazz = tree.types[0]
endpoint_api_methods = f'\n //{clazz.name}'
imports = tree.imports
imports_text = ''
for annotation in clazz.annotations:
if annotation.name == 'Path':
if hasattr(annotation.element, 'value'):
service_path = annotation.element.value.replace('"', '')
else:
service_path = annotation.element.member
if service_path[:-1] != '/':
service_path += "/"
for method in clazz.body:
api_type = ''
query_params = {}
path_params = {}
consume_params = {}
endpoint_path = ''
return_type = ''
try:
if ('public' in method.modifiers) and (len(method.annotations) > 0):
for annotation in method.annotations:
if annotation.name == 'Path':
if hasattr(annotation.element, 'value'):
endpoint_path = service_path + (annotation.element.value.replace('"', ''))
else:
endpoint_path = service_path + annotation.element.member
elif re.search(endpoint_type, annotation.name) is not None:
api_type = annotation.name
if endpoint_path == '':
endpoint_path = service_path
method_name = method.name
if method.return_type is not None:
return_type = return_type_builder(method.return_type).replace("><", ", ")
for type_class in return_type.split("<"):
for imp in imports:
if imp.path.split(".")[len(imp.path.split(".")) - 1] == type_class.replace(">", ""):
imports_text += imp.path + ";"
break
else:
return_type = 'Void'
for param in method.parameters:
if len(param.annotations):
if param.annotations[0].name == 'QueryParam':
query_params[param.name] = param.type.name
elif param.annotations[0].name == 'PathParam':
path_params[param.name] = param.type.name
else:
consume_params[param.name] = param.type.name
signature_return_type = return_type
if signature_return_type == "boolean":
signature_return_type = "Boolean"
elif signature_return_type == "int":
signature_return_type = "Integer"
elif signature_return_type == "float":
signature_return_type = "Float"
elif signature_return_type == "double":
signature_return_type = "Double"
elif signature_return_type == "long":
signature_return_type = "Long"
elif signature_return_type == "byte":
signature_return_type = "Byte"
endpoint_api_methods += f'\n public ResponseEntity<{signature_return_type}> {method_name}(HttpHeaders headers, '
params_text = ''
for param in path_params:
params_text += f' {path_params[param]} {param},'
for param in query_params:
params_text += f' {query_params[param]} {param},'
for param in consume_params:
params_text += f' {consume_params[param]} {param},'
endpoint_api_methods += params_text.lstrip()
if len(params_text) > 0:
endpoint_api_methods = endpoint_api_methods[:-1] + ') {\n'
for param in params_text[:-1].split(","):
for imp in imports:
if imp.path.split(".")[len(imp.path.split(".")) - 1] == param.lstrip().split(" ")[0]:
imports_text += imp.path + ";"
break
else:
endpoint_api_methods = endpoint_api_methods[:-2] + ') {\n'
# body
params_string = ''
for param in path_params:
postfix = ''
params_string += ', ' + param
if path_params[param] == 'int' or path_params[param] == 'Integer':
postfix = '%d'
else:
postfix = '%s'
if endpoint_path.find("{") >= 0:
path = ''
for endpoint_part in endpoint_path.split("{"):
path = path + endpoint_part.replace(param + "}", postfix)
endpoint_path = path
endpoint_path = '"' + endpoint_path.replace("//", "/") + '"'
endpoint_api_methods += f' String path = {endpoint_path}'
if params_string != '':
params_string = params_string[2:]
endpoint_api_methods += f'.formatted({params_string})'
endpoint_api_methods += ';\n'
endpoint_api_methods += ' String uri = UriComponentsBuilder.fromPath(path)\n'
if len(query_params) > 0:
for param in query_params:
endpoint_api_methods += f' .queryParam("{param}", {param})\n'
endpoint_api_methods += ' .build()\n .toUriString();\n\n'
# return
endpoint_api_methods += ' return this.withHeaders(headers)\n'
endpoint_api_methods += ' .withUri(uri)\n'
if api_type == 'GET':
if "<" in return_type:
endpoint_api_methods += ' .get(new ParameterizedTypeReference<>() {{}});\n'
else:
endpoint_api_methods += f' .get({return_type}.class);\n'
else:
if len(consume_params) > 0:
endpoint_api_methods += f' .withBody({list(consume_params.keys())[0]})\n'
if "<" in return_type:
endpoint_api_methods += f' .{api_type.lower()}(new ParameterizedTypeReference<>() {{}});\n'
else:
endpoint_api_methods += f' .{api_type.lower()}({return_type}.class);\n'
endpoint_api_methods += ' }\n'
except Exception as ignored:
pass
imports_text = list(set(imports_text.split(";")))
result_code = {'imports': imports_text, 'methods': endpoint_api_methods}
return result_code
Теперь в классе описания сервиса автоматически генерируются методы для работы с его эндпоинтами. А нам фактически остается написать только тесты после небольшой проверки сгенерированного кода.
Из-за своей универсальности генерация работает не идеально. В ней невозможно учесть особенности всех имеющихся сервисов, но как инструмент для старта — это отличное решение. В дальнейшем возможно расширить наш генератор на работу с изменениями в сервисе. Например, в сервисе добавился или изменился какой-либо эндпоинт, и с помощью повторной генерации можно автоматически добавить методы работы с ним в нашу обвязку сервисных тестов.
Заключение
Вот так нам удалось приступить к выравниванию нашей пирамиды и значительно облегчить процесс выпуска изменений. Заодно мы начали получать объективную картину тестового покрытия наших сервисов.
В планах у нас продолжать развивать генерацию тестов и повышать количественно и качественно тестовое покрытие сервисов.
Вот несколько советов тем, кто тоже планирует работать с пирамидой тестирования:
Проанализируйте свою текущую пирамиду: на каких уровнях у вас наблюдаются проблемы, каковы их причины?
Проработайте возможные решения: какие из них наиболее выгодны в плане снижения рисков и устранения проблем, какие ресурсы необходимы для реализации решений?
Прежде чем начать работу на одном из уровней пирамиды оцените текущие ресурсы: количество сотрудников, их компетенции, степень вовлеченности и загрузку.
Закончу цитатой с демо сервисных тестов одного из наших тестировщиков: “Теперь я пишу только сервисные тесты и на е2е уровень иду лишь при крайней необходимости. Сервисные быстрее и удобнее во всем: проще пишутся, легче поддерживаются, прогоняются на порядки живее е2е”.
AntonZiminRZN
А когда у разработчиков hh.ru руки дойдут до формы с резюме. Там до сих пор основной мессенджер icq со скайпом