Skip to content

Commit f17dd05

Browse files
authored
feat(verify): allow specification of exact call count (#27)
Closes #23
1 parent 2555850 commit f17dd05

File tree

3 files changed

+143
-53
lines changed

3 files changed

+143
-53
lines changed

decoy/__init__.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,18 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
150150

151151
return stub
152152

153-
def verify(self, *_rehearsal_results: Any) -> None:
153+
def verify(self, *_rehearsal_results: Any, times: Optional[int] = None) -> None:
154154
"""Verify a decoy was called using one or more rehearsals.
155155
156156
See [verification usage guide](../usage/verify) for more details.
157157
158158
Arguments:
159159
_rehearsal_results: The return value of rehearsals, unused except
160160
to determine how many rehearsals to verify.
161+
times: How many times the call should appear. If `times` is specifed,
162+
the call count must match exactly, otherwise the call must appear
163+
at least once. The `times` argument must be used with exactly one
164+
rehearsal.
161165
162166
Example:
163167
```python
@@ -187,14 +191,24 @@ def test_create_something(decoy: Decoy):
187191
all_spies = [r.spy_id for r in rehearsals]
188192
all_calls = self._registry.get_calls_by_spy_id(*all_spies)
189193

190-
for i in range(len(all_calls)):
191-
call = all_calls[i]
192-
call_list = all_calls[i : i + len(rehearsals)]
194+
if times is None:
195+
for i in range(len(all_calls)):
196+
call = all_calls[i]
197+
call_list = all_calls[i : i + len(rehearsals)]
193198

194-
if call == rehearsals[0] and call_list == rehearsals:
199+
if call == rehearsals[0] and call_list == rehearsals:
200+
return None
201+
202+
elif len(rehearsals) == 1:
203+
matching_calls = [call for call in all_calls if call == rehearsals[0]]
204+
205+
if len(matching_calls) == times:
195206
return None
196207

197-
raise AssertionError(self._build_verify_error(rehearsals, all_calls))
208+
else:
209+
raise ValueError("Cannot verify multiple rehearsals when using times")
210+
211+
raise AssertionError(self._build_verify_error(rehearsals, all_calls, times))
198212

199213
def _pop_last_rehearsal(self) -> SpyCall:
200214
rehearsal = self._registry.pop_last_call()
@@ -221,10 +235,14 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
221235
return None
222236

223237
def _build_verify_error(
224-
self, rehearsals: Sequence[SpyCall], all_calls: Sequence[SpyCall]
238+
self,
239+
rehearsals: Sequence[SpyCall],
240+
all_calls: Sequence[SpyCall],
241+
times: Optional[int] = None,
225242
) -> str:
226243
rehearsals_len = len(rehearsals)
227244
rehearsals_plural = rehearsals_len != 1
245+
times_plural = times is not None and times != 1
228246

229247
all_calls_len = len(all_calls)
230248
all_calls_plural = all_calls_len != 1
@@ -239,7 +257,8 @@ def _build_verify_error(
239257

240258
return linesep.join(
241259
[
242-
f"Expected call{'s' if rehearsals_plural else ''}:",
260+
f"Expected {f'{times} ' if times is not None else ''}"
261+
f"call{'s' if rehearsals_plural or times_plural else ''}:",
243262
rehearsals_printout,
244263
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
245264
all_calls_printout,

docs/usage/verify.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,21 @@ decoy.verify(
5555
handler.call_second_procedure("world"),
5656
)
5757
```
58+
59+
## Verifying a call count
60+
61+
You may want to verify that a call has been made a certain number of times, or verify that a call was never made. You can use the optional `times` argument to specify call count.
62+
63+
```python
64+
decoy.verify(
65+
handler.should_be_called_twice(),
66+
times=2,
67+
)
68+
69+
decoy.verify(
70+
handler.should_never_be_called(),
71+
times=0,
72+
)
73+
```
74+
75+
You may only use the `times` argument with single rehearsal.

tests/test_verify.py

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88

99
def test_call_function_then_verify(decoy: Decoy) -> None:
1010
"""It should be able to verify a past function call."""
11-
stub = decoy.create_decoy_func(spec=some_func)
11+
spy = decoy.create_decoy_func(spec=some_func)
1212

13-
stub("hello")
14-
stub("goodbye")
13+
spy("hello")
14+
spy("goodbye")
1515

16-
decoy.verify(stub("hello"))
17-
decoy.verify(stub("goodbye"))
16+
decoy.verify(spy("hello"))
17+
decoy.verify(spy("goodbye"))
1818

1919
with pytest.raises(AssertionError) as error_info:
20-
decoy.verify(stub("fizzbuzz"))
20+
decoy.verify(spy("fizzbuzz"))
2121

2222
assert str(error_info.value) == (
2323
f"Expected call:{linesep}"
@@ -30,21 +30,21 @@ def test_call_function_then_verify(decoy: Decoy) -> None:
3030

3131
def test_call_method_then_verify(decoy: Decoy) -> None:
3232
"""It should be able to verify a past method call."""
33-
stub = decoy.create_decoy(spec=SomeClass)
33+
spy = decoy.create_decoy(spec=SomeClass)
3434

35-
stub.foo("hello")
36-
stub.foo("goodbye")
37-
stub.bar(0, 1.0, "2")
38-
stub.bar(3, 4.0, "5")
35+
spy.foo("hello")
36+
spy.foo("goodbye")
37+
spy.bar(0, 1.0, "2")
38+
spy.bar(3, 4.0, "5")
3939

40-
decoy.verify(stub.foo("hello"))
41-
decoy.verify(stub.foo("goodbye"))
40+
decoy.verify(spy.foo("hello"))
41+
decoy.verify(spy.foo("goodbye"))
4242

43-
decoy.verify(stub.bar(0, 1.0, "2"))
44-
decoy.verify(stub.bar(3, 4.0, "5"))
43+
decoy.verify(spy.bar(0, 1.0, "2"))
44+
decoy.verify(spy.bar(3, 4.0, "5"))
4545

4646
with pytest.raises(AssertionError) as error_info:
47-
decoy.verify(stub.foo("fizzbuzz"))
47+
decoy.verify(spy.foo("fizzbuzz"))
4848

4949
assert str(error_info.value) == (
5050
f"Expected call:{linesep}"
@@ -55,7 +55,7 @@ def test_call_method_then_verify(decoy: Decoy) -> None:
5555
)
5656

5757
with pytest.raises(AssertionError) as error_info:
58-
decoy.verify(stub.bar(6, 7.0, "8"))
58+
decoy.verify(spy.bar(6, 7.0, "8"))
5959

6060
assert str(error_info.value) == (
6161
f"Expected call:{linesep}"
@@ -68,14 +68,14 @@ def test_call_method_then_verify(decoy: Decoy) -> None:
6868

6969
def test_verify_with_matcher(decoy: Decoy) -> None:
7070
"""It should still work with matchers as arguments."""
71-
stub = decoy.create_decoy_func(spec=some_func)
71+
spy = decoy.create_decoy_func(spec=some_func)
7272

73-
stub("hello")
73+
spy("hello")
7474

75-
decoy.verify(stub(matchers.StringMatching("ell")))
75+
decoy.verify(spy(matchers.StringMatching("ell")))
7676

7777
with pytest.raises(AssertionError) as error_info:
78-
decoy.verify(stub(matchers.StringMatching("^ell")))
78+
decoy.verify(spy(matchers.StringMatching("^ell")))
7979

8080
assert str(error_info.value) == (
8181
f"Expected call:{linesep}"
@@ -87,48 +87,48 @@ def test_verify_with_matcher(decoy: Decoy) -> None:
8787

8888
def test_call_nested_method_then_verify(decoy: Decoy) -> None:
8989
"""It should be able to verify a past nested method call."""
90-
stub = decoy.create_decoy(spec=SomeNestedClass)
90+
spy = decoy.create_decoy(spec=SomeNestedClass)
9191

92-
stub.child.foo("hello")
93-
stub.child.bar(0, 1.0, "2")
92+
spy.child.foo("hello")
93+
spy.child.bar(0, 1.0, "2")
9494

95-
decoy.verify(stub.child.foo("hello"))
96-
decoy.verify(stub.child.bar(0, 1.0, "2"))
95+
decoy.verify(spy.child.foo("hello"))
96+
decoy.verify(spy.child.bar(0, 1.0, "2"))
9797

9898
with pytest.raises(AssertionError):
99-
decoy.verify(stub.foo("fizzbuzz"))
99+
decoy.verify(spy.foo("fizzbuzz"))
100100

101101

102102
def test_call_no_return_method_then_verify(decoy: Decoy) -> None:
103103
"""It should be able to verify a past void method call."""
104-
stub = decoy.create_decoy(spec=SomeClass)
104+
spy = decoy.create_decoy(spec=SomeClass)
105105

106-
stub.do_the_thing(True)
106+
spy.do_the_thing(True)
107107

108-
decoy.verify(stub.do_the_thing(True))
108+
decoy.verify(spy.do_the_thing(True))
109109

110110
with pytest.raises(AssertionError):
111-
decoy.verify(stub.do_the_thing(False))
111+
decoy.verify(spy.do_the_thing(False))
112112

113113

114114
def test_verify_multiple_calls(decoy: Decoy) -> None:
115115
"""It should be able to verify multiple calls."""
116-
stub = decoy.create_decoy(spec=SomeClass)
117-
stub_func = decoy.create_decoy_func(spec=some_func)
116+
spy = decoy.create_decoy(spec=SomeClass)
117+
spy_func = decoy.create_decoy_func(spec=some_func)
118118

119-
stub.do_the_thing(False)
120-
stub.do_the_thing(True)
121-
stub_func("hello")
119+
spy.do_the_thing(False)
120+
spy.do_the_thing(True)
121+
spy_func("hello")
122122

123123
decoy.verify(
124-
stub.do_the_thing(True),
125-
stub_func("hello"),
124+
spy.do_the_thing(True),
125+
spy_func("hello"),
126126
)
127127

128128
with pytest.raises(AssertionError) as error_info:
129129
decoy.verify(
130-
stub.do_the_thing(False),
131-
stub_func("goodbye"),
130+
spy.do_the_thing(False),
131+
spy_func("goodbye"),
132132
)
133133

134134
assert str(error_info.value) == (
@@ -143,8 +143,8 @@ def test_verify_multiple_calls(decoy: Decoy) -> None:
143143

144144
with pytest.raises(AssertionError) as error_info:
145145
decoy.verify(
146-
stub_func("hello"),
147-
stub.do_the_thing(True),
146+
spy_func("hello"),
147+
spy.do_the_thing(True),
148148
)
149149

150150
assert str(error_info.value) == (
@@ -159,9 +159,9 @@ def test_verify_multiple_calls(decoy: Decoy) -> None:
159159

160160
with pytest.raises(AssertionError) as error_info:
161161
decoy.verify(
162-
stub.do_the_thing(True),
163-
stub.do_the_thing(True),
164-
stub_func("hello"),
162+
spy.do_the_thing(True),
163+
spy.do_the_thing(True),
164+
spy_func("hello"),
165165
)
166166

167167
assert str(error_info.value) == (
@@ -174,3 +174,56 @@ def test_verify_multiple_calls(decoy: Decoy) -> None:
174174
f"2.\tSomeClass.do_the_thing(True){linesep}"
175175
"3.\tsome_func('hello')"
176176
)
177+
178+
179+
def test_verify_call_count(decoy: Decoy) -> None:
180+
"""It should be able to verify a specific call count."""
181+
spy = decoy.create_decoy_func(spec=some_func)
182+
183+
spy("hello")
184+
spy("hello")
185+
186+
decoy.verify(spy("hello"))
187+
decoy.verify(spy("hello"), times=2)
188+
decoy.verify(spy("goodbye"), times=0)
189+
190+
with pytest.raises(AssertionError) as error_info:
191+
decoy.verify(spy("hello"), times=0)
192+
193+
assert str(error_info.value) == (
194+
f"Expected 0 calls:{linesep}"
195+
f"1.\tsome_func('hello'){linesep}"
196+
f"Found 2 calls:{linesep}"
197+
f"1.\tsome_func('hello'){linesep}"
198+
"2.\tsome_func('hello')"
199+
)
200+
201+
with pytest.raises(AssertionError) as error_info:
202+
decoy.verify(spy("hello"), times=1)
203+
204+
assert str(error_info.value) == (
205+
f"Expected 1 call:{linesep}"
206+
f"1.\tsome_func('hello'){linesep}"
207+
f"Found 2 calls:{linesep}"
208+
f"1.\tsome_func('hello'){linesep}"
209+
"2.\tsome_func('hello')"
210+
)
211+
212+
with pytest.raises(AssertionError) as error_info:
213+
decoy.verify(spy("hello"), times=3)
214+
215+
assert str(error_info.value) == (
216+
f"Expected 3 calls:{linesep}"
217+
f"1.\tsome_func('hello'){linesep}"
218+
f"Found 2 calls:{linesep}"
219+
f"1.\tsome_func('hello'){linesep}"
220+
"2.\tsome_func('hello')"
221+
)
222+
223+
224+
def test_verify_call_count_raises_multiple_rehearsals(decoy: Decoy) -> None:
225+
"""It should not be able to verify call count if multiple rehearsals used."""
226+
spy = decoy.create_decoy_func(spec=some_func)
227+
228+
with pytest.raises(ValueError, match="multiple rehearsals"):
229+
decoy.verify(spy("hello"), spy("goodbye"), times=1)

0 commit comments

Comments
 (0)