From fbb5039d7269c34705a7dab39f2a2cea92111859 Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Wed, 5 Oct 2022 20:39:40 +0200 Subject: [PATCH] Implement selective un-spying and un-patching (#319) Co-authored-by: Bruno Oliveira Fixes #259 --- docs/usage.rst | 22 ++++++++++++++++ src/pytest_mock/plugin.py | 35 +++++++++++++++++--------- tests/test_pytest_mock.py | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index f457c74..4c313b0 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -21,6 +21,7 @@ The supported methods are: * `mocker.patch.multiple `_ * `mocker.patch.dict `_ * `mocker.stopall `_ +* `mocker.stop `_ * ``mocker.resetall()``: calls `reset_mock() `_ in all mocked objects up to this point. Also, as a convenience, these names from the ``mock`` module are accessible directly from ``mocker``: @@ -94,6 +95,27 @@ As of version 3.0.0, ``mocker.spy`` also works with ``async def`` functions. .. _#175: https://github.com/pytest-dev/pytest-mock/issues/175 +As of version 3.10, spying can be also selectively stopped. + +.. code-block:: python + + def test_with_unspy(mocker): + class Foo: + def bar(self): + return 42 + + spy = mocker.spy(Foo, "bar") + foo = Foo() + assert foo.bar() == 42 + assert spy.call_count == 1 + mocker.stop(spy) + assert foo.bar() == 42 + assert spy.call_count == 1 + + +``mocker.stop()`` can also be used by ``mocker.patch`` calls. + + Stub ---- diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 9554750..1d52555 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -44,11 +44,10 @@ class MockerFixture: """ def __init__(self, config: Any) -> None: - self._patches = [] # type: List[Any] - self._mocks = [] # type: List[Any] + self._patches_and_mocks: List[Tuple[Any, unittest.mock.MagicMock]] = [] self.mock_module = mock_module = get_mock_module(config) self.patch = self._Patcher( - self._patches, self._mocks, mock_module + self._patches_and_mocks, mock_module ) # type: MockerFixture._Patcher # aliases for convenience self.Mock = mock_module.Mock @@ -82,8 +81,10 @@ def resetall( else: supports_reset_mock_with_args = (self.Mock,) - for m in self._mocks: + for p, m in self._patches_and_mocks: # See issue #237. + if not hasattr(m, "reset_mock"): + continue if isinstance(m, supports_reset_mock_with_args): m.reset_mock(return_value=return_value, side_effect=side_effect) else: @@ -94,10 +95,22 @@ def stopall(self) -> None: Stop all patchers started by this fixture. Can be safely called multiple times. """ - for p in reversed(self._patches): + for p, m in reversed(self._patches_and_mocks): p.stop() - self._patches[:] = [] - self._mocks[:] = [] + self._patches_and_mocks.clear() + + def stop(self, mock: unittest.mock.MagicMock) -> None: + """ + Stops a previous patch or spy call by passing the ``MagicMock`` object + returned by it. + """ + for index, (p, m) in enumerate(self._patches_and_mocks): + if mock is m: + p.stop() + del self._patches_and_mocks[index] + break + else: + raise ValueError("This mock object is not registered") def spy(self, obj: object, name: str) -> unittest.mock.MagicMock: """ @@ -186,9 +199,8 @@ class _Patcher: DEFAULT = object() - def __init__(self, patches, mocks, mock_module): - self._patches = patches - self._mocks = mocks + def __init__(self, patches_and_mocks, mock_module): + self.__patches_and_mocks = patches_and_mocks self.mock_module = mock_module def _start_patch( @@ -200,9 +212,8 @@ def _start_patch( """ p = mock_func(*args, **kwargs) mocked = p.start() # type: unittest.mock.MagicMock - self._patches.append(p) + self.__patches_and_mocks.append((p, mocked)) if hasattr(mocked, "reset_mock"): - self._mocks.append(mocked) # check if `mocked` is actually a mock object, as depending on autospec or target # parameters `mocked` can be anything if hasattr(mocked, "__enter__") and warn_on_mock_enter: diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 8033475..50a5dac 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -1100,3 +1100,56 @@ def test_get_random_number(): result = testdir.runpytest_subprocess() assert "AssertionError" not in result.stderr.str() result.stdout.fnmatch_lines("* 1 passed in *") + + +def test_stop_patch(mocker): + class UnSpy: + def foo(self): + return 42 + + m = mocker.patch.object(UnSpy, "foo", return_value=0) + assert UnSpy().foo() == 0 + mocker.stop(m) + assert UnSpy().foo() == 42 + + with pytest.raises(ValueError): + mocker.stop(m) + + +def test_stop_instance_patch(mocker): + class UnSpy: + def foo(self): + return 42 + + m = mocker.patch.object(UnSpy, "foo", return_value=0) + un_spy = UnSpy() + assert un_spy.foo() == 0 + mocker.stop(m) + assert un_spy.foo() == 42 + + +def test_stop_spy(mocker): + class UnSpy: + def foo(self): + return 42 + + spy = mocker.spy(UnSpy, "foo") + assert UnSpy().foo() == 42 + assert spy.call_count == 1 + mocker.stop(spy) + assert UnSpy().foo() == 42 + assert spy.call_count == 1 + + +def test_stop_instance_spy(mocker): + class UnSpy: + def foo(self): + return 42 + + spy = mocker.spy(UnSpy, "foo") + un_spy = UnSpy() + assert un_spy.foo() == 42 + assert spy.call_count == 1 + mocker.stop(spy) + assert un_spy.foo() == 42 + assert spy.call_count == 1