From fd4fe26d9ed80a2127ffb53b9c5dc4f3bf4532de Mon Sep 17 00:00:00 2001 From: Saif Modan <185626776+saifmodan2006@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:41:30 +0530 Subject: [PATCH] Keep spies calling through after spy.reset_mock(side_effect=True) (#636) Spies call the spied code from their side_effect. For spies whose mock is a MagicMock (classmethods, staticmethods, builtins and classes), calling spy.reset_mock(side_effect=True) cleared that side_effect, causing subsequent calls to return a MagicMock rather than calling through. Override reset_mock on spy objects so that resetting side_effect restores the wrapped function and recorded spy attributes are reset. --- CHANGELOG.rst | 5 +++ docs/usage.rst | 2 +- src/pytest_mock/plugin.py | 12 +++++ tests/test_pytest_mock.py | 95 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d116714..5dee2b3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Releases ======== +UNRELEASED +---------- + +* `#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 ------ diff --git a/docs/usage.rst b/docs/usage.rst index d8ca38f..ceb51fd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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. diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 71e5c2b..88e1167 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -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: diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 8921e6d..f516e8d 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -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."""