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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ UNRELEASED

* `#641 <https://github.com/pytest-dev/pytest-mock/pull/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 <https://github.com/pytest-dev/pytest-mock/pull/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 <https://github.com/pytest-dev/pytest-mock/pull/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
------
Expand Down
19 changes: 9 additions & 10 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <pytest_mock.plugin.MockerFixture object at 0x000001D7EBD26270>

Expand Down Expand Up @@ -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``.
Expand Down
99 changes: 2 additions & 97 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
134 changes: 38 additions & 96 deletions tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Loading