【问题标题】:Python mocking exception to method calls in a context manager上下文管理器中方法调用的 Python 模拟异常
【发布时间】:2018-03-10 13:39:54
【问题描述】:

我正在对一些 python3 代码进行单元测试,这些代码通过上下文管理器在 SMTP 类上调用 sendmail,并尝试捕获异常以记录它们。我可以成功地模拟 SMTP 类并在其他测试中对其进行一些检查(例如验证 send_message 是否被实际调用),但我似乎无法在要引发的类上调用 send_message 的方法记录错误的异常。

要测试的代码(来自 siteidentity_proxy_monitoring.py):

def send_alert(message, email_address):
    with SMTP('localhost') as email:
        try:
            email.send_message(message)
        except SMTPException:
            # retry the send
            print('exception raised') # debugging statement
            try:
                email.send_message(message)
            except:
                logging.error(
                    'Could not send email alert to %s', email_address
                )

单元测试方法:

@unittest.mock.patch('siteidentity_proxy_monitoring.SMTP')
@unittest.mock.patch('siteidentity_proxy_monitoring.logging')
def test_logging_when_email_fails(self, mock_logger, mock_smtp):
    """
    Test that when alert email fails to send, an error is logged
    """
    test_print('Testing logging when email send fails')
    email_instance = mock_smtp.return_value
    email_instance.send_message.side_effect = SMTPException
    siteidentity_proxy_monitoring.send_alert(
        'test message',
        'email@example.com'
    )
    mock_logger.error.assert_called_with(
        'Could not send email alert to %s', 'email@example.com'
    )

测试结果输出:

[TEST] ==> Testing logging when email send fails
F
======================================================================
FAIL: test_logging_when_email_fails (__main__.TestSiteidentityMonitors)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/lib/python3.6/unittest/mock.py",line 1179, in patched
    return func(*args, **keywargs)
  File "tests/siteidentity_monitor_tests.py", line 108, in 
test_logging_when_email_fails
    'Could not send email alert to %s', 'email@example.com' 
  File "/lib/python3.6/unittest/mock.py", line 805, in assert_called_with
     raise AssertionError('Expected call: %s\nNot called' % (expected,))
AssertionError: Expected call: error('Could not send email alert to %s', 'email@example.com')
Not called

----------------------------------------------------------------------
Ran 4 tests in 0.955s

FAILED (failures=1)

我觉得我错过了与调用 __enter____exit__ 相关的一些内容,但我似乎无法弄清楚为什么我的补丁似乎没有触发我期望的副作用到。不幸的是,我遇到的大多数示例和文档并没有深入到在上下文中模拟方法调用(无论如何,据我所知)。

【问题讨论】:

  • 有一个正确模拟上下文管理器in the unittest.mock docs的例子。
  • 感谢@jonrsharpe,但文档似乎指出了如何模拟实际的上下文管理器调用。我遇到的问题是我关心发生在上下文管理器的方法调用。很可能是我对它们的了解有限,但我不知道如何降低范围以获取它们内部调用的方法。
  • 那么你得到的实际输出是什么?
  • 在原始问题中添加了测试输出。

标签: python python-3.x unit-testing mocking python-unittest


【解决方案1】:

刚刚被类似的问题卡住了,这是解决它的方法:

  1. email.send_message(message) 行之前放置一个import pdb;pdb.set_trace()
  2. 运行测试。
  3. 当您进入 PDB 会话时,输入 email.send_message,您会看到类似 <Mock name='mock_smtp().__enter__().send_message' id='...'> 的内容。
  4. 在您的测试用例中,将所有()s 替换为return_value。在您的情况下:mock_smtp.return_value.enter.return_value.send_message.side_effect = SMTPException

【讨论】:

    【解决方案2】:

    这是相同的测试,使用 pytest 和 mocker fixture:

    def test_logging_when_email_fails(mocker):
        mock_logging = mocker.MagicMock(name='logging')
        mocker.patch('siteidentity_proxy_monitoring.logging', new=mock_logging)
        mock_SMTP = mocker.MagicMock(name='SMTP',
                                     spec=siteidentity_proxy_monitoring.SMTP)
        mocker.patch('siteidentity_proxy_monitoring.SMTP', new=mock_SMTP)
        mock_SMTP.return_value.__enter__.return_value.send_message.side_effect = SMTPException
    
        siteidentity_proxy_monitoring.send_alert(
            'test message',
            'email@example.com'
        )
    
        mock_logging.error.assert_called_once_with(
            'Could not send email alert to %s', 'email@example.com')
    

    您可能会发现我编写测试的方式比测试本身更有趣 - 我创建了一个 python library 来帮助我了解语法。

    以下是我系统地解决您的问题的方法:

    我们从您想要的测试调用和我的帮助程序库开始,为引用的模块和类生成模拟,同时准备断言:

    from mock_autogen.pytest_mocker import PytestMocker
    
    import siteidentity_proxy_monitoring
    
    def test_logging_when_email_fails(mocker):
        print(PytestMocker(siteidentity_proxy_monitoring).mock_referenced_classes().mock_modules().prepare_asserts_calls().generate())
        siteidentity_proxy_monitoring.send_alert(
            'test message',
            'email@example.com'
        )
    

    现在测试显然失败了,因为我们还没有模拟 SMTP,但是打印输出很有用:

    # mocked modules
    mock_logging = mocker.MagicMock(name='logging')
    mocker.patch('siteidentity_proxy_monitoring.logging', new=mock_logging)
    # mocked classes
    mock_SMTP = mocker.MagicMock(name='SMTP', spec=siteidentity_proxy_monitoring.SMTP)
    mocker.patch('siteidentity_proxy_monitoring.SMTP', new=mock_SMTP)
    mock_SMTPException = mocker.MagicMock(name='SMTPException', spec=siteidentity_proxy_monitoring.SMTPException)
    mocker.patch('siteidentity_proxy_monitoring.SMTPException', new=mock_SMTPException)
    # calls to generate_asserts, put this after the 'act'
    import mock_autogen
    print(mock_autogen.generator.generate_asserts(mock_logging, name='mock_logging'))
    print(mock_autogen.generator.generate_asserts(mock_SMTP, name='mock_SMTP'))
    print(mock_autogen.generator.generate_asserts(mock_SMTPException, name='mock_SMTPException'))
    

    我在测试调用之前放置了相关的模拟,然后添加了生成断言调用:

    def test_logging_when_email_fails(mocker):
        mock_logging = mocker.MagicMock(name='logging')
        mocker.patch('siteidentity_proxy_monitoring.logging', new=mock_logging)
        mock_SMTP = mocker.MagicMock(name='SMTP',
                                     spec=siteidentity_proxy_monitoring.SMTP)
        mocker.patch('siteidentity_proxy_monitoring.SMTP', new=mock_SMTP)
    
        siteidentity_proxy_monitoring.send_alert(
            'test message',
            'email@example.com'
        )
    
        import mock_autogen
        print(mock_autogen.generator.generate_asserts(mock_logging,
                                                      name='mock_logging'))
        print(mock_autogen.generator.generate_asserts(mock_SMTP, name='mock_SMTP'))
    

    这一次,测试执行给了我一些有用的断言:

    mock_logging.assert_not_called()
    assert 1 == mock_SMTP.call_count
    mock_SMTP.assert_called_once_with('localhost')
    mock_SMTP.return_value.__enter__.assert_called_once_with()
    mock_SMTP.return_value.__enter__.return_value.send_message.assert_called_once_with('test message')
    mock_SMTP.return_value.__exit__.assert_called_once_with(None, None, None)
    

    我们从未进入日志记录部分,因为我们没有抛出任何 SMTPException!幸运的是,我们可以以一种较小的方式更改其中一个断言来创建我们想要的副作用(这是正常进行大量猜测并且该工具非常有用的地方):

    def test_logging_when_email_fails(mocker):
        mock_logging = mocker.MagicMock(name='logging')
        mocker.patch('siteidentity_proxy_monitoring.logging', new=mock_logging)
        mock_SMTP = mocker.MagicMock(name='SMTP',
                                     spec=siteidentity_proxy_monitoring.SMTP)
        mocker.patch('siteidentity_proxy_monitoring.SMTP', new=mock_SMTP)
        mock_SMTP.return_value.__enter__.return_value.send_message.side_effect = SMTPException
    
        siteidentity_proxy_monitoring.send_alert(
            'test message',
            'email@example.com'
        )
    
        import mock_autogen
        print(mock_autogen.generator.generate_asserts(mock_logging,
                                                      name='mock_logging'))
        print(mock_autogen.generator.generate_asserts(mock_SMTP,
                                                      name='mock_SMTP'))
    

    生成器为 mock_logging 创建了正确的模拟:

    mock_logging.error.assert_called_once_with('Could not send email alert to %s', 'email@example.com')
    

    您还获得了更准确的断言,可用于确保您的测试代码确实重试:

    from mock import call
    mock_SMTP.return_value.__enter__.return_value.send_message.assert_has_calls(
        calls=[call('test message'), call('test message'), ])
    

    这就是我获得最初放置的代码的方式!

    【讨论】: