Skip to content
Closed
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 @@ -8,6 +8,7 @@ UNRELEASED
* `#621 <https://github.com/pytest-dev/pytest-mock/pull/621>`_: Fixed ``mocker.stop()`` sometimes stopping the wrong mock, for example with mocks from ``mocker.create_autospec``.
* `#621 <https://github.com/pytest-dev/pytest-mock/pull/621>`_: ``mocker.stop()`` now undoes the most recent patch first when the same object is used in several patches.
* `#623 <https://github.com/pytest-dev/pytest-mock/pull/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 <https://github.com/pytest-dev/pytest-mock/pull/625>`_: Fixed ``assert_has_calls`` introspection to include the arguments of calls to child mocks.

3.16.0
------
Expand Down
124 changes: 104 additions & 20 deletions src/pytest_mock/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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
)


Expand Down
194 changes: 194 additions & 0 deletions tests/test_pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<ANY>" 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:
Expand Down