Skip to content

Commit

Permalink
feat(verify): allow specification of exact call count (#27)
Browse files Browse the repository at this point in the history
Closes #23
  • Loading branch information
mcous authored Jun 22, 2021
1 parent 2555850 commit f17dd05
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 53 deletions.
35 changes: 27 additions & 8 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,18 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:

return stub

def verify(self, *_rehearsal_results: Any) -> None:
def verify(self, *_rehearsal_results: Any, times: Optional[int] = None) -> None:
"""Verify a decoy was called using one or more rehearsals.
See [verification usage guide](../usage/verify) for more details.
Arguments:
_rehearsal_results: The return value of rehearsals, unused except
to determine how many rehearsals to verify.
times: How many times the call should appear. If `times` is specifed,
the call count must match exactly, otherwise the call must appear
at least once. The `times` argument must be used with exactly one
rehearsal.
Example:
```python
Expand Down Expand Up @@ -187,14 +191,24 @@ def test_create_something(decoy: Decoy):
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)]
if times is None:
for i in range(len(all_calls)):
call = all_calls[i]
call_list = all_calls[i : i + len(rehearsals)]

if call == rehearsals[0] and call_list == rehearsals:
if call == rehearsals[0] and call_list == rehearsals:
return None

elif len(rehearsals) == 1:
matching_calls = [call for call in all_calls if call == rehearsals[0]]

if len(matching_calls) == times:
return None

raise AssertionError(self._build_verify_error(rehearsals, all_calls))
else:
raise ValueError("Cannot verify multiple rehearsals when using times")

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

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

def _build_verify_error(
self, rehearsals: Sequence[SpyCall], all_calls: Sequence[SpyCall]
self,
rehearsals: Sequence[SpyCall],
all_calls: Sequence[SpyCall],
times: Optional[int] = None,
) -> str:
rehearsals_len = len(rehearsals)
rehearsals_plural = rehearsals_len != 1
times_plural = times is not None and times != 1

all_calls_len = len(all_calls)
all_calls_plural = all_calls_len != 1
Expand All @@ -239,7 +257,8 @@ def _build_verify_error(

return linesep.join(
[
f"Expected call{'s' if rehearsals_plural else ''}:",
f"Expected {f'{times} ' if times is not None else ''}"
f"call{'s' if rehearsals_plural or times_plural else ''}:",
rehearsals_printout,
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
all_calls_printout,
Expand Down
18 changes: 18 additions & 0 deletions docs/usage/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,21 @@ decoy.verify(
handler.call_second_procedure("world"),
)
```

## Verifying a call count

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.

```python
decoy.verify(
handler.should_be_called_twice(),
times=2,
)

decoy.verify(
handler.should_never_be_called(),
times=0,
)
```

You may only use the `times` argument with single rehearsal.
143 changes: 98 additions & 45 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@

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

stub("hello")
stub("goodbye")
spy("hello")
spy("goodbye")

decoy.verify(stub("hello"))
decoy.verify(stub("goodbye"))
decoy.verify(spy("hello"))
decoy.verify(spy("goodbye"))

with pytest.raises(AssertionError) as error_info:
decoy.verify(stub("fizzbuzz"))
decoy.verify(spy("fizzbuzz"))

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

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

stub.foo("hello")
stub.foo("goodbye")
stub.bar(0, 1.0, "2")
stub.bar(3, 4.0, "5")
spy.foo("hello")
spy.foo("goodbye")
spy.bar(0, 1.0, "2")
spy.bar(3, 4.0, "5")

decoy.verify(stub.foo("hello"))
decoy.verify(stub.foo("goodbye"))
decoy.verify(spy.foo("hello"))
decoy.verify(spy.foo("goodbye"))

decoy.verify(stub.bar(0, 1.0, "2"))
decoy.verify(stub.bar(3, 4.0, "5"))
decoy.verify(spy.bar(0, 1.0, "2"))
decoy.verify(spy.bar(3, 4.0, "5"))

with pytest.raises(AssertionError) as error_info:
decoy.verify(stub.foo("fizzbuzz"))
decoy.verify(spy.foo("fizzbuzz"))

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

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

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

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

stub("hello")
spy("hello")

decoy.verify(stub(matchers.StringMatching("ell")))
decoy.verify(spy(matchers.StringMatching("ell")))

with pytest.raises(AssertionError) as error_info:
decoy.verify(stub(matchers.StringMatching("^ell")))
decoy.verify(spy(matchers.StringMatching("^ell")))

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

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

stub.child.foo("hello")
stub.child.bar(0, 1.0, "2")
spy.child.foo("hello")
spy.child.bar(0, 1.0, "2")

decoy.verify(stub.child.foo("hello"))
decoy.verify(stub.child.bar(0, 1.0, "2"))
decoy.verify(spy.child.foo("hello"))
decoy.verify(spy.child.bar(0, 1.0, "2"))

with pytest.raises(AssertionError):
decoy.verify(stub.foo("fizzbuzz"))
decoy.verify(spy.foo("fizzbuzz"))


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

stub.do_the_thing(True)
spy.do_the_thing(True)

decoy.verify(stub.do_the_thing(True))
decoy.verify(spy.do_the_thing(True))

with pytest.raises(AssertionError):
decoy.verify(stub.do_the_thing(False))
decoy.verify(spy.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)
spy = decoy.create_decoy(spec=SomeClass)
spy_func = decoy.create_decoy_func(spec=some_func)

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

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

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

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

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

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

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

assert str(error_info.value) == (
Expand All @@ -174,3 +174,56 @@ def test_verify_multiple_calls(decoy: Decoy) -> None:
f"2.\tSomeClass.do_the_thing(True){linesep}"
"3.\tsome_func('hello')"
)


def test_verify_call_count(decoy: Decoy) -> None:
"""It should be able to verify a specific call count."""
spy = decoy.create_decoy_func(spec=some_func)

spy("hello")
spy("hello")

decoy.verify(spy("hello"))
decoy.verify(spy("hello"), times=2)
decoy.verify(spy("goodbye"), times=0)

with pytest.raises(AssertionError) as error_info:
decoy.verify(spy("hello"), times=0)

assert str(error_info.value) == (
f"Expected 0 calls:{linesep}"
f"1.\tsome_func('hello'){linesep}"
f"Found 2 calls:{linesep}"
f"1.\tsome_func('hello'){linesep}"
"2.\tsome_func('hello')"
)

with pytest.raises(AssertionError) as error_info:
decoy.verify(spy("hello"), times=1)

assert str(error_info.value) == (
f"Expected 1 call:{linesep}"
f"1.\tsome_func('hello'){linesep}"
f"Found 2 calls:{linesep}"
f"1.\tsome_func('hello'){linesep}"
"2.\tsome_func('hello')"
)

with pytest.raises(AssertionError) as error_info:
decoy.verify(spy("hello"), times=3)

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


def test_verify_call_count_raises_multiple_rehearsals(decoy: Decoy) -> None:
"""It should not be able to verify call count if multiple rehearsals used."""
spy = decoy.create_decoy_func(spec=some_func)

with pytest.raises(ValueError, match="multiple rehearsals"):
decoy.verify(spy("hello"), spy("goodbye"), times=1)

0 comments on commit f17dd05

Please sign in to comment.