Search code examples
pythonmockingpytestmagicmock

How to improve readabilty and maintainability of @patch and MagicMock statements (avoid long names and String identification)?


In my test code I have a lot of boilerplate expressions "Magic", "return_". I also have lengthy strings to identify the paths of the functions to mock. The strings are not automatically replaced during refactoring and I would prefer to directly use the imported functions.

Example code:

from mock import patch, MagicMock
from pytest import raises

@patch(
    'foo.baa.qux.long_module_name.calculation.energy_intensity.intensity_table',
    MagicMock(return_value='mocked_result_table'),
)

Instead I would prefer:

from better_test_module import patch, Mock, raises
from foo.baa.qux.long_module_name.calculation import energy_intensity

@patch(
    energy_intensity.intensity_table,
    Mock('mocked_result_table'),
)

or

@patch(
    energy_intensity.intensity_table,
    'mocked_result_table',
)

I post my corresponding custom implementation as an answer below.

If you have other suggestions, please let me know. I am wondering why the proposed solution is not the default. I do not want to reinvent the wheel. Therefore, if there is an already existing library I could use, please let me know.

Related:

Mock vs MagicMock

How to override __getitem__ on a MagicMock subclass


Solution

  • Create a wrapper module allowing for shorter names and passing functions directly. (If something like this already exists as a pip package, please let me know; don't want to reinvent the wheel.)

    Usage:

    from my_test_utils.mock import patch, Mock, raises    
    from foo.baa.qux.long_module_name.calculation import energy_intensity
    
    @patch(
        energy_intensity.intensity_table,
        Mock('mocked_result_table'),  
    )
    

    First draft for my wrapping code in my_test_utils/mock.py:

    from mock import MagicMock, DEFAULT
    from mock import patch as original_patch
    from pytest import raises as original_raises
    
    
    class Mock(MagicMock):
        # This class serves as a wrapper for MagicMock to allow for shorter syntax
    
        def __new__(cls, *args, **kwargs):
            if len(args) > 0:
                first_argument = args[0]
                mock = MagicMock(return_value=first_argument, *args[1:], **kwargs)
            else:
                mock = MagicMock(**kwargs)
            return mock
    
        def assert_called_once(self, *args, **kwargs):  # pylint: disable = useless-parent-delegation
            # pylint did not find this method without defining it as a proxy
            super().assert_called_once(*args, **kwargs)
    
        def assert_not_called(self, *args, **kwargs):  # pylint: disable = useless-parent-delegation
            # pylint did not find this method without defining it as a proxy
            super().assert_not_called(*args, **kwargs)
    
    
    def patch(item_to_patch, *args, **kwargs):
        if isinstance(item_to_patch, str):
                raise KeyError('Please import and use items directly instead of passing string paths!')
    
        module_path = item_to_patch.__module__
        if hasattr(item_to_patch, '__qualname__'):
            item_path = module_path + '.' + item_to_patch.__qualname__
        else:
            name = _try_to_get_object_name(item_to_patch, module_path)
            item_path = module_path + '.' + name
    
        item_path = item_path.lstrip('_')
    
        return original_patch(item_path, *args, **kwargs)
    
    
    def _try_to_get_object_name(object_to_patch, module_path):
        module = __import__(module_path)
        name = None
        for attribute_name in dir(module):
            attribute = getattr(module, attribute_name)
            if attribute == object_to_patch:
                if name is None:
                    name = attribute_name
                else:
                    # object is not unique within its parent but used twice
                    message = (
                        'Could not identify item to patch because object is not unique.'
                        + ' Please use a unique string path.'
                    )
                    raise KeyError(message)
        if name is None:
            raise KeyError('Could not identify object to patch.')
        return name
        
    
    
    
    def raises(*args):
        # This function serves as a wrapper for raises to be able to import it from the same module as the other functions
        return original_raises(*args)