Привет! Меня зовут Тимур Шарафутдинов, я занимаюсь процессами автоматизации тестирования в «Ростелеком ИТ». Сегодня поделюсь своим опытом реализации 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. При любом изменении в спецификации будет достаточно актуализировать соответствующую модельку — код самих тестов остается неизменным.

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

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

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