Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 72 additions & 19 deletions decoy/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 "<Captor>"
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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: I like this idea of a generic class with a type-cast property to pass to the stub configuration. I think it might be hard to land such a change in Decoy v2, but I think it could be a cool API to implement for all matchers in Decoy v3, if needed.

That way, there could be a public, generic Matcher class, and we could drop a lot of the complexity/silliness around the existing marchers API

"""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 "<Captor>"

@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()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the capture method is not mandatory, and assert "foobar" == captor still works exactly the same. However, I believe that it would be beneficial to be consistent in the docs and always use the capture method. This way, users don't need to change how do they use the captor when specifying a match_type.

print(captor.value) # "foobar"
print(captor.values) # ["foobar"]
```
"""
return _Captor()
return _Captor(match_type)
5 changes: 4 additions & 1 deletion docs/usage/matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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()))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, I propose to be consistent in the docs and always use the capture method.


# trigger the listener
event_handler = captor.value # or, equivalently, captor.values[0]
Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 24 additions & 2 deletions tests/typing/test_typing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,37 @@
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]))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added for completeness

reveal_type(matchers.StringMatching("foobar"))
reveal_type(matchers.ErrorMatching(RuntimeError))
reveal_type(matchers.Captor())
out: |
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]"