diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7c7e6f9..2782e05 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,7 @@ UNRELEASED * `#641 `_: Failures of mock assertions such as ``assert_called_with`` are again reported only once, instead of two or three times with the original error from the ``mock`` module chained before them. * `#642 `_: Mock assertion failures no longer show misleading introspection when they are not about the arguments of the last call: ``assert_called``, ``assert_called_once``, ``assert_not_called``, ``assert_any_call``, their ``await`` counterparts, and ``assert_called_once_with``/``assert_awaited_once_with`` when the mock was not called (awaited) exactly once. The introspection also no longer reports arguments passed by position versus by keyword as different for autospecced mocks, and no longer shows empty ``Args:``/``Kwargs:`` sections when assertion rewriting is not available. +* `#643 `_: ``assert_has_calls`` and ``assert_has_awaits`` failures no longer show pytest introspection, which compared the expected and actual calls position by position and so reported differences between unrelated calls. The mock's own message, listing the expected and actual calls, is still shown. 3.16.1 ------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 4b07ffe..aa39f20 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -26,11 +26,10 @@ of mock call assertions like ``Mock.assert_called_with()`` by hiding internal tr entries from the ``mock`` module. It also adds introspection information on differing call arguments when -``assert_called_with``, ``assert_called_once_with``, ``assert_awaited_with``, -``assert_awaited_once_with``, ``assert_has_calls`` or ``assert_has_awaits`` fail -because of the arguments of the calls. This feature catches the `AssertionError` -raised in the method, and uses pytest's own `advanced assertions`_ to return a -better diff:: +``assert_called_with``, ``assert_called_once_with``, ``assert_awaited_with`` or +``assert_awaited_once_with`` fail because of the arguments of the last call. This +feature catches the `AssertionError` raised in the method, and uses pytest's own +`advanced assertions`_ to return a better diff:: mocker = @@ -61,11 +60,11 @@ better diff:: This is useful when asserting mock calls with many/nested arguments and trying to quickly see the difference. -The other assertions, such as ``assert_called_once``, ``assert_not_called`` or -``assert_any_call``, fail because of how many times the mock was called, or -because none of its calls matches, so there is no single call to compare with the -expected arguments: the mock's own message, which lists the calls, is shown -instead. +The other assertions, such as ``assert_called_once``, ``assert_not_called``, +``assert_any_call`` or ``assert_has_calls``, fail because of how many times the +mock was called, or because none of its calls matches, so there is no single call +to compare with the expected arguments: the mock's own message, which lists the +calls, is shown instead. The introspection requires pytest's assertion rewriting, so it is not shown with ``--assert=plain``. diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index 61b4b83..71e5c2b 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -609,94 +609,6 @@ def assert_wrapper( raise e from None -def _unpack_expected_call(expect_call: Any) -> tuple[Any, Any] | None: - """ - Return the ``(args, kwargs)`` of an expected call, or ``None`` - if its shape is not recognized. - """ - match expect_call: - case (): - return (), {} - case (_, args, kwargs): - return args, kwargs - case (tuple() as args,): - return args, {} - case (str(),): - return (), {} - case (kwargs,): - return (), kwargs - case (str(), tuple() as args): - return args, {} - case (str(), kwargs): - return (), kwargs - case (args, kwargs): - return args, kwargs - case _: - return None - - -def assert_has_calls_wrapper( - __wrapped_mock_method__: Callable[..., Any], - __call_list_attr__: str, - *args: Any, - **kwargs: Any, -) -> None: - __tracebackhide__ = True - try: - __wrapped_mock_method__(*args, **kwargs) - return - except AssertionError as e: - # Bind the arguments like the original method does, so ``calls`` and - # ``any_order`` can be passed either by position or by keyword. - bound = inspect.signature(__wrapped_mock_method__).bind(*args, **kwargs) - bound.apply_defaults() - __mock_self = bound.arguments["self"] - expect_calls = bound.arguments["calls"] - any_order = bound.arguments["any_order"] - call_list = getattr(__mock_self, __call_list_attr__) - msg = str(e) - if ( - not getattr(e, "_mock_introspection_applied", 0) - and not any_order - and call_list is not None - ): - actual_calls = list(call_list) - introspection = "" - for actual_call, expect_call in itertools.zip_longest( - actual_calls, expect_calls - ): - if actual_call is not None: - actual_args, actual_kwargs = actual_call - else: - actual_args = () - actual_kwargs = {} - - if expect_call is not None: - unpacked = _unpack_expected_call(expect_call) - if unpacked is None: - continue - expect_args, expect_kwargs = unpacked - else: - expect_args = () - expect_kwargs = {} - - try: - assert actual_args == expect_args - except AssertionError as e_args: - introspection += "\nArgs:\n" + str(e_args) - try: - assert actual_kwargs == expect_kwargs - except AssertionError as e_kwargs: - introspection += "\nKwargs:\n" + str(e_kwargs) - if introspection: - msg += "\n\npytest introspection follows:\n" + introspection - e = AssertionError(msg) - e._mock_introspection_applied = True # type:ignore[attr-defined] - # Suppress the context, otherwise pytest shows the original error - # (and the mock frames) chained before this one. - raise e from None - - def wrap_assert_not_called(*args: Any, **kwargs: Any) -> None: __tracebackhide__ = True assert_wrapper(_mock_module_originals["assert_not_called"], None, *args, **kwargs) @@ -729,9 +641,7 @@ def wrap_assert_called_once_with(*args: Any, **kwargs: Any) -> None: def wrap_assert_has_calls(*args: Any, **kwargs: Any) -> None: __tracebackhide__ = True - assert_has_calls_wrapper( - _mock_module_originals["assert_has_calls"], "call_args_list", *args, **kwargs - ) + assert_wrapper(_mock_module_originals["assert_has_calls"], None, *args, **kwargs) def wrap_assert_any_call(*args: Any, **kwargs: Any) -> None: @@ -776,12 +686,7 @@ def wrap_assert_awaited_once_with(*args: Any, **kwargs: Any) -> None: def wrap_assert_has_awaits(*args: Any, **kwargs: Any) -> None: __tracebackhide__ = True - assert_has_calls_wrapper( - _mock_module_originals["assert_has_awaits"], - "await_args_list", - *args, - **kwargs, - ) + assert_wrapper(_mock_module_originals["assert_has_awaits"], None, *args, **kwargs) def wrap_assert_any_await(*args: Any, **kwargs: Any) -> None: diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 76b3eb0..8921e6d 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -11,6 +11,7 @@ from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock +from unittest.mock import call import pytest @@ -1244,79 +1245,44 @@ def test_no_calls( with assert_traceback(): stub.assert_has_calls([mocker.call("foo")]) - @pytest.mark.usefixtures("needs_assert_rewrite") - def test_two_tuple_calls(self, mocker: MockerFixture) -> None: - """Expected calls given as ``(args, kwargs)`` pairs, such as the entries of - another mock's ``call_args_list``, must raise ``AssertionError`` on mismatch.""" - expected = mocker.stub() - expected("foo", key="expected") - stub = mocker.stub() - stub("foo", key="actual") - - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_calls(expected.call_args_list) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'expected'" in introspection - assert "'actual'" in introspection - @pytest.mark.parametrize( - "expected_call", + ("actual", "expected_calls"), [ - pytest.param(((2,),), id="args-only"), - pytest.param(({"a": 2},), id="kwargs-only"), + pytest.param(((1,), {}), [((2,), {})], id="args-kwargs"), + pytest.param(((1,), {}), [((2,),)], id="args-only"), + pytest.param(((), {"a": 1}), [({"a": 2},)], id="kwargs-only"), + pytest.param(((1,), {}), [("", (2,))], id="name-args"), + pytest.param(((), {"a": 1}), [("", {"a": 2})], id="name-kwargs"), + pytest.param(((1,), {}), [call(2)], id="call"), ], ) - def test_one_tuple_calls( - self, mocker: MockerFixture, expected_call: tuple[Any, ...] + def test_no_introspection( + self, + mocker: MockerFixture, + actual: tuple[tuple[Any, ...], dict[str, Any]], + expected_calls: list[Any], ) -> None: - """Expected calls given as ``(args,)`` or ``(kwargs,)`` must raise - ``AssertionError`` on mismatch.""" - stub = mocker.MagicMock() - stub(1) - with pytest.raises(AssertionError, match="Calls not found"): - stub.assert_has_calls([expected_call]) - - @pytest.mark.usefixtures("needs_assert_rewrite") - def test_named_two_tuple_calls(self, mocker: MockerFixture) -> None: - """Expected calls given as ``(name, args)`` must be introspected using - ``args``, not the name.""" - stub = mocker.MagicMock() - stub("actual") - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_calls([("", ("expected",))]) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'expected'" in introspection - assert "Kwargs" not in introspection - - @pytest.mark.usefixtures("needs_assert_rewrite") - def test_named_kwargs_two_tuple_calls(self, mocker: MockerFixture) -> None: - """Expected calls given as ``(name, kwargs)`` must be introspected using - ``kwargs``, not the name.""" + """ + No introspection is added: the expected calls may match anywhere in + the actual calls, so there is no single actual call to compare each of + them with. All the shapes accepted as expected calls fail with an + ``AssertionError``. + """ stub = mocker.MagicMock() - stub(key="actual") - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_calls([("", {"key": "expected"})]) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'expected'" in introspection - assert "Args" not in introspection + stub(*actual[0], **actual[1]) + for kwargs in ({}, {"any_order": True}): + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls(expected_calls, **kwargs) + assert "pytest introspection follows:" not in str(exc_info.value) - @pytest.mark.usefixtures("needs_assert_rewrite") - def test_calls_by_keyword(self, mocker: MockerFixture) -> None: + def test_arguments_by_keyword(self, mocker: MockerFixture) -> None: stub = mocker.stub() stub("actual") stub.assert_has_calls(calls=[mocker.call("actual")]) - with pytest.raises(AssertionError) as exc_info: + with pytest.raises(AssertionError): stub.assert_has_calls(calls=[mocker.call("expected")]) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'expected'" in introspection - assert "'actual'" in introspection - - def test_any_order_by_position(self, mocker: MockerFixture) -> None: - stub = mocker.stub() - stub("actual") - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_calls([mocker.call("expected")], True) - assert "pytest introspection follows:" not in str(exc_info.value) + with pytest.raises(AssertionError): + stub.assert_has_calls(calls=[mocker.call("expected")], any_order=True) @pytest.mark.usefixtures("needs_assert_rewrite") @@ -1350,43 +1316,19 @@ async def test_uses_await_arguments( @pytest.mark.asyncio async def test_has_awaits(self, mocker: MockerFixture) -> None: - stub = mocker.AsyncMock() - await stub("awaited", source="awaited") - stub("called", source="called").close() - - stub.assert_has_awaits([mocker.call("awaited", source="awaited")]) - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_awaits([mocker.call("wrong", source="wrong")]) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'awaited'" in introspection - assert "'wrong'" in introspection - assert "'called'" not in introspection - assert "call(" not in introspection - - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_awaits([mocker.call("wrong")], any_order=True) - assert "pytest introspection follows:" not in str(exc_info.value) - - @pytest.mark.asyncio - async def test_has_awaits_calls_by_keyword(self, mocker: MockerFixture) -> None: + """No introspection is added, like for ``assert_has_calls``.""" stub = mocker.AsyncMock() await stub("awaited") + stub.assert_has_awaits([mocker.call("awaited")]) stub.assert_has_awaits(calls=[mocker.call("awaited")]) - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_awaits(calls=[mocker.call("wrong")]) - introspection = str(exc_info.value).split("pytest introspection follows:")[1] - assert "'awaited'" in introspection - assert "'wrong'" in introspection - - @pytest.mark.asyncio - async def test_has_awaits_any_order_by_position( - self, mocker: MockerFixture - ) -> None: - stub = mocker.AsyncMock() - await stub("awaited") - with pytest.raises(AssertionError) as exc_info: - stub.assert_has_awaits([mocker.call("wrong")], True) - assert "pytest introspection follows:" not in str(exc_info.value) + for args, kwargs in [ + (([mocker.call("wrong")],), {}), + (([mocker.call("wrong")], True), {}), + ((), {"calls": [mocker.call("wrong")]}), + ]: + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_awaits(*args, **kwargs) + assert "pytest introspection follows:" not in str(exc_info.value) def test_without_await(self, mocker: MockerFixture) -> None: stub = mocker.AsyncMock()