Structured logging is at its most useful when something goes wrong in a test — the fields say which request, which user, which retry — and that is exactly when it tends to be missing. A service configured to write JSON lines to stdout through structlog shows nothing in caplog. A handler created at import time with logging.StreamHandler(sys.stderr) writes to the real stderr, bypassing pytest's capture, so its output interleaves with the progress dots and is never attached to the failing test. Tests that try to assert "a warning was logged with order_id=42" end up grepping rendered strings, which breaks the first time someone changes the renderer.
None of this is a pytest bug. pytest provides a capture handler on the root logger and redirects the standard streams; logs that go through the standard logging tree are captured, shown for failures and available to caplog. Logs that take another route are not. Getting structured logging to cooperate is a matter of making sure, in tests, every event goes through that tree — and then asserting on fields rather than strings.
Prerequisites
pytest >= 8.0,structlog >= 24.1(for the structlog parts).- Familiarity with Logging and observability for debugging.
Solution
# app/logging_setup.py — one configuration used by the app and by tests.
import logging
import structlog
def configure(json: bool = True) -> None:
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(), # → stdlib logging tree
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=False, # tests reconfigure
)
renderer = structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer()
handler = logging.StreamHandler() # resolves sys.stderr at emit time
handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=renderer))
root = logging.getLogger()
root.handlers[:] = [handler]
root.setLevel(logging.INFO)
# conftest.py
import pytest
from app.logging_setup import configure
@pytest.fixture(autouse=True, scope="session")
def _logging():
configure(json=False) # readable output in failure reports
# test_orders.py — asserting on fields, two ways.
import logging
import structlog
def test_rejected_order_logs_reason(caplog):
caplog.set_level(logging.WARNING, logger="app.orders")
submit_order(order_id=42, qty=0)
[rec] = [r for r in caplog.records if r.name == "app.orders"]
assert rec.msg["event"] == "order_rejected"
assert rec.msg["order_id"] == 42
def test_rejected_order_logs_reason_capture_logs():
with structlog.testing.capture_logs() as events:
submit_order(order_id=42, qty=0)
assert {"event": "order_rejected", "order_id": 42, "reason": "qty"}.items() <= events[0].items()
Why this works
pytest's logging plugin adds two handlers to the root logger for each test: one that feeds caplog, and one that collects records for the report section shown on failure. Any record that propagates to the root logger reaches both. structlog.stdlib.LoggerFactory() makes every structlog logger a thin wrapper around a standard logging.Logger, so structlog events become log records, propagate, and are captured like any other.
ProcessorFormatter.wrap_for_formatter keeps the event dictionary intact on the record — as record.msg — until a handler's formatter renders it. That is why the first test can read rec.msg["order_id"] directly: the structure survives until rendering, and caplog's handler does not render. The JSON renderer only runs in the stream handler, so production output is unchanged.
The stream handler is created without an explicit stream, which makes it resolve sys.stderr at emit time rather than at construction. When pytest swaps sys.stderr for its capture buffer, the handler follows. A handler constructed as StreamHandler(sys.stderr) at import captures the original stream object and keeps writing to the terminal regardless.
Seeing logs while debugging
Captured logs appear in the "Captured log call" section of a failing test's report, which is usually enough. When debugging a hang or a slow test, it is more useful to watch logs live:
pytest -o log_cli=true -o log_cli_level=DEBUG -k test_checkout -s
log_cli streams records to the terminal as they are emitted, with its own format controlled by log_cli_format. Combined with the console renderer in tests, the output is readable key-value lines rather than dense JSON. For CI, where logs are read after the fact, keep the default capture and add -rA to include captured logs for passing tests in the summary when needed.
Asserting on logs without making tests brittle
Once logs are capturable, there is a temptation to assert on all of them, and suites that do end up breaking every time someone rewords a message. Log assertions are worth writing only where the log is the behaviour: an audit event that compliance depends on, a warning that operators alert on, a security event that must include the actor's identity. Everywhere else, logs are diagnostics, and tests should not pin them.
When a log is part of the contract, assert on the fields that carry meaning and ignore the rest. The subset comparison in the second test above — the expected dictionary's items are a subset of the event's items — does exactly that: it checks event, order_id and reason, and stays green when someone adds a request_id or changes the timestamp format. Assert on the event name rather than a human-readable message; event names are identifiers and change rarely, messages are prose and change often.
For negative assertions — "no error was logged during this operation" — filter by level rather than by content: assert not [r for r in caplog.records if r.levelno >= logging.ERROR]. That catches unexpected errors from any logger, including libraries, which is often the most valuable log assertion a test can make. An integration test that passes while the HTTP client logged three connection-reset errors is hiding a problem, and a level-based check surfaces it without knowing anything about the client's messages.
A shared helper keeps these patterns consistent across a suite. A fixture that yields structlog.testing.capture_logs() events, plus a function assert_logged(events, event=..., **fields) that performs the subset match and prints all captured events on failure, makes the intent of each assertion obvious and the failure output useful.
Correlating log lines with the test that produced them
In a large suite, especially under pytest-xdist, logs from many tests end up in the same CI output, and matching a log line to the test that emitted it is tedious. Binding the test's node id into the logging context fixes that with a few lines:
@pytest.fixture(autouse=True)
def _bind_test_id(request):
structlog.contextvars.bind_contextvars(test=request.node.nodeid)
yield
structlog.contextvars.clear_contextvars()
Every event emitted during the test now carries a test field, rendered in both the console and JSON formats. When logs are shipped from CI to a log store, filtering by that field reconstructs the exact sequence of events for one failing test, even when it ran interleaved with dozens of others on the same worker. The clear_contextvars call at teardown also fixes the leak described in the edge cases below, so the fixture pays for itself twice.
The same idea extends to request-scoped identifiers. If the code under test binds a request_id at the start of each request, a failing integration test's captured logs show every event for that request together, and the id links those logs to the matching spans when tracing is also enabled. Together, the test id and the request id give two levels of grouping — which test, and which request within that test — which is usually all that is needed to turn a wall of interleaved CI output into a readable story of what happened before the failure.
Edge cases and failure modes
cache_logger_on_first_use=Truein tests. Loggers bound beforecapture_logsor reconfiguration keep the old processors. Disable caching in tests.- Libraries that call
logging.basicConfig. A straybasicConfigadds a handler bound to stderr. Reset root handlers in your configuration function, as above. - Propagation disabled. A logger with
propagate = Falsenever reaches the root, socaplogmisses it. Enable propagation or attachcaplog.handlerto that logger in the test. - Level filtering.
caplogrecords only what passes both the logger level and the handler level. Usecaplog.set_level(..., logger=...)for the specific logger under test. - Contextvars leaking between tests.
structlog.contextvars.bind_contextvarsstate persists across tests in the same thread. Callclear_contextvars()in a fixture.
Frequently Asked Questions
Why is caplog empty when my code uses structlog?
structlog's default configuration prints directly to stdout instead of going through the standard logging module, so pytest's caplog handler never sees the events. Configure structlog to render through stdlib logging in tests, or use structlog.testing.capture_logs.
Why do my logs disappear when a test passes?
pytest captures log records and only shows them in the report for failing tests. Use -o log_cli=true to stream logs live, or -rA to show captured output for passing tests too.
How do I assert on structured log fields?
With stdlib logging, read record attributes from caplog.records, where fields passed via extra appear as attributes. With structlog, capture_logs returns a list of event dictionaries you can assert on directly.
Related
- Logging and Observability for Debugging — logging strategy for debuggable systems.
- Tracing a Request Through a Test with OpenTelemetry — spans alongside logs.
- Capturing Artifacts from a Failed CI Test Run — keeping logs after CI fails.
- Reproducing CI-Only Test Failures Locally — when logs point at environment.
← Back to Logging and Observability for Debugging