Testing with Structlog¶
Note
To ensure you are using compatible versions, install with the testfixtures[structlog] extra.
structlog builds log entries as event dictionaries
that flow through a configurable chain of processors.
StructlogSource is provided to capture and test
these event dictionaries using LogCapture.
If you have cache_logger_on_first_use enabled and acquire loggers before
LogCapture is installed, those cached loggers will keep their
old processor chain and bypass capture; acquire loggers inside the capture block, or
disable cache_logger_on_first_use during tests.
Note
Structlog also has its own testing tools including the
capture_logs() context manager and
LogCapture processor, which should not be confused with
testfixtures.LogCapture!
As a simple example, you can capture structlog output with a pytest fixture such as this:
import pytest
from testfixtures import LogCapture
from testfixtures.structlog import StructlogSource
@pytest.fixture()
def logs():
with LogCapture(StructlogSource()) as logs_:
yield logs_
You can then check logging in your tests like this:
import structlog
logger = structlog.get_logger()
def test_logging(logs: LogCapture) -> None:
logger.info('42 is fine')
logger.error('13 is not')
logs.check(
('INFO', '42 is fine'),
('ERROR', '13 is not'),
)
See Testing logging for the full LogCapture API, including
check(), check_present(),
actual(), and the entries
attribute.
Inspecting raw event dicts¶
Each Entry in entries
exposes the underlying structlog event dict via its raw attribute:
from testfixtures import LogCapture
from testfixtures.structlog import StructlogSource
with LogCapture(StructlogSource()) as log:
structlog.get_logger().info('hello world')
>>> log.entries[0].raw
{'event': 'hello world', 'level': 'info'}
Checking logging context¶
Structlog supports two ways of carrying contextual data: per-logger
bind(), which returns a new logger with extra
context bound int, and the contextvars-based
bind_contextvars() or
bound_contextvars(), which carry context across
function calls and async tasks. Both end up as keys at the top level of the
event dict.
To full capture context, use the raw() helper:
from testfixtures import LogCapture
from testfixtures.structlog import StructlogSource, raw
request_log = structlog.get_logger().bind(request_id='abc123')
with LogCapture(StructlogSource(raw)) as log:
request_log.info('handling request')
request_log.info('request complete')
log.check(
{'event': 'handling request', 'level': 'info', 'request_id': 'abc123'},
{'event': 'request complete', 'level': 'info', 'request_id': 'abc123'},
)
The contextvars-based API works the same way; it relies on the
merge_contextvars() processor, which is included
in StructlogSource’s default processors so
that bound context shows up in tests with no extra wiring:
from structlog.contextvars import bound_contextvars
with LogCapture(StructlogSource(raw)) as log:
structlog.get_logger().info('before task')
with bound_contextvars(task_id=1234):
structlog.get_logger().info('processing task')
structlog.get_logger().info('after task')
log.check(
{'event': 'before task', 'level': 'info'},
{'event': 'processing task', 'level': 'info', 'task_id': 1234},
{'event': 'after task', 'level': 'info'}
)
Capturing specific fields¶
You can control which fields form the actual value by passing a sequence of
attributes to StructlogSource. Elements may
be string keys into the event dict or callables that receive the event dict.
To capture only the event message:
with LogCapture(StructlogSource('event')) as log:
structlog.get_logger().info('just the event')
log.check('just the event')
You can also mix string keys and callables:
from testfixtures import LogCapture
from testfixtures.structlog import StructlogSource, level_name
with LogCapture(StructlogSource((level_name, 'event'))) as log:
structlog.get_logger().debug('a debug message')
structlog.get_logger().info('something info')
log.check(
('DEBUG', 'a debug message'),
('INFO', 'something info'),
)
For full control, pass a single callable:
def extract(event_dict):
return {'level': event_dict['level'].upper(), 'event': event_dict['event']}
with LogCapture(StructlogSource(extract)) as log:
structlog.get_logger().debug('a debug message')
structlog.get_logger().error('an error')
log.check(
{'level': 'DEBUG', 'event': 'a debug message'},
{'level': 'ERROR', 'event': 'an error'},
)
Filtering by level¶
To capture only messages at or above a minimum level:
with LogCapture(StructlogSource(level='WARNING')) as log:
structlog.get_logger().info('ignored')
structlog.get_logger().warning('captured')
log.check(('WARNING', 'captured'))
Combining with standard library logging¶
If your application uses both structlog and the standard library’s
logging, pass both a LoggingSource and
a StructlogSource to one
LogCapture:
import logging
from testfixtures import LogCapture
from testfixtures.logcapture import LoggingSource
from testfixtures.structlog import StructlogSource
with LogCapture(StructlogSource(), LoggingSource()) as log:
logging.info('from standard library logging')
structlog.get_logger().info('from structlog')
log.check(
('INFO', 'from standard library logging'),
('INFO', 'from structlog'),
)
Note that if your structlog is configured with
structlog.stdlib.LoggerFactory so that structlog calls flow through
stdlib logging, then a single LoggingSource
is enough and adding StructlogSource would
double-capture each event.
Exceptions¶
When code logs an exception, the underlying exception object is stored in
Entry’s exception attribute:
from testfixtures import LogCapture, compare
from testfixtures.structlog import StructlogSource
with LogCapture(StructlogSource()) as logs:
try:
raise ValueError('boom')
except ValueError:
structlog.get_logger().exception('oh no')
compare(logs.entries[-1].exception, expected=ValueError('boom'))
Running selected processors during capture¶
By default StructlogSource bypasses your
configured processor chain and only structlog.stdlib.add_log_level() and
merge_contextvars() run before capture. This
mirrors structlog.testing.capture_logs() and keeps assertions clean of
per-run noise like timestamps and call sites.
If you need different processors during capture you can pass them via processors:
from structlog.contextvars import merge_contextvars
from structlog.processors import KeyValueRenderer
with LogCapture(
StructlogSource(
attributes=raw,
processors=[merge_contextvars, KeyValueRenderer(sort_keys=True)],
)
) as log:
structlog.get_logger().bind(user='alice').info('hi')
log.check("event='hi' user='alice'")
The same pattern works for JSONRenderer:
from structlog.processors import JSONRenderer
with LogCapture(
StructlogSource(
attributes=raw,
processors=[merge_contextvars, JSONRenderer(sort_keys=True)],
)
) as log:
structlog.get_logger().bind(user='alice').info('hi')
log.check('{"event": "hi", "user": "alice"}')
When a renderer is the last processor, the captured value is a string, so
attributes should be set to raw(). See structlog’s
processors guide and capture_logs() for the
underlying mechanics.
Avoid putting structlog.processors.format_exc_info() in processors as it replaces
exc_info with a formatted string and defeats the
exception extraction shown above.
Checking the configuration of your logging¶
StructlogSource is good for checking that your
code is logging the correct messages; just as important is checking that your
application has correctly configured structlog. If you have a setup_logging
function such as this:
import structlog
def setup_logging() -> None:
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt='iso'),
structlog.processors.JSONRenderer(),
],
)
This can be tested with the following unit test:
from testfixtures import compare, Replacer
from testfixtures.mock import Mock, call
def test_setup_logging() -> None:
mocks = Mock()
with Replacer() as replace:
# While used from structlog, configure is defined in structlog._config:
replace.in_module(structlog.configure, mocks.configure, module=structlog)
# Instances of these classes have internal state that does not compare equal,
# so just mock them and ensure we've called them correctly:
replace.in_module(structlog.processors.TimeStamper, mocks.TimeStamper)
replace.in_module(structlog.processors.JSONRenderer, mocks.JSONRenderer)
setup_logging()
compare(
mocks.mock_calls,
expected=[
call.TimeStamper(fmt='iso'),
call.JSONRenderer(),
call.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
mocks.TimeStamper.return_value,
mocks.JSONRenderer.return_value,
],
),
],
)