Testing with Twisted

Note

To ensure you are using compatible versions, install with the testfixtures[twisted] extra.

Due to its longevity, Twisted has its own structured logging framework built around Logger objects and log event dicts. TwistedSource is provided to work with this.

Testing logging

Pass a TwistedSource instance when constructing LogCapture. It replaces all existing Twisted log observers for the duration of the context manager and restores them on exit:

from twisted.logger import Logger
from testfixtures import LogCapture
from testfixtures.twisted import TwistedSource

log = Logger()

with LogCapture(TwistedSource()) as logs:
    log.info('hello {name}', name='world')

logs.check(('INFO', 'hello world'))

See Testing logging for the full LogCapture API, including check(), check_present(), actual(), and the entries attribute.

Inspecting raw events

Each Entry in entries exposes the underlying Twisted log event dict via its raw attribute:

with LogCapture(TwistedSource()) as logs:
    log.info('hello {greeting}', greeting='world')
>>> logs.entries[0].raw
{'greeting': 'world',..., 'log_level': <LogLevel=info>, 'log_namespace': '<unknown>', ...}

The event dict contains all the structured fields Twisted attaches to each log call, including log_namespace, log_source, log_level, log_format along with any keyword arguments passed to the log call. log_failure is present when logging a Failure.

Capturing specific fields

You can control which fields form the actual value by passing an attributes sequence to TwistedSource. Elements may be string keys into the event dict or callables that receive the event dict.

For example, to capture only the formatted message, pass formatEvent() as the sole element:

from twisted.logger import formatEvent

with LogCapture(TwistedSource(attributes=formatEvent)) as logs:
    log.info('hello {greeting}', greeting='world')

logs.check('hello world')

You can also mix string keys and callables:

with LogCapture(TwistedSource(attributes=('log_namespace', formatEvent))) as logs:
    Logger(namespace='myapp').info('started')

logs.check(('myapp', 'started'))

For full control, pass a single callable as attributes:

from testfixtures.twisted import INFO, WARN
logger = Logger(namespace='myapp')

def extract(event):
    return {'level': event['log_level'], 'message': formatEvent(event)}

with LogCapture(TwistedSource(attributes=extract)) as logs:
    logger.info('started')
    logger.warn('low memory')

logs.check(
    {'level': INFO, 'message': 'started'},
    {'level': WARN, 'message': 'low memory'},
)

In the above example, the Twisted log level objects are returned. For convenience in making assertions about these, the following constants are exported from testfixtures.twisted for use in assertions: DEBUG, INFO, WARN, ERROR and CRITICAL.

Filtering by level

To capture only events at or above a minimum level:

with LogCapture(TwistedSource(level='warn')) as capture:
    log.info('ignored')
    log.warn('captured')
capture.check(('WARN', 'captured'))

Combining with standard library logging

If your application uses both Twisted and the standard library’s logging, pass both a LoggingSource and a TwistedSource to one LogCapture to capture and check both:

import logging
from testfixtures import LogCapture, LoggingSource
from testfixtures.twisted import TwistedSource

with LogCapture(LoggingSource(), TwistedSource()) as logs:
    logging.warning('from stdlib')
    log.warn('from twisted')

logs.check(
    ('WARNING', 'from stdlib'),
    ('WARN', 'from twisted'),
)

Note

Twisted’s LogLevel.warn surfaces as 'WARN' while standard library logging uses 'WARNING'.

Exceptions and failures

When code logs a Failure, the underlying exception object is stored in the Entry’s exception attribute:

from testfixtures import LogCapture, compare
from testfixtures.twisted import TwistedSource

with LogCapture(TwistedSource()) as logs:
    try:
        raise ValueError('boom')
    except Exception:
        log.failure('something went wrong')

logs.check(
    ('CRITICAL', 'something went wrong')
)
compare(logs.entries[-1].exception, expected=ValueError('boom'))

Testing logging configuration

TwistedSource is good for checking that your code is logging the correct messages; just as important is checking that your application has correctly configured its Twisted log observers. If you have a setup_logging function such as this:

import sys
from twisted.logger import globalLogPublisher, textFileLogObserver

def setup_logging(stream=None) -> None:
    globalLogPublisher.addObserver(textFileLogObserver(stream or sys.stderr))

This can be tested with a unit test such as the following:

from twisted.logger import FileLogObserver
from testfixtures import Replacer, compare, like

def test_setup_logging() -> None:
    with Replacer() as replace:
        replace(
            globalLogPublisher._observers, [],
            container=globalLogPublisher, name='_observers',
        )
        setup_logging()
        compare(
            globalLogPublisher._observers,
            expected=[like(FileLogObserver)],
        )

Trial test case support

Since Twisted’s trial test runner has its own TestCase class on which you may well need to call flushLoggedErrors() in the event that you’re testing exception logging, a dedicated testfixtures.twisted.LogCapture is also provided, including a make() class method for creating, installing and adding a cleanup for the capture.

This sample test suite shows it all in action:

class TestLogCapture(TestCase):

    def test_simple(self):
        capture = TwistedLogCapture.make(self)
        log.info('er, {greeting}', greeting='hi')
        capture.check((INFO, 'er, hi'))

    def test_captured(self):
        capture = TwistedLogCapture.make(self)
        log.info('er, {greeting}', greeting='hi')
        assert len(capture.events) == 1
        compare(capture.events[0]['log_namespace'], expected='tests.test_twisted')

    def test_fields(self):
        capture = TwistedLogCapture.make(self, fields=('a', 'b'))
        log.info('{a}, {b}', a=1, b=2)
        log.info('{a}, {b}', a=3, b=4)
        capture.check(
            [1, 2],
            [3, 4],
        )

    def test_field(self):
        capture = TwistedLogCapture.make(self, fields=(formatEvent,))
        log.info('er, {greeting}', greeting='hi')
        capture.check('er, hi')

    def test_check_failure_test_minimal(self):
        capture = TwistedLogCapture.make(self)
        try:
            raise Exception('all gone wrong')
        except:
            log.failure('oh dear')
        capture.check_failure_text('all gone wrong')
        self.flushLoggedErrors()

    def test_check_failure_test_maximal(self):
        capture = TwistedLogCapture.make(self)
        try:
            raise TypeError('all gone wrong')
        except:
            log.failure('oh dear')
        log.info("don't look at me...")
        capture.check_failure_text(str(TypeError), index=0, attribute='type')
        self.flushLoggedErrors()
        self.flushLoggedErrors()

    def test_raise_logged_failure(self):
        capture = TwistedLogCapture.make(self)
        try:
            raise TypeError('all gone wrong')
        except:
            log.failure('oh dear')
        with ShouldRaise(Failure) as s:
            capture.raise_logged_failure()
        compare(s.raised.value, expected=TypeError('all gone wrong'))
        self.flushLoggedErrors()

    def test_raise_later_logged_failure(self):
        capture = TwistedLogCapture.make(self)
        try:
            raise ValueError('boom!')
        except:
            log.failure('oh dear')
        try:
            raise TypeError('all gone wrong')
        except:
            log.failure('what now?!')
        with ShouldRaise(Failure) as s:
            capture.raise_logged_failure(start_index=1)
        compare(s.raised.value, expected=TypeError('all gone wrong'))
        self.flushLoggedErrors()

    def test_order_doesnt_matter_ok(self):
        capture = TwistedLogCapture.make(self)
        log.info('Failed to send BAR')
        log.info('Sent FOO, length 1234')
        log.info('Sent 1 Messages')
        capture.check(
            (INFO, S(r'Sent FOO, length \d+')),
            (INFO, 'Failed to send BAR'),
            (INFO, 'Sent 1 Messages'),
            order_matters=False
        )

    def test_order_doesnt_matter_failure(self):
        capture = TwistedLogCapture.make(self)
        log.info('Failed to send BAR')
        log.info('Sent FOO, length 1234')
        log.info('Sent 1 Messages')
        with ShouldAssert(
            "same:\n"
            "[(<LogLevel=info>, 'Failed to send BAR'), (<LogLevel=info>, 'Sent 1 Messages')]\n"
            "\n"
            "in expected but not actual:\n"
            "[(<LogLevel=info>, <S:Sent FOO, length abc>)]\n"
            "\n"
            "in actual but not expected:\n"
            "[(<LogLevel=info>, 'Sent FOO, length 1234')]"
        ):
            capture.check(
                (INFO, S('Sent FOO, length abc')),
                (INFO, 'Failed to send BAR'),
                (INFO, 'Sent 1 Messages'),
                order_matters=False
            )

    def test_order_doesnt_matter_extra_in_expected(self):
        capture = TwistedLogCapture.make(self)
        log.info('Failed to send BAR')
        log.info('Sent FOO, length 1234')
        with ShouldAssert(
            "same:\n"
            "[(<LogLevel=info>, 'Sent FOO, length 1234'),\n"
            " (<LogLevel=info>, 'Failed to send BAR')]\n"
            "\n"
            "in expected but not actual:\n"
            "[(<LogLevel=info>, 'Sent 1 Messages')]"
        ):
            capture.check(
                (INFO, S('Sent FOO, length 1234')),
                (INFO, 'Failed to send BAR'),
                (INFO, 'Sent 1 Messages'),
                order_matters=False
            )

    def test_order_doesnt_matter_extra_in_actual(self):
        capture = TwistedLogCapture.make(self)
        log.info('Failed to send BAR')
        log.info('Sent 1 Messages')
        with ShouldAssert(
            "same:\n"
            "[(<LogLevel=info>, 'Failed to send BAR'), (<LogLevel=info>, 'Sent 1 Messages')]\n"
            "\n"
            "in expected but not actual:\n"
            "[(<LogLevel=info>, <S:Sent FOO, length abc>)]"
        ):
            capture.check(
                (INFO, S('Sent FOO, length abc')),
                (INFO, 'Failed to send BAR'),
                (INFO, 'Sent 1 Messages'),
                order_matters=False
            )