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
----------

* `#648 <https://github.com/pytest-dev/pytest-mock/pull/648>`_: Fixed ``mocker.patch.multiple()`` so that each patched mock can be stopped individually with ``mocker.stop()`` and emits ``PytestMockWarning`` when used as a context manager, matching ``mocker.patch`` and ``mocker.patch.object``.

3.17.0
------

Expand Down
43 changes: 20 additions & 23 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@ class MockCacheItem:
is_spy: bool = False
# The ``side_effect`` a spy uses to call the spied code, restored by ``resetall``.
spy_side_effect: Callable[..., Any] | None = None
# The objects ``resetall`` resets instead of ``mock``, for patches such as
# ``patch.multiple`` whose ``mock`` is a dict of the patched values.
reset_mocks: tuple[Any, ...] | None = None


@dataclass
Expand Down Expand Up @@ -153,13 +150,9 @@ def resetall(
calling the spied code.
"""
for mock_item in self._mock_cache:
reset_mocks = mock_item.reset_mocks
if reset_mocks is None:
reset_mocks = (mock_item.mock,)
for mock in reset_mocks:
self._reset_mock(
mock, return_value=return_value, side_effect=side_effect
)
self._reset_mock(
mock_item.mock, return_value=return_value, side_effect=side_effect
)
# Spies are flagged when created: checking the spy attributes with
# ``hasattr`` would also match any ``MagicMock``.
if mock_item.is_spy:
Expand Down Expand Up @@ -392,9 +385,7 @@ def multiple(
**kwargs: Any,
) -> dict[str, MockType]:
"""API to mock.patch.multiple"""
mocked = self._start_patch(
self.mock_module.patch.multiple,
True,
patcher = self.mock_module.patch.multiple(
target,
spec=spec,
create=create,
Expand All @@ -403,16 +394,22 @@ def multiple(
new_callable=new_callable,
**kwargs,
)
# The returned dict only holds the mocks created for ``DEFAULT``
# values, so the values passed explicitly are collected from the
# patchers.
mock_item = self.__mock_cache.find(mocked)
patchers = [mock_item.patch, *mock_item.patch.additional_patchers]
mock_item.reset_mocks = (
*mocked.values(),
*(p.new for p in patchers if p.new is not self.mock_module.DEFAULT),
)
return mocked
patchers = [patcher, *patcher.additional_patchers]
patcher.additional_patchers.clear()
started: list[Any] = []
result: dict[str, MockType] = {}
try:
for p in patchers:
p.attribute_name = None
mocked = self._start_patch(lambda p=p: p, True)
started.append(mocked)
if p.new is self.mock_module.DEFAULT:
result[p.attribute] = mocked
except BaseException:
for mocked in reversed(started):
self.__mock_cache.remove(mocked)
raise
return result

def dict(
self,
Expand Down
54 changes: 54 additions & 0 deletions tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,41 @@ def get():
mocker.stop(handle_get)
assert Class1.get() == 1

def test_patch_multiple(self, mocker: MockerFixture) -> None:
class Target:
a = 1
b = 2
c = 3

explicit = mocker.MagicMock()
mocks = mocker.patch.multiple(
Target, a=mocker.DEFAULT, b=explicit, c=mocker.DEFAULT
)
assert Target.a is mocks["a"]
assert Target.b is explicit
assert Target.c is mocks["c"]

mocker.stop(mocks["a"])
assert Target.a == 1
assert Target.b is explicit
assert Target.c is mocks["c"]

mocker.stop(explicit)
assert Target.b == 2
assert Target.c is mocks["c"]

with pytest.raises(ValueError, match="not registered"):
mocker.stop(mocks["a"])

def test_patch_multiple_partial_failure(self, mocker: MockerFixture) -> None:
class Target:
a = 1

with pytest.raises(AttributeError):
mocker.patch.multiple(Target, a=mocker.DEFAULT, missing=mocker.DEFAULT)

assert Target.a == 1


class TestContextManagerWarnings:
"""Warnings when patches are misused as context managers."""
Expand Down Expand Up @@ -1818,6 +1853,25 @@ def test_patch(self, mocker: MockerFixture) -> None:

assert warn_record[0].filename == __file__

def test_patch_multiple(self, mocker: MockerFixture) -> None:
expected_warning_msg = (
"Mocks returned by pytest-mock do not need to be used as context managers. "
"The mocker fixture automatically undoes mocking at the end of a test. "
"This warning can be ignored if it was triggered by mocking a context manager. "
"https://pytest-mock.readthedocs.io/en/latest/usage.html#usage-as-context-manager"
)

mocks = mocker.patch.multiple(UnixFS, rm=mocker.DEFAULT, ls=mocker.DEFAULT)
with (
pytest.warns(
PytestMockWarning, match=re.escape(expected_warning_msg)
) as warn_record,
mocks["rm"],
):
pass

assert warn_record[0].filename == __file__

def test_patching_a_context_manager(self, mocker: MockerFixture) -> None:
"""Our message about misusing mocker as a context manager should not affect mocking
context managers (see #192)"""
Expand Down