diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d116714..348c957 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Releases ======== +UNRELEASED +---------- + +* `#656 `_: Added ``mocker.CopyingMock`` to deep-copy mutable call arguments, with support for both mock backends and ``new_callable`` patches. + 3.17.0 ------ diff --git a/docs/usage.rst b/docs/usage.rst index d8ca38f..41b61b1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -49,6 +49,38 @@ the appropriate fixture: * ``session_mocker`` +Copying mutable arguments +------------------------- + +Ordinary mocks store references to their call arguments. If a list or dictionary +is changed after a call, the recorded arguments reflect those changes too. +Use ``mocker.CopyingMock`` to deep-copy arguments before each call: + +.. code-block:: python + + def test_callback(mocker): + callback = mocker.CopyingMock() + payload = {"items": ["first"]} + + callback(payload) + payload["items"].append("second") + + callback.assert_called_once_with({"items": ["first"]}) + +``CopyingMock`` subclasses the configured backend's ``MagicMock``. It can also +be used with ``mocker.patch`` or ``mocker.patch.object`` by passing +``new_callable=mocker.CopyingMock``. Such patches are tracked by ``resetall`` +and automatically undone at the end of the test. Like ``mocker.MagicMock()``, +directly constructed instances are not tracked by ``resetall``. + +Child mocks and the default return-value mock also copy their arguments. +Positional and keyword arguments are copied together, preserving shared +references between them. Objects must support ``copy.deepcopy``; failures to +copy an argument propagate to the caller. A ``side_effect`` or ``wraps`` callable +receives the copies as well, and can still mutate the recorded copies. Use a +regular mock when the original objects' identity or mutation matters. + + Spy --- diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 71e5c2b..18954ff 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -9,6 +9,7 @@ from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping +from copy import deepcopy from dataclasses import dataclass from dataclasses import field from typing import Any @@ -120,6 +121,16 @@ def __init__(self, config: Any) -> None: # aliases for convenience self.Mock = mock_module.Mock self.MagicMock = mock_module.MagicMock + + # The base class depends on the configured mock backend. + class CopyingMock(mock_module.MagicMock): # type: ignore[name-defined, misc] + """A MagicMock that deep-copies arguments before each call.""" + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + args, kwargs = deepcopy((args, kwargs)) + return super().__call__(*args, **kwargs) + + self.CopyingMock = CopyingMock self.NonCallableMock = mock_module.NonCallableMock self.NonCallableMagicMock = mock_module.NonCallableMagicMock self.PropertyMock = mock_module.PropertyMock diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 8921e6d..55e449a 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -1,3 +1,4 @@ +import json import os import platform import re @@ -72,6 +73,104 @@ def ls(cls, path): return os.listdir(path) +class TestCopyingMock: + @pytest.fixture(params=["unittest.mock", "mock"]) + def copying_mocker( + self, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch + ) -> Generator[MockerFixture, None, None]: + backend = pytest.importorskip(request.param) + monkeypatch.setattr( + "pytest_mock.plugin.get_mock_module", lambda config: backend + ) + fixture = MockerFixture(request.config) + yield fixture + fixture.stopall() + + def test_mutable_arguments(self, copying_mocker: MockerFixture) -> None: + mock = copying_mocker.CopyingMock(return_value=42) + data = {"values": [1]} + assert mock(data, values=data["values"]) == 42 + data["values"].append(2) + mock(data, values=data["values"]) + data.clear() + assert mock.call_args_list == [ + copying_mocker.call({"values": [1]}, values=[1]), + copying_mocker.call({"values": [1, 2]}, values=[1, 2]), + ] + + def test_shared_and_recursive_arguments( + self, copying_mocker: MockerFixture + ) -> None: + mock = copying_mocker.CopyingMock() + data: list[Any] = [] + data.append(data) + mock(data, same=data) + args, kwargs = mock.call_args + assert args[0] is kwargs["same"] + assert args[0][0] is args[0] + assert args[0] is not data + + @pytest.mark.parametrize("child", ["attribute", "return_value"]) + def test_child_mocks(self, copying_mocker: MockerFixture, child: str) -> None: + parent = copying_mocker.CopyingMock() + mock = parent.child if child == "attribute" else parent() + data = [1] + mock(data) + data.clear() + mock.assert_called_once_with([1]) + assert isinstance(mock, copying_mocker.CopyingMock) + + @pytest.mark.parametrize("patch_object", [False, True]) + def test_patch_lifecycle( + self, copying_mocker: MockerFixture, patch_object: bool + ) -> None: + original = json.dumps + if patch_object: + mock = copying_mocker.patch.object( + json, "dumps", new_callable=copying_mocker.CopyingMock + ) + else: + mock = copying_mocker.patch( + "json.dumps", new_callable=copying_mocker.CopyingMock + ) + data = ["path"] + json.dumps(data) + data.clear() + mock.assert_called_once_with(["path"]) + copying_mocker.resetall() + mock.assert_not_called() + copying_mocker.stop(mock) + assert json.dumps is original + + def test_side_effect_receives_copy(self, copying_mocker: MockerFixture) -> None: + data = [1] + received: list[list[int]] = [] + mock = copying_mocker.CopyingMock(side_effect=received.append) + mock(data) + assert received == [[1]] + assert received[0] is not data + + def test_uncopyable_argument(self, copying_mocker: MockerFixture) -> None: + class Uncopyable: + def __deepcopy__(self, memo: dict[int, Any]) -> Any: + raise TypeError("cannot copy") + + mock = copying_mocker.CopyingMock() + with pytest.raises(TypeError, match="cannot copy"): + mock(Uncopyable()) + mock.assert_not_called() + + def test_spec_and_default_mock(self, copying_mocker: MockerFixture) -> None: + mock = copying_mocker.CopyingMock(spec_set=["allowed"]) + with pytest.raises(AttributeError): + _ = mock.missing + ordinary = copying_mocker.MagicMock() + data = [1] + ordinary(data) + data.clear() + ordinary.assert_called_once_with([]) + + class AutospecTarget: """ Class that is used for testing create_autospec with child mocks