Skip to content

Commit

Permalink
feat(verify): allow multiple rehearsals in verify (#11)
Browse files Browse the repository at this point in the history
Closes #9
  • Loading branch information
mcous authored Jan 5, 2021
1 parent 6bd7f40 commit 0c4206e
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 24 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ Stubbing and verification of a decoy are **mutually exclusive** within a test. I
- The assertions are redundant
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored

#### Verifying order of multiple calls

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.

```python
decoy.verify(
handler.call_first_procedure("hello"),
handler.call_second_procedure("world"),
)
```

### Usage with async/await

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.
Expand Down
47 changes: 36 additions & 11 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,14 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:

return stub

def verify(self, _rehearsal_result: Optional[ReturnT] = None) -> None:
"""Verify a decoy was called using a rehearsal.
def verify(self, *_rehearsal_results: Any) -> None:
"""Verify a decoy was called using one or more rehearsals.
See [verification](index.md#verification) for more details.
Arguments:
_rehearsal_result: The return value of a rehearsal, unused.
_rehearsal_results: The return value of rehearsals, unused except
to determine how many rehearsals to verify.
Example:
```python
Expand All @@ -134,15 +135,31 @@ def test_create_something(decoy: Decoy):
```
Note:
The "rehearsal" is an actual call to the test fake. The fact that
A "rehearsal" is an actual call to the test fake. The fact that
the call is written inside `verify` is purely for typechecking and
API sugar. Decoy will pop the last call to _any_ fake off its
API sugar. Decoy will pop the last call(s) to _any_ fake off its
call stack, which will end up being the call inside `verify`.
"""
rehearsal = self._pop_last_rehearsal()
all_calls = self._registry.get_calls_by_spy_id(rehearsal.spy_id)
if len(_rehearsal_results) > 1:
rehearsals = list(
reversed(
[self._pop_last_rehearsal() for i in range(len(_rehearsal_results))]
)
)
else:
rehearsals = [self._pop_last_rehearsal()]

all_spies = [r.spy_id for r in rehearsals]
all_calls = self._registry.get_calls_by_spy_id(*all_spies)

for i in range(len(all_calls)):
call = all_calls[i]
call_list = all_calls[i : i + len(rehearsals)]

assert rehearsal in all_calls, self._build_verify_error(rehearsal, all_calls)
if call == rehearsals[0] and call_list == rehearsals:
return None

raise AssertionError(self._build_verify_error(rehearsals, all_calls))

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

def _build_verify_error(
self, rehearsal: SpyCall, all_calls: Sequence[SpyCall]
self, rehearsals: Sequence[SpyCall], all_calls: Sequence[SpyCall]
) -> str:
rehearsals_len = len(rehearsals)
rehearsals_plural = rehearsals_len != 1

all_calls_len = len(all_calls)
all_calls_plural = all_calls_len != 1

rehearsals_printout = linesep.join(
[f"{n + 1}.\t{str(rehearsals[n])}" for n in range(rehearsals_len)]
)

all_calls_printout = linesep.join(
[f"{n + 1}.\t{str(all_calls[n])}" for n in range(all_calls_len)]
)

return linesep.join(
[
"Expected call:",
f"\t{str(rehearsal)}",
f"Expected call{'s' if rehearsals_plural else ''}:",
rehearsals_printout,
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
all_calls_printout,
]
Expand Down
15 changes: 8 additions & 7 deletions decoy/mypy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ def get_method_hook(
def _handle_decoy_call(self, ctx: MethodContext) -> Type:
errors_list = ctx.api.msg.errors.error_info_map.get(ctx.api.path, [])
rehearsal_call_args = ctx.args[0] if len(ctx.args) > 0 else []
error_removals = []

for err in errors_list:
for arg in rehearsal_call_args:
if (
err.code == FUNC_RETURNS_VALUE
and arg.line == err.line
and arg.column == err.column
):
errors_list.remove(err)
if err.code == FUNC_RETURNS_VALUE:
for arg in rehearsal_call_args:
if arg.line == err.line and arg.column == err.column:
error_removals.append(err)

for err in error_removals:
errors_list.remove(err)

return ctx.default_return_type

Expand Down
4 changes: 2 additions & 2 deletions decoy/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def get_stubs_by_spy_id(self, spy_id: int) -> List[Stub[Any]]:
"""
return self._stub_map.get(spy_id, [])

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

def register_spy(self, spy: BaseSpy) -> int:
"""Register a spy for tracking.
Expand Down
73 changes: 69 additions & 4 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_call_function_then_verify(decoy: Decoy) -> None:

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

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

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

assert str(error_info.value) == (
f"Expected call:{linesep}"
f"\tsome_func(<StringMatching '^ell'>){linesep}"
f"1.\tsome_func(<StringMatching '^ell'>){linesep}"
f"Found 1 call:{linesep}"
"1.\tsome_func('hello')"
)
Expand Down Expand Up @@ -109,3 +109,68 @@ def test_call_no_return_method_then_verify(decoy: Decoy) -> None:

with pytest.raises(AssertionError):
decoy.verify(stub.do_the_thing(False))


def test_verify_multiple_calls(decoy: Decoy) -> None:
"""It should be able to verify multiple calls."""
stub = decoy.create_decoy(spec=SomeClass)
stub_func = decoy.create_decoy_func(spec=some_func)

stub.do_the_thing(False)
stub.do_the_thing(True)
stub_func("hello")

decoy.verify(
stub.do_the_thing(True),
stub_func("hello"),
)

with pytest.raises(AssertionError) as error_info:
decoy.verify(
stub.do_the_thing(False),
stub_func("goodbye"),
)

assert str(error_info.value) == (
f"Expected calls:{linesep}"
f"1.\tSomeClass.do_the_thing(False){linesep}"
f"2.\tsome_func('goodbye'){linesep}"
f"Found 3 calls:{linesep}"
f"1.\tSomeClass.do_the_thing(False){linesep}"
f"2.\tSomeClass.do_the_thing(True){linesep}"
"3.\tsome_func('hello')"
)

with pytest.raises(AssertionError) as error_info:
decoy.verify(
stub_func("hello"),
stub.do_the_thing(True),
)

assert str(error_info.value) == (
f"Expected calls:{linesep}"
f"1.\tsome_func('hello'){linesep}"
f"2.\tSomeClass.do_the_thing(True){linesep}"
f"Found 3 calls:{linesep}"
f"1.\tSomeClass.do_the_thing(False){linesep}"
f"2.\tSomeClass.do_the_thing(True){linesep}"
"3.\tsome_func('hello')"
)

with pytest.raises(AssertionError) as error_info:
decoy.verify(
stub.do_the_thing(True),
stub.do_the_thing(True),
stub_func("hello"),
)

assert str(error_info.value) == (
f"Expected calls:{linesep}"
f"1.\tSomeClass.do_the_thing(True){linesep}"
f"2.\tSomeClass.do_the_thing(True){linesep}"
f"3.\tsome_func('hello'){linesep}"
f"Found 3 calls:{linesep}"
f"1.\tSomeClass.do_the_thing(False){linesep}"
f"2.\tSomeClass.do_the_thing(True){linesep}"
"3.\tsome_func('hello')"
)
22 changes: 22 additions & 0 deletions tests/typing/test_mypy_plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
decoy = Decoy()
decoy.verify(noop())
- case: suppresses_multiple_func_returns_value_in_verify
main: |
from decoy import Decoy
def noop() -> None:
pass
decoy = Decoy()
decoy.verify(noop(), noop())
- case: does_not_suppress_other_errors
main: |
from decoy import Decoy
Expand All @@ -40,3 +50,15 @@
stub = decoy.when(do_thing("hello"))
out: |
main:7: error: Too many arguments for "do_thing" [call-arg]
- case: does_not_suppress_other_errors_with_multiple_verify_calls
main: |
from decoy import Decoy
def noop() -> None:
pass
decoy = Decoy()
decoy.verify(noop(), noop("hello"))
out: |
main:7: error: Too many arguments for "noop" [call-arg]

0 comments on commit 0c4206e

Please sign in to comment.