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