From ac779dec7ea2ce7159f50c1d8a243c7e3223a3c5 Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Thu, 20 Aug 2026 19:21:56 +0330 Subject: [PATCH] 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. --- tests/conftest.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 349a412..28bdce4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 (