Skip to content

Commit 0c4206e

Browse files
authored
feat(verify): allow multiple rehearsals in verify (#11)
Closes #9
1 parent 6bd7f40 commit 0c4206e

File tree

6 files changed

+148
-24
lines changed

6 files changed

+148
-24
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ Stubbing and verification of a decoy are **mutually exclusive** within a test. I
144144
- The assertions are redundant
145145
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
146146

147+
#### Verifying order of multiple calls
148+
149+
If your code under test must call several dependencies in order, you may pass multiple rehearsals to `verify`. Decoy will search through the list of all calls made to the given spies and look for the exact rehearsal sequence given, in order.
150+
151+
```python
152+
decoy.verify(
153+
handler.call_first_procedure("hello"),
154+
handler.call_second_procedure("world"),
155+
)
156+
```
157+
147158
### Usage with async/await
148159

149160
Decoy supports async/await out of the box! Pass your async function or class with async methods to `spec` in `decoy.create_decoy_func` or `decoy.create_decoy`, respectively, and Decoy will figure out the rest.

decoy/__init__.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,14 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
115115

116116
return stub
117117

118-
def verify(self, _rehearsal_result: Optional[ReturnT] = None) -> None:
119-
"""Verify a decoy was called using a rehearsal.
118+
def verify(self, *_rehearsal_results: Any) -> None:
119+
"""Verify a decoy was called using one or more rehearsals.
120120
121121
See [verification](index.md#verification) for more details.
122122
123123
Arguments:
124-
_rehearsal_result: The return value of a rehearsal, unused.
124+
_rehearsal_results: The return value of rehearsals, unused except
125+
to determine how many rehearsals to verify.
125126
126127
Example:
127128
```python
@@ -134,15 +135,31 @@ def test_create_something(decoy: Decoy):
134135
```
135136
136137
Note:
137-
The "rehearsal" is an actual call to the test fake. The fact that
138+
A "rehearsal" is an actual call to the test fake. The fact that
138139
the call is written inside `verify` is purely for typechecking and
139-
API sugar. Decoy will pop the last call to _any_ fake off its
140+
API sugar. Decoy will pop the last call(s) to _any_ fake off its
140141
call stack, which will end up being the call inside `verify`.
141142
"""
142-
rehearsal = self._pop_last_rehearsal()
143-
all_calls = self._registry.get_calls_by_spy_id(rehearsal.spy_id)
143+
if len(_rehearsal_results) > 1:
144+
rehearsals = list(
145+
reversed(
146+
[self._pop_last_rehearsal() for i in range(len(_rehearsal_results))]
147+
)
148+
)
149+
else:
150+
rehearsals = [self._pop_last_rehearsal()]
151+
152+
all_spies = [r.spy_id for r in rehearsals]
153+
all_calls = self._registry.get_calls_by_spy_id(*all_spies)
154+
155+
for i in range(len(all_calls)):
156+
call = all_calls[i]
157+
call_list = all_calls[i : i + len(rehearsals)]
144158

145-
assert rehearsal in all_calls, self._build_verify_error(rehearsal, all_calls)
159+
if call == rehearsals[0] and call_list == rehearsals:
160+
return None
161+
162+
raise AssertionError(self._build_verify_error(rehearsals, all_calls))
146163

147164
def _pop_last_rehearsal(self) -> SpyCall:
148165
rehearsal = self._registry.pop_last_call()
@@ -164,18 +181,26 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
164181
return None
165182

166183
def _build_verify_error(
167-
self, rehearsal: SpyCall, all_calls: Sequence[SpyCall]
184+
self, rehearsals: Sequence[SpyCall], all_calls: Sequence[SpyCall]
168185
) -> str:
186+
rehearsals_len = len(rehearsals)
187+
rehearsals_plural = rehearsals_len != 1
188+
169189
all_calls_len = len(all_calls)
170190
all_calls_plural = all_calls_len != 1
191+
192+
rehearsals_printout = linesep.join(
193+
[f"{n + 1}.\t{str(rehearsals[n])}" for n in range(rehearsals_len)]
194+
)
195+
171196
all_calls_printout = linesep.join(
172197
[f"{n + 1}.\t{str(all_calls[n])}" for n in range(all_calls_len)]
173198
)
174199

175200
return linesep.join(
176201
[
177-
"Expected call:",
178-
f"\t{str(rehearsal)}",
202+
f"Expected call{'s' if rehearsals_plural else ''}:",
203+
rehearsals_printout,
179204
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
180205
all_calls_printout,
181206
]

decoy/mypy.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ def get_method_hook(
2121
def _handle_decoy_call(self, ctx: MethodContext) -> Type:
2222
errors_list = ctx.api.msg.errors.error_info_map.get(ctx.api.path, [])
2323
rehearsal_call_args = ctx.args[0] if len(ctx.args) > 0 else []
24+
error_removals = []
2425

2526
for err in errors_list:
26-
for arg in rehearsal_call_args:
27-
if (
28-
err.code == FUNC_RETURNS_VALUE
29-
and arg.line == err.line
30-
and arg.column == err.column
31-
):
32-
errors_list.remove(err)
27+
if err.code == FUNC_RETURNS_VALUE:
28+
for arg in rehearsal_call_args:
29+
if arg.line == err.line and arg.column == err.column:
30+
error_removals.append(err)
31+
32+
for err in error_removals:
33+
errors_list.remove(err)
3334

3435
return ctx.default_return_type
3536

decoy/registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def get_stubs_by_spy_id(self, spy_id: int) -> List[Stub[Any]]:
4747
"""
4848
return self._stub_map.get(spy_id, [])
4949

50-
def get_calls_by_spy_id(self, spy_id: int) -> List[SpyCall]:
50+
def get_calls_by_spy_id(self, *spy_id: int) -> List[SpyCall]:
5151
"""Get a spy's call list by identifier.
5252
5353
Arguments:
@@ -56,7 +56,7 @@ def get_calls_by_spy_id(self, spy_id: int) -> List[SpyCall]:
5656
Returns:
5757
The list of calls matching the given Spy.
5858
"""
59-
return [c for c in self._calls if c.spy_id == spy_id]
59+
return [c for c in self._calls if c.spy_id in spy_id]
6060

6161
def register_spy(self, spy: BaseSpy) -> int:
6262
"""Register a spy for tracking.

tests/test_verify.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_call_function_then_verify(decoy: Decoy) -> None:
2121

2222
assert str(error_info.value) == (
2323
f"Expected call:{linesep}"
24-
f"\tsome_func('fizzbuzz'){linesep}"
24+
f"1.\tsome_func('fizzbuzz'){linesep}"
2525
f"Found 2 calls:{linesep}"
2626
f"1.\tsome_func('hello'){linesep}"
2727
"2.\tsome_func('goodbye')"
@@ -48,7 +48,7 @@ def test_call_method_then_verify(decoy: Decoy) -> None:
4848

4949
assert str(error_info.value) == (
5050
f"Expected call:{linesep}"
51-
f"\tSomeClass.foo('fizzbuzz'){linesep}"
51+
f"1.\tSomeClass.foo('fizzbuzz'){linesep}"
5252
f"Found 2 calls:{linesep}"
5353
f"1.\tSomeClass.foo('hello'){linesep}"
5454
"2.\tSomeClass.foo('goodbye')"
@@ -59,7 +59,7 @@ def test_call_method_then_verify(decoy: Decoy) -> None:
5959

6060
assert str(error_info.value) == (
6161
f"Expected call:{linesep}"
62-
f"\tSomeClass.bar(6, 7.0, '8'){linesep}"
62+
f"1.\tSomeClass.bar(6, 7.0, '8'){linesep}"
6363
f"Found 2 calls:{linesep}"
6464
f"1.\tSomeClass.bar(0, 1.0, '2'){linesep}"
6565
"2.\tSomeClass.bar(3, 4.0, '5')"
@@ -79,7 +79,7 @@ def test_verify_with_matcher(decoy: Decoy) -> None:
7979

8080
assert str(error_info.value) == (
8181
f"Expected call:{linesep}"
82-
f"\tsome_func(<StringMatching '^ell'>){linesep}"
82+
f"1.\tsome_func(<StringMatching '^ell'>){linesep}"
8383
f"Found 1 call:{linesep}"
8484
"1.\tsome_func('hello')"
8585
)
@@ -109,3 +109,68 @@ def test_call_no_return_method_then_verify(decoy: Decoy) -> None:
109109

110110
with pytest.raises(AssertionError):
111111
decoy.verify(stub.do_the_thing(False))
112+
113+
114+
def test_verify_multiple_calls(decoy: Decoy) -> None:
115+
"""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)
118+
119+
stub.do_the_thing(False)
120+
stub.do_the_thing(True)
121+
stub_func("hello")
122+
123+
decoy.verify(
124+
stub.do_the_thing(True),
125+
stub_func("hello"),
126+
)
127+
128+
with pytest.raises(AssertionError) as error_info:
129+
decoy.verify(
130+
stub.do_the_thing(False),
131+
stub_func("goodbye"),
132+
)
133+
134+
assert str(error_info.value) == (
135+
f"Expected calls:{linesep}"
136+
f"1.\tSomeClass.do_the_thing(False){linesep}"
137+
f"2.\tsome_func('goodbye'){linesep}"
138+
f"Found 3 calls:{linesep}"
139+
f"1.\tSomeClass.do_the_thing(False){linesep}"
140+
f"2.\tSomeClass.do_the_thing(True){linesep}"
141+
"3.\tsome_func('hello')"
142+
)
143+
144+
with pytest.raises(AssertionError) as error_info:
145+
decoy.verify(
146+
stub_func("hello"),
147+
stub.do_the_thing(True),
148+
)
149+
150+
assert str(error_info.value) == (
151+
f"Expected calls:{linesep}"
152+
f"1.\tsome_func('hello'){linesep}"
153+
f"2.\tSomeClass.do_the_thing(True){linesep}"
154+
f"Found 3 calls:{linesep}"
155+
f"1.\tSomeClass.do_the_thing(False){linesep}"
156+
f"2.\tSomeClass.do_the_thing(True){linesep}"
157+
"3.\tsome_func('hello')"
158+
)
159+
160+
with pytest.raises(AssertionError) as error_info:
161+
decoy.verify(
162+
stub.do_the_thing(True),
163+
stub.do_the_thing(True),
164+
stub_func("hello"),
165+
)
166+
167+
assert str(error_info.value) == (
168+
f"Expected calls:{linesep}"
169+
f"1.\tSomeClass.do_the_thing(True){linesep}"
170+
f"2.\tSomeClass.do_the_thing(True){linesep}"
171+
f"3.\tsome_func('hello'){linesep}"
172+
f"Found 3 calls:{linesep}"
173+
f"1.\tSomeClass.do_the_thing(False){linesep}"
174+
f"2.\tSomeClass.do_the_thing(True){linesep}"
175+
"3.\tsome_func('hello')"
176+
)

tests/typing/test_mypy_plugin.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
decoy = Decoy()
3030
decoy.verify(noop())
3131
32+
- case: suppresses_multiple_func_returns_value_in_verify
33+
main: |
34+
from decoy import Decoy
35+
36+
def noop() -> None:
37+
pass
38+
39+
decoy = Decoy()
40+
decoy.verify(noop(), noop())
41+
3242
- case: does_not_suppress_other_errors
3343
main: |
3444
from decoy import Decoy
@@ -40,3 +50,15 @@
4050
stub = decoy.when(do_thing("hello"))
4151
out: |
4252
main:7: error: Too many arguments for "do_thing" [call-arg]
53+
54+
- case: does_not_suppress_other_errors_with_multiple_verify_calls
55+
main: |
56+
from decoy import Decoy
57+
58+
def noop() -> None:
59+
pass
60+
61+
decoy = Decoy()
62+
decoy.verify(noop(), noop("hello"))
63+
out: |
64+
main:7: error: Too many arguments for "noop" [call-arg]

0 commit comments

Comments
 (0)