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

Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК 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)