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
35 changes: 30 additions & 5 deletions decoy/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,32 @@
but highly recommended.
"""

from typing import Iterable
from typing import TYPE_CHECKING, Iterable, Union, get_type_hints

import pytest

from decoy import Decoy

if TYPE_CHECKING:
from decoy.next import Decoy as DecoyNext


@pytest.fixture()
def decoy() -> Iterable[Decoy]:
def decoy(
request: pytest.FixtureRequest,
) -> "Iterable[Union[Decoy, DecoyNext]]":
"""Get a [decoy.Decoy][] container and [reset it][decoy.Decoy.reset] after the test.

This function is function-scoped [pytest fixture][] that will be
automatically inserted by the plugin.

!!! tip

This fixture will automatically opt-into the [v3 preview API][v3-preview]
if annotated with `decoy.next.Decoy`.

[pytest fixture]: https://docs.pytest.org/en/latest/how-to/fixtures.html
[v3-preview]: ./v3/about.md

!!! example
```python
Expand All @@ -28,6 +39,20 @@ def test_my_thing(decoy: Decoy) -> None:
# ...
```
"""
decoy = Decoy()
yield decoy
decoy.reset()
try:
decoy_hint = get_type_hints(request.function).get("decoy")
is_next = decoy_hint.__module__.startswith("decoy.next")

# purely defensive, probably won't ever raise
except Exception: # pragma: no cover
is_next = False

if is_next:
from decoy.next import Decoy as DecoyNext

with DecoyNext.create() as decoy_next:
yield decoy_next
else:
decoy = Decoy()
yield decoy
decoy.reset()
8 changes: 0 additions & 8 deletions docs/v3/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,6 @@ Then, start trying out the new API!
- 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")
Expand Down
20 changes: 9 additions & 11 deletions docs/v3/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,16 @@ Recommended migration from v2:

## Setup

For an incremental migration, you can set up a replacement `decoy` fixture wherever tests are being migrated:
For an incremental migration, annotate a test's `decoy` fixture as `decoy.next.Decoy` to automatically opt-in that test to the preview API.

```python
import collections.abc
import pytest

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

@pytest.fixture()
def decoy() -> collections.abc.Iterator[Decoy]:
"""Create a Decoy 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")
```

## When
Expand Down Expand Up @@ -189,6 +186,7 @@ In v3, `__enter__` and `__exit__` can still be stubbed to test advanced context

## Other breaking changes

- The `mypy` plugin is no longer needed and will be removed
- [`Decoy.mock`][decoy.next.Decoy.mock] is more strict about its arguments, and will raise [`MockSpecInvalidError`][decoy.errors.MockSpecInvalidError] if passed an invalid `spec` value.
- [`IncorrectCallWarning`][decoy.warnings.IncorrectCallWarning] has been upgraded to an error: [`SignatureMismatchError`][decoy.errors.SignatureMismatchError].
- Some "public" attributes have been removed from error classes.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@ test = "pytest -f"
test-once = "coverage run --branch --source=decoy -m pytest --mypy-same-process"
coverage = "coverage report"
coverage-xml = "coverage xml"
coverage-html = "coverage html"
docs = "mkdocs serve --livereload"
build-docs = "mkdocs build"
build-package = "uv build"
check-ci = ["check", "lint", "format-check"]
test-ci = ["test-once", "coverage-xml"]
test-coverage = ["test-once", "coverage-html", "coverage"]

[tool.pytest.ini_options]
addopts = "--color=yes --mypy-ini-file=tests/legacy/typing/mypy.ini --mypy-only-local-stub"
Expand Down
8 changes: 0 additions & 8 deletions tests/test_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import collections.abc
import dataclasses
import sys
from typing import Any, Callable, NamedTuple
Expand Down Expand Up @@ -33,13 +32,6 @@
)


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


def test_matcher() -> None:
"""It matches based on a TypeIs function."""

Expand Down
8 changes: 0 additions & 8 deletions tests/test_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import collections.abc
import inspect
import sys
from typing import Any
Expand All @@ -22,13 +21,6 @@
)


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


def test_create_mock(decoy: Decoy) -> None:
"""It creates a callable mock that no-ops."""
subject = decoy.mock(name="alice")
Expand Down
62 changes: 59 additions & 3 deletions tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Tests for Decoy's pytest plugin."""

import sys

import pytest


def test_pytest_decoy_fixture(testdir: pytest.Testdir) -> None:
"""It should add a decoy test fixture."""
# create a temporary pytest test file
"""It adds a decoy test fixture."""
testdir.makepyfile(
"""
from decoy import Decoy
Expand All @@ -17,5 +18,60 @@ def test_decoy(decoy):

result = testdir.runpytest_subprocess()

# check that all 4 tests passed
result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
def test_pytest_decoy_next_fixture(testdir: pytest.Testdir) -> None:
"""It opts into the decoy.next API via annotation."""
testdir.makepyfile(
"""
from decoy.next import Decoy

def test_decoy(decoy: Decoy):
assert isinstance(decoy, Decoy)
"""
)

result = testdir.runpytest_subprocess()

result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
def test_pytest_decoy_next_fixture_fallback(testdir: pytest.Testdir) -> None:
"""It falls back to v2 if annotations are messed up."""
testdir.makepyfile(
"""
from decoy import Decoy

def test_decoy(decoy: 'Oops'):
assert isinstance(decoy, Decoy)
"""
)

result = testdir.runpytest_subprocess()

result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
def test_pytest_decoy_next_fixture_nesting(testdir: pytest.Testdir) -> None:
"""It opts into the decoy.next API via annotation for derived fixtures."""
testdir.makepyfile(
"""
import pytest
from decoy.next import Decoy, Mock

@pytest.fixture()
def mock(decoy: Decoy):
return decoy.mock(name="mock")

def test_decoy(decoy: Decoy, mock: Mock):
assert isinstance(mock, Mock)
"""
)

result = testdir.runpytest_subprocess()

result.assert_outcomes(passed=1)
8 changes: 0 additions & 8 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import collections.abc
import os
import sys

Expand All @@ -22,13 +21,6 @@
)


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


def test_verify(decoy: Decoy) -> None:
"""It no-ops if a call is verified."""
subject = decoy.mock(name="subject")
Expand Down
7 changes: 0 additions & 7 deletions tests/test_when.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,6 @@
)


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


def test_when_then_return(decoy: Decoy) -> None:
"""It returns a value."""
subject = decoy.mock(name="subject")
Expand Down