Skip to content

Commit 48741e4

Browse files
Alejandro-FAmcous
andauthored
feat(matchers): add strictly-typed ValueCaptor (#270)
Co-authored-by: Michael Cousins <michael@cousins.io>
1 parent 54c92fb commit 48741e4

File tree

4 files changed

+112
-41
lines changed

4 files changed

+112
-41
lines changed

decoy/matchers.py

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,17 @@ def test_logger_called(decoy: Decoy):
2828
"""
2929

3030
from re import compile as compile_re
31-
from typing import Any, List, Mapping, Optional, Pattern, Type, TypeVar, cast
32-
33-
__all__ = [
34-
"Anything",
35-
"Captor",
36-
"ErrorMatching",
37-
"IsA",
38-
"IsNot",
39-
"StringMatching",
40-
]
31+
from typing import (
32+
Any,
33+
Generic,
34+
List,
35+
Mapping,
36+
Optional,
37+
Pattern,
38+
Type,
39+
TypeVar,
40+
cast,
41+
)
4142

4243

4344
class _AnythingOrNone:
@@ -361,12 +362,32 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT:
361362
return cast(ErrorT, _ErrorMatching(error, match))
362363

363364

364-
class _Captor:
365+
CapturedT = TypeVar("CapturedT")
366+
367+
368+
class ValueCaptor(Generic[CapturedT]):
369+
"""Match anything, capturing its value for further assertions.
370+
371+
Compare against the `matcher` property to capture a value.
372+
The last captured value is available via `captor.value`,
373+
while all captured values are stored in `captor.values`.
374+
375+
!!! example
376+
```python
377+
captor = ValueCaptor[str]()
378+
assert "foobar" == captor.matcher
379+
print(captor.value) # "foobar"
380+
print(captor.values) # ["foobar"]
381+
```
382+
"""
383+
384+
_values: List[object]
385+
365386
def __init__(self) -> None:
366-
self._values: List[Any] = []
387+
self._values = []
367388

368389
def __eq__(self, target: object) -> bool:
369-
"""Capture compared value, always returning True."""
390+
"""Captors are always "equal" to a given target."""
370391
self._values.append(target)
371392
return True
372393

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

377398
@property
378-
def value(self) -> Any:
379-
"""Get the captured value.
399+
def matcher(self) -> CapturedT:
400+
"""Match anything, capturing its value.
401+
402+
This method exists as a type-checking convenience.
403+
"""
404+
return cast(CapturedT, self)
405+
406+
@property
407+
def value(self) -> object:
408+
"""The latest captured value.
380409
381410
Raises:
382-
AssertionError: if no value was captured.
411+
AssertionError: no value has been captured.
383412
"""
384413
if len(self._values) == 0:
385414
raise AssertionError("No value captured by captor.")
386415

387416
return self._values[-1]
388417

389418
@property
390-
def values(self) -> List[Any]:
391-
"""Get all captured values."""
419+
def values(self) -> List[object]:
420+
"""All captured values."""
392421
return self._values
393422

394423

395424
def Captor() -> Any:
396-
"""Match anything, capturing its value.
397-
398-
The last captured value will be set to `captor.value`. All captured
399-
values will be placed in the `captor.values` list, which can be
400-
helpful if a captor needs to be triggered multiple times.
425+
"""Match anything, capturing its value for further assertions.
401426
402-
!!! example
403-
```python
404-
captor = Captor()
405-
assert "foobar" == captor
406-
print(captor.value) # "foobar"
407-
print(captor.values) # ["foobar"]
408-
```
427+
!!! tip
428+
Prefer [decoy.matchers.ValueCaptor][], which has better type annotations.
409429
"""
410-
return _Captor()
430+
return ValueCaptor()

docs/usage/matchers.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w
99
| Matcher | Description |
1010
| --------------------------------- | ---------------------------------------------------- |
1111
| [decoy.matchers.Anything][] | Matches any value that isn't `None` |
12+
| [decoy.matchers.AnythingOrNone][] | Matches any value including `None` |
1213
| [decoy.matchers.DictMatching][] | Matches a `dict` based on some of its values |
14+
| [decoy.matchers.ListMatching][] | Matches a `list` based on some of its values |
1315
| [decoy.matchers.ErrorMatching][] | Matches an `Exception` based on its type and message |
1416
| [decoy.matchers.HasAttributes][] | Matches an object based on its attributes |
1517
| [decoy.matchers.IsA][] | Matches using `isinstance` |
1618
| [decoy.matchers.IsNot][] | Matches anything that isn't a given value |
1719
| [decoy.matchers.StringMatching][] | Matches a string against a regular expression |
18-
| [decoy.matchers.Captor][] | Captures the comparison value (see below) |
20+
| [decoy.matchers.ValueCaptor][] | Captures the comparison value (see below) |
1921

2022
## Basic usage
2123

@@ -45,7 +47,7 @@ def test_log_warning(decoy: Decoy):
4547

4648
## Capturing values
4749

48-
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][].
50+
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][].
4951

5052
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.
5153

@@ -61,13 +63,13 @@ from .event_consumer import EventConsumer
6163
def test_event_listener(decoy: Decoy):
6264
event_source = decoy.mock(cls=EventSource)
6365
subject = EventConsumer(event_source=event_source)
64-
captor = matchers.Captor()
66+
captor = matchers.ValueCaptor()
6567

6668
# subject registers its listener when started
6769
subject.start_consuming()
6870

6971
# verify listener attached and capture the listener
70-
decoy.verify(event_source.register(event_listener=captor))
72+
decoy.verify(event_source.register(event_listener=captor.matcher))
7173

7274
# trigger the listener
7375
event_handler = captor.value # or, equivalently, captor.values[0]
@@ -77,7 +79,7 @@ def test_event_listener(decoy: Decoy):
7779
assert subject.has_heard_event is True
7880
```
7981

80-
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.
82+
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.
8183

8284
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).
8385

tests/test_matchers.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Matcher tests."""
22

33
from collections import namedtuple
4-
from typing import Any, List, NamedTuple
4+
from typing import List, NamedTuple
55

66
import pytest
77

@@ -151,17 +151,44 @@ def test_error_matching_matcher() -> None:
151151
assert RuntimeError("ah!") != matchers.ErrorMatching(RuntimeError, "ah$")
152152

153153

154-
def test_captor_matcher() -> None:
154+
def test_captor_matcher_legacy() -> None:
155155
"""It should have a captor matcher that captures the compared value."""
156156
captor = matchers.Captor()
157-
comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()]
157+
comparisons: List[object] = [
158+
1,
159+
False,
160+
None,
161+
{},
162+
[],
163+
("hello", "world"),
164+
SomeClass(),
165+
]
158166

159167
for i, compare in enumerate(comparisons):
160168
assert compare == captor
161169
assert captor.value is compare
162170
assert captor.values == comparisons[0 : i + 1]
163171

164172

173+
def test_argument_captor_matcher() -> None:
174+
"""It should have a strictly-typed value captor matcher."""
175+
captor = matchers.ValueCaptor[object]()
176+
comparisons: List[object] = [
177+
1,
178+
False,
179+
None,
180+
{},
181+
[],
182+
("hello", "world"),
183+
SomeClass(),
184+
]
185+
186+
for i, compare in enumerate(comparisons):
187+
assert compare == captor.matcher
188+
assert captor.value is compare
189+
assert captor.values == comparisons[0 : i + 1]
190+
191+
165192
def test_captor_matcher_raises_if_no_value() -> None:
166193
"""The captor matcher should raise an assertion error if no value."""
167194
captor = matchers.Captor()

tests/typing/test_typing.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,37 @@
118118
from decoy import matchers
119119
120120
reveal_type(matchers.Anything())
121+
reveal_type(matchers.AnythingOrNone())
121122
reveal_type(matchers.IsA(str))
122123
reveal_type(matchers.IsNot(str))
124+
reveal_type(matchers.HasAttributes({"foo": "bar"}))
125+
reveal_type(matchers.DictMatching({"foo": 1}))
126+
reveal_type(matchers.ListMatching([1]))
123127
reveal_type(matchers.StringMatching("foobar"))
124128
reveal_type(matchers.ErrorMatching(RuntimeError))
125129
reveal_type(matchers.Captor())
126130
out: |
127131
main:3: note: Revealed type is "Any"
128132
main:4: note: Revealed type is "Any"
129133
main:5: note: Revealed type is "Any"
130-
main:6: note: Revealed type is "builtins.str"
131-
main:7: note: Revealed type is "builtins.RuntimeError"
134+
main:6: note: Revealed type is "Any"
135+
main:7: note: Revealed type is "Any"
132136
main:8: note: Revealed type is "Any"
137+
main:9: note: Revealed type is "Any"
138+
main:10: note: Revealed type is "builtins.str"
139+
main:11: note: Revealed type is "builtins.RuntimeError"
140+
main:12: note: Revealed type is "Any"
141+
142+
- case: captor_mimics_types
143+
main: |
144+
from decoy import matchers
145+
146+
captor = matchers.ValueCaptor[str]()
147+
148+
reveal_type(captor.matcher)
149+
reveal_type(captor.value)
150+
reveal_type(captor.values)
151+
out: |
152+
main:5: note: Revealed type is "builtins.str"
153+
main:6: note: Revealed type is "builtins.object"
154+
main:7: note: Revealed type is "builtins.list[builtins.object]"

0 commit comments

Comments
 (0)