Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement selective un-spying and un-patching #319

Merged
merged 3 commits into from Oct 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/usage.rst
Expand Up @@ -21,6 +21,7 @@ The supported methods are:
* `mocker.patch.multiple <https://docs.python.org/3/library/unittest.mock.html#patch-multiple>`_
* `mocker.patch.dict <https://docs.python.org/3/library/unittest.mock.html#patch-dict>`_
* `mocker.stopall <https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch.stopall>`_
* `mocker.stop <https://docs.python.org/3/library/unittest.mock.html#patch-methods-start-and-stop>`_
* ``mocker.resetall()``: calls `reset_mock() <https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.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``:
Expand Down Expand Up @@ -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
----

Expand Down
35 changes: 23 additions & 12 deletions src/pytest_mock/plugin.py
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it is best to raise an error if the given mock is not registered by us, to avoid mistakes being silent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point

else:
raise ValueError("This mock object is not registered")

def spy(self, obj: object, name: str) -> unittest.mock.MagicMock:
"""
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_pytest_mock.py
Expand Up @@ -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