-2

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

Stefan
  • 10,010
  • 7
  • 61
  • 117

1 Answers1

0

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)
Stefan
  • 10,010
  • 7
  • 61
  • 117
  • With the wrapper, how does `@patch` work when the target is something other than a function? – slothrop Jun 27 '23 at 12:40
  • Until now I only patch functions. If you want to be able to pass other items, you can extend the wrapper accordingly, e.g. with isinstance(patched_item, function). Feel free to edit the answer to adapt it to your needs. – Stefan Jun 27 '23 at 12:46
  • I think there are some cases this wouldn't work for, even by extending the wrapper. e.g. with the normal patch decorator, I can do `@patch('os.environ', {'foo': 'bar'})`. The wrapper would struggle with that, because the `os.environ` object has `__module__` but not `__name__`, so the string `'os.environ'` can't be recovered from the object. – slothrop Jun 27 '23 at 17:01
  • Thank you for the hint. I adapted the answer accordingly. It should work now if the value of the item is only used once/first. For the very special case that it is not unique one could ask the user to fall back to the string version. Then replace the first exception with the call to the original function. – Stefan Jun 27 '23 at 21:56