在鼻子下面测试Python代码时应如何验证日志消息?


78

我正在尝试编写一个简单的单元测试,该测试将验证在特定条件下我的应用程序中的类将通过标准日志记录API记录错误。我想不出最干净的方法来测试这种情况。

我知道鼻子已经通过它的日志记录插件捕获了日志输出,但这似乎旨在作为失败测试的报告和调试辅助工具。

我可以看到两种方法:

  • 以零散方式(mymodule.logging =模拟日志模块)或使用适当的模拟库来模拟日志记录模块。
  • 编写或使用现有的鼻子插件捕获输出并进行验证。

如果我采用前一种方法,那么我想知道在嘲笑日志记录模块之前将全局状态重置为原来状态的最干净方法是什么。

期待您对此的提示和技巧...


2
现在有一种内置的方法可以执行此操作:docs.python.org/3/library/…–
wkschwartz

Answers:


23

我曾经模拟过记录器,但是在这种情况下,我发现最好使用记录处理程序,因此我根据jkp建议的文档编写了该记录处理程序(现已失效,但已缓存在Internet Archive上

class MockLoggingHandler(logging.Handler):
    """Mock logging handler to check for expected logs."""

    def __init__(self, *args, **kwargs):
        self.reset()
        logging.Handler.__init__(self, *args, **kwargs)

    def emit(self, record):
        self.messages[record.levelname.lower()].append(record.getMessage())

    def reset(self):
        self.messages = {
            'debug': [],
            'info': [],
            'warning': [],
            'error': [],
            'critical': [],
        }

1
上面的链接无效,我想知道是否有人可以发布有关如何使用此代码的信息。当我尝试添加此日志记录处理程序时,尝试将其用作时会不断收到错误消息AttributeError: class MockLoggingHandler has no attribute 'level'
兰迪

127

从python 3.4开始,标准unittest库提供了一个新的测试断言上下文管理器assertLogs。从文档

with self.assertLogs('foo', level='INFO') as cm:
    logging.getLogger('foo').info('first message')
    logging.getLogger('foo.bar').error('second message')
    self.assertEqual(cm.output, ['INFO:foo:first message',
                                 'ERROR:foo.bar:second message'])

38

幸运的是,您不必自己写这些东西。该testfixtures程序包提供了一个上下文管理器,可以捕获with语句主体中发生的所有日志记录输出。您可以在这里找到软件包:

http://pypi.python.org/pypi/testfixtures

以下是有关如何测试日志记录的文档:

http://testfixtures.readthedocs.org/en/latest/logging.html


2
这种解决方案不仅看起来更优雅,而且实际上对我有用,而其他解决方案却没有(我的日志来自多个线程)。
Paulo SantAnna 2014年

33

更新:不再需要下面的答案。请改用内置的Python方法

该答案扩展了https://stackoverflow.com/a/1049375/1286628中完成的工作。处理程序大致相同(使用,构造函数更惯用super)。此外,我还演示了如何在标准库的中使用处理程序unittest

class MockLoggingHandler(logging.Handler):
    """Mock logging handler to check for expected logs.

    Messages are available from an instance's ``messages`` dict, in order, indexed by
    a lowercase log level string (e.g., 'debug', 'info', etc.).
    """

    def __init__(self, *args, **kwargs):
        self.messages = {'debug': [], 'info': [], 'warning': [], 'error': [],
                         'critical': []}
        super(MockLoggingHandler, self).__init__(*args, **kwargs)

    def emit(self, record):
        "Store a message from ``record`` in the instance's ``messages`` dict."
        try:
            self.messages[record.levelname.lower()].append(record.getMessage())
        except Exception:
            self.handleError(record)

    def reset(self):
        self.acquire()
        try:
            for message_list in self.messages.values():
                message_list.clear()
        finally:
            self.release()

然后,您可以在标准库中使用处理程序,unittest.TestCase如下所示:

import unittest
import logging
import foo

class TestFoo(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        super(TestFoo, cls).setUpClass()
        # Assuming you follow Python's logging module's documentation's
        # recommendation about naming your module's logs after the module's
        # __name__,the following getLogger call should fetch the same logger
        # you use in the foo module
        foo_log = logging.getLogger(foo.__name__)
        cls._foo_log_handler = MockLoggingHandler(level='DEBUG')
        foo_log.addHandler(cls._foo_log_handler)
        cls.foo_log_messages = cls._foo_log_handler.messages

    def setUp(self):
        super(TestFoo, self).setUp()
        self._foo_log_handler.reset() # So each test is independent

    def test_foo_objects_fromble_nicely(self):
        # Do a bunch of frombling with foo objects
        # Now check that they've logged 5 frombling messages at the INFO level
        self.assertEqual(len(self.foo_log_messages['info']), 5)
        for info_message in self.foo_log_messages['info']:
            self.assertIn('fromble', info_message)

感谢您解释如何利用Gustavo的答案并扩展它。
哈什迪普

2
现在有一种内置的方法可以执行此操作:docs.python.org/3/library/…–
wkschwartz

1
在setUpClass中,foo_log.addHandler()调用在foo_log_handler变量之前缺少下划线
PaulR

这对于python 2.x仍然有用。
jdhildeb

1
现在大概所有仍在使用Python 2编写的代码都已经过测试。如果您处于项目的测试编写阶段,则最好立即切换到Python 3。Python 2将在一年半左右的时间内失去支持(包括安全更新)。
wkschwartz

12

布兰登的答案:

pip install testfixtures

片段:

import logging
from testfixtures import LogCapture
logger = logging.getLogger('')


with LogCapture() as logs:
    # my awesome code
    logger.error('My code logged an error')
assert 'My code logged an error' in str(logs)

注:以上不与调用冲突nosetests并获得logCapture的输出插件工具


3

作为Reef回答的后续,我随意使用pymox编写示例。它引入了一些额外的辅助函数,使对函数和方法的存根更加容易。

import logging

# Code under test:

class Server(object):
    def __init__(self):
        self._payload_count = 0
    def do_costly_work(self, payload):
        # resource intensive logic elided...
        pass
    def process(self, payload):
        self.do_costly_work(payload)
        self._payload_count += 1
        logging.info("processed payload: %s", payload)
        logging.debug("payloads served: %d", self._payload_count)

# Here are some helper functions
# that are useful if you do a lot
# of pymox-y work.

import mox
import inspect
import contextlib
import unittest

def stub_all(self, *targets):
    for target in targets:
        if inspect.isfunction(target):
            module = inspect.getmodule(target)
            self.StubOutWithMock(module, target.__name__)
        elif inspect.ismethod(target):
            self.StubOutWithMock(target.im_self or target.im_class, target.__name__)
        else:
            raise NotImplementedError("I don't know how to stub %s" % repr(target))
# Monkey-patch Mox class with our helper 'StubAll' method.
# Yucky pymox naming convention observed.
setattr(mox.Mox, 'StubAll', stub_all)

@contextlib.contextmanager
def mocking():
    mocks = mox.Mox()
    try:
        yield mocks
    finally:
        mocks.UnsetStubs() # Important!
    mocks.VerifyAll()

# The test case example:

class ServerTests(unittest.TestCase):
    def test_logging(self):
        s = Server()
        with mocking() as m:
            m.StubAll(s.do_costly_work, logging.info, logging.debug)
            # expectations
            s.do_costly_work(mox.IgnoreArg()) # don't care, we test logging here.
            logging.info("processed payload: %s", 'hello')
            logging.debug("payloads served: %d", 1)
            # verified execution
            m.ReplayAll()
            s.process('hello')

if __name__ == '__main__':
    unittest.main()

2
我喜欢您对contextmanager装饰器的创新使用,以实现“范围内的模拟”。真好
jkp,2009年

PS:这是PyMOX缺乏pep8一致性的真正遗憾。
jkp

2

您应该使用模拟,因为有一天您可能想要将记录器更改为一个数据库。如果它在鼻子测试期间尝试连接到数据库,您将不会高兴。

即使抑制标准输出,模拟仍将继续进行。

我用过pyMox的存根。记住在测试后取消存根。


+1 AOP的一些好处。而不是将每个后端包装在通用样式的类/对象中。
艾登·贝尔


1

如果您定义这样的辅助方法:

import logging

def capture_logging():
    records = []

    class CaptureHandler(logging.Handler):
        def emit(self, record):
            records.append(record)

        def __enter__(self):
            logging.getLogger().addHandler(self)
            return records

        def __exit__(self, exc_type, exc_val, exc_tb):
            logging.getLogger().removeHandler(self)

    return CaptureHandler()

然后,您可以编写如下的测试代码:

    with capture_logging() as log:
        ... # trigger some logger warnings
    assert len(log) == ...
    assert log[0].getMessage() == ...


0

键入@Reef的答案,我确实尝试了以下代码。它对我来说对Python 2.7(如果您安装了模拟)和Python 3.4都很好。

"""
Demo using a mock to test logging output.
"""

import logging
try:
    import unittest
except ImportError:
    import unittest2 as unittest

try:
    # Python >= 3.3
    from unittest.mock import Mock, patch
except ImportError:
    from mock import Mock, patch

logging.basicConfig()
LOG=logging.getLogger("(logger under test)")

class TestLoggingOutput(unittest.TestCase):
    """ Demo using Mock to test logging INPUT. That is, it tests what
    parameters were used to invoke the logging method, while still
    allowing actual logger to execute normally.

    """
    def test_logger_log(self):
        """Check for Logger.log call."""
        original_logger = LOG
        patched_log = patch('__main__.LOG.log',
                            side_effect=original_logger.log).start()

        log_msg = 'My log msg.'
        level = logging.ERROR
        LOG.log(level, log_msg)

        # call_args is a tuple of positional and kwargs of the last call
        # to the mocked function.
        # Also consider using call_args_list
        # See: https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.call_args
        expected = (level, log_msg)
        self.assertEqual(expected, patched_log.call_args[0])


if __name__ == '__main__':
    unittest.main()
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.