
Привет! Меня зовут Тимур Шарафутдинов, я занимаюсь процессами автоматизации тестирования в «Ростелеком ИТ». Сегодня поделюсь своим опытом реализации model based-подхода в написании python API автотестов на проекте «База заказов».
Проект представляет из себя приложение с микросервисной архитектурой для обработки, хранения, конфигурирации заказов, нотифицирования целевых систем и встроенным механизмом запуска процессов подключения услуг. Приложение является модулем общей системы и не имеет фронта как такового, только API интерфейс.
Небольшая преамбула
Подходя к разработке архитектуры стека автотестов, стояла задача сделать стек:
-
с лаконичным кодом;
-
с проверками соответствия спецификации:
-
обязательности/необязательности атрибутов;
-
корректности сохранения атрибутов в POST/PUT/PATCH ручках;
-
корректности схемы ответа сервиса.
-
удобным и адаптивным под изменения спецификации.
Изменения могли вноситься достаточно часто, плюс модели в спецификации могли использоваться в разных ручках, а то и в одной на нескольких уровнях иерархии.
Например:
"state": { "value": "", "date": "", "changedBy": null }
state может находиться как у самого объекта, так и у других различных компонентов. И при изменении спецификации объекта нам нужно будет актуализировать все сопутствующие тесты.
Хорошо бы описать модели объектов в одном месте, написать методы по работе с ними, которые бы использовались в тестах. Тогда при изменениях в спецификации нам достаточно будет актуализировать наши описанные модели, а тесты сами адаптируются благодаря написанным методам.
Получается задача на целый фреймворк, отсюда вопрос — что здесь может помочь?
-
Первое. Предполагается, что наши сервисы умеют обрабатывать ошибки и формируют структурированный отчет в ответе — что/где/какая ошибка.

Что: 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
Прелести текущего подхода
-
Тесты выглядят лаконично, в том числе благодаря параметризации.
-
Тесты могут работать с любым количеством уровней внутренней иерархии объектов и с объектами любой сложности.
-
При любом изменении в спецификации будет достаточно актуализировать соответствующую модельку — код самих тестов остается неизменным.
Это лишь один из вариантов реализации — тут можно много чего еще докрутить и придумать, например, эти же модельки можно использовать для генерации тестовых данных и т.д. Моей же целью было пошарить рабочую идею реализации.
Возможно это кого-то подтолкнет к своему решению (буду рад если поможет!) или к улучшенной версии текущего решения (делитесь им в комментариях).
ссылка на оригинал статьи https://habr.com/ru/company/rostelecom/blog/684278/

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