From f17dd050690dea3e6f736fe6298bf4db38aec1ee Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Tue, 22 Jun 2021 08:52:50 -0400 Subject: [PATCH] feat(verify): allow specification of exact call count (#27) Closes #23 --- decoy/__init__.py | 35 ++++++++--- docs/usage/verify.md | 18 ++++++ tests/test_verify.py | 143 +++++++++++++++++++++++++++++-------------- 3 files changed, 143 insertions(+), 53 deletions(-) diff --git a/decoy/__init__.py b/decoy/__init__.py index 699bcff..2517ba3 100644 --- a/decoy/__init__.py +++ b/decoy/__init__.py @@ -150,7 +150,7 @@ 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. @@ -158,6 +158,10 @@ def verify(self, *_rehearsal_results: Any) -> None: 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 @@ -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() @@ -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 @@ -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, diff --git a/docs/usage/verify.md b/docs/usage/verify.md index c48ae30..e5873dc 100644 --- a/docs/usage/verify.md +++ b/docs/usage/verify.md @@ -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. diff --git a/tests/test_verify.py b/tests/test_verify.py index b0e69c6..63fac4d 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -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}" @@ -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}" @@ -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}" @@ -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}" @@ -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) == ( @@ -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) == ( @@ -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) == ( @@ -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)