How to Override Pydantic BaseSettings in FastAPI Tests

fastapi pydantic pytest dependency injection python

The cleanest way to override Pydantic BaseSettings in FastAPI tests is to define your settings as a dependency with Depends, then swap it out with app.dependency_overrides in your test fixtures. This gives you full control over every setting value per test, avoids touching environment variables, and keeps your production code untouched. If your settings object lives as a module-level singleton instead of a dependency, you have alternative approaches. Even so, refactoring toward dependency injection is almost always the better path.

Step 1: Define Settings as a FastAPI Dependency

Expose your settings through a callable that FastAPI's DI system can resolve. This is the critical design decision that makes everything testable.

from functools import lru_cache
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = "postgresql://localhost/prod"
    api_key: str = "real-key"
    debug: bool = False

    model_config = {"env_prefix": "APP_"}

# Cache so the same instance is reused across requests.
@lru_cache
def get_settings() -> Settings:
    return Settings()

Step 2: Inject Settings Into Your Route

Use Depends(get_settings) in your route signature. This is what lets dependency_overrides intercept the call during tests.

from fastapi import Depends, FastAPI

app = FastAPI()

@app.get("/health")
def health_check(settings: Settings = Depends(get_settings)):
    return {
        "debug": settings.debug,
        "database": settings.database_url,
    }

Step 3: Override Settings in Your Test Suite

Create a fixture that swaps get_settings with a function returning your test configuration. The dependency_overrides attribute is a plain dict that maps the original callable to a replacement.

import pytest
from fastapi.testclient import TestClient
from app.config import Settings, get_settings
from app.main import app

@pytest.fixture
def test_settings():
    return Settings(
        database_url="sqlite:///test.db",
        api_key="test-key",
        debug=True,
    )

@pytest.fixture
def client(test_settings):
    app.dependency_overrides[get_settings] = lambda: test_settings
    yield TestClient(app)
    # Always clean up to avoid leaking state between tests.
    app.dependency_overrides.clear()
def test_health_returns_test_settings(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["debug"] is True
    assert data["database"] == "sqlite:///test.db"

Alternative: Monkeypatching Environment Variables

If you inherited a codebase where settings are a module-level singleton (not injected via Depends), you can use monkeypatch.setenv to control what Pydantic reads from the environment. This works because BaseSettings reads environment variables at instantiation time, so you need to force re-instantiation after patching. This approach is brittle because it depends on import order and cache state, which is exactly why dependency injection is superior.

def test_with_monkeypatched_env(monkeypatch):
    monkeypatch.setenv("APP_DATABASE_URL", "sqlite:///test.db")
    monkeypatch.setenv("APP_API_KEY", "patched-key")
    monkeypatch.setenv("APP_DEBUG", "true")

    # Force fresh instantiation; bypass any lru_cache.
    get_settings.cache_clear()
    settings = get_settings()

    assert settings.database_url == "sqlite:///test.db"
    assert settings.debug is True

    # Clean up the cache so other tests get a fresh instance.
    get_settings.cache_clear()

Alternative: Using model_copy for Per-Test Overrides

When you need to override one or two fields for a specific test without constructing an entirely new Settings object, use model_copy (Pydantic v2) to derive a modified instance from your base test settings. This is especially useful when your settings class has many required fields and you want to test the effect of changing a single one.

def test_debug_mode_off(test_settings):
    # Override a single field from the base test settings.
    custom = test_settings.model_copy(update={"debug": False})
    app.dependency_overrides[get_settings] = lambda: custom
    client = TestClient(app)

    response = client.get("/health")
    assert response.json()["debug"] is False

    app.dependency_overrides.clear()

Gotchas and Pitfalls

The lru_cache trap: If you use @lru_cache on get_settings (which the FastAPI docs recommend), the monkeypatch approach silently does nothing unless you call get_settings.cache_clear() before and after each test. The dependency_overrides approach bypasses the cached function entirely, which is another reason it's the better choice.

Pydantic v1 vs v2: In Pydantic v1, settings live in pydantic.BaseSettings. In v2, they moved to the separate pydantic-settings package. The method .copy(update=...) became .model_copy(update=...). If you see ImportError: cannot import name 'BaseSettings' from 'pydantic', install pydantic-settings.

Forgetting dependency_overrides.clear(): If you forget to clear the overrides dict, test ordering creates flaky, nondeterministic failures. Always use a fixture with yield and clean up afterward, as shown above. Better yet, use addfinalizer or scope the fixture to function (the default) so that cleanup runs automatically per test.

Validation still runs: Pydantic validates the values you pass to the override Settings(...) constructor, which is a good thing. It catches test configurations that would also be invalid in production. Don't use model_construct() to skip validation unless you're specifically testing how your app handles invalid config.

Async Tests with httpx

If you're using httpx.AsyncClient instead of the synchronous TestClient, the override mechanism is identical. The dependency_overrides dict works regardless of sync or async transport.

import httpx
import pytest

@pytest.fixture
async def async_client(test_settings):
    app.dependency_overrides[get_settings] = lambda: test_settings
    async with httpx.AsyncClient(
        transport=httpx.ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        yield client
    app.dependency_overrides.clear()

@pytest.mark.anyio
async def test_health_async(async_client):
    response = await async_client.get("/health")
    assert response.json()["debug"] is True

Summary

Use Depends(get_settings) in your routes and app.dependency_overrides[get_settings] in your tests. This is the idiomatic FastAPI pattern. It sidesteps every caching and import-order issue and gives you per-test control without touching environment variables. Reserve monkeypatch.setenv for integration tests where you specifically want to verify that your settings class reads environment variables correctly. Reserve model_copy for deriving single-field variants from a shared fixture. Always clean up dependency_overrides after each test.

← Back to all articles