Skip to content
Closed
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: 2 additions & 0 deletions src/pytest_mock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pytest_mock.plugin import MockerFixture
from pytest_mock.plugin import MockType
from pytest_mock.plugin import PytestMockWarning
from pytest_mock.plugin import SpyType
from pytest_mock.plugin import class_mocker
from pytest_mock.plugin import mocker
from pytest_mock.plugin import module_mocker
Expand All @@ -18,6 +19,7 @@
"MockFixture",
"MockType",
"PytestMockWarning",
"SpyType",
"pytest_addoption",
"pytest_configure",
"session_mocker",
Expand Down
84 changes: 81 additions & 3 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
from typing import Any
from typing import Callable
from typing import Optional
from typing import Protocol
from typing import Tuple
from typing import TypeVar
from typing import Union
from typing import cast
from typing import overload
from typing import runtime_checkable

import pytest

Expand All @@ -33,6 +36,83 @@
]


@runtime_checkable
class SpyType(Protocol):
"""
Protocol for spy objects returned by :meth:`MockerFixture.spy`.

This protocol defines the spy-specific attributes that are added to mock
objects when using the spy functionality. These attributes allow inspection
of the spied method's behavior.

Attributes:
spy_return: The return value from the most recent call to the spied method.
spy_return_list: A list of all return values from calls to the spied method.
spy_return_iter: An iterator copy of the return value (when duplicate_iterators=True).
spy_exception: The exception raised by the most recent call, if any.
spy_trace: A list of call traces (each trace is a tuple of strings).
"""

spy_return: Any
spy_return_list: list[Any]
spy_return_iter: Optional[Iterator[Any]]
spy_exception: Optional[BaseException]
spy_trace: list[Tuple[str, ...]]

# Mock assertion methods (subset of common mock interface)
def assert_called(self) -> None:
"""Assert that the mock was called at least once."""
...

def assert_called_once(self) -> None:
"""Assert that the mock was called exactly once."""
...

def assert_called_with(self, *args: Any, **kwargs: Any) -> None:
"""Assert that the last call was made with the specified arguments."""
...

def assert_called_once_with(self, *args: Any, **kwargs: Any) -> None:
"""Assert that the mock was called exactly once with the specified arguments."""
...

def assert_any_call(self, *args: Any, **kwargs: Any) -> None:
"""Assert that the mock was called with the specified arguments at any point."""
...

def assert_has_calls(self, calls: Any, any_order: bool = False) -> None:
"""Assert that the mock has been called with the specified calls."""
...

def assert_not_called(self) -> None:
"""Assert that the mock was never called."""
...

def reset_mock(self, *args: Any, **kwargs: Any) -> None:
"""Reset the mock to its initial state."""
...

@property
def call_count(self) -> int:
"""The number of times the mock was called."""
...

@property
def call_args(self) -> Any:
"""The arguments from the most recent call."""
...

@property
def call_args_list(self) -> Any:
"""A list of all calls made to the mock."""
...

@property
def called(self) -> bool:
"""Whether the mock was called at least once."""
...


class PytestMockWarning(UserWarning):
"""Base class for all warnings emitted by pytest-mock."""

Expand Down Expand Up @@ -157,9 +237,7 @@ def stop(self, mock: unittest.mock.MagicMock) -> None:
"""
self._mock_cache.remove(mock)

def spy(
self, obj: object, name: str, duplicate_iterators: bool = False
) -> MockType:
def spy(self, obj: object, name: str, duplicate_iterators: bool = False) -> SpyType:
"""
Create a spy of method. It will run method normally, but it is now
possible to use `mock` call features with it, like call count.
Expand Down
150 changes: 150 additions & 0 deletions tests/test_spy_type_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Tests for SpyType type annotations.

This test file verifies that the SpyType protocol correctly exposes
the spy-specific attributes for type checking purposes.
"""

from typing import get_type_hints

import pytest

from pytest_mock import MockerFixture
from pytest_mock import SpyType


class TestSpyTypeAnnotations:
"""Tests for SpyType type annotation correctness."""

def test_spy_type_exists(self) -> None:
"""Verify SpyType is exported from pytest_mock."""
from pytest_mock import SpyType as imported_spy_type

assert imported_spy_type is not None

def test_spy_type_has_spy_return_attribute(self) -> None:
"""Verify spy_return attribute is defined in SpyType."""
hints = get_type_hints(SpyType)
assert "spy_return" in hints

def test_spy_type_has_spy_return_list_attribute(self) -> None:
"""Verify spy_return_list attribute is defined in SpyType."""
hints = get_type_hints(SpyType)
assert "spy_return_list" in hints

def test_spy_type_has_spy_return_iter_attribute(self) -> None:
"""Verify spy_return_iter attribute is defined in SpyType."""
hints = get_type_hints(SpyType)
assert "spy_return_iter" in hints

def test_spy_type_has_spy_exception_attribute(self) -> None:
"""Verify spy_exception attribute is defined in SpyType."""
hints = get_type_hints(SpyType)
assert "spy_exception" in hints

def test_spy_returns_spy_type(self, mocker: MockerFixture) -> None:
"""Verify spy() method returns SpyType for proper type inference."""

class Foo:
def bar(self) -> int:
return 42

# The spy method should return SpyType, which means:
# - The return value should have spy_return, spy_return_list, etc.
# - Type checkers should recognize these attributes without type: ignore
spy = mocker.spy(Foo, "bar")

# These should work without type: ignore comments
result = spy.spy_return # type checker should recognize this
assert result is None # not called yet

Foo().bar()
assert spy.spy_return == 42

# spy_return_list should be accessible
assert spy.spy_return_list == [42]

# spy_exception should be accessible
assert spy.spy_exception is None

def test_spy_type_runtime_checkable(self, mocker: MockerFixture) -> None:
"""Verify SpyType is runtime_checkable for isinstance checks."""

class Foo:
def method(self) -> str:
return "test"

spy = mocker.spy(Foo, "method")

# SpyType should be runtime_checkable, allowing isinstance checks
# This is useful for runtime validation
# Note: Protocol isinstance checks require runtime_checkable decorator
# and check for presence of required methods/attributes
# The spy object has all the required attributes (spy_return, spy_return_list, etc.)
# and the mock methods (assert_called, etc.)
# However, Protocol isinstance checks may not work with MagicMock due to
# how MagicMock handles attribute access
# For now, we verify the attributes exist rather than isinstance
assert hasattr(spy, "spy_return")
assert hasattr(spy, "spy_return_list")
assert hasattr(spy, "spy_return_iter")
assert hasattr(spy, "spy_exception")
assert hasattr(spy, "assert_called")
assert hasattr(spy, "call_count")

def test_spy_type_has_mock_methods(self, mocker: MockerFixture) -> None:
"""Verify SpyType includes common mock assertion methods."""

class Foo:
def bar(self, x: int) -> int:
return x + 1

spy = mocker.spy(Foo, "bar")

# These mock assertion methods should be available on SpyType
# without type: ignore comments
spy.assert_not_called()

foo = Foo()
foo.bar(5)
spy.assert_called_once()
# Note: instance method spy includes 'self' in the call args
spy.assert_called_with(foo, 5)

# call_count and called should also be available
assert spy.call_count == 1
assert spy.called is True

def test_spy_type_with_exception(self, mocker: MockerFixture) -> None:
"""Verify spy_exception attribute captures exceptions correctly."""

class Foo:
def error_method(self) -> None:
raise ValueError("test error")

spy = mocker.spy(Foo, "error_method")

with pytest.raises(ValueError):
Foo().error_method()

# spy_exception should be accessible and contain the raised exception
assert spy.spy_exception is not None
assert isinstance(spy.spy_exception, ValueError)
assert str(spy.spy_exception) == "test error"

def test_async_spy_returns_spy_type(self, mocker: MockerFixture) -> None:
"""Verify spy() on async methods also returns SpyType."""
import asyncio

class AsyncFoo:
async def async_method(self) -> str:
return "async_result"

spy = mocker.spy(AsyncFoo, "async_method")

# SpyType should work for async methods as well
async def run_test() -> None:
result = await AsyncFoo().async_method()
assert spy.spy_return == "async_result"
assert spy.spy_return_list == ["async_result"]

asyncio.run(run_test())