Testing structlog Output with pytest
Logging assertions written against rendered text break every time someone changes a formatter. structlog makes a better contract available: the event dictionary, which is stable regardless of how it is eventually rendered. This page covers the two capture mechanisms, when each is correct, and the fixture hygiene that keeps a global configuration from leaking across a test suite. It builds on the structlog processors and pipelines guide, part of the modern Python logging libraries deep dive section.
capture_logs passes whether or not redaction works.Prerequisites
pip install "structlog>=24.1.0,<26.0.0" \
"pytest>=8.0.0,<9.0.0"
Implementation
Step 1 — Reset configuration between tests. structlog's configuration is process-global and, with cache_logger_on_first_use, sticks to loggers already created. An autouse fixture removes the whole class of order-dependent failures.
# conftest.py
import pytest
import structlog
@pytest.fixture(autouse=True)
def _reset_structlog():
structlog.reset_defaults()
yield
structlog.reset_defaults()
Step 2 — Capture events for behavioural tests. capture_logs swaps the chain for a capturing one and yields a list of dicts. This is the right tool when the question is "did the code log the right thing", not "did the pipeline format it correctly".
from structlog.testing import capture_logs
def test_rejected_order_is_logged_with_the_reason():
with capture_logs() as events:
reject_order(order_id=8812, reason="insufficient_stock")
assert len(events) == 1
assert events[0]["event"] == "order rejected"
assert events[0]["order_id"] == 8812
assert events[0]["reason"] == "insufficient_stock"
assert events[0]["log_level"] == "warning"
Note log_level rather than level: capture_logs adds the method name under that key, since your add_log_level processor is one of the ones it bypassed. Asserting on level here is a test that fails for the wrong reason.
Step 3 — Keep the real chain when the pipeline is under test. Swap only the factory. Every configured processor runs, so redaction, sampling and enrichment are genuinely exercised.
import pytest
import structlog
from structlog.testing import CapturingLoggerFactory
@pytest.fixture
def rendered():
factory = CapturingLoggerFactory()
configure() # your production configure()
structlog.configure(logger_factory=factory) # swap the tail only
return factory.logger.calls
def test_token_is_redacted_by_the_real_chain(rendered):
structlog.get_logger("checkout").info("login", token="sk-live-9f3c8a2b")
payload = rendered[0].args[0] # what the renderer produced
assert "sk-live" not in payload
assert '"token": "[redacted]"' in payload
factory.logger.calls is a list of CapturedCall objects with args and kwargs, so the assertion runs against the rendered string — which is the correct level of detail here, because the rendering is what is being tested.
Step 4 — Cover the standard-library half. Records from dependencies never pass through structlog's capture. Assert on them with caplog, which requires the pipeline to be configured with stdlib.LoggerFactory.
import logging
def test_library_records_are_enriched(caplog):
configure() # stdlib.LoggerFactory + ProcessorFormatter
with caplog.at_level(logging.WARNING):
logging.getLogger("urllib3").warning("retrying connection")
assert caplog.records[0].name == "urllib3"
assert caplog.records[0].levelname == "WARNING"
Step 5 — Assert on fields, never on substrings. A test that greps a rendered line couples itself to the renderer and to key ordering, and it can match text you did not intend.
# brittle: passes or fails on formatting decisions
assert "order rejected" in capsys.readouterr().out
# stable: survives any renderer change
assert any(e["event"] == "order rejected" and e["order_id"] == 8812 for e in events)
cache_logger_on_first_use is correct in production and precisely what makes test isolation mandatory.Configuration options
| Concern | Tool | Notes |
|---|---|---|
| Event assertions | capture_logs |
bypasses the chain; level is log_level |
| Pipeline assertions | CapturingLoggerFactory |
full chain runs; assert on rendered output |
| Library records | caplog |
needs stdlib.LoggerFactory |
| Serialiser contract | StringIO handler + json.loads |
the only one covering json.dumps |
| Isolation | structlog.reset_defaults |
autouse fixture, before and after |
| Ordering | cache_logger_on_first_use |
the reason isolation is needed at all |
Verification
Run the suite and confirm the two capture styles disagree in the way they should — the raw capture sees the secret, the full-chain capture does not.
pytest tests/test_logging.py -v
Expected Output:
tests/test_logging.py::test_rejected_order_is_logged_with_the_reason PASSED
tests/test_logging.py::test_token_is_redacted_by_the_real_chain PASSED
tests/test_logging.py::test_library_records_are_enriched PASSED
A useful sanity check while writing these: temporarily remove the redaction processor from the configuration. test_token_is_redacted_by_the_real_chain must fail. If it still passes, the test is capturing at the wrong point and is not testing what its name claims.
Common mistakes
The redaction test passes with redaction removed
Error signature: deleting a processor breaks nothing in the suite.
Root cause: the test used capture_logs, which replaces the whole chain.
Remediation: use CapturingLoggerFactory for anything that asserts on processor behaviour, and verify by deleting the processor once.
KeyError: 'level' inside a captured event
Error signature: the assertion fails on a key that definitely exists in production output.
Root cause: capture_logs bypasses add_log_level and records the method name as log_level.
Remediation: assert on log_level under capture_logs, or switch to the full-chain fixture if the field name itself matters.
Tests pass alone and fail in the suite
Error signature: order-dependent failures that disappear with -k.
Root cause: a test called structlog.configure and the cached loggers carried into later tests.
Remediation: add the autouse reset_defaults fixture, and configure inside a fixture rather than at module import.
What is worth asserting on
Logging assertions have a bad reputation because most of them test the wrong thing: they pin a message string, break when someone rewords it, and get deleted. A useful test asserts on behaviour that a consumer depends on, and there are exactly three kinds.
The event happened. A record with a specific event name was emitted, at a specific level, when a specific thing occurred. This is a behavioural assertion about the code, and it is worth writing for records that something downstream acts on — an alert, an audit trail, a support workflow. It is not worth writing for a debug line.
The fields are present and correctly typed. A record carries order_id as an integer, duration_ms as a float, trace_id as a 32-character hex string. This is a contract test: a dashboard or an alert depends on those fields existing with those types, and nothing else in the build will notice if a refactor drops one.
The policy applied. Secrets are masked, probe traffic is dropped, the sampler kept the first N. These test the pipeline rather than the application, and they need the full-chain fixture rather than capture_logs.
def test_order_rejection_is_auditable():
with capture_logs() as events:
reject_order(order_id=8812, reason="insufficient_stock")
audit = [e for e in events if e["event"] == "order rejected"]
assert len(audit) == 1
assert audit[0]["log_level"] == "warning"
assert isinstance(audit[0]["order_id"], int) # the type is the contract
assert audit[0]["reason"] == "insufficient_stock"
| Assertion | Tests | Fixture | Worth writing for |
|---|---|---|---|
| The event happened | application behaviour | capture_logs |
records something acts on |
| Fields and types | the record contract | capture_logs |
anything a dashboard reads |
| The policy applied | the pipeline | CapturingLoggerFactory |
redaction, sampling, filtering |
| The exact message text | nothing useful | — | never |
Testing what is not logged
The negative assertion is the one that catches security regressions, and it needs care to be meaningful. Asserting that a specific string is absent from the output passes trivially — most strings are absent from most output — so the test has to first establish that the record exists and then assert on its contents.
def test_login_records_never_carry_the_password(rendered):
submit_login(username="ada", password="hunter2")
assert rendered, "no record was emitted — the test is not testing anything"
payload = "".join(str(call.args) for call in rendered)
assert "ada" in payload # the record is genuinely about this login
assert "hunter2" not in payload # and the secret is not in it
The first assertion is what makes the third meaningful. Without it, a refactor that removes the log call entirely leaves the test green, and the next person to add a log call there has no protection at all.
Keeping the suite fast
Logging tests are cheap individually and can become slow in aggregate if each one reconfigures a full pipeline. Two habits keep that in check. Configure at the fixture level with scope="module" where the configuration does not vary, so a hundred tests share one setup. And prefer capture_logs for the majority of assertions — it replaces the chain rather than running it, which makes it both faster and immune to changes in the configuration under test.
One more habit is worth adopting early: assert on the number of records, not only on their contents. A test that finds the expected event among five records passes equally well when a refactor starts emitting the same event three times, which is a real regression — duplicated records inflate volume, distort counts and confuse anyone reading a timeline. assert len(matching) == 1 costs nothing and catches the class of change that adds a handler, adds a second call site, or turns propagation back on.
Reserve the full-chain fixture for the handful of tests that genuinely exercise the pipeline. In a typical service that is a redaction test, a sampling test and a format-contract test: three tests that run the real chain, and everything else running against captured event dictionaries.
Related
- structlog processors and pipelines — the parent guide: the chain these tests exercise.
- Writing custom structlog processors — the processors worth testing directly as functions.
- structlog in Celery workers — testing a configuration that is applied in a forked process.
- structlog architecture and setup — the production configuration under test.
- Benchmarking Python logging libraries — measuring the same chain rather than asserting on it.
Frequently Asked Questions
What is the difference between capture_logs and CapturingLoggerFactory?
capture_logs replaces the whole processor chain with a capturing one, so it is fast and isolated but your real processors never run — a redaction processor under test would not be exercised. CapturingLoggerFactory replaces only the logger factory, so the entire configured chain runs and you assert on what the renderer produced. Use the first for testing application behaviour, the second for testing the pipeline itself.
Why does caplog not see my structlog output?
Because caplog hooks the standard library's handler machinery, and structlog only reaches it when configured with stdlib.LoggerFactory. With PrintLoggerFactory or a capturing factory the records never become LogRecords, so caplog stays empty. Either configure the stdlib factory in tests or use structlog's own capture.
Do I need to reset structlog between tests?
Yes, if any test calls structlog.configure. Configuration is global, and with cache_logger_on_first_use enabled, loggers created in an earlier test keep their frozen chain. An autouse fixture calling structlog.reset_defaults, ordered before your own configuration fixture, removes a whole class of ordering-dependent failures.
How do I assert that something was NOT logged?
Capture, then assert on the absence of a matching event: assert not any(e['event'] == 'order rejected' for e in caplog_events). Do it with an explicit predicate rather than a substring check on the rendered output, because a substring can match a longer message you did not intend to cover.