diff --git a/AUTHORS b/AUTHORS index 35d220e00..30ea946b0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -155,6 +155,7 @@ Justyna Janczyszyn Kale Kundert Kamran Ahmad Karl O. Pinc +Karthikeyan Singaravelan Katarzyna Jachim Katarzyna Król Katerina Koukiou diff --git a/changelog/7527.improvement.rst b/changelog/7527.improvement.rst new file mode 100644 index 000000000..726acffa9 --- /dev/null +++ b/changelog/7527.improvement.rst @@ -0,0 +1 @@ +When a comparison between `namedtuple` instances of the same type fails, pytest now shows the differing field names (possibly nested) instead of their indexes. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 93fa48b8e..da1ffd15e 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -110,6 +110,10 @@ def isset(x: Any) -> bool: return isinstance(x, (set, frozenset)) +def isnamedtuple(obj: Any) -> bool: + return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None + + def isdatacls(obj: Any) -> bool: return getattr(obj, "__dataclass_fields__", None) is not None @@ -171,14 +175,20 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: if istext(left) and istext(right): explanation = _diff_text(left, right, verbose) else: - if issequence(left) and issequence(right): + if type(left) == type(right) and ( + isdatacls(left) or isattrs(left) or isnamedtuple(left) + ): + # Note: unlike dataclasses/attrs, namedtuples compare only the + # field values, not the type or field names. But this branch + # intentionally only handles the same-type case, which was often + # used in older code bases before dataclasses/attrs were available. + explanation = _compare_eq_cls(left, right, verbose) + elif issequence(left) and issequence(right): explanation = _compare_eq_sequence(left, right, verbose) elif isset(left) and isset(right): explanation = _compare_eq_set(left, right, verbose) elif isdict(left) and isdict(right): explanation = _compare_eq_dict(left, right, verbose) - elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): - explanation = _compare_eq_cls(left, right, verbose) elif verbose > 0: explanation = _compare_eq_verbose(left, right) if isiterable(left) and isiterable(right): @@ -408,6 +418,10 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: elif isattrs(left): all_fields = left.__attrs_attrs__ fields_to_check = [field.name for field in all_fields if getattr(field, "eq")] + elif isnamedtuple(left): + fields_to_check = left._fields + else: + assert False indent = " " same = [] diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 02ecaf125..289fe5b08 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1,3 +1,4 @@ +import collections import sys import textwrap from typing import Any @@ -987,6 +988,44 @@ class TestAssert_reprcompare_attrsclass: assert lines is None +class TestAssert_reprcompare_namedtuple: + def test_namedtuple(self) -> None: + NT = collections.namedtuple("NT", ["a", "b"]) + + left = NT(1, "b") + right = NT(1, "c") + + lines = callequal(left, right) + assert lines == [ + "NT(a=1, b='b') == NT(a=1, b='c')", + "", + "Omitting 1 identical items, use -vv to show", + "Differing attributes:", + "['b']", + "", + "Drill down into differing attribute b:", + " b: 'b' != 'c'", + " - c", + " + b", + "Use -v to get the full diff", + ] + + def test_comparing_two_different_namedtuple(self) -> None: + NT1 = collections.namedtuple("NT1", ["a", "b"]) + NT2 = collections.namedtuple("NT2", ["a", "b"]) + + left = NT1(1, "b") + right = NT2(2, "b") + + lines = callequal(left, right) + # Because the types are different, uses the generic sequence matcher. + assert lines == [ + "NT1(a=1, b='b') == NT2(a=2, b='b')", + "At index 0 diff: 1 != 2", + "Use -v to get the full diff", + ] + + class TestFormatExplanation: def test_special_chars_full(self, pytester: Pytester) -> None: # Issue 453, for the bug this would raise IndexError