Приветствую, Хабр!

Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК Infowatch.

Наша команда столкнулась с популярными болями автотестов для API:

  • одно изменение в API требует обновления нескольких тестов;

  • проверка структуры ответа распределена по тестам и не централизована;

  • валидация вложенных структур и генерируемых полей требует дополнительного кода.

И мы задались вопросом: какой инструмент для валидации контракта нам подойдёт?

В этой статье расскажем о нашем переосмыслении подхода к тестированию API с помощью внедрения Pydantic.

Рассмотрим простой пример теста на создание пользователя:

# ../your_project/tests/user/test_crud_user.py

 

def test_create_user():

    user_creation_body = {

        "USERNAME": "John_Smith",

        "EMAIL": "john@example.com",

        "DISPLAY_NAME": "John Smith",

    }

    response = requests.post(f"{SOME_URL}/user", json=user_creation_body)

    response.raise_for_status()

    data = response.json()

    assert data["USERNAME"] == user_creation_body["USERNAME"]

    assert data["EMAIL"] == user_creation_body["EMAIL"]

    assert data["DISPLAY_NAME"] == user_creation_body["DISPLAY_NAME"]

    assert isinstance(data["USER_ID"], int)

Тест проверяет успешность выполнения запроса и соответствие данных в конкретных полях. Но такой подход имеет ряд недостатков:

  • Нет проверки всей структуры данных ответа

  • Нет единого описания контракта

  • Дублирование assert (валидация типа для data["USER_ID"] нужна во всех тестах с участием сущности USER)

  • Изменения API могут пройти незаметно в непроверяемых полях

Поэтому мы решили выделить контракт API в отдельный слой валидации. Для этого нужен инструмент, который:

  • Валидирует структуру на основе единого контракта

  • Реализован независимо

  • Падает при расхождении с API

  • Внедряется и масштабируется поэтапно

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

Pydantic v2 решает эти задачи за счет строгой типизации, встроенной валидации и предсказуемого поведения, поэтому он лучше всего соответствовал нашим требованиям.

Запрос из теста в предыдущем примере

requests.post(f"{SOME_URL}/user", json=user_creation_body).json()

Вернет нам следующий json:

{

 "USER_ID": 69,

 "USERNAME": "John_Smith",

 "DISPLAY_NAME": "John Smith",

 "EMAIL": "john@example.com",

 "NOTE": null,

 "CREATE_DATE": "2026-01-01T00:01:30.897869",

 "STATUS": 1

}

Из документации к API проекта мы можем узнать требования к значениям полей. Создадим по ней Pydantic-модель для тела ответа. Модель описывает структуры данных с помощью аннотаций типов Python. Она определяет, какие поля ожидаются в ответе, их типы и ограничения, а также автоматически валидирует данные при создании объекта. Каждое поле является атрибутом модели с удобным вызовом.

# ../your_project/models/user/response.py

 

 

from datetime import datetime

from enum import IntEnum

from pydantic import BaseModel, ConfigDict, EmailStr, Field, PositiveInt

 

 

class UserStatuses(IntEnum):

   active = 1

   inactive = 0

 

 

class UserResponseModel(BaseModel):

  # Конфигурируем модель: задаем правила валидации и обработки полей.

  model_config = ConfigDict(

    extra="forbid",  # Запрет новых полей в ответе API.

    populate_by_name=True,  # Возможность использовать как alias полей, так и их имена.

    alias_generator=lambda field_name: field_name.upper() # Генерируем alias (UPPER_CASE) для сопоставления формата полей в модели и в API.

  )

 

  # Допускается только позитивное число

  user_id: PositiveInt

 

  # Строка длиной от 8 до 32 символов

  username: str = Field(min_length=8, max_length=32)

  display_name: str = Field(min_length=8, max_length=32)

 

  # Валидный формат email. Под капотом EmailStr либо email-validator.

  email: EmailStr

 

  # Необязательное поле.

  note: str | None = None

 

  # Дата и время в формате datetime.

  create_date: datetime

 

  # Значение из enum UserStatuses

  status: UserStatuses

Теперь в проекте есть модуль с описанием контракта для создания юзера. Он отвечает на вопрос: «Каким должен быть ответ?». По сути это исполняемая документация для API, клиентское описание контракта.

Обновленный тест проверяет структуру через pydantic-модель, а assert’ы отвечают только за бизнес-логику.

# ../your_project/tests/user/test_crud_user.py

 

from ..models.user.response import UserResponseModel

 

def test_create_user():

    user_creation_body = {

        "USERNAME": "John_Smith",

        "EMAIL": "john@example.com",

        "DISPLAY_NAME": "John Smith",

    }

    response = requests.post(f"{SOME_URL}/user", json=user_creation_body)

    response.raise_for_status()

    data = response.json()

 

    user_model = UserResponseModel.model_validate(data)

    

    # Поля из тела ответа - атрибуты Pydantic-модели

    assert user_model.USERNAME == user_creation_body["USERNAME"]

    assert user_model.EMAIL == user_creation_body["EMAIL"]

    assert user_model.DISPLAY_NAME == user_creation_body["DISPLAY_NAME"]

Теперь, если в поле “CREATE_DATE” вернется значение “31-03-2026” и добавится новое поле "IS_BLOCKED": false, то тест упадёт, даже если на эти поля нет assert. 

Для примера посмотрим сырое исключение ValidationError:

pydantic_core._pydantic_core.ValidationError: 2 validation errors for UserResponseModel

 

CREATE_DATE

  Input should be a valid datetime or date, invalid character in year [type=datetime_from_date_parsing, input_value='31-03-2026', input_type=str]

    For further information visit https://errors.pydantic.dev/2.11/v/datetime_from_date_parsing

 

IS_BLOCKED

  Extra inputs are not permitted [type=extra_forbidden, input_value=False, input_type=bool]

    For further information visit https://errors.pydantic.dev/2.11/v/extra_forbidden

Чем больше моделей, тем проще  добавление новых тестов. Так как модели абстрагированы в отдельном слое, они могут переиспользоваться в других моделях, расширяя их контракт, часто даже без необходимости написания нового кода.

Например, NotificationModel использует уже готовые модели UserResponseModel и TemplateModel:

# ../your_project/models/notifications/response.py

from datetime import datetime

from ..models.user.response import UserResponseModel

from ..models.templates.response import TemplateResponseModel

from pydantic import BaseModel

 

class NotificationResponseModel(BaseModel):

    model_config = ConfigDict(

      extra="forbid",

      populate_by_name=True,

      alias_generator=lambda field_name: field_name.upper()

   )   

   notification_id: str

   display_name: str

   create_date: datetime

   # Автоматические рекурсивные валидации по каждому элементу списка

   recipients: list[UserResponseModel]

   templates: list[TemplateResponseModel]

А UserResponseModel участвует и в создании NotificationResponseModel, и в тесте test_add_notification без дублирования кода. Любое изменение в Pydantic-модели автоматически задействовано во всех тестах и моделях, которые её используют.

# ../your_project/tests/notification/test_add_notification.py

 

from ..models.notifications.response import NotificationResponseModel

 

 

def test_add_notification():

    notification_add_body = {

        "DISPLAY_NAME": "John Smith",

        "RECIPIENTS": [..],

        "TEMPLATES": [..],

    }

    response = requests.post(f"{SOME_URL}/notification", json=notification_add_body)

    data = response.json()

    notification_model = NotificationResponseModel.model_validate(data)

    assert notification_model.DISPLAY_NAME == notification_add_body["DISPLAY_NAME"]

Наш опыт внедрения подхода с Pydantic

Мы декомпозировали эпик на задачи и поэтапно реализовали поддержку Pydantic во фреймворке проекта, описали наиболее важные «ручки» для сложных сущностей, добавляли модели при изменениях существующих тестов и выносили повторяющиеся структуры в общие модели.

Не пришлось переписывать все и сразу, на одну «ручку» приходится примерно 1-3 модели в зависимости от структуры. Отличным инструментом для ускорения генерации моделей стал datamodel-code-generator. По OpenAPI-спецификации он в один вызов генерирует модели, которые затем можно привести к стилю проекта и дополнить валидацией. Пример:

datamodel-codegen \

  --input your_openapi_spec.yaml \             # путь к исходной OpenAPI-спецификации

  --input-file-type openapi \                  # указываем тип спецификации

  --output your_path/ \                        # путь для генерации моделей

  --target-python-version 3.13 \               # версия Python для использования актуального синтаксиса

  --output-model-type pydantic_v2.BaseModel \  # использовать Pydantic v2 BaseModel как базовый класс моделей

  --snake-case-field \                         # конвертировать имена полей в snake_case

  --use-double-quotes \                        # использовать двойные кавычки в сгенерированном коде

  --use-schema-description \                   # переносить описание из схемы в docstring или Field(description=...)

  --formatters ruff-check ruff-format          # проверять код через Ruff (линтеры + автоформатирование)

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

Как итог, мы изменили сам подход к тестированию API ключевого продукта InfoWatch:

  • Проверки разделены на два независимых слоя: валидации структуры ответа и валидацию бизнес-логики.

  • Автоматически и централизованно отслеживаем изменения в API

  • Сократили количество assert в тестах за счёт переноса проверок структуры в модели

  • Увеличили покрытие за счет переиспользования моделей

Этот подход особенно хорошо масштабируется в больших проектах, где API активно развивается.

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