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

* `#656 <https://github.com/pytest-dev/pytest-mock/pull/656>`_: Added ``mocker.CopyingMock`` to deep-copy mutable call arguments, with support for both mock backends and ``new_callable`` patches.

3.17.0
------

Expand Down
32 changes: 32 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
11 changes: 11 additions & 0 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import platform
import re
Expand Down Expand Up @@ -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
Expand Down