diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5973d65..7c7e6f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,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. 3.16.1 ------ diff --git a/docs/configuration.rst b/docs/configuration.rst index bfac227..4b07ffe 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -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 = + mocker = 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: diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index ece8610..61b4b83 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -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) @@ -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: @@ -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, ) @@ -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: @@ -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, ) @@ -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: diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index 89dc4cc..76b3eb0 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -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.""" @@ -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( @@ -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(