diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96f2656..7b1f323 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ poetry run poe format Decoy's documentation is built with [mkdocs][], which you can use to preview the documentation site locally. ```bash -poetry run docs +poetry run poe docs ``` ## Deploying diff --git a/decoy/matchers.py b/decoy/matchers.py index a31942c..1840cad 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -27,12 +27,15 @@ def test_logger_called(decoy: Decoy): equality comparisons (`==`) for stubbing and verification. """ +from abc import abstractmethod from re import compile as compile_re -from typing import cast, Any, List, Mapping, Optional, Pattern, Type, TypeVar +from typing import cast, overload, Any, List, Mapping, Optional, Pattern, Protocol, Type, TypeVar __all__ = [ "Anything", + "AnythingOrNone", + "ArgumentCaptor", "Captor", "ErrorMatching", "IsA", @@ -362,50 +365,100 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: return cast(ErrorT, _ErrorMatching(error, match)) -class _Captor: - def __init__(self) -> None: - self._values: List[Any] = [] +CapturedT = TypeVar("CapturedT") - def __eq__(self, target: object) -> bool: - """Capture compared value, always returning True.""" - self._values.append(target) - return True - def __repr__(self) -> str: - """Return a string representation of the matcher.""" - return "" +class ArgumentCaptor(Protocol[CapturedT]): + """Captures method arguments for later assertions. + + Use the `capture()` method to pass the captor as an argument when verifying a method. + The last captured argument is available via `captor.value`, and all captured arguments + are stored in `captor.values`. + + !!! example + ```python + captor: ArgumentCaptor[str] = Captor(match_type=str) + assert "foobar" == captor.capture() + assert 2 != captor.capture() + print(captor.value) # "foobar" + print(captor.values) # ["foobar"] + ``` + """ + def capture(self) -> CapturedT: + """Match anything, capturing its value. + + !!! note + This method exists solely to match the target argument type and suppress type checker warnings. + """ + return cast(CapturedT, self) @property - def value(self) -> Any: + @abstractmethod + def value(self) -> CapturedT: """Get the captured value. Raises: AssertionError: if no value was captured. """ + + @property + @abstractmethod + def values(self) -> List[CapturedT]: + """Get all captured values.""" + + +class _Captor(ArgumentCaptor[CapturedT]): + _values: List[CapturedT] + _match_type: Type[CapturedT] + + def __init__(self, match_type: Type[CapturedT]) -> None: + self._values = [] + self._match_type = match_type + + def __eq__(self, target: object) -> bool: + if isinstance(target, self._match_type): + self._values.append(target) + return True + return False + + def __repr__(self) -> str: + """Return a string representation of the matcher.""" + return "" + + @property + def value(self) -> CapturedT: if len(self._values) == 0: raise AssertionError("No value captured by captor.") - return self._values[-1] @property - def values(self) -> List[Any]: - """Get all captured values.""" + def values(self) -> List[CapturedT]: return self._values -def Captor() -> Any: - """Match anything, capturing its value. +MatchT = TypeVar("MatchT") + + +@overload +def Captor() -> Any: ... +@overload +def Captor(match_type: Type[MatchT]) -> ArgumentCaptor[MatchT]: ... +def Captor(match_type: Type[object] = object) -> Any: + """Match anything, capturing its value for further assertions. 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. + Arguments: + match_type: Optional type to match. + !!! example ```python captor = Captor() - assert "foobar" == captor + assert "foobar" == captor.capture() print(captor.value) # "foobar" print(captor.values) # ["foobar"] ``` """ - return _Captor() + return _Captor(match_type) diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index 05b5896..e3256f2 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -9,6 +9,7 @@ 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.ErrorMatching][] | Matches an `Exception` based on its type and message | | [decoy.matchers.HasAttributes][] | Matches an object based on its attributes | @@ -67,7 +68,7 @@ def test_event_listener(decoy: Decoy): 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.capture())) # trigger the listener event_handler = captor.value # or, equivalently, captor.values[0] @@ -81,6 +82,8 @@ This is a pretty verbose way of writing a test, so in general, you may want to a 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). +If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.Captor][] (e.g. `Captor(match_type=str)`). + ## Writing custom matchers You can write your own matcher class and use it wherever you would use a built-in matcher. All you need to do is define a class with an `__eq__` method: diff --git a/tests/test_matchers.py b/tests/test_matchers.py index c99be73..6ea9be5 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -154,6 +154,18 @@ def test_captor_matcher() -> None: assert captor.values == comparisons[0 : i + 1] +def test_captor_matcher_with_match_type() -> None: + """It should have a captor matcher that captures the compared value if it matches the type.""" + captor = matchers.Captor(int) + comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] + + for compare in comparisons: + is_equal = compare == captor.capture() + assert is_equal == isinstance(compare, int) + + assert captor.values == [1, False] + + 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..b6e1d6d 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.Captor(str) + + reveal_type(captor) + reveal_type(captor.capture()) + reveal_type(captor.values) + out: | + main:5: note: Revealed type is "decoy.matchers.ArgumentCaptor[builtins.str]" + main:6: note: Revealed type is "builtins.str" + main:7: note: Revealed type is "builtins.list[builtins.str]"