Asserting on Log Output with pytest caplog
pytest's caplog fixture is the natural tool for testing that code logs what it should, and it is frequently used in ways that make tests brittle or blind. This page covers what it captures and when, how to assert on the fields downstream systems depend on rather than on wording, the propagation setting that silently hides records from it, and the formatting stage it deliberately does not see. It is a task article under log testing and verification, part of the Python logging fundamentals and structured data section.
Prerequisites
pip install "pytest>=8.0.0,<9.0.0"
Implementation
Step 1 — Set the capture level on the logger under test. By default caplog captures at the root logger's effective level, which is usually WARNING. Info and debug records from the code under test are discarded before caplog sees them. Setting the level for the specific logger, rather than globally, captures what the test needs without filling the capture with every library's debug output.
import logging
def test_order_accepted_is_logged(caplog, order_service):
caplog.set_level(logging.INFO, logger="orders")
order_service.accept(order_id="ord_7")
Step 2 — Filter captured records by logger and level. During any non-trivial test, other code logs too — the framework, the database driver, an HTTP client. Asserting on the total number of captured records couples the test to all of them. Selecting the records from the logger under test, at the level of interest, keeps it about the behaviour being tested.
accepted = [
r for r in caplog.records
if r.name == "orders" and r.levelno == logging.INFO
and getattr(r, "event", None) == "order_accepted"
]
assert len(accepted) == 1
Step 3 — Assert on attributes, not on the rendered message. Fields passed through extra become attributes of the LogRecord. Asserting on them directly is both simpler than parsing the message and far more stable, because they are the contract that dashboards and alert rules use. The message text is free to change.
record = accepted[0]
assert record.order_id == "ord_7"
assert record.event == "order_accepted"
assert isinstance(record.amount_cents, int) # type matters to the index
Step 4 — Check exception information when the call should carry a traceback. A call that should use log.exception but uses log.error produces a record with no traceback, and the difference only matters during an incident. Asserting on exc_info catches it.
def test_gateway_timeout_logs_the_exception(caplog, payment_service, gateway):
caplog.set_level(logging.ERROR, logger="billing")
gateway.fail_with(TimeoutError("read timed out"))
payment_service.charge(order_id="ord_9", amount=100)
(record,) = [r for r in caplog.records if r.name == "billing"]
assert record.exc_info is not None, "use log.exception so the traceback is kept"
assert record.exc_info[0] is TimeoutError
Expected Output: a failure that names the fix when someone replaces exception with error.
FAILED test_billing_logging.py::test_gateway_timeout_logs_the_exception
AssertionError: use log.exception so the traceback is kept
Step 5 — Make sure the logger propagates. If a logger is configured with its own handlers and propagate: False, records stop there and caplog — attached at the root — never sees them. The test fails with an empty capture and the logging looks broken when it is working exactly as configured. Either assert on that logger's own handler in the test, or temporarily enable propagation for the duration of the test.
@pytest.fixture
def propagate(monkeypatch):
def _enable(name: str):
monkeypatch.setattr(logging.getLogger(name), "propagate", True)
return _enable
def test_audit_event(caplog, propagate, admin_service):
propagate("audit")
caplog.set_level(logging.INFO, logger="audit")
admin_service.delete_user("u_42")
assert any(getattr(r, "action", None) == "user_deleted" for r in caplog.records)
Step 6 — Clear between phases of a longer test. A test that performs several operations and asserts on each should clear the capture between them, so each assertion sees only the records from its own phase. Without it, an assertion that an event did not happen can be satisfied or violated by records left over from an earlier step, which produces exactly the kind of flaky failure that erodes trust in a suite.
caplog.clear()
order_service.cancel(order_id="ord_7")
assert any(getattr(r, "event", None) == "order_cancelled" for r in caplog.records)
What caplog does not tell you
It is worth being clear about the limits, because a green caplog suite is easy to mistake for evidence that logging works end to end.
It does not run your formatter. caplog's handler formats with its own simple format for its text property; your production formatter — JSON, field renaming, timestamp handling — never runs. A formatter that raises on every record, or that silently drops a field, passes every caplog test. Testing the formatter needs the real handler chain and a captured output stream, as described in snapshot testing structured log output.
It does not run your filters. Filters attached to your handlers — redaction, rate limiting, sampling — are skipped, because caplog attaches its own handler. A test asserting that a secret is absent from a caplog record proves nothing about whether it reaches the output. Redaction filters attached to loggers rather than handlers do run, which is one argument for attaching them there.
It does not see non-propagating loggers. As in step 5. This is the most common reason for a caplog test that "cannot see" a log statement that obviously executes.
It sees records from every thread. Background threads that log during the test contribute records. That is usually harmless when records are filtered by logger name and troublesome when a test asserts on counts.
Within those limits, it is exactly the right tool for the question it answers: did this code path produce this event, at this level, with these fields.
Testing structlog through caplog
Services using structlog can use caplog as well, provided structlog is configured to hand its events to the standard library rather than rendering and printing them itself. The pattern is to route structlog through a standard library logger with a formatter that renders at the handler, which is the same arrangement recommended for production in migrating from standard logging to structlog.
With that arrangement, a structlog call produces a standard LogRecord whose message is the event dictionary. caplog captures it like any other record, and the structured fields are available on the record — either as the message dictionary itself or, with the appropriate processor, as record attributes. Assertions then look the same as for standard library logging, and the tests do not care which library produced the event.
Where structlog is configured to print directly, bypassing the standard library, caplog sees nothing at all. structlog provides its own capture helper for that case, which records the event dictionaries directly and supports the same style of field assertions. The choice between the two follows the production configuration: test through the path production actually uses, so the test exercises the same routing that will run in the service.
A small shared helper that extracts the event dictionary from a captured record, regardless of which library produced it, keeps assertions uniform across a codebase that uses both — which is common during a migration and worth making painless.
Configuration options
| caplog feature | Use | Note |
|---|---|---|
set_level(level, logger=…) |
capture below the root's level | scoped to one logger; reset after the test |
records |
the LogRecord objects |
assert on attributes |
record_tuples |
(name, level, message) |
quick checks; message is fragile |
text |
caplog's own formatting | not your production format |
clear() |
reset between phases | isolates assertions |
at_level(...) |
a context manager | temporary level for a block |
Verification
Confirm the test would actually fail if the event were removed — the check most logging tests never get.
# temporarily comment out the log call in the code under test, then:
pytest tests/test_order_logging.py -q
Expected Output: a failure, which proves the test is testing something.
F
FAILED tests/test_order_logging.py::test_order_accepted_is_logged
assert 0 == 1
A test that still passes with the log call removed is asserting on records from somewhere else — usually another logger emitting a similar event.
Common mistakes
Capturing at the default level. Error signature: an empty capture for an info-level event. Root cause: the root logger's effective level is warning. Remediation: set_level on the logger under test.
Asserting on total record count. Error signature: tests that break when a library adds a debug statement. Root cause: counting records from every logger. Remediation: filter by logger name and event.
Asserting on message text. Error signature: tests updated with every wording change. Root cause: coupling to prose. Remediation: assert on attributes.
A non-propagating logger. Error signature: caplog never sees a statement that clearly executes. Root cause: propagate: False in the configuration. Remediation: enable propagation for the test, or assert on that logger's own handler.
Setting the level globally. Error signature: captures full of framework and driver debug output, and slow tests. Root cause: set_level with no logger argument lowers the root. Remediation: scope the level to the logger under test.
Treating caplog as a formatter test. Error signature: a green suite and broken production output. Root cause: caplog captures before formatting. Remediation: add an output-level test for the formatter and filters.
Frequently Asked Questions
What does caplog capture?
LogRecord objects, via a handler attached to the root logger, before any of your application's handlers format them. It also offers the text rendered with its own simple format, which is not your production format.
Why does caplog see nothing from my logger?
Usually propagation. caplog's handler is on the root logger, so a logger with propagate set to False — common in dictConfig setups that give a logger its own handlers — never passes records up to it. The level is the second most common cause.
How do I assert on extra fields?
Fields passed via extra become attributes on the LogRecord, so assert on them as attributes: record.order_id rather than parsing them out of the message. For structlog, configure it to render through the standard library and the fields arrive the same way.
Does caplog test my formatter?
No. It captures records before your formatter runs. A test that passes with caplog says nothing about whether your JSON formatter works, which needs a test that captures the formatter's actual output.