diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a018383..f94cb22 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ UNRELEASED * `#621 `_: Fixed ``mocker.stop()`` sometimes stopping the wrong mock, for example with mocks from ``mocker.create_autospec``. * `#621 `_: ``mocker.stop()`` now undoes the most recent patch first when the same object is used in several patches. * `#623 `_: Fixed misleading introspection in ``assert_has_awaits`` failures, and ``assert_has_calls``/``assert_has_awaits`` raising ``IndexError`` instead of ``AssertionError`` when ``calls`` is passed by keyword. +* `#625 `_: Fixed ``assert_has_calls`` introspection to include the arguments of calls to child mocks. 3.16.0 ------ diff --git a/src/pytest_mock/plugin.py b/src/pytest_mock/plugin.py index f61c44b..2055f04 100644 --- a/src/pytest_mock/plugin.py +++ b/src/pytest_mock/plugin.py @@ -532,32 +532,110 @@ def assert_wrapper( raise e # noqa:TRY201 -def _unpack_expected_call(expect_call: Any) -> tuple[Any, Any] | None: +def _unpack_expected_call(expect_call: Any) -> tuple[Any, Any, Any] | None: """ - Return the ``(args, kwargs)`` of an expected call, or ``None`` + Return the ``(name, args, kwargs)`` of a call, or ``None`` if its shape is not recognized. """ match expect_call: case (): - return (), {} - case (_, args, kwargs): - return args, kwargs + return "", (), {} + case (name, args, kwargs): + return name, args, kwargs case (tuple() as args,): - return args, {} - case (str(),): - return (), {} + return "", args, {} + case (str() as name,): + return name, (), {} case (kwargs,): - return (), kwargs - case (str(), tuple() as args): - return args, {} - case (str(), kwargs): - return (), kwargs + return "", (), kwargs + case (str() as name, tuple() as args): + return name, args, {} + case (str() as name, kwargs): + return name, (), kwargs case (args, kwargs): - return args, kwargs + return "", args, kwargs case _: return None +def _get_mismatched_calls( + mock: Any, actual_calls: Iterable[Any], expect_calls: Iterable[Any] +) -> Iterator[tuple[Any, Any]]: + actual_with_keys = [] + for actual_call in actual_calls: + actual = _unpack_expected_call(actual_call) + if actual is not None: + key = _unpack_expected_call(mock._call_matcher(actual)) + actual_with_keys.append((actual, key)) + + expected_calls = [] + candidates = [] + for expect_call in expect_calls: + expected = _unpack_expected_call(expect_call) + if expected is None: + continue + expected_calls.append(expected) + expected_key = _unpack_expected_call(mock._call_matcher(expected)) + matching_indices = [] + for index, (actual, actual_key) in enumerate(actual_with_keys): + if ( + actual[0] == expected[0] + and expected_key is not None + and actual_key is not None + # Keep expected-side ANY and custom matcher equality semantics. + and expected_key[1:] == actual_key[1:] + ): + matching_indices.append(index) + candidates.append(matching_indices) + + actual_matches: list[int | None] = [None] * len(actual_with_keys) + expected_matches: list[int | None] = [None] * len(expected_calls) + + def reserve_match(expected_index: int) -> None: + # Find an augmenting path so a broad matcher can move to another actual + # call instead of taking the only match for a later expectation. + pending = [expected_index] + seen_expected = {expected_index} + parents: dict[int, int] = {} + for current_expected in pending: + for actual_index in candidates[current_expected]: + if actual_index in parents: + continue + parents[actual_index] = current_expected + matched_expected = actual_matches[actual_index] + if matched_expected is None: + path_actual: int | None = actual_index + while path_actual is not None: + path_expected = parents[path_actual] + previous_actual = expected_matches[path_expected] + actual_matches[path_actual] = path_expected + expected_matches[path_expected] = path_actual + path_actual = previous_actual + return + if matched_expected not in seen_expected: + seen_expected.add(matched_expected) + pending.append(matched_expected) + + # Reserve a maximum one-to-one set of exact matches before selecting the + # remaining same-name argument mismatches for introspection. + for expected_index in range(len(expected_calls)): + reserve_match(expected_index) + + remaining_actual = [ + actual + for index, (actual, _) in enumerate(actual_with_keys) + if actual_matches[index] is None + ] + for expected_index, expected in enumerate(expected_calls): + if expected_matches[expected_index] is not None: + continue + for index, actual in enumerate(remaining_actual): + if actual[0] == expected[0]: + del remaining_actual[index] + yield actual, expected + break + + def assert_has_calls_wrapper( __wrapped_mock_method__: Callable[..., Any], __call_list_attr__: str, @@ -585,11 +663,17 @@ def assert_has_calls_wrapper( ): actual_calls = list(call_list) introspection = "" - for actual_call, expect_call in itertools.zip_longest( - actual_calls, expect_calls - ): + call_pairs = ( + _get_mismatched_calls(__mock_self, actual_calls, expect_calls) + if __call_list_attr__ == "mock_calls" + else itertools.zip_longest(actual_calls, expect_calls) + ) + for actual_call, expect_call in call_pairs: if actual_call is not None: - actual_args, actual_kwargs = actual_call + unpacked = _unpack_expected_call(actual_call) + if unpacked is None: + continue + _, actual_args, actual_kwargs = unpacked else: actual_args = () actual_kwargs = {} @@ -598,7 +682,7 @@ def assert_has_calls_wrapper( unpacked = _unpack_expected_call(expect_call) if unpacked is None: continue - expect_args, expect_kwargs = unpacked + _, expect_args, expect_kwargs = unpacked else: expect_args = () expect_kwargs = {} @@ -649,7 +733,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 + _mock_module_originals["assert_has_calls"], "mock_calls", *args, **kwargs ) diff --git a/tests/test_pytest_mock.py b/tests/test_pytest_mock.py index e0c9859..196e202 100644 --- a/tests/test_pytest_mock.py +++ b/tests/test_pytest_mock.py @@ -971,6 +971,200 @@ def test_any_order_by_position(self, mocker: MockerFixture) -> None: stub.assert_has_calls([mocker.call("expected")], True) assert "pytest introspection follows:" not in str(exc_info.value) + @pytest.mark.parametrize("keyword", [False, True]) + @pytest.mark.usefixtures("needs_assert_rewrite") + def test_nested_call_arguments(self, mocker: MockerFixture, keyword: bool) -> None: + stub = mocker.MagicMock() + if keyword: + stub.child(value="actual") + expected = mocker.call.child(value="expected") + else: + stub.child("actual") + expected = mocker.call.child("expected") + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls([expected]) + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert "'actual'" in introspection + assert "'expected'" in introspection + + +@pytest.mark.usefixtures("needs_assert_rewrite") +class TestAssertHasCallsPairing: + def test_context_manager_missing_direct_call(self, mocker: MockerFixture) -> None: + stub = mocker.MagicMock() + with stub: + stub("a") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls([mocker.call("a"), mocker.call("b")]) + + assert "pytest introspection follows:" not in str(exc_info.value) + assert "call('b')" in str(exc_info.value) + + def test_mixed_calls_pair_arguments_by_name(self, mocker: MockerFixture) -> None: + stub = mocker.MagicMock() + with stub: + stub("direct-actual") + stub.child("child-actual", key="child-actual") + stub("matching") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [ + mocker.call("direct-expected"), + mocker.call.child("child-expected", key="child-expected"), + mocker.call("matching"), + ] + ) + + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert "assert ('direct-actual',) == ('direct-expected',)" in introspection + assert "assert ('child-actual',) == ('child-expected',)" in introspection + assert ( + "assert {'key': 'child-actual'} == {'key': 'child-expected'}" + in introspection + ) + assert introspection.count("\nArgs:\n") == 2 + assert introspection.count("\nKwargs:\n") == 1 + assert "matching" not in introspection + assert "None" not in introspection + assert "__enter__" not in introspection + assert "__exit__" not in introspection + + def test_unmatched_names_keep_original_message(self, mocker: MockerFixture) -> None: + stub = mocker.MagicMock() + stub.a("actual") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls([mocker.call.b("expected")]) + + message = str(exc_info.value) + assert "call.a('actual')" in message + assert "call.b('expected')" in message + assert "pytest introspection follows:" not in message + + @pytest.mark.parametrize("child", [False, True]) + def test_exact_matches_reserved_before_pairing( + self, mocker: MockerFixture, child: bool + ) -> None: + stub = mocker.MagicMock() + invoke = stub.child if child else stub + expected = mocker.call.child if child else mocker.call + invoke("same", key="same") + invoke("different", key="different") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [expected("expected", key="expected"), expected("same", key="same")] + ) + + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert "assert ('different',) == ('expected',)" in introspection + assert "assert {'key': 'different'} == {'key': 'expected'}" in introspection + assert introspection.count("\nArgs:\n") == 1 + assert introspection.count("\nKwargs:\n") == 1 + assert "'same'" not in introspection + + def test_duplicate_expectations_use_distinct_calls( + self, mocker: MockerFixture + ) -> None: + stub = mocker.MagicMock() + stub.child("same") + stub.child("different") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [mocker.call.child("same"), mocker.call.child("same")] + ) + + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert "assert ('different',) == ('same',)" in introspection + assert introspection.count("\nArgs:\n") == 1 + + def test_order_only_failure_keeps_original_message( + self, mocker: MockerFixture + ) -> None: + stub = mocker.MagicMock() + stub.first("first") + stub.second("second") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [mocker.call.second("second"), mocker.call.first("first")] + ) + + assert "pytest introspection follows:" not in str(exc_info.value) + + def test_any_exact_match_is_reserved(self, mocker: MockerFixture) -> None: + stub = mocker.MagicMock() + stub.child("first") + stub.child("second") + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [mocker.call.child("expected"), mocker.call.child(mocker.ANY)] + ) + + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert introspection.count("\nArgs:\n") == 1 + assert "'expected'" in introspection + assert "" not in introspection + + def test_autospec_exact_match_uses_signature(self, mocker: MockerFixture) -> None: + def target(value: int) -> None: + pass + + stub = mocker.create_autospec(target) + stub(1) + stub(2) + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls([mocker.call(value=3), mocker.call(value=1)]) + + introspection = str(exc_info.value).split("pytest introspection follows:")[1] + assert "assert (2,)" in introspection + assert "{'value': 3}" in introspection + assert "assert (1,)" not in introspection + assert "{'value': 1}" not in introspection + assert introspection.count("\nArgs:\n") == 1 + assert introspection.count("\nKwargs:\n") == 1 + + @pytest.mark.parametrize("use_any", [False, True]) + @pytest.mark.parametrize("extra_actual", [False, True]) + def test_overlapping_matchers_preserve_literal_matches( + self, mocker: MockerFixture, use_any: bool, extra_actual: bool + ) -> None: + class MatchesStrings: + def __eq__(self, other: object) -> bool: + return isinstance(other, str) + + stub = mocker.MagicMock() + stub.child("same") + stub.child("different") + if extra_actual: + stub.child("unmatched") + matcher = mocker.ANY if use_any else MatchesStrings() + + with pytest.raises(AssertionError) as exc_info: + stub.assert_has_calls( + [ + mocker.call.child(matcher), + mocker.call.child("same"), + mocker.call.child("missing"), + ] + ) + + message = str(exc_info.value) + if extra_actual: + introspection = message.split("pytest introspection follows:")[1] + assert "assert ('unmatched',) == ('missing',)" in introspection + assert introspection.count("\nArgs:\n") == 1 + assert "'same'" not in introspection + assert "'different'" not in introspection + else: + assert "call.child('missing')" in message + assert "pytest introspection follows:" not in message + @pytest.mark.usefixtures("needs_assert_rewrite") class TestAsyncAssertions: