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






















