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

Комментарии (1)


  1. AntonZiminRZN
    26.06.2024 14:52
    +1

    А когда у разработчиков hh.ru руки дойдут до формы с резюме. Там до сих пор основной мессенджер icq со скайпом