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:
Ali Zarinkolah
2026-08-20 19:21:56 +03:30
parent 9e8987968c
commit ac779dec7e

View File

@@ -1,7 +1,8 @@
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Iterator
import pytest
import pytest_asyncio
import structlog
from asgi_lifespan import LifespanManager
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
@@ -10,6 +11,25 @@ from src.config import Settings
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
def settings() -> Settings:
# 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)
@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
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
async with (