obsidian/wiki/concepts/python-fastapi-module-level-singletons.md
2026-04-30 21:42:22 +01:00

3.2 KiB

title aliases tags sources created updated
Module-Level Singletons Break pytest — Use Lazy Initialisation
module-level-settings-pytest
lazy-singleton-fastapi
settings-import-time-instantiation
python
fastapi
pytest
testing
pydantic
daily/2026-04-30.md
2026-04-30 2026-04-30

Module-Level Singletons Break pytest — Use Lazy Initialisation

Instantiating Settings(), SomeService(), or any object that reads environment variables at module import time causes pytest to fail when those env vars are not set in the test environment — even for tests that never call that module's functions. Python imports all referenced modules on import, so settings = Settings() at the top of config.py runs as soon as any test file imports anything from that package.

Key Points

  • Settings() at module level runs at import time, not at call time
  • pytest imports modules eagerly — a test for routes/health.py may trigger config.pySettings()ValidationError
  • The failure looks like a config error, not a test design problem
  • Fix: wrap in @lru_cache function or @property so instantiation is deferred to first use
  • Pydantic BaseSettings validation runs in __init__ — there is no "lazy" mode

Details

Anti-Pattern

# config.py  ← runs at import time
settings = Settings()          # crashes if MONGO_URL not set in test env

# service.py
db_service = DatabaseService() # same problem
from functools import lru_cache

@lru_cache
def get_settings() -> Settings:
    return Settings()

# Use as FastAPI dependency
@router.get("/")
async def handler(settings: Settings = Depends(get_settings)):
    ...

Tests can override with app.dependency_overrides[get_settings] = lambda: FakeSettings().

Fix 2 — @property on a config holder

class _Config:
    _settings: Settings | None = None

    @property
    def settings(self) -> Settings:
        if self._settings is None:
            self._settings = Settings()
        return self._settings

config = _Config()  # safe — no Settings() call yet

Fix 3 — pytest monkeypatch / .env file

For tests that genuinely need the real Settings, provide env vars via a conftest.py:

@pytest.fixture(autouse=True)
def env_vars(monkeypatch):
    monkeypatch.setenv("MONGO_URL", "mongodb://localhost:27017/test")
    monkeypatch.setenv("SECRET_KEY", "test-secret")

Why Python 3.14 Makes This Worse

Python 3.14 has no pre-built wheels for Rust-extension packages (pydantic-core, cryptography). Poetry silently installs a pure-Python fallback that may behave differently or be missing functionality. Always pin python = "^3.11" in pyproject.toml and run tests in Docker matching the production Python version.

Sources

  • daily/2026-04-30.md — Session 13:36, test suite fixes; module-level Settings() crashes, aiohttp mock pattern