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 @@ -5,6 +5,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.

3.16.1
------
Expand Down
38 changes: 24 additions & 14 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,50 @@ 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
calling the helper methods. This features catches `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``,
``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::


mocker = <pytest_mock.MockerFixture object at 0x0381E2D0>
mocker = <pytest_mock.plugin.MockerFixture object at 0x000001D7EBD26270>

def test(mocker):
m = mocker.Mock()
m('fo')
> m.assert_called_once_with('', bar=4)
E AssertionError: Expected call: mock('', bar=4)
E Actual call: mock('fo')
E AssertionError: expected call not found.
E Expected: mock('', bar=4)
E Actual: mock('fo')
E
E pytest introspection follows:
E
E Args:
E assert ('fo',) == ('',)
E
E At index 0 diff: 'fo' != ''
E Use -v to get the full diff
E Use -v to get more diff
E Kwargs:
E assert {} == {'bar': 4}
E Right contains more items:
E
E Right contains 1 more item:
E {'bar': 4}
E Use -v to get the full diff


test_foo.py:6: AssertionError
========================== 1 failed in 0.03 seconds ===========================
E Use -v to get more diff

test_foo.py:4: AssertionError

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 introspection requires pytest's assertion rewriting, so it is not shown with
``--assert=plain``.

This feature is probably safe, but if you encounter any problems it can be disabled in
your ``pytest.ini`` file:

Expand Down
104 changes: 68 additions & 36 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,34 +533,73 @@ def _mocker(pytestconfig: Any) -> Generator[MockerFixture, None, None]:
_mock_module_originals: dict[str, Any] = {}


_CallArgs = tuple[tuple[Any, ...], dict[str, Any]]


def _bind_to_spec(
mock: Any, actual: _CallArgs, expected: _CallArgs
) -> tuple[_CallArgs, _CallArgs]:
"""
Bind the actual and expected arguments to the mock's spec signature, like
the mock does before comparing them, so ``f(1, b=2)`` and ``f(1, 2)`` are
not reported as different. Return them unchanged when the mock has no
signature or either side does not match it.
"""
sig = getattr(mock, "_spec_signature", None)
if sig is None:
return actual, expected
try:
bound_actual = sig.bind(*actual[0], **actual[1])
bound_expected = sig.bind(*expected[0], **expected[1])
except TypeError:
return actual, expected
return (
(bound_actual.args, bound_actual.kwargs),
(bound_expected.args, bound_expected.kwargs),
)


def assert_wrapper(
__wrapped_mock_method__: Callable[..., Any],
__call_args_attr__: str,
__call_args_attr__: str | None,
*args: Any,
**kwargs: Any,
) -> None:
"""
Call the wrapped assertion, hiding its frames from the traceback.

When ``__call_args_attr__`` is given, the failure message also gets
pytest's introspection of the differences between the arguments of the
mock's last call (or await) and the expected arguments.
"""
__tracebackhide__ = True
try:
__wrapped_mock_method__(*args, **kwargs)
return
except AssertionError as e:
if getattr(e, "_mock_introspection_applied", 0):
msg = str(e)
else:
msg = str(e)
if (
not getattr(e, "_mock_introspection_applied", 0)
and __call_args_attr__ is not None
):
__mock_self = args[0]
msg = str(e)
call_args = getattr(__mock_self, __call_args_attr__)
if call_args is not None:
actual_args, actual_kwargs = call_args
(actual_args, actual_kwargs), (expect_args, expect_kwargs) = (
_bind_to_spec(__mock_self, tuple(call_args), (args[1:], kwargs))
)
introspection = ""
try:
assert actual_args == args[1:]
assert actual_args == expect_args
except AssertionError as e_args:
introspection += "\nArgs:\n" + str(e_args)
# Without assertion rewriting the message is empty.
if str(e_args):
introspection += "\nArgs:\n" + str(e_args)
try:
assert actual_kwargs == kwargs
assert actual_kwargs == expect_kwargs
except AssertionError as e_kwargs:
introspection += "\nKwargs:\n" + str(e_kwargs)
if str(e_kwargs):
introspection += "\nKwargs:\n" + str(e_kwargs)
if introspection:
msg += "\n\npytest introspection follows:\n" + introspection
e = AssertionError(msg)
Expand Down Expand Up @@ -660,9 +699,7 @@ def assert_has_calls_wrapper(

def wrap_assert_not_called(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_not_called"], "call_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_not_called"], None, *args, **kwargs)


def wrap_assert_called_with(*args: Any, **kwargs: Any) -> None:
Expand All @@ -674,15 +711,19 @@ def wrap_assert_called_with(*args: Any, **kwargs: Any) -> None:

def wrap_assert_called_once(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_called_once"], "call_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_called_once"], None, *args, **kwargs)


def wrap_assert_called_once_with(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
# If the mock was not called exactly once, the failure is about the call
# count, not the arguments of the last call.
call_args_attr = "call_args" if args[0].call_count == 1 else None
assert_wrapper(
_mock_module_originals["assert_called_once_with"], "call_args", *args, **kwargs
_mock_module_originals["assert_called_once_with"],
call_args_attr,
*args,
**kwargs,
)


Expand All @@ -695,23 +736,17 @@ def wrap_assert_has_calls(*args: Any, **kwargs: Any) -> None:

def wrap_assert_any_call(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_any_call"], "call_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_any_call"], None, *args, **kwargs)


def wrap_assert_called(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_called"], "call_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_called"], None, *args, **kwargs)


def wrap_assert_not_awaited(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_not_awaited"], "await_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_not_awaited"], None, *args, **kwargs)


def wrap_assert_awaited_with(*args: Any, **kwargs: Any) -> None:
Expand All @@ -723,16 +758,17 @@ def wrap_assert_awaited_with(*args: Any, **kwargs: Any) -> None:

def wrap_assert_awaited_once(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_awaited_once"], "await_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_awaited_once"], None, *args, **kwargs)


def wrap_assert_awaited_once_with(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
# If the mock was not awaited exactly once, the failure is about the await
# count, not the arguments of the last await.
await_args_attr = "await_args" if args[0].await_count == 1 else None
assert_wrapper(
_mock_module_originals["assert_awaited_once_with"],
"await_args",
await_args_attr,
*args,
**kwargs,
)
Expand All @@ -750,16 +786,12 @@ def wrap_assert_has_awaits(*args: Any, **kwargs: Any) -> None:

def wrap_assert_any_await(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_any_await"], "await_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_any_await"], None, *args, **kwargs)


def wrap_assert_awaited(*args: Any, **kwargs: Any) -> None:
__tracebackhide__ = True
assert_wrapper(
_mock_module_originals["assert_awaited"], "await_args", *args, **kwargs
)
assert_wrapper(_mock_module_originals["assert_awaited"], None, *args, **kwargs)


def wrap_assert_methods(config: Any) -> None:
Expand Down
84 changes: 83 additions & 1 deletion tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,24 @@ def test_foo(mocker):
assert "During handling of the above exception" not in result.stdout.str()
assert "mock.py" not in result.stdout.str()

def test_no_introspection_without_assert_rewrite(self, testdir: Any) -> None:
"""
Without assertion rewriting there is no introspection to show, so the
message does not get empty "Args:" and "Kwargs:" sections.
"""
testdir.makepyfile(
"""
def test_foo(mocker):
stub = mocker.stub()
stub(1, greet='hello')
stub.assert_called_once_with(2, greet='hey')
"""
)
result = testdir.runpytest_subprocess("--assert=plain")
result.assert_outcomes(failed=1)
assert "pytest introspection follows:" not in result.stdout.str()
assert "Args:" not in result.stdout.str()


class TestAssertHasCalls:
"""``assert_has_calls``, including the shapes accepted as expected calls."""
Expand Down Expand Up @@ -1307,7 +1325,7 @@ class TestAsyncAssertions:

@pytest.mark.parametrize(
"assertion",
["assert_awaited_with", "assert_awaited_once_with", "assert_any_await"],
["assert_awaited_with", "assert_awaited_once_with"],
)
@pytest.mark.asyncio
async def test_uses_await_arguments(
Expand Down Expand Up @@ -1441,6 +1459,70 @@ def test_called_kwargs(self, mocker: MockerFixture) -> None:
stub.assert_called_with(**wrong_kwargs)
stub.assert_called_once_with(**wrong_kwargs)

@pytest.mark.parametrize(
"assertion",
[
"assert_called_once()",
"assert_not_called()",
"assert_any_call(3)",
# Fails because of the call count, not the arguments.
"assert_called_once_with(3)",
],
)
def test_no_single_expected_call(
self, mocker: MockerFixture, assertion: str
) -> None:
"""
No introspection when the failure is not about the arguments of the last
call, as comparing them with the expected arguments would be misleading.
"""
stub = mocker.stub()
stub(1)
stub(2)
with pytest.raises(AssertionError) as exc_info:
eval(f"stub.{assertion}")
assert "pytest introspection follows:" not in str(exc_info.value)

@pytest.mark.parametrize(
"assertion",
[
"assert_awaited_once()",
"assert_not_awaited()",
"assert_any_await(3)",
# Fails because of the await count, not the arguments.
"assert_awaited_once_with(3)",
],
)
@pytest.mark.asyncio
async def test_no_single_expected_await(
self, mocker: MockerFixture, assertion: str
) -> None:
stub = mocker.AsyncMock()
await stub(1)
await stub(2)
with pytest.raises(AssertionError) as exc_info:
eval(f"stub.{assertion}")
assert "pytest introspection follows:" not in str(exc_info.value)

def test_autospec_binds_arguments(self, mocker: MockerFixture) -> None:
"""
Arguments are bound to the spec signature before comparing them, like
the mock does, so passing an argument by position or by keyword does not
show up as a difference.
"""

def target(a: int, b: int, c: int = 0) -> None:
pass

stub = mocker.create_autospec(target)
stub(1, b=2, c=3)
stub.assert_called_once_with(1, 2, c=3)
with pytest.raises(AssertionError) as exc_info:
stub.assert_called_once_with(1, 2, c=4)
introspection = str(exc_info.value).split("pytest introspection follows:")[1]
assert "assert (1, 2, 3) == (1, 2, 4)" in introspection
assert "Kwargs" not in introspection

def test_detailed(self, testdir: Any) -> None:
"""Check that the "mock_use_standalone" is being used."""
testdir.makeini(
Expand Down
Loading