fix(tests): isolate structlog global state from lifespan-triggering fixtures
Why: - Any test using the client/api_client fixtures runs the app's real lifespan, which calls the production configure_logging() -- setting cache_logger_on_first_use=True (ADR-0011). That permanently monkeypatches the .bind method on whichever module-level logger = structlog.get_logger(__name__) instance is used first. structlog.reset_defaults() only resets *global* config, not that per-instance mutation, so once triggered, structlog.testing.capture_logs() silently stops intercepting events in every test that runs afterward in the same pytest process -- order-dependent flakiness with no useful failure message (assertions just see an empty list). Changes: - Added two autouse fixtures: one no-ops configure_logging for tests that spin up the app via LifespanManager (they test HTTP behavior, not logging output, so they don't need the real thing), one resets structlog defaults after every test as defense in depth. Impact: - Test-only; makes capture_logs()-based assertions reliable regardless of test execution order.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
import structlog
|
||||||
from asgi_lifespan import LifespanManager
|
from asgi_lifespan import LifespanManager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -10,6 +11,25 @@ from src.config import Settings
|
|||||||
from src.main import create_app
|
from src.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_structlog_after_test() -> Iterator[None]:
|
||||||
|
"""Undo any real `configure_logging()` call before the next test runs.
|
||||||
|
|
||||||
|
Any test that exercises the app's lifespan (directly, or via the `client`/
|
||||||
|
`api_client` fixtures below and in `test_domains_api.py`) calls the real
|
||||||
|
`configure_logging()`, which mutates *global* structlog/stdlib state --
|
||||||
|
including `cache_logger_on_first_use=True`. Left in place, that setting
|
||||||
|
silently breaks `structlog.testing.capture_logs()` in unrelated tests
|
||||||
|
later in the same pytest process: a module-level
|
||||||
|
`logger = structlog.get_logger(__name__)` cached under the real config no
|
||||||
|
longer routes through `capture_logs()`'s temporary processor swap, so
|
||||||
|
assertions on captured events see nothing (ADR-0016: isolate per test --
|
||||||
|
this generalizes to global config mutations, not just data).
|
||||||
|
"""
|
||||||
|
yield
|
||||||
|
structlog.reset_defaults()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def settings() -> Settings:
|
def settings() -> Settings:
|
||||||
# Every external dependency points at a closed port so unit tests never
|
# Every external dependency points at a closed port so unit tests never
|
||||||
@@ -36,6 +56,24 @@ def app(settings: Settings) -> FastAPI:
|
|||||||
return create_app(settings)
|
return create_app(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_real_logging_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Prevent the app lifespan from calling the real `configure_logging()`.
|
||||||
|
|
||||||
|
It sets `cache_logger_on_first_use=True` (ADR-0011), which permanently
|
||||||
|
monkeypatches the `.bind` method on whichever module-level
|
||||||
|
`logger = structlog.get_logger(__name__)` instance is used first --
|
||||||
|
`structlog.reset_defaults()` only resets *global* config, not that
|
||||||
|
per-instance mutation, so real configuration leaking into one test would
|
||||||
|
silently break `structlog.testing.capture_logs()` in every test that runs
|
||||||
|
afterward in the same process (ADR-0016: isolate per test). Tests that
|
||||||
|
spin up the full app via `LifespanManager` (`client`, `api_client`) are
|
||||||
|
testing HTTP behavior, not logging output, so they don't need it for
|
||||||
|
real.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr("src.bootstrap.lifespan.configure_logging", lambda *a, **k: None)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
|
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
|
||||||
async with (
|
async with (
|
||||||
|
|||||||
Reference in New Issue
Block a user