Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Releases
========

UNRELEASED
----------

* `#636 <https://github.com/pytest-dev/pytest-mock/issues/636>`_: Fixed spies on ``classmethod``, ``staticmethod``, builtins and classes returning a ``MagicMock`` instead of calling the spied code after calling ``spy.reset_mock(side_effect=True)``. Also ensures ``spy.reset_mock()`` resets recorded spy attributes (``spy_return``, etc.).

3.17.0
------

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ are available (like ``assert_called_once_with`` or ``call_count`` in the example
* ``spy_exception``: contain the last exception value raised by the spied function/method when
it was last called, or ``None`` if no exception was raised.

``mocker.resetall()`` sets these attributes back to their initial values.
``mocker.resetall()`` and ``spy.reset_mock()`` set these attributes back to their initial values.

Besides functions and normal methods, ``mocker.spy`` also works for class and static methods.

Expand Down
12 changes: 12 additions & 0 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ async def async_wrapper(*args, **kwargs):
mock_item.is_spy = True
mock_item.spy_side_effect = wrapped
_reset_spy(spy_obj)
if hasattr(spy_obj, "reset_mock"):
orig_reset_mock = spy_obj.reset_mock

@functools.wraps(orig_reset_mock)
def _spy_reset_mock(*args: Any, **kwargs: Any) -> Any:
ret = orig_reset_mock(*args, **kwargs)
_reset_spy(spy_obj)
if kwargs.get("side_effect", False):
spy_obj.side_effect = wrapped
return ret

spy_obj.reset_mock = _spy_reset_mock # type: ignore[method-assign]
return spy_obj

def stub(self, name: str | None = None) -> unittest.mock.MagicMock:
Expand Down
95 changes: 95 additions & 0 deletions tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,101 @@ def class_bar(cls, arg: int) -> int:
assert call() == 4
assert spy.spy_return == 4

@pytest.mark.parametrize("return_value", [False, True])
@pytest.mark.parametrize("side_effect", [False, True])
@pytest.mark.parametrize(
"target",
[
"classmethod",
"staticmethod",
"builtin",
"class",
],
)
def test_spy_reset_mock_keeps_calling_through(
self, mocker: MockerFixture, target: str, return_value: bool, side_effect: bool
) -> None:
"""Spies keep calling the spied code after ``spy.reset_mock()``."""

class Foo:
Int = int

@classmethod
def class_bar(cls, arg: int) -> int:
return arg * 3

@staticmethod
def static_bar(arg: int) -> int:
return arg * 4

if target == "classmethod":
spy = mocker.spy(Foo, "class_bar")
call: Callable[[], object] = lambda: Foo.class_bar(2)
expected: object = 6
elif target == "staticmethod":
spy = mocker.spy(Foo, "static_bar")
call = lambda: Foo.static_bar(2)
expected = 8
elif target == "builtin":
expected = os.getcwd()
spy = mocker.spy(os, "getcwd")
call = os.getcwd
else:
spy = mocker.spy(Foo, "Int")
call = lambda: Foo.Int("5")
expected = 5
assert call() == expected

spy.reset_mock(return_value=return_value, side_effect=side_effect)

assert spy.call_count == 0
assert spy.spy_return_list == []
assert call() == expected
assert spy.spy_return == expected
assert spy.spy_return_list == [expected]

def test_spy_reset_mock_side_effect_override(self, mocker: MockerFixture) -> None:
"""``spy.reset_mock(side_effect=True)`` clears a ``side_effect`` set on a spy,
going back to calling the spied code."""

class Foo:
@classmethod
def class_bar(cls, arg: int) -> int:
return arg * 2

spy = mocker.spy(Foo, "class_bar")
call = lambda: Foo.class_bar(2)
spy.side_effect = ValueError
with pytest.raises(ValueError):
call()

spy.reset_mock(side_effect=True)

assert call() == 4
assert spy.spy_return == 4

def test_function_spy_reset_mock(self, mocker: MockerFixture) -> None:
"""Function and method spies reset recorded spy attributes on ``spy.reset_mock()``."""

class Foo:
def bar(self, arg: int) -> int:
return arg * 2

foo = Foo()
spy = mocker.spy(foo, "bar")
assert foo.bar(2) == 4
assert spy.call_count == 1
assert spy.spy_return == 4
assert spy.spy_return_list == [4]

spy.reset_mock()

assert spy.call_count == 0
assert spy.spy_return_list == []
assert foo.bar(3) == 6
assert spy.spy_return == 6
assert spy.spy_return_list == [6]

def test_mock_spy_attributes(self, mocker: MockerFixture) -> None:
"""Attributes named like the spy attributes on mocks that are not spies
are reset like any other attribute, not replaced."""
Expand Down