Skip to content

Commit f8c49eb

Browse files
authored
feat(matchers): add argument captor matcher (#19)
Closes #17
1 parent 8b5aeaf commit f8c49eb

File tree

11 files changed

+508
-313
lines changed

11 files changed

+508
-313
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,41 @@ def test_log_warning(decoy: Decoy):
198198
logger.warn(matchers.StringMatching("request ID abc123efg456"))
199199
)
200200
```
201+
202+
#### Capturing values with `matchers.captor`
203+
204+
When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given decoy.
205+
206+
For example, our test subject may register an anonymous event listener handler with a dependency, and we want to test our subject's behavior when the event listener is triggered.
207+
208+
```py
209+
import pytest
210+
from typing import cast, Optional
211+
from decoy import Decoy, matchers
212+
213+
from .event_source import EventSource
214+
from .event_consumer import EventConsumer
215+
216+
217+
def test_event_listener(decoy: Decoy):
218+
event_source = decoy.create_decoy(spec=EventSource)
219+
subject = EventConsumer(event_source=event_source)
220+
captor = matchers.Captor()
221+
222+
# subject registers its listener when started
223+
subject.start_consuming()
224+
225+
# verify listener attached and capture the listener
226+
decoy.verify(event_source.register(event_listener=captor))
227+
228+
# trigger the listener
229+
event_handler = captor.value # or, equivalently, captor.values[0]
230+
231+
assert subject.has_heard_event is False
232+
event_handler()
233+
assert subject.has_heard_event is True
234+
```
235+
236+
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 anonymous / private functions.
237+
238+
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).

decoy/matchers.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_logger_called(decoy: Decoy):
2525
equality comparisons for stubbing and verification.
2626
"""
2727
from re import compile as compile_re
28-
from typing import cast, Any, Optional, Pattern, Type
28+
from typing import cast, Any, List, Optional, Pattern, Type
2929

3030

3131
__all__ = [
@@ -34,6 +34,7 @@ def test_logger_called(decoy: Decoy):
3434
"IsNot",
3535
"StringMatching",
3636
"ErrorMatching",
37+
"Captor",
3738
]
3839

3940

@@ -197,3 +198,52 @@ def ErrorMatching(error: Type[Exception], match: Optional[str] = None) -> Except
197198
```
198199
"""
199200
return cast(Exception, _ErrorMatching(error, match))
201+
202+
203+
class _Captor:
204+
def __init__(self) -> None:
205+
self._values: List[Any] = []
206+
207+
def __eq__(self, target: object) -> bool:
208+
"""Capture compared value, always returning True."""
209+
self._values.append(target)
210+
return True
211+
212+
def __repr__(self) -> str:
213+
"""Return a string representation of the matcher."""
214+
return "<Captor>"
215+
216+
@property
217+
def value(self) -> Any:
218+
"""Get the captured value.
219+
220+
Raises:
221+
AssertionError: if no value was captured.
222+
"""
223+
if len(self._values) == 0:
224+
raise AssertionError("No value captured by captor.")
225+
226+
return self._values[-1]
227+
228+
@property
229+
def values(self) -> List[Any]:
230+
"""Get all captured values."""
231+
return self._values
232+
233+
234+
def Captor() -> Any:
235+
"""Match anything, capturing its value.
236+
237+
The last captured value will be set to `captor.value`. All captured
238+
values will be placed in the `captor.values` list, which can be
239+
helpful if a captor needs to be triggered multiple times.
240+
241+
Example:
242+
```python
243+
captor = Captor()
244+
assert "foobar" == captor
245+
print(captor.value) # "foobar"
246+
print(captor.values) # ["foobar"]
247+
```
248+
"""
249+
return _Captor()

decoy/mypy/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Decoy mypy plugin entrypoint."""
2+
from .plugin import plugin
3+
4+
__all__ = ["plugin"]
File renamed without changes.

decoy/spy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def __getattr__(self, name: str) -> Any:
8383
# according to Python, `get_type_hints` will raise.
8484
# Rather than fail to create a spy with an inscrutable error,
8585
# gracefully fallback to a specification-less spy.
86-
hints = get_type_hints(self._spec) # type: ignore[arg-type]
86+
hints = get_type_hints(self._spec)
8787
child_spec = getattr(
8888
self._spec,
8989
name,

mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
files = decoy,tests
33
strict = True
44
show_error_codes = True
5-
plugins = decoy.mypy
5+
plugins = decoy/mypy/plugin.py

0 commit comments

Comments
 (0)