--- title: "Module-Level Singletons Break pytest — Use Lazy Initialisation" aliases: - module-level-settings-pytest - lazy-singleton-fastapi - settings-import-time-instantiation tags: - python - fastapi - pytest - testing - pydantic sources: - "daily/2026-04-30.md" created: 2026-04-30 updated: 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.py` → `Settings()` → `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 ```python # config.py ← runs at import time settings = Settings() # crashes if MONGO_URL not set in test env # service.py db_service = DatabaseService() # same problem ``` ### Fix 1 — `@lru_cache` function (recommended for FastAPI) ```python 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 ```python 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`: ```python @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. ## Related Concepts - [[wiki/concepts/poetry-docker-version-mismatch]] — Poetry / Python version mismatch causing silent failures - [[wiki/concepts/time-sleep-blocks-asyncio]] — another class of import-time footgun in async FastAPI ## Sources - [[daily/2026-04-30.md]] — Session 13:36, test suite fixes; module-level Settings() crashes, aiohttp mock pattern