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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2020-2023, Michael Cousins
Copyright (c) 2020-2026, Michael Cousins

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
8 changes: 8 additions & 0 deletions codebook.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
words = [
"matcher's",
"matchers",
"mock's",
"mundo",
"stubbings",
"verden",
]
41 changes: 7 additions & 34 deletions decoy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@


class MockNameRequiredError(ValueError):
"""An error raised if a name is not provided for a mock.

See the [MockNameRequiredError guide][] for more details.

[MockNameRequiredError guide]: usage/errors-and-warnings.md#mocknamerequirederror
"""
"""A name was not provided for a mock."""

@classmethod
def create(cls) -> "MockNameRequiredError":
Expand All @@ -26,20 +21,16 @@ def create(cls) -> "MockNameRequiredError":


class MockSpecInvalidError(TypeError):
"""An value passed as a mock spec is not valid."""
"""A value passed as a mock spec is not valid."""


class MissingRehearsalError(ValueError):
"""An error raised when a Decoy method is called without rehearsal(s).
"""A Decoy method was called without rehearsal(s).

This error is raised if you use [`when`][decoy.Decoy.when],
[`verify`][decoy.Decoy.verify], or [`prop`][decoy.Decoy.prop] incorrectly
in your tests. When using async/await, this error can be triggered if you
forget to include `await` with your rehearsal.

See the [MissingRehearsalError guide][] for more details.

[MissingRehearsalError guide]: usage/errors-and-warnings.md#missingrehearsalerror
"""

@classmethod
Expand All @@ -57,13 +48,10 @@ class ThenDoActionNotCallableError(TypeError):


class MockNotAsyncError(TypeError):
"""An error raised when an asynchronous function is used with a synchronous mock.
"""An asynchronous function was passed to a synchronous mock.

This error is raised if you pass an `async def` function
to a synchronous stub's `then_do` method.
See the [MockNotAsyncError guide][] for more details.

[MockNotAsyncError guide]: usage/errors-and-warnings.md#mocknotasyncerror
to a synchronous stub's [`then_do`][decoy.Stub.then_do] method.
"""


Expand All @@ -72,17 +60,7 @@ class SignatureMismatchError(TypeError):


class VerifyError(AssertionError):
"""An error raised when actual calls do not match rehearsals given to `verify`.

See [spying with verify][] for more details.

[spying with verify]: usage/verify.md

Attributes:
rehearsals: Rehearsals that were being verified.
calls: Actual calls to the mock(s).
times: The expected number of calls to the mock, if any.
"""
"""A [`Decoy.verify`][decoy.Decoy.verify] assertion failed."""

rehearsals: Sequence[VerifyRehearsal]
calls: Sequence[SpyEvent]
Expand Down Expand Up @@ -119,9 +97,4 @@ def create(


class VerifyOrderError(VerifyError):
"""An error raised when the order of calls do not match expectations.

See [spying with verify][] for more details.

[spying with verify]: usage/verify.md
"""
"""A [`Decoy.verify_order`][decoy.next.Decoy.verify_order] assertion failed."""
2 changes: 1 addition & 1 deletion docs/usage/matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ When testing certain APIs, especially callback APIs, it can be helpful to captur

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.

```py
```python
import pytest
from typing import cast, Optional
from decoy import Decoy, matchers
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ say_hello = decoy.mock(name="say_hello")

say_hello("foobar")

decoy.verify(matchers.StringMatching("^foo"), times=1) # passes
decoy.verify(matchers.StringMatching("^bar"), times=1) # raises
decoy.verify(say_hello(matchers.StringMatching("^foo")), times=1) # passes
decoy.verify(say_hello(matchers.StringMatching("^bar")), times=1) # raises
```

## Verifying with async/await
Expand Down
54 changes: 54 additions & 0 deletions docs/v3/about.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Decoy v3 Preview

The next major version of Decoy is a ground-up rebuild of the library. In the years since Decoy was first written, the Python typing system has advanced, especially when it comes to typing functions. These advancements, especially the addition of [`ParamSpec`][paramspec], unblocked a much simpler API (as well as internal implementation) for Decoy.

In order to ease the migration to the new API, the v3 API has been added as a preview to the v2 release (starting with `v2.4.0`) as `decoy.next`.

```diff
- from decoy import Decoy
+ from decoy.next import Decoy
```

!!! warning

`decoy.next` is a **preview** and not subject to semver.
No major changes are anticipated, but cannot be guaranteed until
Decoy v3 is released.

## Setup

In order to use the v3 preview, you must be using:

- `decoy >= 2.4.0`
- `python >= 3.10`

Then, start trying out the new API!

```diff
- from decoy import Decoy
+ from decoy.next import Decoy


+ @pytest.fixture()
+ def decoy() -> collections.abc.Iterator[Decoy]:
+ """Create a Decoy v3 preview instance for testing."""
+ with Decoy.create() as decoy:
+ yield decoy


def test_when(decoy: Decoy) -> None:
mock = decoy.mock(cls=SomeClass)
- decoy.when(mock.foo("hello")).then_return("world")
+ decoy.when(mock.foo).called_with("hello").then_return("world")


def test_verify(decoy: Decoy) -> None:
mock = decoy.mock(cls=SomeClass)
mock.foo("hello")
- decoy.verify(mock.foo("hello"))
+ decoy.verify(mock.foo).called_with("hello")
```

See the [migration guide](./migration.md) for more details.

[paramspec]: https://docs.python.org/3/library/typing.html#typing.ParamSpec
9 changes: 9 additions & 0 deletions docs/v3/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# API Reference

!!! warning

`decoy.next` is a **preview** and not subject to semver.
No major changes are anticipated, but cannot be guaranteed until
Decoy v3 is released.

::: decoy.next
122 changes: 122 additions & 0 deletions docs/v3/attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Mock attributes

Python [property attributes][] provide an interface for creating read-only properties and properties with getters, setters, and deleters. You can use Decoy to stub these properties.

[property attributes]: https://docs.python.org/3/library/functions.html#property

## Default behavior

Unlike mock method calls - which have a default return value of `None` - Decoy's default return value for attribute access is **another mock**. You don't need to configure anything explicitly if you need a `@property` getter to return another mock; Decoy will inspect type annotations and configure the proper child mock for you.

```python
class Child:
...

class Parent:
@property
def child(self) -> Child:
...

mock_parent = decoy.mock(cls=Parent)

assert isinstance(mock_parent, Dependency)
assert isinstance(mock_parent.child, Child)
```

You may also manually set any mock attribute to "opt out" of Decoy for a given attribute. To opt back into Decoy, delete the attribute.

```python
dep = decoy.mock(cls=Parent)

dep.child = "don't worry about it"
assert dep.child == "don't worry about it"

del dep.child
assert isinstance(mock_parent.child, Child)
```

## Stub attribute access

### Stub a getter

Use [`When.get`][decoy.next.When.get] stub a return value for an attribute instead of returning a child mock.

```python
dependency = decoy.mock(name="dependency")

decoy.when(dependency.some_property).get().then_return(42)

assert dep.some_property == 42
```

You can also configure any other behavior, like raising an error.

```python
dependency = decoy.mock(name="dependency")

decoy
.when(dependency.some_property)
.get()
.then_raise(RuntimeError("oh no"))

with pytest.raises(RuntimeError, match="oh no"):
dependency.some_property
```

### Stub a setter or deleter

While you cannot stub a return value for a getter or setter, because set and delete expressions do not return a value in Python, you _can_ stub a `raise` or a side effect with [`When.set`][decoy.next.When.set] and [`When.delete`][decoy.next.When.delete].

```python
dependency = decoy.mock(name="dependency")

decoy
.when(dependency.some_property)
.set(42)
.then_raise(RuntimeError("oh no"))

decoy
.when(dependency.some_property)
.delete()
.then_raise(RuntimeError("what a disaster"))

with pytest.raises(RuntimeError, match="oh no"):
dependency.some_property = 42

with pytest.raises(RuntimeError, match="what a disaster"):
del dependency.some_property
```

## Verify property access

You can verify calls to property setters and deleters with [`Verify.set`][decoy.next.Verify.set] and [`Verify.delete`][decoy.next.Verify.delete].

!!! tip

Use this feature sparingly! If you're designing a dependency that triggers a side-effect, consider using a regular method, instead.

Mocking and verifying property setters and deleters is most useful for testing code that needs to interact with older or legacy dependencies that would be prohibitively expensive to redesign.

### Verify a setter

Use [`Verify.set`][decoy.next.Verify.set] to check that an attribute was set.

```python
dependency = decoy.mock(name="dependency")

dependency.some_property = 42

decoy.verify(dependency.some_property).set(42)
```

### Verify a deleter

Use [`Verify.delete`][decoy.next.Verify.delete] to check that an attribute was deleted.

```python
dependency = decoy.mock(name="dependency")

del dependency.some_property

decoy.verify(dependency.some_property).delete)
```
76 changes: 76 additions & 0 deletions docs/v3/context-managers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Mock context managers

In Python, `with` statement [context managers][] provide an interface to execute code inside a given "runtime context." This context can define consistent, failsafe setup and teardown behavior. For example, Python's built-in file objects provide a context manager interface to ensure the underlying file resource is opened and closed cleanly, without the caller having to explicitly deal with it:

```python
with open("hello-world.txt", "r") as f:
contents = f.read()
```

You can use Decoy to mock out your dependencies that provide a context manager interface.

[context managers]: https://docs.python.org/3/reference/datamodel.html#context-managers

## Generator-based context managers

Using the [contextlib][] module, you can [decorate a generator function][] or method to turn its yielded value into a context manager. To mock a generator function context manager, use [`Stub.then_enter_with`][decoy.next.Stub.then_enter_with].

```python
@contextlib.contextmanager
def open_config(path: str) -> collections.abc.Iterator[bool]:
...


def test_generator_context_manager(decoy: Decoy) -> None:
mock_open_config = decoy.mock(func=open_config)

decoy
.when(mock_open_config)
.called_with("some_flag")
.then_enter_with(True)

with mock_open_config("some_flag") as result:
assert result is True
```

[contextlib]: https://docs.python.org/3/library/contextlib.html
[decorate a generator function]: https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager

## Class-based context managers

You can stub out a context manager's `__enter__` and `__exit__` method like any other method.

```python
def test_context_manager(decoy: Decoy) -> None:
subject = decoy.mock(name="cm")

decoy.when(subject.__enter__).called_with().then_return("hello world")

with subject as result:
assert result == "hello world"

decoy.verify(subject.__exit__).called_with(None, None, None)
```

This also works with asynchronous `__aenter__` and `__aexit__`

## Context manager state

You can also configure stubs and verifications to only match calls made while a context manager mock is entered using the `is_entered` option to `called_with`.

| `is_entered` | Matching behavior |
| ------------ | ----------------------------------------------------------- |
| `True` | Only matches calls made between `__enter__` and `__exit__` |
| `False` | Only matches calls made if context manager is _not_ entered |
| `None` | Match the call regardless of context manager entry state |

```python
decoy
.when(subject.read, is_entered=True)
.called_with("some_flag")
.then_return(True)

decoy
.verify(subject.write, is_entered=True)
.called_with("some_flag", "new_value")
```
Loading