Пирамида тестирования в hh.ru

от автора

Всем привет! Меня зовут Александр, в 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е”.


ссылка на оригинал статьи https://habr.com/ru/articles/824134/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *