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

97 lines
3.2 KiB
Markdown

---
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