diff --git a/decoy/matchers.py b/decoy/matchers.py index dd821ff..9174ccc 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -28,16 +28,17 @@ def test_logger_called(decoy: Decoy): """ from re import compile as compile_re -from typing import Any, List, Mapping, Optional, Pattern, Type, TypeVar, cast - -__all__ = [ - "Anything", - "Captor", - "ErrorMatching", - "IsA", - "IsNot", - "StringMatching", -] +from typing import ( + Any, + Generic, + List, + Mapping, + Optional, + Pattern, + Type, + TypeVar, + cast, +) class _AnythingOrNone: @@ -361,12 +362,32 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: return cast(ErrorT, _ErrorMatching(error, match)) -class _Captor: +CapturedT = TypeVar("CapturedT") + + +class ValueCaptor(Generic[CapturedT]): + """Match anything, capturing its value for further assertions. + + Compare against the `matcher` property to capture a value. + The last captured value is available via `captor.value`, + while all captured values are stored in `captor.values`. + + !!! example + ```python + captor = ValueCaptor[str]() + assert "foobar" == captor.matcher + print(captor.value) # "foobar" + print(captor.values) # ["foobar"] + ``` + """ + + _values: List[object] + def __init__(self) -> None: - self._values: List[Any] = [] + self._values = [] def __eq__(self, target: object) -> bool: - """Capture compared value, always returning True.""" + """Captors are always "equal" to a given target.""" self._values.append(target) return True @@ -375,11 +396,19 @@ def __repr__(self) -> str: return "" @property - def value(self) -> Any: - """Get the captured value. + def matcher(self) -> CapturedT: + """Match anything, capturing its value. + + This method exists as a type-checking convenience. + """ + return cast(CapturedT, self) + + @property + def value(self) -> object: + """The latest captured value. Raises: - AssertionError: if no value was captured. + AssertionError: no value has been captured. """ if len(self._values) == 0: raise AssertionError("No value captured by captor.") @@ -387,24 +416,15 @@ def value(self) -> Any: return self._values[-1] @property - def values(self) -> List[Any]: - """Get all captured values.""" + def values(self) -> List[object]: + """All captured values.""" return self._values def Captor() -> Any: - """Match anything, capturing its value. - - The last captured value will be set to `captor.value`. All captured - values will be placed in the `captor.values` list, which can be - helpful if a captor needs to be triggered multiple times. + """Match anything, capturing its value for further assertions. - !!! example - ```python - captor = Captor() - assert "foobar" == captor - print(captor.value) # "foobar" - print(captor.values) # ["foobar"] - ``` + !!! tip + Prefer [decoy.matchers.ValueCaptor][], which has better type annotations. """ - return _Captor() + return ValueCaptor() diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index 05b5896..5910a16 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -9,13 +9,15 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w | Matcher | Description | | --------------------------------- | ---------------------------------------------------- | | [decoy.matchers.Anything][] | Matches any value that isn't `None` | +| [decoy.matchers.AnythingOrNone][] | Matches any value including `None` | | [decoy.matchers.DictMatching][] | Matches a `dict` based on some of its values | +| [decoy.matchers.ListMatching][] | Matches a `list` based on some of its values | | [decoy.matchers.ErrorMatching][] | Matches an `Exception` based on its type and message | | [decoy.matchers.HasAttributes][] | Matches an object based on its attributes | | [decoy.matchers.IsA][] | Matches using `isinstance` | | [decoy.matchers.IsNot][] | Matches anything that isn't a given value | | [decoy.matchers.StringMatching][] | Matches a string against a regular expression | -| [decoy.matchers.Captor][] | Captures the comparison value (see below) | +| [decoy.matchers.ValueCaptor][] | Captures the comparison value (see below) | ## Basic usage @@ -45,7 +47,7 @@ def test_log_warning(decoy: Decoy): ## Capturing values -When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.Captor][]. +When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.ValueCaptor][]. For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered. @@ -61,13 +63,13 @@ from .event_consumer import EventConsumer def test_event_listener(decoy: Decoy): event_source = decoy.mock(cls=EventSource) subject = EventConsumer(event_source=event_source) - captor = matchers.Captor() + captor = matchers.ValueCaptor() # subject registers its listener when started subject.start_consuming() # verify listener attached and capture the listener - decoy.verify(event_source.register(event_listener=captor)) + decoy.verify(event_source.register(event_listener=captor.matcher)) # trigger the listener event_handler = captor.value # or, equivalently, captor.values[0] @@ -77,7 +79,7 @@ def test_event_listener(decoy: Decoy): assert subject.has_heard_event is True ``` -This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.Captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. +This is a pretty verbose way of writing a test, so in general, approach using `matchers.ValueCaptor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 1f2555c..a0b8942 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -1,7 +1,7 @@ """Matcher tests.""" from collections import namedtuple -from typing import Any, List, NamedTuple +from typing import List, NamedTuple import pytest @@ -151,10 +151,18 @@ def test_error_matching_matcher() -> None: assert RuntimeError("ah!") != matchers.ErrorMatching(RuntimeError, "ah$") -def test_captor_matcher() -> None: +def test_captor_matcher_legacy() -> None: """It should have a captor matcher that captures the compared value.""" captor = matchers.Captor() - comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] + comparisons: List[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] for i, compare in enumerate(comparisons): assert compare == captor @@ -162,6 +170,25 @@ def test_captor_matcher() -> None: assert captor.values == comparisons[0 : i + 1] +def test_argument_captor_matcher() -> None: + """It should have a strictly-typed value captor matcher.""" + captor = matchers.ValueCaptor[object]() + comparisons: List[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] + + for i, compare in enumerate(comparisons): + assert compare == captor.matcher + assert captor.value is compare + assert captor.values == comparisons[0 : i + 1] + + def test_captor_matcher_raises_if_no_value() -> None: """The captor matcher should raise an assertion error if no value.""" captor = matchers.Captor() diff --git a/tests/typing/test_typing.yml b/tests/typing/test_typing.yml index 707a484..628720d 100644 --- a/tests/typing/test_typing.yml +++ b/tests/typing/test_typing.yml @@ -118,8 +118,12 @@ from decoy import matchers reveal_type(matchers.Anything()) + reveal_type(matchers.AnythingOrNone()) reveal_type(matchers.IsA(str)) reveal_type(matchers.IsNot(str)) + reveal_type(matchers.HasAttributes({"foo": "bar"})) + reveal_type(matchers.DictMatching({"foo": 1})) + reveal_type(matchers.ListMatching([1])) reveal_type(matchers.StringMatching("foobar")) reveal_type(matchers.ErrorMatching(RuntimeError)) reveal_type(matchers.Captor()) @@ -127,6 +131,24 @@ main:3: note: Revealed type is "Any" main:4: note: Revealed type is "Any" main:5: note: Revealed type is "Any" - main:6: note: Revealed type is "builtins.str" - main:7: note: Revealed type is "builtins.RuntimeError" + main:6: note: Revealed type is "Any" + main:7: note: Revealed type is "Any" main:8: note: Revealed type is "Any" + main:9: note: Revealed type is "Any" + main:10: note: Revealed type is "builtins.str" + main:11: note: Revealed type is "builtins.RuntimeError" + main:12: note: Revealed type is "Any" + +- case: captor_mimics_types + main: | + from decoy import matchers + + captor = matchers.ValueCaptor[str]() + + reveal_type(captor.matcher) + reveal_type(captor.value) + reveal_type(captor.values) + out: | + main:5: note: Revealed type is "builtins.str" + main:6: note: Revealed type is "builtins.object" + main:7: note: Revealed type is "builtins.list[builtins.object]"