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