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