MagicMock Mock'ed Function is not 'called'

Question:

I an trying to mock an api function send_message from stmplib.SMTP in Python using MagicMock.

a simplified version of my function looks like

#email_sender.py
def sendMessage():
    msg = EmailMessage()
    #Skipping code for populating msg

    with smtplib.SMTP("localhost") as server:
        server.send_message(msg)

I want to mock server.send_message call for my unit test. I have searched SO for some pointers and tried to follow a similar question.

Here is my unit test code based on the above question:

#email_sender_test.py
import email_sender as es
def test_send_message_input_success() -> None:
with patch("smtplib.SMTP", spec=smtplib.SMTP) as mock_client:
    mock_client.configure_mock(
        **{
            "send_message.return_value": None
        }
    )
    
    es.sendMessage()
    
    #assert below Passes
    assert mock_client.called == True
    
    #assert below Fails
    assert mock_client.send_message.called == True

Any idea what I am doing wrong which is causing the assert mock_client.send_message.called == True to fail?

Thanks!

Asked By: N0000B

||

Answers:

You are testing the wrong mock attribute. Your code doesn’t call smtplib.SMTP.send_message. It might do so indirectly with the real object but a Mock can’t actually be sure of that nor should you want it to be that magical.

Take a look at what your code does with the smtp.SMTP mock; this is important because you’ll need to follow the same path in your test setup:

  • the mock is called: smtplib.SMTP("localhost"). Calling a mock will return a new mock object to stand in as the instance.
  • the instance mock is used as a context manager: with ... as server:. Python calls the __enter__() method and the return value of that method is bound to the name given by as. The instance mock will produce a new mock object for this. In fact, even the .__enter__ attribute is a mock object standing in for the method object.
  • The context manager mock, finally, is used to send the message: server.send_message(...).

The mock library actually creates these extra mock objects just once, then reuses them whenever needed, and you can access them in your setup, via the Mock.return_value attributes. You can use these to follow the same trail:

with patch("smtplib.SMTP", spec=smtplib.SMTP) as mock_smtp:
    mock_instance = mock_smtp.return_value  # smtplib.SMTP("localhost")
    mock_cm = mock_instance.__enter__.return_value  # with ... as server

    # set up the server.send_message call return value
    mock_cm.send_message.return_value = None

    # run the function-under-test
    es.sendMessage()

    # assert things were called
    assert mock_cm.send_message.called

Note: there is no need to add == True; that’s just doubling up on testing if true is true to produce true. assert already takes care of the check for you.

You can try these things out in an interactive interpreter too:

>>> import smtplib
>>> from unittest.mock import patch
>>> patcher = patch("smtplib.SMTP", spec=smtplib.SMTP)
>>> mock_smtp = patcher.start()  # same as what with patch() as ... does
>>> smtplib.SMTP
<MagicMock name='SMTP' spec='SMTP' id='4356608448'>
>>> smtplib.SMTP("localhost")
<NonCallableMagicMock name='SMTP()' spec='SMTP' id='4356726496'>
>>> with smtplib.SMTP("localhost") as server:
...     pass
... 
>>> server
<MagicMock name='SMTP().__enter__()' id='4356772768'>
>>> server.send_message("some argument")  # not set to return None here
<MagicMock name='SMTP().__enter__().send_message()' id='4356805728'>
>>> mock_smtp.return_value.__enter__.return_value.send_message.called
True

Note how each new mock object has a name that reflects how we got there! For future tests, try printing mocked objects in your code-under-test to see how to reach the right objects in your test setup, or use a debugger to inspect the objects. It’ll help you figure out what is going on.

Answered By: Martijn Pieters
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.