Skip to content

Commit b35bc1a

Browse files
authored
Merge pull request #91 from kaste/the-deepcopy-case
2 parents d2901d3 + 5bfcf6d commit b35bc1a

4 files changed

Lines changed: 146 additions & 7 deletions

File tree

docs/recipes.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,28 @@ It's basically the same problem, but we need to add support for the context mana
7373
assert res.text == 'Ok'
7474

7575

76+
Deepcopies
77+
----------
78+
79+
Python's `deepcopy` is tied to `__deepcopy__`, in a nutshell `deepcopy(m)` will call `m.__deepcopy__()`.
80+
For a strict mock, `deepcopy(m)` will raise an error as long as the call is unexpected -- as usual.
81+
82+
While you could completely fake it -- `when(m).__deepcopy__(...).thenReturn(42)` -- you could also enable
83+
the standard implementation by configuring the mock, e.g. `mock({"__deepcopy__": None}, strict=True)`.
84+
85+
Dumb mocks are copied correctly by default.
86+
87+
However, there is a possible catch: deep mutable objects must be set on the mock's instance, not the class.
88+
And the constructors configuration is set on the class, not the instance. Huh? Let's show an example:
89+
90+
m = mock()
91+
m.foo = [1] # <= this is set on the instance, not the class
92+
93+
m = mock({"foo": [1]}) # <= this is set on the class, not the instance
94+
95+
Don't rely on that latter "feature", initially the configurataion was meant to only set methods, and especially
96+
special, dunder methods, -- and properties. If we get proper support for properties, we'll likely make a change
97+
here too.
98+
99+
Btw, `copy` will *just work* for strict mocks and does not raise an error when not configured/expected. This is
100+
just not implemented and considered not-worth-the-effort.

mockito/mocking.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
]
4141

4242

43-
class _Dummy(object):
43+
class _Dummy:
4444
# We spell out `__call__` here for convenience. All other magic methods
4545
# must be configured before use, but we want `mock`s to be callable by
4646
# default.
@@ -232,7 +232,7 @@ def __repr__(self):
232232

233233
OMITTED = _OMITTED()
234234

235-
def mock(config_or_spec=None, spec=None, strict=OMITTED):
235+
def mock(config_or_spec=None, spec=None, strict=OMITTED): # noqa: C901
236236
"""Create 'empty' objects ('Mocks').
237237
238238
Will create an empty unconfigured object, that you can pass
@@ -295,9 +295,22 @@ def __getattr__(self, method_name):
295295
__tracebackhide__ = operator.methodcaller(
296296
"errisinstance", AttributeError
297297
)
298-
299-
raise AttributeError(
298+
# deepcopy catches a possible AttributeError, fallback
299+
# to an arbitrary RuntimeError
300+
error_type = (
301+
RuntimeError
302+
if method_name == '__deepcopy__'
303+
else AttributeError
304+
)
305+
raise error_type(
300306
"'Dummy' has no attribute %r configured" % method_name)
307+
308+
if (
309+
method_name != "__call__"
310+
and method_name == "__{}__".format(method_name[2:-2])
311+
):
312+
raise AttributeError(method_name)
313+
301314
return functools.partial(
302315
remembered_invocation_builder, theMock, method_name)
303316

tests/deepcopy_test.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import pytest
2+
from copy import copy, deepcopy
3+
from mockito import mock, when
4+
5+
6+
class TestDeepcopy:
7+
def test_dumb_mocks_are_copied_correctly(self):
8+
m = mock()
9+
m.foo = [1]
10+
n = deepcopy(m)
11+
assert m is not n
12+
assert n.foo == [1]
13+
14+
m.foo.append(2)
15+
assert n.foo == [1]
16+
17+
def test_strict_mocks_raise_on_unexpected_calls(self):
18+
m = mock(strict=True)
19+
with pytest.raises(RuntimeError) as exc:
20+
deepcopy(m)
21+
assert str(exc.value) == (
22+
"'Dummy' has no attribute '__deepcopy__' configured"
23+
)
24+
25+
def test_configured_strict_mock_answers_correctly(self):
26+
m = mock(strict=True)
27+
when(m).__deepcopy__(...).thenReturn(42)
28+
assert deepcopy(m) == 42
29+
30+
def test_setting_none_enables_the_standard_implementation(self):
31+
m = mock({"__deepcopy__": None}, strict=True)
32+
m.foo = [1]
33+
34+
n = deepcopy(m)
35+
assert m is not n
36+
assert n.foo == [1]
37+
38+
m.foo.append(2)
39+
assert n.foo == [1]
40+
41+
42+
@pytest.mark.xfail(reason=(
43+
"the configuration is set on the mock's class, not the instance, "
44+
"which deepcopy does not copy"
45+
))
46+
def test_deepcopy_of_a_configured_mock_is_a_new_mock(self):
47+
m = mock({"foo": [1]}, strict=True)
48+
n = deepcopy(m)
49+
50+
m.foo.append(2)
51+
assert n.foo == [1]
52+
53+
54+
55+
class TestCopy:
56+
def test_dumb_mocks_are_copied_correctly(self):
57+
m = mock()
58+
m.foo = [1]
59+
n = copy(m)
60+
assert m is not n
61+
assert n.foo == [1]
62+
63+
m.foo.append(2)
64+
assert n.foo == [1, 2]
65+
66+
@pytest.mark.xfail(reason=(
67+
"not working for `copy` because __copy__ is accessed on the class, "
68+
"not the instance"
69+
))
70+
def test_strict_mocks_raise_on_unexpected_calls(self):
71+
m = mock(strict=True)
72+
with pytest.raises(RuntimeError) as exc:
73+
copy(m)
74+
assert str(exc.value) == (
75+
"'Dummy' has no attribute '__copy__' configured"
76+
)

tests/mocking_properties_test.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
2-
from mockito import mock, when
2+
from mockito import mock, verify, when
3+
from mockito.invocation import return_
34

45
def test_deprecated_a(unstub):
56
# Setting on `__class__` is confusing for users
@@ -40,10 +41,34 @@ def test_deprecated_c(unstub):
4041
m.tx
4142

4243

43-
def test_recommended_approach():
44-
prop = mock(strict=True)
44+
def test_recommended_approach_1(unstub):
45+
prop = mock()
4546
when(prop).__get__(Ellipsis).thenRaise(ValueError)
4647

4748
m = mock({'tx': prop})
4849
with pytest.raises(ValueError):
4950
m.tx
51+
verify(prop).__get__(...)
52+
53+
54+
def test_recommended_approach_2(unstub):
55+
prop = mock()
56+
when(prop).__get__(Ellipsis).thenReturn(42)
57+
58+
m = mock({'tx': prop})
59+
assert m.tx == 42
60+
verify(prop).__get__(...)
61+
62+
63+
def test_recommended_approach_3(unstub):
64+
prop = mock({"__get__": return_(42)})
65+
m = mock({'tx': prop})
66+
assert m.tx == 42
67+
verify(prop).__get__(...)
68+
69+
70+
def test_recommended_approach_4(unstub):
71+
# Elegant but you can't `verify` the usage explicitly
72+
# which makes it moot -- why not just set `m.tx = 42` then?
73+
m = mock({'tx': property(return_(42))})
74+
assert m.tx == 42

0 commit comments

Comments
 (0)