pythonmockingpytestmagicmock

Mocking an imported function with pytest


I would like to test a email sending method I wrote. In file, format_email.py I import send_email.

 from cars.lib.email import send_email

 class CarEmails(object):

    def __init__(self, email_client, config):
        self.email_client = email_client
        self.config = config

    def send_cars_email(self, recipients, input_payload):

After formatting the email content in send_cars_email() I send the email using the method I imported earlier.

 response_code = send_email(data, self.email_client)

in my test file test_car_emails.py

@pytest.mark.parametrize("test_input,expected_output", test_data)
def test_email_payload_formatting(test_input, expected_output):
    emails = CarsEmails(email_client=MagicMock(), config=config())
    emails.send_email = MagicMock()
    emails.send_cars_email(*test_input)
    emails.send_email.assert_called_with(*expected_output)

When I run the test it fails on assertion not called. I believe The issue is where I am mocking the send_email function.

Where should I be mocking this function?


Solution

  • What you are mocking with the line emails.send_email = MagicMock() is the function

    class CarsEmails:
    
        def send_email(self):
            ...
    

    that you don't have. This line will thus only add a new function to your emails object. However, this function is not called from your code and the assignment will have no effect at all. Instead, you should mock the function send_email from the cars.lib.email module.

    mocking the function where it is used

    Once you have imported the function send_email via from cars.lib.email import send_email in your module format_email.py, it becomes available under the name format_email.send_email. Since you know the function is called there, you can mock it under its new name:

    from unittest.mock import patch
    
    from format_email import CarsEmails
    
    @pytest.mark.parametrize("test_input,expected_output", test_data)
    def test_email_payload_formatting(config, test_input, expected_output):
        emails = CarsEmails(email_client=MagicMock(), config=config)
        with patch('format_email.send_email') as mocked_send:
            emails.send_cars_email(*test_input)
            mocked_send.assert_called_with(*expected_output)
    

    mocking the function where it is defined

    Update:

    It really helps to read the section Where to patch in the unittest docs (also see the comment from Martijn Pieters suggesting it):

    The basic principle is that you patch where an object is looked up, which is not necessarily the same place as where it is defined.

    So stick with the mocking of the function in usage places and don't start with refreshing the imports or aligning them in correct order. Even when there should be some obscure usecase when the source code of format_email would be inaccessible for some reason (like when it is a cythonized/compiled C/C++ extension module), you still have only two possible ways of doing the import, so just try out both mocking possibilities as described in Where to patch and use the one that succeeds.

    Original answer:

    You can also mock send_email function in its original module:

    with patch('cars.lib.email.send_email') as mocked_send:
        ...
    

    but be aware that if you have called the import of send_email in format_email.py before the patching, patching cars.lib.email won't have any effect on code in format_email since the function is already imported, so the mocked_send in the example below won't be called:

    from format_email import CarsEmails
    
    ...
    
    emails = CarsEmails(email_client=MagicMock(), config=config)
    with patch('cars.lib.email.send_email') as mocked_send:
        emails.send_cars_email(*test_input)
        mocked_send.assert_called_with(*expected_output)
    

    To fix that, you should either import format_email for the first time after the patch of cars.lib.email:

    with patch('cars.lib.email.send_email') as mocked_send:
        from format_email import CarsEmails
        emails = CarsEmails(email_client=MagicMock(), config=config)
        emails.send_cars_email(*test_input)
        mocked_send.assert_called_with(*expected_output)
    

    or reload the module e.g. with importlib.reload():

    import importlib
    
    import format_email
    
    with patch('cars.lib.email.send_email') as mocked_send:
        importlib.reload(format_email)
        emails = format_email.CarsEmails(email_client=MagicMock(), config=config)
        emails.send_cars_email(*test_input)
        mocked_send.assert_called_with(*expected_output)
    

    Not that pretty either way, if you ask me. I'd stick with mocking the function in the module where it is called.