97 lines
3.2 KiB
Markdown
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
|