diff --git a/changelog/13652.bugfix.rst b/changelog/13652.bugfix.rst new file mode 100644 index 00000000000..907859c0e0e --- /dev/null +++ b/changelog/13652.bugfix.rst @@ -0,0 +1 @@ +pytest now reports the actual extra item in ``Left contains one more item`` and ``Right contains one more item`` assertion messages. This works for lists and tuples, including mixed ``list``/``tuple`` comparisons and when both sequences contain the same ``nan`` object. If pytest cannot determine the extra item, it keeps the previous message. diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py index cada856cc85..4984c809c7b 100644 --- a/src/_pytest/assertion/_compare_sequence.py +++ b/src/_pytest/assertion/_compare_sequence.py @@ -43,6 +43,24 @@ def _compare_eq_iterable( yield highlighter(line.rstrip(), lexer="diff") +def _items_equal(a: object, b: object) -> bool: + if a is b: + return True + try: + return bool(a == b) + except Exception: + return False + + +def _suffix_matches( + longer: Sequence[object], shorter: Sequence[object], index: int +) -> bool: + return all( + _items_equal(longer[index + 1 + offset], shorter[index + offset]) + for offset in range(len(shorter) - index) + ) + + def _compare_eq_sequence( left: Sequence[object], right: Sequence[object], @@ -52,8 +70,10 @@ def _compare_eq_sequence( comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) len_left = len(left) len_right = len(right) + found_diff = False + i = -1 for i in range(min(len_left, len_right)): - if left[i] != right[i]: + if not _items_equal(left[i], right[i]): if comparing_bytes: # when comparing bytes, we want to see their ascii representation # instead of their numeric values (#5260) @@ -73,6 +93,7 @@ def _compare_eq_sequence( f"At index {i} diff:" f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}" ) + found_diff = True break if comparing_bytes: @@ -84,11 +105,17 @@ def _compare_eq_sequence( if len_diff: if len_diff > 0: dir_with_more = "Left" - extra = saferepr(left[len_right]) + if found_diff and len_diff == 1 and _suffix_matches(left, right, i): + extra = saferepr(left[i]) + else: + extra = saferepr(left[len_right]) else: len_diff = 0 - len_diff dir_with_more = "Right" - extra = saferepr(right[len_left]) + if found_diff and len_diff == 1 and _suffix_matches(right, left, i): + extra = saferepr(right[i]) + else: + extra = saferepr(right[len_left]) if len_diff == 1: yield f"{dir_with_more} contains one more item: {highlighter(extra)}" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 035aad31c54..ef9c1ebad97 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from collections.abc import Mapping from collections.abc import MutableSequence +from collections.abc import Sequence import dataclasses import sys import textwrap @@ -1170,6 +1171,84 @@ def test_sequence_different_items(self) -> None: " )", ] + @pytest.mark.parametrize( + ("left", "right", "expected"), + [ + ([], [1], "Right contains one more item: 1"), + ([1], [], "Left contains one more item: 1"), + ([1, 2, 3], [1, 2, 3, 4], "Right contains one more item: 4"), + ([1, 2, 3], [1, 2, 0, 3], "Right contains one more item: 0"), + ([1, 2, 0, 3], [1, 2, 3], "Left contains one more item: 0"), + ([1, 2, 3], (0, 1, 2, 3), "Right contains one more item: 0"), + ((1, 2, 3), [0, 1, 2, 3], "Right contains one more item: 0"), + ([1, 2, 3], (1, 2, 0, 3), "Right contains one more item: 0"), + ([1, 2, 0, 3], (1, 2, 3), "Left contains one more item: 0"), + ([1, 2, 9], [1, 3], "Left contains one more item: 9"), + ([1, 3], [1, 2, 9], "Right contains one more item: 9"), + ( + [1, 2, 3], + [0, 1, 2, 3, 4], + "Right contains 2 more items, first extra item: 3", + ), + ], + ) + def test_sequence_extra_item_message( + self, left: Sequence[object], right: Sequence[object], expected: str + ) -> None: + lines = callequal(left, right, verbose=1) + assert lines is not None + assert expected in lines + + def test_sequence_extra_item_nan_identity(self) -> None: + nan = float("nan") + + lines = callequal([1, nan], [0, 1, nan], verbose=1) + assert lines is not None + assert "Right contains one more item: 0" in lines + + lines = callequal([0, 1, nan], [1, nan], verbose=1) + assert lines is not None + assert "Left contains one more item: 0" in lines + + lines = callequal([nan, 1], [nan, 0, 1], verbose=1) + assert lines is not None + assert "Right contains one more item: 0" in lines + assert not any("nan != nan" in line for line in lines) + + lines = callequal([1, nan], [1, nan, 0], verbose=1) + assert lines is not None + assert "Right contains one more item: 0" in lines + assert not any("nan != nan" in line for line in lines) + + def test_sequence_extra_item_broken_eq_falls_back(self) -> None: + class BrokenEq: + def __eq__(self, other: object) -> bool: + raise RuntimeError("boom") + + def __repr__(self) -> str: + return "BrokenEq()" + + lines = callequal([1, BrokenEq()], [0, 1, BrokenEq()], verbose=1) + assert lines is not None + assert "Right contains one more item: BrokenEq()" in lines + assert not any("representation of details failed" in line for line in lines) + + def test_sequence_extra_item_ambiguous_eq_falls_back(self) -> None: + class AmbiguousEq: + def __eq__(self, other: object) -> Any: + return self + + def __bool__(self) -> bool: + raise ValueError("ambiguous") + + def __repr__(self) -> str: + return "AmbiguousEq()" + + lines = callequal([1, AmbiguousEq()], [0, 1, AmbiguousEq()], verbose=1) + assert lines is not None + assert "Right contains one more item: AmbiguousEq()" in lines + assert not any("representation of details failed" in line for line in lines) + def test_set(self) -> None: expl = callequal({0, 1}, {0, 2}) assert expl is not None diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py index 653559c8a99..34e900d6ea7 100644 --- a/testing/test_error_diffs.py +++ b/testing/test_error_diffs.py @@ -66,7 +66,7 @@ def test_this(): > assert result == expected E assert [1, 3] == [1, 2, 3] E At index 1 diff: 3 != 2 - E Right contains one more item: 3 + E Right contains one more item: 2 E Full diff: (-: missing in left side, +: extra in left side) E [ E 1, @@ -76,6 +76,28 @@ def test_this(): """, id="Compare lists, one item missing", ), + pytest.param( + """ + def test_this(): + result = [1, 2, 3] + expected = [1, 2, 0, 3] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 2, 3] == [1, 2, 0, 3] + E At index 2 diff: 3 != 0 + E Right contains one more item: 0 + E Full diff: (-: missing in left side, +: extra in left side) + E [ + E 1, + E 2, + E - 0, + E 3, + E ] + """, + id="Compare lists, one extra item inserted mid-list", + ), pytest.param( """ def test_this():