diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d116714..89cb9a8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Releases ======== +UNRELEASED +---------- + +* `#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 ------ diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 71e5c2b..92329f7 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -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 @@ -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: @@ -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, @@ -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, diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 8921e6d..9f7ce2c 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -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.""" @@ -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)"""