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