Учим автотесты API адаптироваться под требования

от автора

Привет! Меня зовут Тимур Шарафутдинов, я занимаюсь процессами автоматизации тестирования в «Ростелеком ИТ». Сегодня поделюсь своим опытом реализации model based-подхода в написании python API автотестов на проекте «База заказов».

Проект представляет из себя приложение с микросервисной архитектурой для обработки, хранения, конфигурирации заказов, нотифицирования целевых систем и встроенным механизмом запуска процессов подключения услуг. Приложение является модулем общей системы и не имеет фронта как такового, только API интерфейс.

Небольшая преамбула

Подходя к разработке архитектуры стека автотестов, стояла задача сделать стек:

  1. с лаконичным кодом;

  2. с проверками соответствия спецификации:

  • обязательности/необязательности атрибутов;

  • корректности сохранения атрибутов в POST/PUT/PATCH ручках;

  • корректности схемы ответа сервиса.

  1. удобным и адаптивным под изменения спецификации.

Изменения могли вноситься достаточно часто, плюс модели в спецификации могли использоваться в разных ручках, а то и в одной на нескольких уровнях иерархии.

Например:

    "state": {         "value": "",         "date": "",         "changedBy": null     }

state может находиться как у самого объекта, так и у других различных компонентов. И при изменении спецификации объекта нам нужно будет актуализировать все сопутствующие тесты.

Хорошо бы описать модели объектов в одном месте, написать методы по работе с ними, которые бы использовались в тестах. Тогда при изменениях в спецификации нам достаточно будет актуализировать наши описанные модели, а тесты сами адаптируются благодаря написанным методам.

Получается задача на целый фреймворк, отсюда вопрос — что здесь может помочь?

  • Первое. Предполагается, что наши сервисы умеют обрабатывать ошибки и формируют структурированный отчет в ответе — что/где/какая ошибка.

    Что: region и roleType Где: путь к полям лежит в списке "loc" Ошибка: field_required/missing
    Что: region и roleType Где: путь к полям лежит в списке «loc» Ошибка: field_required/missing

    Это нам понадобится для проверки обязательности полей. Мы будем собирать список неотправленных обязательных полей (вместе с путями к ним json) и сравнивать с тем, что нам вернул сервис.

  • Второе. Воспользуемся библиотекой сериализации/валидации, которую обычно используют в веб-сервисах: там описаны объекты данных, которые сервис будет принимать и передавать. Мы будем использовать её для описания моделей и валидации данных в ответе сервиса. Выбор пал на библиотеку pydantic — у меня уже был опыт работы с ней, когда нужно было написать микросервис на FastAPI, и он отлично нам подходит.

Знакомимся с pydantic

Модели, описанные сериализаторами pydantic’a, выглядят очень понятными. Объекты в спецификации — это питоновские классы, наследованные от класса pydantic BaseModel, где имя поля — это имя атрибута класса. Значение атрибута класса — это объект класса pydantic Field, с помощью которого мы будем описывать небходимые свойства поля:

  • обязательность/необязательность — за это отвечает первый аргумент класса: Field(...) — так мы говорим что поле обязательное;
    Field(None) — так, что оно не обязательное.

У Field есть еще свойства, их можно посмотреть в документации или в исходном коде. И да, типы полей в класе мы проставляем питоновскими тайп хинтами.

Допустим у нас есть такая спецификация:

Описание ручки
Описание ручки
Описание моделей
Описание моделей

Тогда в коде наши модели будут выглядеть так:

import typing as t from pydantic import BaseModel, Field        class ExternalObj(BaseModel):     number: str = Field(...)     name: str = Field(None)     class Order(BaseModel):     number: str = Field(None)     externalObjects: t.List[ExternalObj] = Field(...)     branch: str = Field(...)     createDate: datetime = Field(...)     state: State = Field(None)     note: Note = Field(None) 

Мы разобрались с тем, как будем описывать модели. Теперь перейдем к методам.

Пишем методы

Предположим, что тестовые данные у вас как-то формируются (генерацию тестовых данных оставляем за рамками статьи).

Начнем с метода удаления полей в нашем тестовом json’e.
Метод будет принимать:

  • flag — какие будем удалять поля: обязательные 'required' или необязательные 'optional';

  • data — тестовую дату, в которой будем проводить чистку;

  • model — модель данных, которую мы передаем в дате;

  • paths — пути расположения этой модели в дате (прим. автора — случай со State когда он может быть передан у разных объектов), cами пути мы передаем в кортежах. В нашем примере: [('state',)('externalObjects', 0, 'state')]

def delete_fields(flag: str, data: dict, model: PydanticModel, paths: list | tuple) -> None:     if isinstance(paths, tuple):         paths = [paths]      fields_to_delete = []     if flag == 'required':         fields_to_delete = model.schema()['required']     elif flag == 'optional':         fields_to_delete = _get_optional_fields(model)      for item in paths:         obj = _get_value_by_path(data, item)         for field in fields_to_delete:             obj.pop(field)

Как я уже упоминал выше, для проверки обязательности полей мы будем сравнивать список неотправленных полей с ответом сервиса.
На языке python мы будем собирать множество кортежей.

Далее опишем метод сбора из модели обязательных атрибутов с их путями.
Метод будет принимать:

  • model — модель данных, в которой будем искать обязательные атрибуты;

  • path — путь расположения этой модели в теле

А возвращать будет множество кортежей с путями обязательных атрибутов: {(branch, ), (externalObjects, 0, number)...}

def get_required_fields_paths(model: PydanticModel, path: tuple | list) -> set:     paths = set()     required_fields = _get_required_fields(model)      if isinstance(path, list):         for i in range(len(path)):             for field in required_fields:                 paths.add(path[i]+(field,))     elif isinstance(path, tuple):         for field in required_fields:             paths.add(path+(field,))      return paths

Следующий — метод получения пропущенных неотправленных полей
В него мы будем передавать:

  • unsent_fields — множество неотправленных полей, который получим из метода выше;

  • service_error_data — тело ответа сервиса.

def get_missing_fields(unsent_fields: set, service_response: dict) -> set:     error_data = set(tuple(i) for i in [i['loc'][1:] for i in service_response if i['msg'] == 'field required'])      return unsent_fields.difference(error_data) 

Тут же ответ сервиса будет распарсен, и начнется сравнение объекта «А» — множества обязательных полей, которые мы не отправили, с объектом «Б» — множеством полей, по которым вернулась ошибка field required от сервиса. И метод вернет результат сравнения.

Так как обязательные атрибуты могут в себе содержать объекты с обязательными атрибутами [в нашем примере это externalObjects], то тесты нужно будет параметризовать, чтобы проверить обязательность объекта и его атрибутов.

Напишем метод параметризации, возвращающий список и по которому будем итерироваться в тестах:

def parametrize_list_of_objects(model: PydanticModel, required: bool = False) -> list:     objects = []     if required:         try:             _get_required_fields(model)             objects = [(model, ())]         except KeyError:             pass     _build_list_for_parametrize(model, objects, (), required)     return objects

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

Вспомогательные методы, которые используются в упомянутых выше методах:

def _get_value_by_path(data: dict, path: tuple) -> dict:     current_val = data     for key in path:         current_val = current_val[key]      return current_val   def _get_required_fields(model: PydanticModel) -> list:     if '$ref' in model.schema():         required_fields = model.schema()['definitions'][f'{model.__name__}']['required']     else:         required_fields = model.schema()['required']      return required_fields   def _get_optional_fields(model: PydanticModel) -> list:     if '$ref' in model.schema():         all_fields = set(model.schema()['definitions'][f'{model.__name__}']['properties'].keys())     else:         all_fields = set(model.schema()['properties'].keys())      try:         required_fields = set(_get_required_fields(model))     except KeyError:         required_fields = set()      return list(all_fields.difference(required_fields))   def _build_list_for_parametrize(model: PydanticModel, built_list_of_fields: list, path: tuple, required: bool) -> None:     fields_properties = model.schema()['properties']     for i in fields_properties:         is_list = False         obj = {}         if 'type' in fields_properties[i]:             if fields_properties[i]['type'] == 'array':                 obj = fields_properties[i]['items']                 is_list = True         else:             obj = fields_properties[i]          if '$ref' in obj:             child_model = globals()[obj['$ref'][14:]]             if required:                 try:                     _get_required_fields(child_model)                 except KeyError:                     continue             if is_list:                 local_path = path + (i, 0)             else:                 local_path = path + (i,)             built_list_of_fields.append((child_model, local_path))             _build_list_for_parametrize(child_model, built_list_of_fields, local_path, required) 

Итак, модели описаны, методы написаны — приступим к написанию тестов.

Пишем тесты

import pytest  from api.client import order_base_client from resources.prepare_data import prepare_data from helpers.general import get_missing_fields from helpers.models import delete_fields, get_required_fields_paths, parametrize_list_of_objects from serializers.orders import Order   fields_to_test = parametrize_list_of_objects(Order)   @pytest.mark.parametrize('model, path', fields_to_test) def test_optional_fields(model, path):      # подготавливаем тестовые данные     order = prepare_data('create_order')     # удаляем обязательные атрибуты     delete_fields('optional', order, model, path)      # отправляем запрос на создание заказа     create_order = order_base_client.create_order(order)      # проверяем успешный код ответа и признак создания сущности     assert create_order.status_code == 201, f'{create_order.text}'     assert type(create_order.json()['id']) is int  ------------------------------------------------------------------ fields_to_test = parametrize_list_of_objects(Order, required=True)   @pytest.mark.parametrize('model, path', fields_to_test) def test_required_fields(model, path):      # подготавливаем тестовые данные     order = prepare_data('create_order')      # подготавливаем наш объект "А" - обязательные поля которые будут удалены     fields_to_delete = get_required_fields_paths(model, path)     # удаляем обязательные атрибуты     delete_fields('required', order, model, path)      # отправляем запрос на создание заказа     create_order = order_base_client.create_order(order)      # проверяем код ответа     assert create_order.status_code == 422, f'{create_order.text}'     # проверяем что по всем не отправленным атрибутам пришла ошибка     missing_fields = get_missing_fields(fields_to_delete, create_order.json()['detail'])     assert len(missing_fields) == 0, missing_fields   def test_base():        # подготавливаем тестовые данные     order = prepare_data('create_order')     # создаем заказ     create_order = order_base_client.create_order(order)     # получаем его id для получения     order_id = create_order.json()['id']      # получаем созданный заказ из сервиса     get_order = order_base_client.get_order(order_id)      assert Order(**order).dict() == Order(**get_order.json()).dict()     assert get_order.status_code == 204, f'{get_order.json()}'     assert get_order.content == b''

Параметризированный тест — test_optional_fields будет проверять необязательность опциональных полей,test_required_fields будет проверять свойство обязательности обязательных полей соответственно.

test_base проверит соответствует ли ответ ручки получения заказа спецификации: валидация произойдет в момент создания объекта сериализатора — Order(**get_order.json()), и сохранились ли все атрибуты, которые мы отправили при создании заказа — Order(**order).dict() == Order(**get_order.json()).dict().

Шаблон структуры проекта оставлю тут: https://github.com/fastmelodic/model-based-api-test-project

Прелести текущего подхода

  1. Тесты выглядят лаконично, в том числе благодаря параметризации.

  2. Тесты могут работать с любым количеством уровней внутренней иерархии объектов и с объектами любой сложности.

  3. При любом изменении в спецификации будет достаточно актуализировать соответствующую модельку — код самих тестов остается неизменным.

Это лишь один из вариантов реализации — тут можно много чего еще докрутить и придумать, например, эти же модельки можно использовать для генерации тестовых данных и т.д. Моей же целью было пошарить рабочую идею реализации.

Возможно это кого-то подтолкнет к своему решению (буду рад если поможет!) или к улучшенной версии текущего решения (делитесь им в комментариях).


ссылка на оригинал статьи https://habr.com/ru/company/rostelecom/blog/684278/


Комментарии

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

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