From d1b742de185d3c00d00f267613c7ec03b833c1e4 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Mon, 30 Nov 2020 11:55:06 -0500 Subject: [PATCH] fix: replace unittest.mock usage with custom spy (#3) MagicMock behavior in creation and usage didn't end up fitting cleanly into the Decoy API, especially with asynchronous fakes and fakes that involve several layers of classes. This commit replaces MagicMock with a very similar Spy class, that basically takes exactly what Decoy needs from unittest.mock. --- README.md | 40 ++++---- decoy/__init__.py | 105 ++++++++++----------- decoy/matchers.py | 27 +++++- decoy/mock.py | 29 ------ decoy/registry.py | 141 +++++++++++++++++----------- decoy/spy.py | 112 ++++++++++++++++++++++ decoy/stub.py | 16 ++-- docs/api.md | 6 +- docs/contributing.md | 17 +++- docs/why.md | 77 ++++++++++----- mkdocs.yml | 6 ++ poetry.lock | 132 +++++++++++++------------- pyproject.toml | 6 +- tests/common.py | 11 +++ tests/test_decoy.py | 75 ++------------- tests/test_registry.py | 123 +++++++++++------------- tests/test_spy.py | 206 +++++++++++++++++++++++++++++++++++++++++ tests/test_verify.py | 4 +- tests/test_when.py | 38 +++++++- 19 files changed, 763 insertions(+), 408 deletions(-) delete mode 100644 decoy/mock.py create mode 100644 decoy/spy.py create mode 100644 tests/test_spy.py diff --git a/README.md b/README.md index 5216adc..745413b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ def decoy() -> Decoy: return Decoy() ``` -Why is this important? The `Decoy` container tracks every test double that is created during a test so that you can define assertions using fully-typed rehearsals of your test double. It's important to wipe this slate clean for every test so you don't leak memory or have any state preservation between tests. +Why is this important? The `Decoy` container tracks every fake that is created during a test so that you can define assertions using fully-typed rehearsals of your test double. It's important to wipe this slate clean for every test so you don't leak memory or have any state preservation between tests. [pytest]: https://docs.pytest.org/ @@ -115,16 +115,16 @@ from decoy import Decoy, verify from .logger import Logger def log_warning(msg: str, logger: Logger) -> None: - logger.warn(msg) + logger.warn(msg) def test_log_warning(decoy: Decoy): - logger = decoy.create_decoy(spec=Logger) + logger = decoy.create_decoy(spec=Logger) - # call code under test - some_result = log_warning("oh no!", logger) + # call code under test + some_result = log_warning("oh no!", logger) - # verify double called correctly - decoy.verify(logger.warn("oh no!")) + # verify double called correctly + decoy.verify(logger.warn("oh no!")) ``` ### Matchers @@ -141,19 +141,19 @@ from decoy import Decoy, matchers from .logger import Logger def log_warning(msg: str, logger: Logger) -> None: - logger.warn(msg) + logger.warn(msg) def test_log_warning(decoy: Decoy): - logger = decoy.create_decoy(spec=Logger) - - # call code under test - some_result = log_warning( - "Oh no, something horrible went wrong with request ID abc123efg456", - logger=logger - ) - - # verify double called correctly - decoy.verify( - mock_logger.warn(matchers.StringMatching("something went wrong")) - ) + logger = decoy.create_decoy(spec=Logger) + + # call code under test + some_result = log_warning( + "Oh no, something horrible went wrong with request ID abc123efg456", + logger=logger + ) + + # verify double called correctly + decoy.verify( + mock_logger.warn(matchers.StringMatching("something went wrong")) + ) ``` diff --git a/decoy/__init__.py b/decoy/__init__.py index 04eec09..f493576 100644 --- a/decoy/__init__.py +++ b/decoy/__init__.py @@ -1,17 +1,16 @@ """Decoy test double stubbing and verification library.""" -from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type +from typing import cast, Any, Optional, Type -from .mock import create_decoy_mock, DecoyMock from .registry import Registry +from .spy import create_spy, SpyCall from .stub import Stub -from .types import Call, ClassT, FuncT, ReturnT +from .types import ClassT, FuncT, ReturnT class Decoy: """Decoy test double state container.""" _registry: Registry - _last_decoy_id: Optional[int] def __init__(self) -> None: """Initialize the state container for test doubles and stubs. @@ -29,17 +28,18 @@ def decoy() -> Decoy: ``` """ self._registry = Registry() - self._last_decoy_id = None def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT: """Create a class decoy for `spec`. Arguments: spec: A class definition that the decoy should mirror. - is_async: Set to `True` if the class has `await`able methods. + is_async: Force the returned spy to be asynchronous. In most cases, + this argument is unnecessary, since the Spy will use `spec` to + determine if a method should be asynchronous. Returns: - A `MagicMock` or `AsyncMock`, typecast as an instance of `spec`. + A spy typecast as an instance of `spec`. Example: ```python @@ -49,8 +49,12 @@ def test_get_something(decoy: Decoy): ``` """ - decoy = self._create_and_register_mock(spec=spec, is_async=is_async) - return cast(ClassT, decoy) + spy = create_spy( + spec=spec, is_async=is_async, handle_call=self._handle_spy_call + ) + self._registry.register_spy(spy) + + return cast(ClassT, spy) def create_decoy_func( self, spec: Optional[FuncT] = None, *, is_async: bool = False @@ -59,10 +63,12 @@ def create_decoy_func( Arguments: spec: A function that the decoy should mirror. - is_async: Set to `True` if the function is `await`able. + is_async: Force the returned spy to be asynchronous. In most cases, + this argument is unnecessary, since the Spy will use `spec` to + determine if the function should be asynchronous. Returns: - A `MagicMock` or `AsyncMock`, typecast as the function given for `spec`. + A spy typecast as `spec` function. Example: ```python @@ -71,9 +77,12 @@ def test_create_something(decoy: Decoy): # ... ``` """ - decoy = self._create_and_register_mock(spec=spec, is_async=is_async) + spy = create_spy( + spec=spec, is_async=is_async, handle_call=self._handle_spy_call + ) + self._registry.register_spy(spy) - return cast(FuncT, decoy) + return cast(FuncT, spy) def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]: """Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call. @@ -84,18 +93,24 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]: _rehearsal_result: The return value of a rehearsal, used for typechecking. Returns: - A Stub to configure using `then_return` or `then_raise`. + A stub to configure using `then_return` or `then_raise`. Example: ```python db = decoy.create_decoy(spec=Database) decoy.when(db.exists("some-id")).then_return(True) ``` + + Note: + The "rehearsal" is an actual call to the test fake. The fact that + the call is written inside `when` is purely for typechecking and + API sugar. Decoy will pop the last call to _any_ fake off its + call stack, which will end up being the call inside `when`. """ - decoy_id, rehearsal = self._pop_last_rehearsal() + rehearsal = self._pop_last_rehearsal() stub = Stub[ReturnT](rehearsal=rehearsal) - self._registry.register_stub(decoy_id, stub) + self._registry.register_stub(rehearsal.spy_id, stub) return stub @@ -116,50 +131,32 @@ def test_create_something(decoy: Decoy): decoy.verify(gen_id("model-prefix_")) ``` - """ - decoy_id, rehearsal = self._pop_last_rehearsal() - decoy = self._registry.get_decoy(decoy_id) - - if decoy is None: - raise ValueError("verify must be called with a decoy rehearsal") - - decoy.assert_has_calls([rehearsal]) - def _create_and_register_mock(self, spec: Any, is_async: bool) -> DecoyMock: - decoy = create_decoy_mock(is_async=is_async, spec=spec) - decoy_id = self._registry.register_decoy(decoy) - side_effect = self._create_track_call_and_act(decoy_id) - - decoy.configure_mock(side_effect=side_effect) - - return decoy - - def _pop_last_rehearsal(self) -> Tuple[int, Call]: - decoy_id = self._last_decoy_id + Note: + The "rehearsal" is an actual call to the test fake. The fact that + the call is written inside `verify` is purely for typechecking and + API sugar. Decoy will pop the last call to _any_ fake off its + call stack, which will end up being the call inside `verify`. + """ + rehearsal = self._pop_last_rehearsal() - if decoy_id is not None: - rehearsal = self._registry.pop_decoy_last_call(decoy_id) - self._last_decoy_id = None + assert rehearsal in self._registry.get_calls_by_spy_id(rehearsal.spy_id) - if rehearsal is not None: - return (decoy_id, rehearsal) + def _pop_last_rehearsal(self) -> SpyCall: + rehearsal = self._registry.pop_last_call() - raise ValueError("when/verify must be called with a decoy rehearsal") + if rehearsal is None: + raise ValueError("when/verify must be called with a decoy rehearsal") - def _create_track_call_and_act(self, decoy_id: int) -> Callable[..., Any]: - def track_call_and_act( - *args: Sequence[Any], **_kwargs: Mapping[str, Any] - ) -> Any: - self._last_decoy_id = decoy_id + return rehearsal - last_call = self._registry.peek_decoy_last_call(decoy_id) - stubs = reversed(self._registry.get_decoy_stubs(decoy_id)) + def _handle_spy_call(self, call: SpyCall) -> Any: + self._registry.register_call(call) - if last_call is not None: - for stub in stubs: - if stub._rehearsal == last_call: - return stub._act() + stubs = self._registry.get_stubs_by_spy_id(call.spy_id) - return None + for stub in reversed(stubs): + if stub._rehearsal == call: + return stub._act() - return track_call_and_act + return None diff --git a/decoy/matchers.py b/decoy/matchers.py index bf6d1ea..a1efb37 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -1,4 +1,29 @@ -"""Matcher helpers.""" +"""Matcher helpers. + +A "matcher" is a helper class with an `__eq__` method defined. Use them +anywhere in your test where you would use an actual value for equality +(`==`) comparision. + +Matchers help you loosen assertions where strict adherence to an exact value +is not relevent to what you're trying to test. + +Example: + ```python + from decoy import Decoy, matchers + + # ... + + def test_logger_called(decoy: Decoy): + # ... + decoy.verify( + logger.log(msg=matchers.StringMatching("hello")) + ) + ``` + +Note: + Identity comparisons (`is`) will not work with matchers. Decoy only uses + equality comparisons for stubbing and verification. +""" from re import compile as compile_re from typing import cast, Any, Optional, Pattern, Type diff --git a/decoy/mock.py b/decoy/mock.py deleted file mode 100644 index 6e43a60..0000000 --- a/decoy/mock.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Custom unittest.mock subclasses.""" - -from mock import AsyncMock, MagicMock -from typing import Any, Union - - -class SyncDecoyMock(MagicMock): - """MagicMock variant where all child mocks use the parent side_effect.""" - - def _get_child_mock(self, **kwargs: Any) -> Any: - return super()._get_child_mock(**kwargs, side_effect=self.side_effect) - - -class AsyncDecoyMock(AsyncMock): # type: ignore[misc] - """AsyncMock variant where all child mocks use the parent side_effect.""" - - def _get_child_mock(self, **kwargs: Any) -> Any: - return super()._get_child_mock(**kwargs, side_effect=self.side_effect) - - -DecoyMock = Union[SyncDecoyMock, AsyncDecoyMock] - - -def create_decoy_mock(is_async: bool, **kwargs: Any) -> DecoyMock: - """Create a MagicMock or AsyncMock.""" - if is_async is False: - return SyncDecoyMock(**kwargs) - else: - return AsyncDecoyMock(**kwargs) diff --git a/decoy/registry.py b/decoy/registry.py index da4ade4..dbd56c2 100644 --- a/decoy/registry.py +++ b/decoy/registry.py @@ -1,68 +1,97 @@ -"""Decoy and stub configuration registry.""" -from mock import Mock -from weakref import finalize, WeakValueDictionary -from typing import TYPE_CHECKING, Any, Dict, List, Optional +"""Decoy and stub registration module.""" +from collections import deque +from weakref import finalize +from typing import Any, Deque, Dict, List, Optional -from .mock import DecoyMock +from .spy import BaseSpy, SpyCall from .stub import Stub -from .types import Call - -if TYPE_CHECKING: - DecoyMapType = WeakValueDictionary[int, DecoyMock] class Registry: """Decoy and stub configuration registry. - The Registry collects weak-references to decoys created in order to - automatically clean up stub configurations when a decoy goes out of scope. + The registry collects weak-references to spies and spy calls created in + order to clean up stub configurations when a spy goes out of scope. """ + _calls: Deque[SpyCall] + def __init__(self) -> None: """Initialize a Registry.""" - self._decoy_map: DecoyMapType = WeakValueDictionary() self._stub_map: Dict[int, List[Stub[Any]]] = {} - - def register_decoy(self, decoy: DecoyMock) -> int: - """Register a decoy for tracking.""" - decoy_id = id(decoy) - - self._decoy_map[decoy_id] = decoy - finalize(decoy, self._clear_decoy_stubs, decoy_id) - - return decoy_id - - def register_stub(self, decoy_id: int, stub: Stub[Any]) -> None: - """Register a stub for tracking.""" - stub_list = self.get_decoy_stubs(decoy_id) - self._stub_map[decoy_id] = stub_list + [stub] - - def get_decoy(self, decoy_id: int) -> Optional[Mock]: - """Get a decoy by identifier.""" - return self._decoy_map.get(decoy_id) - - def get_decoy_stubs(self, decoy_id: int) -> List[Stub[Any]]: - """Get a decoy's stub list by identifier.""" - return self._stub_map.get(decoy_id, []) - - def peek_decoy_last_call(self, decoy_id: int) -> Optional[Call]: - """Get a decoy's last call.""" - decoy = self._decoy_map.get(decoy_id, None) - - if decoy is not None and len(decoy.mock_calls) > 0: - return decoy.mock_calls[-1] - - return None - - def pop_decoy_last_call(self, decoy_id: int) -> Optional[Call]: - """Pop a decoy's last call off of its call stack.""" - decoy = self._decoy_map.get(decoy_id, None) - - if decoy is not None and len(decoy.mock_calls) > 0: - return decoy.mock_calls.pop() - - return None - - def _clear_decoy_stubs(self, decoy_id: int) -> None: - if decoy_id in self._stub_map: - del self._stub_map[decoy_id] + self._calls = deque() + + @property + def last_call(self) -> Optional[SpyCall]: + """Peek the last call in the registry's call stack.""" + if len(self._calls) > 0: + return self._calls[-1] + else: + return None + + def pop_last_call(self) -> Optional[SpyCall]: + """Pop the last call made off the registry's call stack.""" + if len(self._calls) > 0: + return self._calls.pop() + else: + return None + + def get_stubs_by_spy_id(self, spy_id: int) -> List[Stub[Any]]: + """Get a spy's stub list by identifier. + + Arguments: + spy_id: The unique identifer of the Spy to look up. + + Returns: + The list of stubs matching the given Spy. + """ + return self._stub_map.get(spy_id, []) + + def get_calls_by_spy_id(self, spy_id: int) -> List[SpyCall]: + """Get a spy's call list by identifier. + + Arguments: + spy_id: The unique identifer of the Spy to look up. + + Returns: + The list of calls matching the given Spy. + """ + return [c for c in self._calls if c.spy_id == spy_id] + + def register_spy(self, spy: BaseSpy) -> int: + """Register a spy for tracking. + + Arguments: + spy: The spy to track. + + Returns: + The spy's unique identifier. + """ + spy_id = id(spy) + finalize(spy, self._clear_spy, spy_id) + return spy_id + + def register_call(self, call: SpyCall) -> None: + """Register a spy call for tracking. + + Arguments: + call: The call to track. + """ + self._calls.append(call) + + def register_stub(self, spy_id: int, stub: Stub[Any]) -> None: + """Register a stub for tracking. + + Arguments: + spy_id: The unique identifer of the Spy to look up. + stub: The stub to track. + """ + stub_list = self.get_stubs_by_spy_id(spy_id) + self._stub_map[spy_id] = stub_list + [stub] + + def _clear_spy(self, spy_id: int) -> None: + """Clear all references to a given spy_id.""" + self._calls = deque([c for c in self._calls if c.spy_id != spy_id]) + + if spy_id in self._stub_map: + del self._stub_map[spy_id] diff --git a/decoy/spy.py b/decoy/spy.py new file mode 100644 index 0000000..ec83869 --- /dev/null +++ b/decoy/spy.py @@ -0,0 +1,112 @@ +"""Spy objects. + +Classes in this module are heavily inspired by the +[unittest.mock library](https://docs.python.org/3/library/unittest.mock.html). +""" +from __future__ import annotations +from dataclasses import dataclass +from inspect import isclass, iscoroutinefunction +from typing import get_type_hints, Any, Callable, Dict, Optional, Tuple + + +@dataclass(frozen=True) +class SpyCall: + """A dataclass representing a call to a spy. + + Attributes: + spy_id: Identifier of the spy that made the call + args: Arguments list of the call + kwargs: Keyword arguments list of the call + """ + + spy_id: int + args: Tuple[Any, ...] + kwargs: Dict[str, Any] + + +CallHandler = Callable[[SpyCall], Any] + + +class BaseSpy: + """Spy object base class. + + - Pretends to be another class, if another class is given as a spec + - Lazily constructs child spies when an attribute is accessed + """ + + def __init__(self, handle_call: CallHandler, spec: Optional[Any] = None) -> None: + """Initialize a BaseSpy from a call handler and an optional spec object.""" + self._spec = spec + self._handle_call: CallHandler = handle_call + self._spy_children: Dict[str, BaseSpy] = {} + + @property # type: ignore[misc] + def __class__(self) -> Any: + """Ensure Spy can pass `instanceof` checks.""" + if isclass(self._spec): + return self._spec + + return type(self) + + def __getattr__(self, name: str) -> Any: + """Get a property of the spy. + + Lazily constructs child spies, basing them on type hints if available. + """ + if name in self._spy_children: + return self._spy_children[name] + + child_spec = None + + if isclass(self._spec): + hints = get_type_hints(self._spec) # type: ignore[arg-type] + child_spec = getattr( + self._spec, + name, + hints.get(name), + ) + + if isinstance(child_spec, property): + hints = get_type_hints(child_spec.fget) + child_spec = hints.get("return") + + spy = create_spy( + handle_call=self._handle_call, + spec=child_spec, + ) + + self._spy_children[name] = spy + + return spy + + +class Spy(BaseSpy): + """An object that records all calls made to itself and its children.""" + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Handle a call to the spy.""" + return self._handle_call(SpyCall(id(self), args, kwargs)) + + +class AsyncSpy(Spy): + """An object that records all async. calls made to itself and its children.""" + + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Handle a call to the spy asynchronously.""" + return self._handle_call(SpyCall(id(self), args, kwargs)) + + +def create_spy( + handle_call: CallHandler, + spec: Optional[Any] = None, + is_async: bool = False, +) -> Any: + """Create a Spy from a spec. + + Functions and classes passed to `spec` will be inspected (and have any type + annotations inspected) to ensure `AsyncSpy`'s are returned where necessary. + """ + if iscoroutinefunction(spec) or is_async is True: + return AsyncSpy(handle_call) + + return Spy(handle_call=handle_call, spec=spec) diff --git a/decoy/stub.py b/decoy/stub.py index 467d631..0fd5f18 100644 --- a/decoy/stub.py +++ b/decoy/stub.py @@ -2,17 +2,18 @@ from collections import deque from typing import Deque, Generic, Optional -from .types import Call, ReturnT +from .spy import SpyCall +from .types import ReturnT class Stub(Generic[ReturnT]): """A rehearsed test double stub that may perform an action.""" - _rehearsal: Call + _rehearsal: SpyCall _values: Deque[ReturnT] _error: Optional[Exception] - def __init__(self, rehearsal: Call) -> None: + def __init__(self, rehearsal: SpyCall) -> None: """Initialize the stub from a rehearsal call. Arguments: @@ -40,12 +41,13 @@ def then_raise(self, error: Exception) -> None: See [stubbing](/#stubbing) for more details. - Note: setting a stub to raise will prevent you from writing new - rehearsals, because they will raise. If you need to make more calls - to `when`, you'll need to wrap your rehearsal in a `try`. - Arguments: error: The error to raise. + + Note: + Setting a stub to raise will prevent you from writing new + rehearsals, because they will raise. If you need to make more calls + to `when`, you'll need to wrap your rehearsal in a `try`. """ self._value = None self._error = error diff --git a/docs/api.md b/docs/api.md index ac3475c..2ce70ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,7 @@ # API Reference -::: decoy +::: decoy.Decoy + +::: decoy.stub.Stub + +::: decoy.matchers diff --git a/docs/contributing.md b/docs/contributing.md index 750d79e..a8a0f56 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -24,10 +24,10 @@ Decoy's tests are run using [pytest][]. poetry run pytest ``` -You can also run tests in watch mode using [pytest-watch][]. +You can also run tests in watch mode using [pytest-xdist][]. ```bash -poetry run pytest-watch +poetry run pytest --looponfail ``` ### Checks @@ -47,12 +47,21 @@ Decoy's source code is formatted using [black][]. poetry run black . ``` +### Documentation + +Decoy's documentation is built with [mkdocs][], which you can use to preview the documentation site locally. + +```bash +poetry run mkdocs serve +``` + [poetry]: https://python-poetry.org/ [pytest]: https://docs.pytest.org/ -[pytest-watch]: https://github.com/joeyespo/pytest-watch +[pytest-xdist]: https://github.com/pytest-dev/pytest-xdist [mypy]: https://mypy.readthedocs.io [flake8]: https://flake8.pycqa.org [black]: https://black.readthedocs.io +[mkdocs]: https://www.mkdocs.org/ ## Deploying @@ -76,7 +85,7 @@ git add pyproject.toml # replace ${release_version} with the actual version string git commit -m "chore(release): ${release_version}" git tag -a v${release_version} -m "chore(release): ${release_version}" -git push --folow-tags +git push --follow-tags ``` [semantic versioning]: https://semver.org/ diff --git a/docs/why.md b/docs/why.md index 048f7f3..1faff41 100644 --- a/docs/why.md +++ b/docs/why.md @@ -2,16 +2,54 @@ The Python testing world already has [unittest.mock][] for creating fakes, so why is a library like Decoy even necessary? -The `MagicMock` class (and friends) provided by the Python standard library are great, which is why Decoy uses them under the hood. They are, however: +The `Mock` class (and friends) provided by the Python standard library are great! They are, however: -- Not very opinionated in how they are used -- Not able to adhere to type annotations of your actual interfaces +- Flexible to the point of sometimes feeling un-opinionated +- Not geared towards mimicking the type annotations of your actual interfaces +- Geared towards call-then-assert-called test patterns -At its core, Decoy wraps `MagicMock` with a more opinionated, strictly typed interface to encourage well written tests and, ultimately, well written source code. +At its core, Decoy uses test fakes that stripped down, slightly more opinionated versions of `Mock` that are designed to work with type annotations. [unittest.mock]: https://docs.python.org/3/library/unittest.mock.html -## Recommended Reading +## Opinions + +Decoy is meant to be an "opinionated" library. The opinions that Decoy (and its API) holds, are: + +### Test doubles should be complete mocks of dependencies + +- `unittest.mock.Mock` can partially mock using the `wraps` parameter +- Decoy considers [partial mocks][] to be code smell +- If you find yourself wanted to partially mock a dependency, consider: + - Is the dependency in question properly factored according to [single-responsibility principle][]? Complexity should often move to another dependency rather than to a new method of the same object + - Are you using a partial mock to ease testing with a complex (or third-party) dependency, and if so, is that test better as an integration test? + +[partial mocks]: https://github.com/testdouble/contributing-tests/wiki/Partial-Mock +[single-responsibilty principle]: https://en.wikipedia.org/wiki/Single-responsibility_principle + +### Without configuration, test doubles should no-op + +- `unittest.mock.Mock`'s default return value from an unconfigured method is another `Mock` +- Other mocking libraries exist that `assert` on an unconfigured call +- In either case, the test subject ends up more likely to trigger a `raise` that is unrelated to the test in question + +### Tests should be organized into ["arrange", "act", and "assert"][arrange act assert] phases + +- `unittest.mock` is well-geared towards this usage, especially with spies +- When stubbing data dependencies, though, `unittest.mock`'s most straightforward usage involves separate "arrange" and "assert" steps for the same `Mock` + - i.e. set `return_value`, then later assert that it was called correctly + +[arrange act assert]: https://github.com/testdouble/contributing-tests/wiki/Arrange-Act-Assert + +### Stubs should not return unconditionally + +- Setting `unittest.mock.Mock::return_value` is unconditional, in that all subsequent calls to that mock will receive that return value, even if they are called incorrectly + - When setting `return_value`, you always need to check `assert_called_with` to really make sure the code under test is behaving +- Decoy's API requires stubs to be configured with arguments to return anything, which means the code under test won't receive data unless it uses the dependency correctly + - This prevents false test passes when the dependency is _not_ called correctly and the `assert` step is missing or forgotten + - It also makes an "assert stub was called correctly" step redundant because the data from the stubbed dependency should be fed into the output of the code under test + +### Recommended reading Decoy is heavily influenced by and/or stolen from the [testdouble.js][] and [Mockito][] projects. These projects have both been around for a while (especially Mockito), and their docs are valuable resources for learning how to test and mock effectively. @@ -24,7 +62,7 @@ If you have the time, you should check out: [testdouble.js]: https://github.com/testdouble/testdouble.js [mockito]: https://site.mockito.org/ -## Creating and Using a Stub +## Comparing MagicMock and Decoy for Stubbing A [stub][] is a specific type of test fake that: @@ -37,7 +75,6 @@ For the following examples, let's assume: - We're testing a library to deal with `Book` objects - That library depends on a `Database` provider to store objects in a database -- That library depends on a `Logger` interface to log access [stub]: https://en.wikipedia.org/wiki/Test_stub @@ -143,16 +180,15 @@ def mock_book() -> Book: return cast(Book, {"title": "The Metamorphosis"}) ``` -Decoy wraps `MagicMock` with a simple, rehearsal-based stubbing interface. +Decoy uses a simple, rehearsal-based stubbing interface. ```python def test_get_book(decoy: Decoy, mock_database: Database, mock_book: Book) -> None: # arrange stub to return mock data when called correctly - decoy.when( - # this is a rehearsal, which might look a little funny at first - # it's an actual call to the test double that Decoy captures - mock_database.get_by_id("unique-id") - ).then_return(mock_book) + # the input argument specification is in the form of a "rehearsal," + # which might look a little funny at first, but ends up being quite + # easy to work and reason with once you're used to it + decoy.when(mock_database.get_by_id("unique-id")).then_return(mock_book) # exercise the code under test result = get_book("unique-id", database=mock_database) @@ -167,13 +203,15 @@ Benefits to note over the vanilla `MagicMock` versions: - Inputs and outputs from the dependency are specified together - You specify the inputs _before_ outputs, which can be easier to grok - The entire test fits neatly into "arrange", "act", and "assert" phases -- Decoy casts test doubles as the actual types they are mimicking +- Decoy typecasts test doubles as the actual types they are mimicking - This means stub configuration arguments _and_ return values are type-checked -## Creating and Using a Spy +## Comparing MagicMock and Decoy for Spying A [spy][] is another kind of test fake that simply records calls made to it. Spies are useful to model dependencies that are used for their side-effects rather than providing or calculating data. +In this example, we add the requirement that our bookshelf library logs access using a `Logger` interface. + [spy]: https://github.com/testdouble/contributing-tests/wiki/Spy ### Spying with a MagicMock @@ -181,17 +219,12 @@ A [spy][] is another kind of test fake that simply records calls made to it. Spi ```python # setup from pytest -from decoy import Decoy +from unittest.mock import MagicMock from my_lib.logger import Logger from my_lib.bookshelf import get_book, Book -@pytest.fixture -def decoy() -> Decoy: - return Decoy() - - @pytest.fixture def mock_logger() -> MagicMock: return MagicMock(spec=Logger) @@ -241,6 +274,6 @@ def test_get_book_logs(decoy: Decoy, mock_logger: Logger) -> None: # assert logger spy was called correctly # uses the same type-checked "rehearsal" syntax as stubbing decoy.verify( - mock_logger(level="debug", msg="Get book unique-id") + mock_logger.log(level="debug", msg="Get book unique-id") ) ``` diff --git a/mkdocs.yml b/mkdocs.yml index 43f3f55..e255127 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,8 +23,14 @@ plugins: handlers: python: rendering: + show_root_heading: True + show_object_full_path: True show_source: False + heading_level: 2 markdown_extensions: + - toc: + permalink: "#" + - admonition - pymdownx.highlight - pymdownx.superfences diff --git a/poetry.lock b/poetry.lock index 0a17b06..83b46a3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,11 @@ +[[package]] +category = "dev" +description = "apipkg: namespace control and lazy-import mechanism" +name = "apipkg" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.5" + [[package]] category = "dev" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." @@ -79,6 +87,7 @@ version = "7.1.2" [[package]] category = "dev" description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" name = "colorama" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -86,11 +95,17 @@ version = "0.4.4" [[package]] category = "dev" -description = "Pythonic argument parser, that will make you smile" -name = "docopt" +description = "execnet: rapid multi-Python deployment" +name = "execnet" optional = false -python-versions = "*" -version = "0.6.2" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.7.1" + +[package.dependencies] +apipkg = ">=1.4" + +[package.extras] +testing = ["pre-commit"] [[package]] category = "dev" @@ -321,19 +336,6 @@ pytkdocs = ">=0.2.0,<0.10.0" [package.extras] tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "mkdocs-material (>=5.5.12,<6.0.0)", "mypy (>=0.782,<0.783)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"] -[[package]] -category = "main" -description = "Rolling backport of unittest.mock for all Pythons" -name = "mock" -optional = false -python-versions = ">=3.6" -version = "4.0.2" - -[package.extras] -build = ["twine", "wheel", "blurb"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - [[package]] category = "dev" description = "Optional static typing for Python" @@ -387,11 +389,10 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" +version = "20.7" [package.dependencies] pyparsing = ">=2.0.2" -six = "*" [[package]] category = "dev" @@ -401,14 +402,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.8.1" -[[package]] -category = "dev" -description = "File system general utilities" -name = "pathtools" -optional = false -python-versions = "*" -version = "0.1.2" - [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -529,17 +522,32 @@ testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] [[package]] category = "dev" -description = "Local continuous test runner with pytest and watchdog." -name = "pytest-watch" +description = "run tests in isolated forked subprocesses" +name = "pytest-forked" optional = false -python-versions = "*" -version = "4.2.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.3.0" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +category = "dev" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +name = "pytest-xdist" +optional = false +python-versions = ">=3.5" +version = "2.1.0" [package.dependencies] -colorama = ">=0.3.3" -docopt = ">=0.4.0" -pytest = ">=2.6.4" -watchdog = ">=0.6.0" +execnet = ">=1.1" +pytest = ">=6.0.0" +pytest-forked = "*" + +[package.extras] +psutil = ["psutil (>=3.0)"] +testing = ["filelock"] [[package]] category = "dev" @@ -616,7 +624,7 @@ marker = "python_version > \"2.7\"" name = "tqdm" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "4.53.0" +version = "4.54.0" [package.extras] dev = ["py-make (>=0.1.0)", "twine", "argopt", "pydoc-markdown", "wheel"] @@ -637,20 +645,6 @@ optional = false python-versions = "*" version = "3.7.4.3" -[[package]] -category = "dev" -description = "Filesystem events monitoring" -name = "watchdog" -optional = false -python-versions = "*" -version = "0.10.4" - -[package.dependencies] -pathtools = ">=0.1.1" - -[package.extras] -watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] - [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" @@ -665,11 +659,15 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] -content-hash = "85d940ae0ced9f2e661912b5a096b6ccbe0b9d9f6e4978d0a01fb5040de56b2e" +content-hash = "d035493bcb82eba8c4e65d16ef5165c0702ab3c23c4babcb0917326b1c55721b" lock-version = "1.0" python-versions = "^3.7" [metadata.files] +apipkg = [ + {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, + {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -698,8 +696,9 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] -docopt = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +execnet = [ + {file = "execnet-1.7.1-py2.py3-none-any.whl", hash = "sha256:d4efd397930c46415f62f8a31388d6be4f27a91d7550eb79bc64a756e0056547"}, + {file = "execnet-1.7.1.tar.gz", hash = "sha256:cacb9df31c9680ec5f95553976c4da484d407e85e41c83cb812aa014f0eddc50"}, ] flake8 = [ {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, @@ -798,10 +797,6 @@ mkdocstrings = [ {file = "mkdocstrings-0.13.6-py3-none-any.whl", hash = "sha256:79d2a16b8c86a467bdc84846dfb90552551d2d9fd35578df9f92de13fb3b4537"}, {file = "mkdocstrings-0.13.6.tar.gz", hash = "sha256:79e5086c79f60d1ae1d4b222f658d348ebdd6302c970cc06ee8394f2839d7c4d"}, ] -mock = [ - {file = "mock-4.0.2-py3-none-any.whl", hash = "sha256:3f9b2c0196c60d21838f307f5825a7b86b678cedc58ab9e50a8988187b4d81e0"}, - {file = "mock-4.0.2.tar.gz", hash = "sha256:dd33eb70232b6118298d516bbcecd26704689c386594f0f3c4f13867b2c56f72"}, -] mypy = [ {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, @@ -826,16 +821,13 @@ nltk = [ {file = "nltk-3.5.zip", hash = "sha256:845365449cd8c5f9731f7cb9f8bd6fd0767553b9d53af9eb1b3abf7700936b35"}, ] packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, + {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"}, + {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"}, ] pathspec = [ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] -pathtools = [ - {file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"}, -] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -876,8 +868,13 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, ] -pytest-watch = [ - {file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"}, +pytest-forked = [ + {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"}, + {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"}, +] +pytest-xdist = [ + {file = "pytest-xdist-2.1.0.tar.gz", hash = "sha256:82d938f1a24186520e2d9d3a64ef7d9ac7ecdf1a0659e095d18e596b8cbd0672"}, + {file = "pytest_xdist-2.1.0-py3-none-any.whl", hash = "sha256:7c629016b3bb006b88ac68e2b31551e7becf173c76b977768848e2bbed594d90"}, ] pytkdocs = [ {file = "pytkdocs-0.9.0-py3-none-any.whl", hash = "sha256:12ed87d71b3518301c7b8c12c1a620e4b481a9d2fca1038aea665955000fad7f"}, @@ -1001,8 +998,8 @@ tornado = [ {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, ] tqdm = [ - {file = "tqdm-4.53.0-py2.py3-none-any.whl", hash = "sha256:5ff3f5232b19fa4c5531641e480b7fad4598819f708a32eb815e6ea41c5fa313"}, - {file = "tqdm-4.53.0.tar.gz", hash = "sha256:3d3f1470d26642e88bd3f73353cb6ff4c51ef7d5d7efef763238f4bc1f7e4e81"}, + {file = "tqdm-4.54.0-py2.py3-none-any.whl", hash = "sha256:9e7b8ab0ecbdbf0595adadd5f0ebbb9e69010e0bd48bbb0c15e550bf2a5292df"}, + {file = "tqdm-4.54.0.tar.gz", hash = "sha256:5c0d04e06ccc0da1bd3fa5ae4550effcce42fcad947b4a6cafa77bdc9b09ff22"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, @@ -1032,9 +1029,6 @@ typing-extensions = [ {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, ] -watchdog = [ - {file = "watchdog-0.10.4.tar.gz", hash = "sha256:e38bffc89b15bafe2a131f0e1c74924cf07dcec020c2e0a26cccd208831fcd43"}, -] zipp = [ {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, diff --git a/pyproject.toml b/pyproject.toml index a5cf043..398b3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -mock = "^4.0.2" [tool.poetry.dev-dependencies] black = "^20.8b1" @@ -32,9 +31,12 @@ mkdocs-material = "^6.1.6" mkdocstrings = "^0.13.6" mypy = "^0.790" pytest = "^6.1.2" -pytest-watch = "^4.2.0" pytest-asyncio = "^0.14.0" +pytest-xdist = "^2.1.0" [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" + +[tool.pytest.ini_options] +addopts = "--color=yes" diff --git a/tests/common.py b/tests/common.py index ed223fa..19ebcef 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,4 +1,5 @@ """Common test interfaces.""" +from typing import Any class SomeClass: @@ -46,6 +47,16 @@ async def do_the_thing(self, flag: bool) -> None: ... +def noop(*args: Any, **kwargs: Any) -> Any: + """No-op.""" + pass + + def some_func(val: str) -> str: """Test function.""" return "can't touch this" + + +async def some_async_func(val: str) -> str: + """Async test function.""" + return "can't touch this" diff --git a/tests/test_decoy.py b/tests/test_decoy.py index 5db3e7f..c5d376b 100644 --- a/tests/test_decoy.py +++ b/tests/test_decoy.py @@ -1,78 +1,19 @@ """Tests for the Decoy double creator.""" -from mock import MagicMock, AsyncMock -from typing import Callable - from decoy import Decoy -from .common import SomeClass, SomeNestedClass, some_func - - -def test_decoy_creates_magicmock(decoy: Decoy) -> None: - """It should be able to create a MagicMock from a class.""" - stub = decoy.create_decoy(spec=SomeClass) - - assert isinstance(stub, MagicMock) - assert isinstance(stub, SomeClass) - +from decoy.spy import Spy -def test_decoy_creates_nested_magicmock(decoy: Decoy) -> None: - """It should be able to create a nested MagicMock from a class.""" - stub = decoy.create_decoy(spec=SomeNestedClass) +from .common import SomeClass, some_func - assert isinstance(stub, MagicMock) - assert isinstance(stub, SomeNestedClass) - assert isinstance(stub.child, MagicMock) +def test_decoy_creates_spy(decoy: Decoy) -> None: + """It should be able to create a Spy from a class.""" + stub = decoy.create_decoy(spec=SomeClass) -def test_decoy_creates_asyncmock(decoy: Decoy) -> None: - """It should be able to create an AsyncMock from a class.""" - stub = decoy.create_decoy(spec=SomeClass, is_async=True) - - assert isinstance(stub, AsyncMock) # type: ignore[misc] assert isinstance(stub, SomeClass) -def test_decoy_creates_func_magicmock(decoy: Decoy) -> None: - """It should be able to create a MagicMock from a function.""" - stub = decoy.create_decoy_func(spec=some_func, is_async=False) - - assert isinstance(stub, MagicMock) - - -def test_decoy_creates_func_asyncmock(decoy: Decoy) -> None: - """It should be able to create an AsyncMock from a function.""" - stub = decoy.create_decoy_func(spec=some_func, is_async=True) - - assert isinstance(stub, AsyncMock) # type: ignore[misc] - - -def test_decoy_creates_func_without_spec(decoy: Decoy) -> None: - """It should be able to create a function without a spec.""" - stub: Callable[..., str] = decoy.create_decoy_func() - - assert isinstance(stub, MagicMock) - - -def test_decoy_functions_return_none(decoy: Decoy) -> None: - """Decoy functions should return None by default.""" +def test_decoy_creates_func_spy(decoy: Decoy) -> None: + """It should be able to create a Spy from a class.""" stub = decoy.create_decoy_func(spec=some_func) - assert stub("hello") is None - - -def test_decoy_methods_return_none(decoy: Decoy) -> None: - """Decoy classes should return None by default.""" - stub = decoy.create_decoy(spec=SomeClass) - - assert stub.foo("hello") is None - assert stub.bar(1, 2.0, "3") is None - - -def test_decoy_nested_methods_return_none(decoy: Decoy) -> None: - """Decoy classes should return None by default.""" - stub = decoy.create_decoy(spec=SomeNestedClass) - - print(dir(SomeNestedClass)) - print(dir(stub)) - - assert stub.foo("hello") is None - assert stub.child.bar(1, 2.0, "3") is None + assert isinstance(stub, Spy) diff --git a/tests/test_registry.py b/tests/test_registry.py index 709958c..2b6b35d 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,12 +1,13 @@ """Tests for the decoy registry.""" import pytest -from gc import collect as collect_garbage -from mock import call, MagicMock, Mock from typing import Any +from decoy.spy import create_spy, SpyCall from decoy.registry import Registry from decoy.stub import Stub +from .common import noop + @pytest.fixture def registry() -> Registry: @@ -14,94 +15,76 @@ def registry() -> Registry: return Registry() -def test_register_decoy(registry: Registry) -> None: - """It should register a decoy and return a unique identifier.""" - decoy_1 = MagicMock() - decoy_2 = MagicMock() - decoy_id_1 = registry.register_decoy(decoy_1) - decoy_id_2 = registry.register_decoy(decoy_2) - - assert decoy_id_1 != decoy_id_2 - assert registry.get_decoy(decoy_id_1) == decoy_1 - assert registry.get_decoy(decoy_id_2) == decoy_2 - - -def test_get_decoy_with_no_decoy(registry: Registry) -> None: - """Peek should return None if the ID does not match.""" - result = registry.get_decoy(42) - assert result is None - - -def test_peek_decoy_last_call(registry: Registry) -> None: - """It should be able to peek the last decoy call by ID.""" - decoy = MagicMock() - decoy_id = registry.register_decoy(decoy) - - decoy.method(foo="hello", bar="world") - - result = registry.peek_decoy_last_call(decoy_id) - assert result == call.method(foo="hello", bar="world") +def test_register_spy(registry: Registry) -> None: + """It should register a spy and return a unique identifier.""" + spy = create_spy(handle_call=noop) - result = registry.peek_decoy_last_call(decoy_id) - assert result == call.method(foo="hello", bar="world") + spy_id = registry.register_spy(spy) + assert spy_id == id(spy) -def test_peek_decoy_last_call_with_no_decoy(registry: Registry) -> None: - """Peek should return None if the ID does not match.""" - result = registry.peek_decoy_last_call(42) - assert result is None +def test_register_call(registry: Registry) -> None: + """It should register a spy call.""" + spy = create_spy(handle_call=noop) + call_1 = SpyCall(spy_id=id(spy), args=(1,), kwargs={}) + call_2 = SpyCall(spy_id=id(spy), args=(2,), kwargs={}) -def test_pop_decoy_last_call(registry: Registry) -> None: - """It should be able to pop the last decoy call by ID.""" - decoy = MagicMock() - decoy_id = registry.register_decoy(decoy) + registry.register_spy(spy) + registry.register_call(call_1) + registry.register_call(call_2) - decoy.method(foo="hello", bar="world") + assert registry.last_call == call_2 - result = registry.pop_decoy_last_call(decoy_id) - assert result == call.method(foo="hello", bar="world") - result = registry.pop_decoy_last_call(decoy_id) - assert result is None +def test_pop_last_call(registry: Registry) -> None: + """It should be able to pop the last spy call.""" + spy = create_spy(handle_call=noop) + call_1 = SpyCall(spy_id=id(spy), args=(1,), kwargs={}) + call_2 = SpyCall(spy_id=id(spy), args=(2,), kwargs={}) + registry.register_spy(spy) + registry.register_call(call_1) + registry.register_call(call_2) -def test_pop_decoy_last_call_with_no_decoy(registry: Registry) -> None: - """Pop should return None if the ID does not match.""" - result = registry.pop_decoy_last_call(42) - assert result is None + assert registry.pop_last_call() == call_2 + assert registry.last_call == call_1 def test_register_stub(registry: Registry) -> None: - """It should register a decoy and return a unique identifier.""" - decoy = MagicMock() - decoy_id = registry.register_decoy(decoy) + """It should register a stub.""" + spy = create_spy(handle_call=noop) + spy_id = registry.register_spy(spy) + call_1 = SpyCall(spy_id=id(spy), args=(1,), kwargs={}) + call_2 = SpyCall(spy_id=id(spy), args=(2,), kwargs={}) - stub_1 = Stub[Any](call(1, 2, 3)) - stub_2 = Stub[Any](call(4, 5, 6)) + stub_1 = Stub[Any](call_1) + stub_2 = Stub[Any](call_2) - assert registry.get_decoy_stubs(decoy_id) == [] + assert registry.get_stubs_by_spy_id(spy_id) == [] - registry.register_stub(decoy_id=decoy_id, stub=stub_1) - registry.register_stub(decoy_id=decoy_id, stub=stub_2) + registry.register_stub(spy_id=spy_id, stub=stub_1) + registry.register_stub(spy_id=spy_id, stub=stub_2) - assert registry.get_decoy_stubs(decoy_id) == [stub_1, stub_2] + assert registry.get_stubs_by_spy_id(spy_id) == [stub_1, stub_2] -def test_registered_decoys_clean_up_automatically(registry: Registry) -> None: - """It should clean up when the decoy goes out of scope.""" - decoy = Mock() - stub = Stub[Any](call(1, 2, 3)) +def test_registered_calls_clean_up_automatically(registry: Registry) -> None: + """It should clean up when the spy goes out of scope.""" + spy = create_spy(handle_call=noop) + call_1 = SpyCall(spy_id=id(spy), args=(1,), kwargs={}) + stub_1 = Stub[Any](call_1) - decoy_id = registry.register_decoy(decoy) - registry.register_stub(decoy_id, stub) + spy_id = registry.register_spy(spy) + registry.register_call(call_1) + registry.register_stub(spy_id=spy_id, stub=stub_1) - decoy(foo="hello", bar="world") + assert registry.last_call == call_1 + assert registry.get_stubs_by_spy_id(spy_id) == [stub_1] - # decoy goes out of scope and garbage is collected - del decoy - collect_garbage() + # spy goes out of scope and garbage is collected + del spy - # registry no longer has references to the decoy not its stubs - assert registry.get_decoy(decoy_id) is None - assert registry.get_decoy_stubs(decoy_id) == [] + # registry no longer has references to the calls + assert registry.last_call is None + assert registry.get_stubs_by_spy_id(spy_id) == [] diff --git a/tests/test_spy.py b/tests/test_spy.py new file mode 100644 index 0000000..0b31837 --- /dev/null +++ b/tests/test_spy.py @@ -0,0 +1,206 @@ +"""Tests for spy creation.""" +import pytest + +from typing import Any +from decoy.spy import create_spy, AsyncSpy, SpyCall + +from .common import ( + some_func, + some_async_func, + SomeClass, + SomeAsyncClass, + SomeNestedClass, +) + +pytestmark = pytest.mark.asyncio + + +def test_create_spy() -> None: + """It should be able to create a test spy.""" + calls = [] + + spy = create_spy(handle_call=lambda c: calls.append(c)) + + spy(1, 2, 3) + spy(four=4, five=5, six=6) + spy(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy), args=(1, 2, 3), kwargs={}), + SpyCall(spy_id=id(spy), args=(), kwargs={"four": 4, "five": 5, "six": 6}), + SpyCall(spy_id=id(spy), args=(7,), kwargs={"eight": 8, "nine": 9}), + ] + + +def test_create_spy_from_spec_function() -> None: + """It should be able to create a test spy from a spec function.""" + calls = [] + + spy = create_spy(spec=some_func, handle_call=lambda c: calls.append(c)) + + spy(1, 2, 3) + spy(four=4, five=5, six=6) + spy(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy), args=(1, 2, 3), kwargs={}), + SpyCall(spy_id=id(spy), args=(), kwargs={"four": 4, "five": 5, "six": 6}), + SpyCall(spy_id=id(spy), args=(7,), kwargs={"eight": 8, "nine": 9}), + ] + + +async def test_create_spy_from_async_spec_function() -> None: + """It should be able to create a test spy from an async function.""" + calls = [] + + spy: AsyncSpy = create_spy( + spec=some_async_func, handle_call=lambda c: calls.append(c) + ) + + await spy(1, 2, 3) + await spy(four=4, five=5, six=6) + await spy(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy), args=(1, 2, 3), kwargs={}), + SpyCall(spy_id=id(spy), args=(), kwargs={"four": 4, "five": 5, "six": 6}), + SpyCall(spy_id=id(spy), args=(7,), kwargs={"eight": 8, "nine": 9}), + ] + + +def test_create_spy_from_spec_class() -> None: + """It should be able to create a test spy from a spec class.""" + calls = [] + + spy = create_spy(spec=SomeClass, handle_call=lambda c: calls.append(c)) + + spy.foo(1, 2, 3) + spy.bar(four=4, five=5, six=6) + spy.do_the_thing(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy.foo), args=(1, 2, 3), kwargs={}), + SpyCall(spy_id=id(spy.bar), args=(), kwargs={"four": 4, "five": 5, "six": 6}), + SpyCall(spy_id=id(spy.do_the_thing), args=(7,), kwargs={"eight": 8, "nine": 9}), + ] + + +async def test_create_spy_from_async_spec_class() -> None: + """It should be able to create a test spy from a class with async methods.""" + calls = [] + + spy = create_spy(spec=SomeAsyncClass, handle_call=lambda c: calls.append(c)) + + await spy.foo(1, 2, 3) + await spy.bar(four=4, five=5, six=6) + await spy.do_the_thing(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy.foo), args=(1, 2, 3), kwargs={}), + SpyCall(spy_id=id(spy.bar), args=(), kwargs={"four": 4, "five": 5, "six": 6}), + SpyCall(spy_id=id(spy.do_the_thing), args=(7,), kwargs={"eight": 8, "nine": 9}), + ] + + +def test_create_nested_spy() -> None: + """It should be able to create a spy that goes several properties deep.""" + calls = [] + + spy = create_spy(spec=SomeNestedClass, handle_call=lambda c: calls.append(c)) + + spy.foo(1, 2, 3) + spy.child.bar(four=4, five=5, six=6) + spy.child.do_the_thing(7, eight=8, nine=9) + + assert calls == [ + SpyCall(spy_id=id(spy.foo), args=(1, 2, 3), kwargs={}), + SpyCall( + spy_id=id(spy.child.bar), + args=(), + kwargs={"four": 4, "five": 5, "six": 6}, + ), + SpyCall( + spy_id=id(spy.child.do_the_thing), + args=(7,), + kwargs={"eight": 8, "nine": 9}, + ), + ] + + +async def test_create_nested_spy_using_property_type_hints() -> None: + """It should be able to dive using type hints on @property getters.""" + + class _SomeClass: + @property + def _async_child(self) -> SomeAsyncClass: + ... + + @property + def _sync_child(self) -> SomeClass: + ... + + calls = [] + spy = create_spy(spec=_SomeClass, handle_call=lambda c: calls.append(c)) + + await spy._async_child.bar(four=4, five=5, six=6) + spy._sync_child.do_the_thing(7, eight=8, nine=9) + + assert calls == [ + SpyCall( + spy_id=id(spy._async_child.bar), + args=(), + kwargs={"four": 4, "five": 5, "six": 6}, + ), + SpyCall( + spy_id=id(spy._sync_child.do_the_thing), + args=(7,), + kwargs={"eight": 8, "nine": 9}, + ), + ] + + +async def test_create_nested_spy_using_class_type_hints() -> None: + """It should be able to dive using type hints on the class.""" + + class _SomeClass: + _async_child: SomeAsyncClass + _sync_child: SomeClass + + calls = [] + spy = create_spy(spec=_SomeClass, handle_call=lambda c: calls.append(c)) + + await spy._async_child.bar(four=4, five=5, six=6) + spy._sync_child.do_the_thing(7, eight=8, nine=9) + + assert calls == [ + SpyCall( + spy_id=id(spy._async_child.bar), + args=(), + kwargs={"four": 4, "five": 5, "six": 6}, + ), + SpyCall( + spy_id=id(spy._sync_child.do_the_thing), + args=(7,), + kwargs={"eight": 8, "nine": 9}, + ), + ] + + +async def test_spy_returns_handler_value() -> None: + """The spy should return the value from its call handler when called.""" + call_count = 0 + + def _handle_call(call: Any) -> int: + nonlocal call_count + call_count = call_count + 1 + return call_count + + sync_spy = create_spy(spec=some_func, handle_call=_handle_call) + async_spy = create_spy(spec=some_async_func, handle_call=_handle_call) + + assert [ + sync_spy(), + await async_spy(), + sync_spy(), + await async_spy(), + ] == [1, 2, 3, 4] diff --git a/tests/test_verify.py b/tests/test_verify.py index b056b71..07f9fc5 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -47,10 +47,10 @@ def test_verify_with_matcher(decoy: Decoy) -> None: stub("hello") - decoy.verify(stub(matchers.StringMatching("ello"))) + decoy.verify(stub(matchers.StringMatching("ell"))) with pytest.raises(AssertionError): - decoy.verify(stub(matchers.StringMatching("^ello"))) + decoy.verify(stub(matchers.StringMatching("^ell"))) def test_call_nested_method_then_verify(decoy: Decoy) -> None: diff --git a/tests/test_when.py b/tests/test_when.py index 7aed2f4..c90604c 100644 --- a/tests/test_when.py +++ b/tests/test_when.py @@ -1,6 +1,5 @@ """Tests for the Decoy double creator.""" import pytest -from mock import MagicMock from decoy import Decoy, matchers from .common import some_func, SomeClass, SomeAsyncClass, SomeNestedClass @@ -66,7 +65,7 @@ def test_stub_with_matcher(decoy: Decoy) -> None: """It should still work with matchers as arguments.""" stub = decoy.create_decoy_func(spec=some_func) - decoy.when(stub(matchers.StringMatching("ello"))).then_return("world") + decoy.when(stub(matchers.StringMatching("ell"))).then_return("world") assert stub("hello") == "world" @@ -99,7 +98,7 @@ def test_stub_multiple_returns(decoy: Decoy) -> None: def test_cannot_stub_without_rehearsal(decoy: Decoy) -> None: """It should require a rehearsal to stub.""" - bad_stub = MagicMock() + bad_stub = some_func # stubbing without a valid decoy should fail with pytest.raises(ValueError, match="rehearsal"): @@ -120,7 +119,7 @@ def test_stub_nested_method_then_return(decoy: Decoy) -> None: @pytest.mark.asyncio async def test_stub_async_method(decoy: Decoy) -> None: """It should be able to stub an async method.""" - stub = decoy.create_decoy(spec=SomeAsyncClass, is_async=True) + stub = decoy.create_decoy(spec=SomeAsyncClass) decoy.when(await stub.foo("hello")).then_return("world") decoy.when(await stub.bar(0, 1.0, "2")).then_raise(ValueError("oh no")) @@ -129,3 +128,34 @@ async def test_stub_async_method(decoy: Decoy) -> None: with pytest.raises(ValueError, match="oh no"): await stub.bar(0, 1.0, "2") + + +def test_stub_nested_sync_class_in_async(decoy: Decoy) -> None: + """It should be able to stub a sync child instance of an async class.""" + + class _AsyncWithSync: + @property + def _sync_child(self) -> SomeClass: + ... + + stub = decoy.create_decoy(spec=_AsyncWithSync) + + decoy.when(stub._sync_child.foo("hello")).then_return("world") + + assert stub._sync_child.foo("hello") == "world" + + +@pytest.mark.asyncio +async def test_stub_nested_async_class_in_sync(decoy: Decoy) -> None: + """It should be able to stub an async child instance of an sync class.""" + + class _SyncWithAsync: + @property + def _async_child(self) -> SomeAsyncClass: + ... + + stub = decoy.create_decoy(spec=_SyncWithAsync) + + decoy.when(await stub._async_child.foo("hello")).then_return("world") + + assert await stub._async_child.foo("hello") == "world"