Skip to content
Merged
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
82 changes: 51 additions & 31 deletions decoy/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -375,36 +396,35 @@ def __repr__(self) -> str:
return "<Captor>"

@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.")

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()
12 changes: 7 additions & 5 deletions docs/usage/matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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]
Expand All @@ -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).

Expand Down
33 changes: 30 additions & 3 deletions tests/test_matchers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Matcher tests."""

from collections import namedtuple
from typing import Any, List, NamedTuple
from typing import List, NamedTuple

import pytest

Expand Down Expand Up @@ -151,17 +151,44 @@ 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
assert captor.value is compare
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()
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
Contributor 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.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]"
Loading