• /
  • /

Как изменить настройки Pydantic в тестах Pytest: пошаговое руководство

Рассказываем, как модифицировать настройки Pydantic в тестах с использованием Pytest. Это поможет DevOps-специалистам, кто работает с автоматизацией, тестированием инфраструктуры и настройкой CI/CD-пайплайнов.

Введение

Pydantic — это популярная библиотека Python для валидации данных и управления настройками. Она позволяет определять модели данных с помощью аннотаций типов, которые автоматически проверяются во время выполнения. Одной из ключевых особенностей Pydantic является модуль

pydantic-settings, который упрощает работу с настройками приложения.


Однако при написании тестов часто возникает необходимость временно изменить настройки приложения, чтобы протестировать различные сценарии. Например, тебе может понадобиться изменить значения настроек, такие как параметры подключения к базе данных или API-токены, без изменения исходного кода или переменных окружения. В этой статье покажем, как можно исправить (patch) настройки Pydantic в тестах с использованием Pytest.


Модуль pydantic-settings делает две вещи: он автоматически считывает переменные окружения из файла .env и позволяет декларативно преобразовывать строковые значения в нужные типы, такие как целые числа, булевы значения и т.д. Кроме того, он позволяет переопределять переменные, заданные в .env, с помощью экспорта их в shell.


Например, если у вас есть переменная FOO в файле .env, заданная так:

FOO="some_value"

Можно переопределить её, выполнив:

export FOO="other_value"

И pydantic-settings автоматически подхватит переопределённое значение без лишних сложностей.


Это удобно, но может усложнить написание детерминированных модульных тестов. Если объект настроек неявно подтягивает значения конфигурации как из файла окружения, так и из shell, тестирование функций, использующих эти значения, может стать ненадёжным. К тому же, в целом считается плохой практикой, если модульные тесты зависят от переменных окружения.

Проблема

Рассмотрим типичный процесс создания объекта настроек. У нас есть следующая структура приложения:

.
├── src
│   ├── __init__.py
│   ├── config.py
│   └── main.py
├── tests
│   ├── __init__.py
│   └── test_main.py
└── .env

В файле src/config.py мы определяем класс настроек следующим образом:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # Переопределяем значения по умолчанию значениями из файла .env
    model_config = SettingsConfigDict(env_file=".env")

    env_var_1: str = "default_value"
    env_var_2: int = 123
    env_var_3: bool = False

Соответствующие значения переменных окружения определены в файле .env. Pydantic автоматически преобразует определения в верхнем регистре в нижний:

ENV_VAR_1="overridden_value"
ENV_VAR_2="42"
ENV_VAR_3="true"

Далее мы создаём экземпляр класса Settings в файле src/__init__.py:

from src.config import Settings

settings = Settings()

Наконец, мы используем значения конфигурации в src/main.py:

from src import settings

def read_env() -> tuple[str, int, bool]:
    return settings.env_var_1, settings.env_var_2, settings.env_var_3

if __name__ == "__main__":
    env_var_1, env_var_2, env_var_3 = read_env()
    print(f"{env_var_1=}")
    print(f"{env_var_2=}")
    print(f"{env_var_3=}")

Из корневой директории запустим файл main.py с помощью команды:

python -m src.main

Это покажет, что pydantic-settings творит свою магию — считывает файл .env и переопределяет значения конфигурации по умолчанию:

env_var_1='overridden_value'
env_var_2=42
env_var_3=True

Но теперь тестирование функции read_env становится сложным. Обычно мы бы попытались патчить переменные окружения в фикстуре Pytest и затем тестировать значения следующим образом:

# tests/test_main.py

import os
from collections.abc import Iterator
from unittest.mock import patch

import pytest
from src.main import read_env

@pytest.fixture
def patch_env_vars() -> Iterator[None]:
    with patch.dict(
        os.environ,
        {
            "ENV_VAR_1": "test_env_var_1",
            "ENV_VAR_2": "456",
            "ENV_VAR_3": "True",
        },
    ):
        yield

def test_read_env(patch_env_vars: None) -> None:
    env_var_1, env_var_2, env_var_3 = read_env()
    assert env_var_1 == "test_env_var_1"
    assert env_var_2 == 456
    assert env_var_3 is True

Но тест провалится, потому что мы инициализируем класс Settings в файле src/__init__.py, и Pydantic обрабатывает файл окружения и переменные до того, как Pytest успеет вмешаться.


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


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


Итак, патчинг переменных окружения не работает. Что же делать?

import pytest
from src.main import read_env
from src import settings, Settings
from collections.abc import Iterator
from pytest import FixtureRequest

@pytest.fixture
def patch_settings(request: FixtureRequest) -> Iterator[Settings]:
    # Делаем копию оригинальных настроек
    original_settings = settings.model_copy()

    # Собираем переменные окружения для патчинга
    env_vars_to_patch = getattr(request, "param", {})

    # Патчим настройки, устанавливая значения по умолчанию
    for k, v in settings.model_fields.items():
        setattr(settings, k, v.default)

    # Патчим настройки с переданными параметризованными переменными
    for key, val in env_vars_to_patch.items():
        # Вызываем ошибку, если переменная не определена в настройках
        if not hasattr(settings, key):
            raise ValueError(f"Неизвестная настройка: {key}")

        # Вызываем ошибку, если тип переменной неверный
        expected_type = getattr(settings, key).__class__
        if not isinstance(val, expected_type):
            raise ValueError(
                f"Неверный тип для {key}: {val.__class__} вместо "
                f"{expected_type}"
            )
        setattr(settings, key, val)

    yield settings

    # Восстанавливаем оригинальные настройки
    settings.__dict__.update(original_settings.__dict__)

Здесь patch_settings — это параметризуемая фикстура, где можно опционально передать значения через pytest.mark.parametrize, чтобы переопределить определённые атрибуты конфигурации. Если ничего не переопределяете, фикстура устанавливает атрибуты экземпляра Settings в их значения по умолчанию, заданные в классе.


Сначала мы создаём копию оригинального экземпляра настроек. Затем сбрасываем атрибуты экземпляра Settings на их значения по умолчанию. Далее переходим к переопределению любых значений, переданных через декоратор @parametrize. При этом мы также проверяем правильность типов входящих значений и вызываем ошибку, если что-то не так.


Наконец, мы возвращаем пропатченный экземпляр и сбрасываем всё обратно к оригинальным значениям после завершения теста.


Можно использовать фикстуру следующим образом:

def test_read_env(patch_settings: Settings) -> None:
    env_var_1, env_var_2, env_var_3 = read_env()
    assert env_var_1 == "default_value"
    assert env_var_2 == 123
    assert env_var_3 is False

@pytest.mark.parametrize(
    "patch_settings",
    [
        {"env_var_1": "patched_value", "env_var_2": 456},
        {"env_var_2": 459},
    ],
    indirect=True,
)
def test_read_env_override(patch_settings: Settings) -> None:
    env_var_1, env_var_2, env_var_3 = read_env()
    assert env_var_1 == patch_settings.env_var_1
    assert env_var_2 == patch_settings.env_var_2
    assert env_var_3 == patch_settings.env_var_3

В первом случае мы ничего не переопределяем, поэтому тесты используют экземпляр Settings со всеми значениями по умолчанию. Во втором тесте мы переопределяем несколько значений, и функция read_env использует переопределённые значения.


В любом случае тесты не зависят напрямую от переменных окружения, что снижает вероятность "странных действий на расстоянии".

Альтернативный подход: использование pytest-dotenv

Если хотите, чтобы Pydantic загружал настройки из переменных окружения, можно использовать плагин pytest-dotenv для управления .env-файлами в тестах. Этот подход полезен, если настройки зависят от переменных окружения, а не от прямого создания объекта.


Установка:


Установите плагин pytest-dotenv:

pip install pytest-dotenv

Пример теста:


Создайте тестовый файл .env.test

DATABASE_URL=sqlite:///test.db
API_KEY=test_api_key
DEBUG=True

Напиши тест с использованием pytest-dotenv:

import pytest
from app import get_database_connection
from settings import settings

@pytest.mark.dotenv(".env.test")
def test_debug_connection_with_dotenv():
    result = get_database_connection()
    assert result == "Debug connection to sqlite:///test.db"
    assert settings.debug is True

Объяснение:


  • Аннотация @pytest.mark.dotenv(".env.test") указывает Pytest загрузить переменные окружения из файла .env.test.
  • Pydantic автоматически подхватит эти переменные при создании объекта settings.
  • Это позволяет тестировать приложение с разными наборами настроек, просто меняя .env-файлы.

Заключение

Исправление настроек Pydantic в тестах Pytest — это мощный способ управления конфигурацией приложения во время тестирования. Использование фикстуры patch_settings позволяет временно сбрасывать настройки на значения по умолчанию и переопределять их при необходимости, что делает тесты изолированными и надёжными. Альтернативно, плагин pytest-dotenv даёт возможность управлять настройками через переменные окружения, что может быть удобнее в некоторых сценариях.


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


Примечания:


  • Если возникают проблемы с загрузкой переменных окружения, убедись, что файл .env правильно настроен и доступен для Pydantic.
  • Для более сложных приложений можно комбинировать оба подхода: использовать patch_settings для точечных изменений и pytest-dotenv для управления глобальными настройками.
У нас есть курсы под любую категорию DevOps, а также региональная скидка для казахстанцев
Инвестируйте в обучение DevOps