diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7eef5be..7bc8f62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,9 @@ jobs: os: [ubuntu-latest, windows-latest] tox_env: ["py"] include: + - python: "3.10" + os: ubuntu-latest + tox_env: "pytest6" - python: "3.12" os: ubuntu-latest tox_env: "norewrite" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9fd5499..9b4c9dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,5 @@ exclude: '^($|.*\.bin)' repos: - - repo: https://github.com/psf/black - rev: 23.9.1 - hooks: - - id: black - args: [--safe, --quiet] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - repo: local hooks: - id: rst @@ -18,13 +8,14 @@ repos: files: ^(CHANGELOG.rst|README.rst|HOWTORELEASE.rst|changelog/.*)$ language: python additional_dependencies: [pygments, restructuredtext_lint] - - repo: https://github.com/asottile/reorder-python-imports - rev: v3.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.3 hooks: - - id: reorder-python-imports - args: ['--application-directories=.:src'] + - id: ruff + args: ["--fix"] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.0 # NOTE: keep this in sync with tox.ini + rev: v1.9.0 hooks: - id: mypy files: ^(src|tests) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 052f803..005dd03 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,23 @@ Releases ======== +3.14.0 (2024-03-21) +------------------- + +* `#415 `_: ``MockType`` and ``AsyncMockType`` can be imported from ``pytest_mock`` for type annotation purposes. + +* `#420 `_: Fixed a regression which would cause ``mocker.patch.object`` to not being properly cleared between tests. + + +3.13.0 (2024-03-21) +------------------- + +* `#417 `_: ``spy`` now has ``spy_return_list``, which is a list containing all the values returned by the spied function. +* ``pytest-mock`` now requires ``pytest>=6.2.5``. +* `#410 `_: pytest-mock's ``setup.py`` file is removed. + If you relied on this file, e.g. to install pytest using ``setup.py install``, + please see `Why you shouldn't invoke setup.py directly `_ for alternatives. + 3.12.0 (2023-10-19) ------------------- diff --git a/RELEASING.rst b/RELEASING.rst index 8db6e7c..2c734f1 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -3,5 +3,10 @@ Here are the steps on how to make a new release. 1. Create a ``release-VERSION`` branch from ``upstream/main``. 2. Update ``CHANGELOG.rst``. 3. Push the branch to ``upstream``. -4. Once all tests pass, start the ``deploy`` workflow manually. +4. Once all tests pass, start the ``deploy`` workflow manually or via: + + ``` + gh workflow run deploy.yml --repo pytest-dev/pytest-mock --ref release-VERSION -f version=VERSION + ``` + 5. Merge the PR. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a5dd8a4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +## Security contact information + +This project is not used in production, so there should be no security concerns. + +However as part of the Tidelift offering, the policy to report a security vulnerability is to use the +[Tidelift security contact](https://tidelift.com/security). + +Tidelift will coordinate the fix and disclosure. diff --git a/docs/usage.rst b/docs/usage.rst index 1b29d45..c576dd0 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -79,7 +79,8 @@ are available (like ``assert_called_once_with`` or ``call_count`` in the example In addition, spy objects contain two extra attributes: -* ``spy_return``: contains the returned value of the spied function. +* ``spy_return``: contains the last returned value of the spied function. +* ``spy_return_list``: contains a list of all returned values of the spied function (new in ``3.13``). * ``spy_exception``: contain the last exception value raised by the spied function/method when it was last called, or ``None`` if no exception was raised. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9865b37 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm[toml]", +] +build-backend = "setuptools.build_meta" + +[project] +name = "pytest-mock" +description = "Thin-wrapper around the mock package for easier use with pytest" +authors = [ + {name = "Bruno Oliveira", email = "nicoddemus@gmail.com"}, +] +dependencies = [ + "pytest>=6.2.5", +] +dynamic = ["version"] +requires-python = ">=3.8" +readme = "README.rst" +license = {text = "MIT"} +keywords = ["pytest", "mock"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Pytest", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Testing", +] + +[project.urls] +Homepage = "https://github.com/pytest-dev/pytest-mock/" +Documentation = "https://pytest-mock.readthedocs.io/en/latest/" +Changelog = "https://pytest-mock.readthedocs.io/en/latest/changelog.html" +Source = "https://github.com/pytest-dev/pytest-mock/" +Tracker = "https://github.com/pytest-dev/pytest-mock/issues" + +[project.optional-dependencies] +dev = [ + "pre-commit", + "pytest-asyncio", + "tox", +] + +[project.entry-points.pytest11] +pytest_mock = "pytest_mock" + +[tool.setuptools.package-data] +pytest_mock = ["py.typed"] + +[tool.setuptools_scm] +write_to = "src/pytest_mock/_version.py" + +[tool.ruff.lint] +extend-select = ["I001"] + +[tool.ruff.lint.isort] +force-single-line = true +known-third-party = ["src"] diff --git a/scripts/gen-release-notes.py b/scripts/gen-release-notes.py index a41eaf2..9e6c4bb 100644 --- a/scripts/gen-release-notes.py +++ b/scripts/gen-release-notes.py @@ -6,6 +6,7 @@ 3. Writes to ``scripts/latest-release-notes.md``, which can be used with https://github.com/softprops/action-gh-release. """ + from pathlib import Path import pypandoc diff --git a/setup.py b/setup.py deleted file mode 100644 index 748f804..0000000 --- a/setup.py +++ /dev/null @@ -1,47 +0,0 @@ -from setuptools import find_packages -from setuptools import setup - -setup( - name="pytest-mock", - entry_points={"pytest11": ["pytest_mock = pytest_mock"]}, - packages=find_packages(where="src"), - package_dir={"": "src"}, - platforms="any", - package_data={ - "pytest_mock": ["py.typed"], - }, - python_requires=">=3.8", - install_requires=["pytest>=5.0"], - use_scm_version={"write_to": "src/pytest_mock/_version.py"}, - setup_requires=["setuptools_scm"], - url="https://github.com/pytest-dev/pytest-mock/", - license="MIT", - author="Bruno Oliveira", - author_email="nicoddemus@gmail.com", - description="Thin-wrapper around the mock package for easier use with pytest", - long_description=open("README.rst", encoding="utf-8").read(), - long_description_content_type="text/x-rst", - keywords="pytest mock", - extras_require={"dev": ["pre-commit", "tox", "pytest-asyncio"]}, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Framework :: Pytest", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Software Development :: Testing", - ], - project_urls={ - "Documentation": "https://pytest-mock.readthedocs.io/en/latest/", - "Changelog": "https://pytest-mock.readthedocs.io/en/latest/changelog.html", - "Source": "https://github.com/pytest-dev/pytest-mock/", - "Tracker": "https://github.com/pytest-dev/pytest-mock/issues", - }, -) diff --git a/src/pytest_mock/__init__.py b/src/pytest_mock/__init__.py index 0afa47c..75fd27a 100644 --- a/src/pytest_mock/__init__.py +++ b/src/pytest_mock/__init__.py @@ -1,18 +1,22 @@ +from pytest_mock.plugin import AsyncMockType +from pytest_mock.plugin import MockerFixture +from pytest_mock.plugin import MockType +from pytest_mock.plugin import PytestMockWarning from pytest_mock.plugin import class_mocker from pytest_mock.plugin import mocker -from pytest_mock.plugin import MockerFixture from pytest_mock.plugin import module_mocker from pytest_mock.plugin import package_mocker from pytest_mock.plugin import pytest_addoption from pytest_mock.plugin import pytest_configure -from pytest_mock.plugin import PytestMockWarning from pytest_mock.plugin import session_mocker MockFixture = MockerFixture # backward-compatibility only (#204) __all__ = [ + "AsyncMockType", "MockerFixture", "MockFixture", + "MockType", "PytestMockWarning", "pytest_addoption", "pytest_configure", diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 673f98b..1e0a0b2 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -2,23 +2,25 @@ import builtins import functools import inspect -import sys import unittest.mock import warnings +from dataclasses import dataclass +from dataclasses import field from typing import Any from typing import Callable -from typing import cast from typing import Dict from typing import Generator from typing import Iterable +from typing import Iterator from typing import List from typing import Mapping from typing import Optional -from typing import overload from typing import Tuple from typing import Type from typing import TypeVar from typing import Union +from typing import cast +from typing import overload import pytest @@ -27,22 +29,58 @@ _T = TypeVar("_T") -if sys.version_info >= (3, 8): - AsyncMockType = unittest.mock.AsyncMock - MockType = Union[ - unittest.mock.MagicMock, - unittest.mock.AsyncMock, - unittest.mock.NonCallableMagicMock, - ] -else: - AsyncMockType = Any - MockType = Union[unittest.mock.MagicMock, unittest.mock.NonCallableMagicMock] +AsyncMockType = unittest.mock.AsyncMock +MockType = Union[ + unittest.mock.MagicMock, + unittest.mock.AsyncMock, + unittest.mock.NonCallableMagicMock, +] class PytestMockWarning(UserWarning): """Base class for all warnings emitted by pytest-mock.""" +@dataclass +class MockCacheItem: + mock: MockType + patch: Optional[Any] = None + + +@dataclass +class MockCache: + """ + Cache MagicMock and Patcher instances so we can undo them later. + """ + + cache: List[MockCacheItem] = field(default_factory=list) + + def _find(self, mock: MockType) -> MockCacheItem: + for mock_item in self.cache: + if mock_item.mock is mock: + return mock_item + raise ValueError("This mock object is not registered") + + def add(self, mock: MockType, **kwargs: Any) -> MockCacheItem: + self.cache.append(MockCacheItem(mock=mock, **kwargs)) + return self.cache[-1] + + def remove(self, mock: MockType) -> None: + mock_item = self._find(mock) + if mock_item.patch: + mock_item.patch.stop() + self.cache.remove(mock_item) + + def clear(self) -> None: + for mock_item in reversed(self.cache): + if mock_item.patch is not None: + mock_item.patch.stop() + self.cache.clear() + + def __iter__(self) -> Iterator[MockCacheItem]: + return iter(self.cache) + + class MockerFixture: """ Fixture that provides the same interface to functions in the mock module, @@ -50,11 +88,9 @@ class MockerFixture: """ def __init__(self, config: Any) -> None: - self._patches_and_mocks: List[Tuple[Any, unittest.mock.MagicMock]] = [] + self._mock_cache: MockCache = MockCache() self.mock_module = mock_module = get_mock_module(config) - self.patch = self._Patcher( - self._patches_and_mocks, mock_module - ) # type: MockerFixture._Patcher + self.patch = self._Patcher(self._mock_cache, mock_module) # type: MockerFixture._Patcher # aliases for convenience self.Mock = mock_module.Mock self.MagicMock = mock_module.MagicMock @@ -77,7 +113,7 @@ def create_autospec( m: MockType = self.mock_module.create_autospec( spec, spec_set, instance, **kwargs ) - self._patches_and_mocks.append((None, m)) + self._mock_cache.add(m) return m def resetall( @@ -95,37 +131,33 @@ def resetall( else: supports_reset_mock_with_args = (self.Mock,) - for p, m in self._patches_and_mocks: + for mock_item in self._mock_cache: # See issue #237. - if not hasattr(m, "reset_mock"): + if not hasattr(mock_item.mock, "reset_mock"): continue - if isinstance(m, supports_reset_mock_with_args): - m.reset_mock(return_value=return_value, side_effect=side_effect) + # NOTE: The mock may be a dictionary + if hasattr(mock_item.mock, "spy_return_list"): + mock_item.mock.spy_return_list = [] + if isinstance(mock_item.mock, supports_reset_mock_with_args): + mock_item.mock.reset_mock( + return_value=return_value, side_effect=side_effect + ) else: - m.reset_mock() + mock_item.mock.reset_mock() def stopall(self) -> None: """ Stop all patchers started by this fixture. Can be safely called multiple times. """ - for p, m in reversed(self._patches_and_mocks): - if p is not None: - p.stop() - self._patches_and_mocks.clear() + self._mock_cache.clear() def stop(self, mock: unittest.mock.MagicMock) -> None: """ Stops a previous patch or spy call by passing the ``MagicMock`` object returned by it. """ - for index, (p, m) in enumerate(self._patches_and_mocks): - if mock is m: - p.stop() - del self._patches_and_mocks[index] - break - else: - raise ValueError("This mock object is not registered") + self._mock_cache.remove(mock) def spy(self, obj: object, name: str) -> MockType: """ @@ -137,14 +169,6 @@ def spy(self, obj: object, name: str) -> MockType: :return: Spy object. """ method = getattr(obj, name) - if inspect.isclass(obj) and isinstance( - inspect.getattr_static(obj, name), (classmethod, staticmethod) - ): - # Can't use autospec classmethod or staticmethod objects before 3.7 - # see: https://bugs.python.org/issue23078 - autospec = False - else: - autospec = inspect.ismethod(method) or inspect.isfunction(method) def wrapper(*args, **kwargs): spy_obj.spy_return = None @@ -156,6 +180,7 @@ def wrapper(*args, **kwargs): raise else: spy_obj.spy_return = r + spy_obj.spy_return_list.append(r) return r async def async_wrapper(*args, **kwargs): @@ -168,6 +193,7 @@ async def async_wrapper(*args, **kwargs): raise else: spy_obj.spy_return = r + spy_obj.spy_return_list.append(r) return r if asyncio.iscoroutinefunction(method): @@ -175,8 +201,11 @@ async def async_wrapper(*args, **kwargs): else: wrapped = functools.update_wrapper(wrapper, method) + autospec = inspect.ismethod(method) or inspect.isfunction(method) + spy_obj = self.patch.object(obj, name, side_effect=wrapped, autospec=autospec) spy_obj.spy_return = None + spy_obj.spy_return_list = [] spy_obj.spy_exception = None return spy_obj @@ -214,8 +243,8 @@ class _Patcher: DEFAULT = object() - def __init__(self, patches_and_mocks, mock_module): - self.__patches_and_mocks = patches_and_mocks + def __init__(self, mock_cache, mock_module): + self.__mock_cache = mock_cache self.mock_module = mock_module def _start_patch( @@ -227,22 +256,18 @@ def _start_patch( """ p = mock_func(*args, **kwargs) mocked: MockType = p.start() - self.__patches_and_mocks.append((p, mocked)) + self.__mock_cache.add(mock=mocked, patch=p) if hasattr(mocked, "reset_mock"): # check if `mocked` is actually a mock object, as depending on autospec or target # parameters `mocked` can be anything if hasattr(mocked, "__enter__") and warn_on_mock_enter: - if sys.version_info >= (3, 8): - depth = 5 - else: - depth = 4 mocked.__enter__.side_effect = lambda: warnings.warn( "Mocks returned by pytest-mock do not need to be used as context managers. " "The mocker fixture automatically undoes mocking at the end of a test. " "This warning can be ignored if it was triggered by mocking a context manager. " "https://pytest-mock.readthedocs.io/en/latest/remarks.html#usage-as-context-manager", PytestMockWarning, - stacklevel=depth, + stacklevel=5, ) return mocked @@ -256,7 +281,7 @@ def object( spec_set: Optional[object] = None, autospec: Optional[object] = None, new_callable: object = None, - **kwargs: Any + **kwargs: Any, ) -> MockType: """API to mock.patch.object""" if new is self.DEFAULT: @@ -272,7 +297,7 @@ def object( spec_set=spec_set, autospec=autospec, new_callable=new_callable, - **kwargs + **kwargs, ) def context_manager( @@ -285,7 +310,7 @@ def context_manager( spec_set: Optional[builtins.object] = None, autospec: Optional[builtins.object] = None, new_callable: builtins.object = None, - **kwargs: Any + **kwargs: Any, ) -> MockType: """This is equivalent to mock.patch.object except that the returned mock does not issue a warning when used as a context manager.""" @@ -302,7 +327,7 @@ def context_manager( spec_set=spec_set, autospec=autospec, new_callable=new_callable, - **kwargs + **kwargs, ) def multiple( @@ -313,7 +338,7 @@ def multiple( spec_set: Optional[builtins.object] = None, autospec: Optional[builtins.object] = None, new_callable: Optional[builtins.object] = None, - **kwargs: Any + **kwargs: Any, ) -> Dict[str, MockType]: """API to mock.patch.multiple""" return self._start_patch( @@ -325,7 +350,7 @@ def multiple( spec_set=spec_set, autospec=autospec, new_callable=new_callable, - **kwargs + **kwargs, ) def dict( @@ -333,7 +358,7 @@ def dict( in_dict: Union[Mapping[Any, Any], str], values: Union[Mapping[Any, Any], Iterable[Tuple[Any, Any]]] = (), clear: bool = False, - **kwargs: Any + **kwargs: Any, ) -> Any: """API to mock.patch.dict""" return self._start_patch( @@ -342,7 +367,7 @@ def dict( in_dict, values=values, clear=clear, - **kwargs + **kwargs, ) @overload @@ -355,9 +380,8 @@ def __call__( spec_set: Optional[builtins.object] = ..., autospec: Optional[builtins.object] = ..., new_callable: None = ..., - **kwargs: Any - ) -> MockType: - ... + **kwargs: Any, + ) -> MockType: ... @overload def __call__( @@ -369,9 +393,8 @@ def __call__( spec_set: Optional[builtins.object] = ..., autospec: Optional[builtins.object] = ..., new_callable: None = ..., - **kwargs: Any - ) -> _T: - ... + **kwargs: Any, + ) -> _T: ... @overload def __call__( @@ -383,9 +406,8 @@ def __call__( spec_set: Optional[builtins.object], autospec: Optional[builtins.object], new_callable: Callable[[], _T], - **kwargs: Any - ) -> _T: - ... + **kwargs: Any, + ) -> _T: ... @overload def __call__( @@ -398,9 +420,8 @@ def __call__( autospec: Optional[builtins.object] = ..., *, new_callable: Callable[[], _T], - **kwargs: Any - ) -> _T: - ... + **kwargs: Any, + ) -> _T: ... def __call__( self, @@ -411,7 +432,7 @@ def __call__( spec_set: Optional[builtins.object] = None, autospec: Optional[builtins.object] = None, new_callable: Optional[Callable[[], Any]] = None, - **kwargs: Any + **kwargs: Any, ) -> Any: """API to mock.patch""" if new is self.DEFAULT: @@ -426,7 +447,7 @@ def __call__( spec_set=spec_set, autospec=autospec, new_callable=new_callable, - **kwargs + **kwargs, ) diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index fdeed0c..fc16490 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -9,10 +9,10 @@ from typing import Generator from typing import Tuple from typing import Type +from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest - from pytest_mock import MockerFixture from pytest_mock import PytestMockWarning @@ -23,11 +23,8 @@ platform.python_implementation() == "PyPy", reason="could not make it work on pypy" ) -# Python 3.8 changed the output formatting (bpo-35500), which has been ported to mock 3.0 -NEW_FORMATTING = sys.version_info >= (3, 8) - -if sys.version_info[:2] >= (3, 8): - from unittest.mock import AsyncMock +# Python 3.11.7 changed the output formatting, https://github.com/python/cpython/issues/111019 +NEWEST_FORMATTING = sys.version_info >= (3, 11, 7) @pytest.fixture @@ -172,12 +169,7 @@ def test_mock_patch_dict_resetall(mocker: MockerFixture) -> None: "NonCallableMock", "PropertyMock", "sentinel", - pytest.param( - "seal", - marks=pytest.mark.skipif( - sys.version_info < (3, 7), reason="seal is present on 3.7 and above" - ), - ), + "seal", ], ) def test_mocker_aliases(name: str, pytestconfig: Any) -> None: @@ -240,15 +232,14 @@ def test_repr_with_name(self, mocker: MockerFixture) -> None: def __test_failure_message(self, mocker: MockerFixture, **kwargs: Any) -> None: expected_name = kwargs.get("name") or "mock" - if NEW_FORMATTING: - msg = "expected call not found.\nExpected: {0}()\nActual: not called." + if NEWEST_FORMATTING: + msg = "expected call not found.\nExpected: {0}()\n Actual: not called." else: - msg = "Expected call: {0}()\nNot called" + msg = "expected call not found.\nExpected: {0}()\nActual: not called." expected_message = msg.format(expected_name) stub = mocker.stub(**kwargs) - with pytest.raises(AssertionError) as exc_info: + with pytest.raises(AssertionError, match=re.escape(expected_message)): stub.assert_called_with() - assert str(exc_info.value) == expected_message def test_failure_message_with_no_name(self, mocker: MagicMock) -> None: self.__test_failure_message(mocker) @@ -257,10 +248,6 @@ def test_failure_message_with_no_name(self, mocker: MagicMock) -> None: def test_failure_message_with_name(self, mocker: MagicMock, name: str) -> None: self.__test_failure_message(mocker, name=name) - @pytest.mark.skipif( - sys.version_info[:2] < (3, 8), - reason="This Python version doesn't have `AsyncMock`.", - ) def test_async_stub_type(self, mocker: MockerFixture) -> None: assert isinstance(mocker.async_stub(), AsyncMock) @@ -277,8 +264,13 @@ def bar(self, arg): assert other.bar(arg=10) == 20 foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 + assert foo.bar(arg=11) == 22 + assert foo.bar(arg=12) == 24 + assert spy.spy_return == 24 + assert spy.spy_return_list == [20, 22, 24] # Ref: https://docs.python.org/3/library/exceptions.html#exception-hierarchy @@ -314,17 +306,37 @@ def bar(self, arg): assert str(spy.spy_exception) == f"Error with {v}" -def test_instance_method_spy_autospec_true(mocker: MockerFixture) -> None: +def test_instance_class_static_method_spy_autospec_true(mocker: MockerFixture) -> None: class Foo: def bar(self, arg): return arg * 2 + @classmethod + def baz(cls, arg): + return arg * 2 + + @staticmethod + def qux(arg): + return arg * 2 + foo = Foo() - spy = mocker.spy(foo, "bar") + instance_method_spy = mocker.spy(foo, "bar") with pytest.raises( AttributeError, match="'function' object has no attribute 'fake_assert_method'" ): - spy.fake_assert_method(arg=5) + instance_method_spy.fake_assert_method(arg=5) + + class_method_spy = mocker.spy(Foo, "baz") + with pytest.raises( + AttributeError, match="Mock object has no attribute 'fake_assert_method'" + ): + class_method_spy.fake_assert_method(arg=5) + + static_method_spy = mocker.spy(Foo, "qux") + with pytest.raises( + AttributeError, match="Mock object has no attribute 'fake_assert_method'" + ): + static_method_spy.fake_assert_method(arg=5) def test_spy_reset(mocker: MockerFixture) -> None: @@ -336,10 +348,12 @@ def bar(self, x): spy = mocker.spy(Foo, "bar") assert spy.spy_return is None + assert spy.spy_return_list == [] assert spy.spy_exception is None Foo().bar(10) assert spy.spy_return == 30 + assert spy.spy_return_list == [30] assert spy.spy_exception is None # Testing spy can still be reset (#237). @@ -348,10 +362,12 @@ def bar(self, x): with pytest.raises(ValueError): Foo().bar(0) assert spy.spy_return is None + assert spy.spy_return_list == [] assert str(spy.spy_exception) == "invalid x" Foo().bar(15) assert spy.spy_return == 45 + assert spy.spy_return_list == [45] assert spy.spy_exception is None @@ -387,6 +403,7 @@ class Foo(Base): calls = [mocker.call(foo, arg=10), mocker.call(other, arg=10)] assert spy.call_args_list == calls assert spy.spy_return == 20 + assert spy.spy_return_list == [20, 20] @skip_pypy @@ -400,19 +417,10 @@ def bar(cls, arg): assert Foo.bar(arg=10) == 20 Foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert Foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert Foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 - - -@skip_pypy -def test_class_method_spy_autospec_false(mocker: MockerFixture) -> None: - class Foo: - @classmethod - def bar(cls, arg): - return arg * 2 - - spy = mocker.spy(Foo, "bar") - spy.fake_assert_method() + assert spy.spy_return_list == [20] @skip_pypy @@ -429,8 +437,10 @@ class Foo(Base): assert Foo.bar(arg=10) == 20 Foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert Foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert Foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 + assert spy.spy_return_list == [20] @skip_pypy @@ -449,8 +459,10 @@ def bar(cls, arg): assert Foo.bar(arg=10) == 20 Foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert Foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert Foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 + assert spy.spy_return_list == [20] @skip_pypy @@ -464,8 +476,10 @@ def bar(arg): assert Foo.bar(arg=10) == 20 Foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert Foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert Foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 + assert spy.spy_return_list == [20] @skip_pypy @@ -482,8 +496,10 @@ class Foo(Base): assert Foo.bar(arg=10) == 20 Foo.bar.assert_called_once_with(arg=10) # type:ignore[attr-defined] assert Foo.bar.spy_return == 20 # type:ignore[attr-defined] + assert Foo.bar.spy_return_list == [20] # type:ignore[attr-defined] spy.assert_called_once_with(arg=10) assert spy.spy_return == 20 + assert spy.spy_return_list == [20] def test_callable_like_spy(testdir: Any, mocker: MockerFixture) -> None: @@ -504,6 +520,7 @@ def __call__(self, x): uut.call_like(10) spy.assert_called_once_with(10) assert spy.spy_return == 20 + assert spy.spy_return_list == [20] async def test_instance_async_method_spy(mocker: MockerFixture) -> None: @@ -547,7 +564,12 @@ def assert_argument_introspection(left: Any, right: Any) -> Generator[None, None # NOTE: we assert with either verbose or not, depending on how our own # test was run by examining sys.argv verbose = any(a.startswith("-v") for a in sys.argv) - expected = "\n ".join(util._compare_eq_iterable(left, right, verbose)) + if int(pytest.__version__.split(".")[0]) < 8: + expected = "\n ".join(util._compare_eq_iterable(left, right, verbose)) # type:ignore[arg-type] + else: + expected = "\n ".join( + util._compare_eq_iterable(left, right, lambda t, *_: t, verbose) + ) assert expected in str(e) else: raise AssertionError("DID NOT RAISE") @@ -855,35 +877,26 @@ def test(mocker): """ ) result = testdir.runpytest("-s") - if NEW_FORMATTING: - expected_lines = [ - "*AssertionError: expected call not found.", - "*Expected: mock('', bar=4)", - "*Actual: mock('fo')", - ] - else: - expected_lines = [ - "*AssertionError: Expected call: mock('', bar=4)*", - "*Actual call: mock('fo')*", - ] + expected_lines = [ + "*AssertionError: expected call not found.", + "*Expected: mock('', bar=4)", + "*Actual: mock('fo')", + ] expected_lines += [ "*pytest introspection follows:*", "*Args:", "*assert ('fo',) == ('',)", "*At index 0 diff: 'fo' != ''*", - "*Use -v to get more diff*", + "*Use -v to*", "*Kwargs:*", "*assert {} == {'bar': 4}*", "*Right contains* more item*", "*{'bar': 4}*", - "*Use -v to get more diff*", + "*Use -v to*", ] result.stdout.fnmatch_lines(expected_lines) -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="AsyncMock is present on 3.8 and above" -) @pytest.mark.usefixtures("needs_assert_rewrite") def test_detailed_introspection_async(testdir: Any) -> None: """Check that the "mock_use_standalone" is being used.""" @@ -912,12 +925,12 @@ async def test(mocker): "*Args:", "*assert ('fo',) == ('',)", "*At index 0 diff: 'fo' != ''*", - "*Use -v to get more diff*", + "*Use -v to*", "*Kwargs:*", "*assert {} == {'bar': 4}*", "*Right contains* more item*", "*{'bar': 4}*", - "*Use -v to get more diff*", + "*Use -v to*", ] result.stdout.fnmatch_lines(expected_lines) @@ -1028,7 +1041,7 @@ def my_func(): with dummy_module.MyContext() as v: return v - m = mocker.patch.object(dummy_module, "MyContext") + mocker.patch.object(dummy_module, "MyContext") assert isinstance(my_func(), mocker.MagicMock) @@ -1251,3 +1264,28 @@ def foo(self): mocker.stop(spy) assert un_spy.foo() == 42 assert spy.call_count == 1 + + +def test_stop_multiple_patches(mocker: MockerFixture) -> None: + """Regression for #420.""" + + class Class1: + @staticmethod + def get(): + return 1 + + class Class2: + @staticmethod + def get(): + return 2 + + def handle_get(): + return 3 + + mocker.patch.object(Class1, "get", handle_get) + mocker.patch.object(Class2, "get", handle_get) + + mocker.stopall() + + assert Class1.get() == 1 + assert Class2.get() == 2 diff --git a/tox.ini b/tox.ini index a2b3e21..8082b5a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ [tox] minversion = 3.5.3 -envlist = py{38,39,310,311,312}, norewrite +envlist = py{38,39,310,311,312}, norewrite, pytest6 [testenv] deps = coverage mock pytest-asyncio + pytest6: pytest==6.2.5 commands = coverage run --append --source={envsitepackagesdir}/pytest_mock -m pytest tests --color=yes