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 использует переопределённые значения.
В любом случае тесты не зависят напрямую от переменных окружения, что снижает вероятность "странных действий на расстоянии".
Если хотите, чтобы 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
Объяснение:
Исправление настроек Pydantic в тестах Pytest — это мощный способ управления конфигурацией приложения во время тестирования. Использование фикстуры patch_settings позволяет временно сбрасывать настройки на значения по умолчанию и переопределять их при необходимости, что делает тесты изолированными и надёжными. Альтернативно, плагин pytest-dotenv даёт возможность управлять настройками через переменные окружения, что может быть удобнее в некоторых сценариях.
Оба подхода помогают писать надёжные тесты, которые проверяют поведение приложения в различных условиях, не затрагивая исходный код или реальные настройки. Выберите тот метод, который лучше подходит для проекта, и начинайте тестировать с уверенностью.
Примечания: