pytest-dev/pytest-mock

Spying on pydantic objects

alex-linx opened this issue · 3 comments

Hey, the other day I was writing some tests for some pydantic objects, trying to use spy.

Apparently it is not possible to spy on methods of a pydantic object, probably because they override __getattr__ and __setattr__..

Here is a minimal example

from pydantic import BaseModel


class MyModel(BaseModel):
    def my_method(self):
        pass


def test_my_model(mocker):
    my_model = MyModel()
    spy = mocker.spy(my_model, "my_method")
    my_model.my_method()
    spy.assert_called_once()

When running pytest I get an error on the line spy = mocker.spy(my_model, "my_method").

dummy.py::test_my_model FAILED                                           [100%]
dummy.py:8 (test_my_model)
self = <unittest.mock._patch object at 0x10478b490>

    def __enter__(self):
        """Perform the patch."""
        new, spec, spec_set = self.new, self.spec, self.spec_set
        autospec, kwargs = self.autospec, self.kwargs
        new_callable = self.new_callable
        self.target = self.getter()
    
        # normalise False to None
        if spec is False:
            spec = None
        if spec_set is False:
            spec_set = None
        if autospec is False:
            autospec = None
    
        if spec is not None and autospec is not None:
            raise TypeError("Can't specify spec and autospec")
        if ((spec is not None or autospec is not None) and
            spec_set not in (True, None)):
            raise TypeError("Can't provide explicit spec_set *and* spec or autospec")
    
        original, local = self.get_original()
    
        if new is DEFAULT and autospec is None:
            inherit = False
            if spec is True:
                # set spec to the object we are replacing
                spec = original
                if spec_set is True:
                    spec_set = original
                    spec = None
            elif spec is not None:
                if spec_set is True:
                    spec_set = spec
                    spec = None
            elif spec_set is True:
                spec_set = original
    
            if spec is not None or spec_set is not None:
                if original is DEFAULT:
                    raise TypeError("Can't use 'spec' with create=True")
                if isinstance(original, type):
                    # If we're patching out a class and there is a spec
                    inherit = True
            if spec is None and _is_async_obj(original):
                Klass = AsyncMock
            else:
                Klass = MagicMock
            _kwargs = {}
            if new_callable is not None:
                Klass = new_callable
            elif spec is not None or spec_set is not None:
                this_spec = spec
                if spec_set is not None:
                    this_spec = spec_set
                if _is_list(this_spec):
                    not_callable = '__call__' not in this_spec
                else:
                    not_callable = not callable(this_spec)
                if _is_async_obj(this_spec):
                    Klass = AsyncMock
                elif not_callable:
                    Klass = NonCallableMagicMock
    
            if spec is not None:
                _kwargs['spec'] = spec
            if spec_set is not None:
                _kwargs['spec_set'] = spec_set
    
            # add a name to mocks
            if (isinstance(Klass, type) and
                issubclass(Klass, NonCallableMock) and self.attribute):
                _kwargs['name'] = self.attribute
    
            _kwargs.update(kwargs)
            new = Klass(**_kwargs)
    
            if inherit and _is_instance_mock(new):
                # we can only tell if the instance should be callable if the
                # spec is not a list
                this_spec = spec
                if spec_set is not None:
                    this_spec = spec_set
                if (not _is_list(this_spec) and not
                    _instance_callable(this_spec)):
                    Klass = NonCallableMagicMock
    
                _kwargs.pop('name')
                new.return_value = Klass(_new_parent=new, _new_name='()',
                                         **_kwargs)
        elif autospec is not None:
            # spec is ignored, new *must* be default, spec_set is treated
            # as a boolean. Should we check spec is not None and that spec_set
            # is a bool?
            if new is not DEFAULT:
                raise TypeError(
                    "autospec creates the mock for you. Can't specify "
                    "autospec and new."
                )
            if original is DEFAULT:
                raise TypeError("Can't use 'autospec' with create=True")
            spec_set = bool(spec_set)
            if autospec is True:
                autospec = original
    
            new = create_autospec(autospec, spec_set=spec_set,
                                  _name=self.attribute, **kwargs)
        elif kwargs:
            # can't set keyword args when we aren't creating the mock
            # XXXX If new is a Mock we could call new.configure_mock(**kwargs)
            raise TypeError("Can't pass kwargs to a mock we aren't creating")
    
        new_attr = new
    
        self.temp_original = original
        self.is_local = local
        self._exit_stack = contextlib.ExitStack()
        try:
>           setattr(self.target, self.attribute, new_attr)

../../../../../.pyenv/versions/3.9.16/lib/python3.9/unittest/mock.py:1501: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

>   ???
E   ValueError: "MyModel" object has no field "my_method"

pydantic/main.py:357: ValueError

During handling of the above exception, another exception occurred:

mocker = <pytest_mock.plugin.MockerFixture object at 0x10478b1f0>

    def test_my_model(mocker):
        my_model = MyModel()
>       spy = mocker.spy(my_model, "my_method")

dummy.py:11: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../.venv/lib/python3.9/site-packages/pytest_mock/plugin.py:163: in spy
    spy_obj = self.patch.object(obj, name, side_effect=wrapped, autospec=autospec)
../../.venv/lib/python3.9/site-packages/pytest_mock/plugin.py:249: in object
    return self._start_patch(
../../.venv/lib/python3.9/site-packages/pytest_mock/plugin.py:214: in _start_patch
    mocked = p.start()  # type: unittest.mock.MagicMock
../../../../../.pyenv/versions/3.9.16/lib/python3.9/unittest/mock.py:1540: in start
    result = self.__enter__()
../../../../../.pyenv/versions/3.9.16/lib/python3.9/unittest/mock.py:1514: in __enter__
    if not self.__exit__(*sys.exc_info()):
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <unittest.mock._patch object at 0x10478b490>
exc_info = (<class 'ValueError'>, ValueError('"MyModel" object has no field "my_method"'), <traceback object at 0x10478ea80>)

    def __exit__(self, *exc_info):
        """Undo the patch."""
        if self.is_local and self.temp_original is not DEFAULT:
            setattr(self.target, self.attribute, self.temp_original)
        else:
>           delattr(self.target, self.attribute)
E           AttributeError: my_method

../../../../../.pyenv/versions/3.9.16/lib/python3.9/unittest/mock.py:1522: AttributeError

Hi @alex-linx,

Indeed if pydantic overwrites __setattr__ it might not be possible for spy to work.

spy is just a thin wrapper which calls patch.object with a side_effect which calls the original method.

If you can reproduce the same error with:

with patch.object(my_model, "my_method", side_effect=my_model.my_method):
    ...

Then I don't think there's no action for pytest-mock.

Closing for now, feel free to follow with further questions.