Testing with Structlog ====================== .. currentmodule:: testfixtures.structlog .. note:: To ensure you are using compatible versions, install with the ``testfixtures[structlog]`` extra. .. invisible-code-block: python try: import structlog except ImportError: structlog = None .. skip: start if(structlog is None, reason="No structlog installed") .. invisible-code-block: python structlog.reset_defaults() structlog.contextvars.clear_contextvars() `structlog `_ builds log entries as event dictionaries that flow through a configurable chain of :doc:`processors `. :class:`StructlogSource` is provided to capture and test these event dictionaries using :class:`~testfixtures.LogCapture`. If you have ``cache_logger_on_first_use`` enabled and acquire loggers before :class:`~testfixtures.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 :doc:`testing tools ` including the :func:`~structlog.testing.capture_logs` context manager and :class:`~structlog.testing.LogCapture` processor, which should not be confused with :class:`testfixtures.LogCapture`! As a simple example, you can capture structlog output with a `pytest`__ fixture such as this: __ https://docs.pytest.org/ .. code-block:: python 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: .. code-block:: python 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'), ) .. invisible-code-block: python from sybil.testing import run_pytest run_pytest(test_logging, fixtures=[logs]) See :doc:`logging` for the full :class:`~testfixtures.LogCapture` API, including :meth:`~testfixtures.LogCapture.check`, :meth:`~testfixtures.LogCapture.check_present`, :meth:`~testfixtures.LogCapture.actual`, and the :attr:`~testfixtures.LogCapture.entries` attribute. Inspecting raw event dicts -------------------------- Each :class:`~testfixtures.logcapture.Entry` in :attr:`~testfixtures.LogCapture.entries` exposes the underlying structlog event dict via its ``raw`` attribute: .. code-block:: python 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 :meth:`~structlog.BoundLoggerBase.bind`, which returns a new logger with extra context bound int, and the contextvars-based :func:`~structlog.contextvars.bind_contextvars` or :func:`~structlog.contextvars.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 :func:`raw` helper: .. code-block:: python 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 :func:`~structlog.contextvars.merge_contextvars` processor, which is included in :class:`~testfixtures.structlog.StructlogSource`'s default ``processors`` so that bound context shows up in tests with no extra wiring: .. code-block:: python 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 :class:`~testfixtures.structlog.StructlogSource`. Elements may be string keys into the event dict or callables that receive the event dict. To capture only the event message: .. code-block:: python 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: .. code-block:: python 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: .. code-block:: python 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: .. code-block:: python 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 :mod:`logging`, pass both a :class:`~testfixtures.logcapture.LoggingSource` and a :class:`~testfixtures.structlog.StructlogSource` to one :class:`~testfixtures.LogCapture`: .. code-block:: python 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 :class:`structlog.stdlib.LoggerFactory` so that structlog calls flow *through* stdlib logging, then a single :class:`~testfixtures.logcapture.LoggingSource` is enough and adding :class:`~testfixtures.structlog.StructlogSource` would double-capture each event. Exceptions ---------- When code logs an exception, the underlying exception object is stored in :attr:`~testfixtures.logcapture.Entry`'s ``exception`` attribute: .. code-block:: python 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 :class:`~testfixtures.structlog.StructlogSource` bypasses your configured processor chain and only :func:`structlog.stdlib.add_log_level` and :func:`~structlog.contextvars.merge_contextvars` run before capture. This mirrors :func:`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``: .. code-block:: python 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 :class:`~structlog.processors.JSONRenderer`: .. code-block:: python 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 :func:`raw`. See structlog's :doc:`processors guide ` and :func:`~structlog.testing.capture_logs` for the underlying mechanics. Avoid putting :func:`structlog.processors.format_exc_info` in ``processors`` as it replaces ``exc_info`` with a formatted string and defeats the :attr:`~testfixtures.logcapture.Entry.exception` extraction shown above. Checking the configuration of your logging ------------------------------------------ :class:`~testfixtures.structlog.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: .. code-block:: python 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: .. code-block:: python 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, ], ), ], ) .. check it: >>> test_setup_logging() >>> structlog.reset_defaults()