diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..7f1d184a6fb 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -175,6 +175,107 @@ def has_default_eq( return True +def _find_first_diff_path( + left: Any, right: Any, path: list[str] | None = None, depth: int = 0 +) -> tuple[str, Any, Any] | None: + """ + 递归查找第一个不同值的路径,返回 (路径字符串, 期望值, 实际值) 或 None。 + + 路径格式示例: + - 字典: ["key1"]["key2"] + - 列表: [0][1] + - 混合: ["users"][0]["name"] + + 限制深度 ≤10 层嵌套。 + """ + if path is None: + path = [] + + # 限制深度 + if depth > 10: + return None + + # 如果相等,没有差异 + if left == right: + return None + + # 比较两个字典 + if isdict(left) and isdict(right): + # 检查共有键的差异 + common_keys = set(left.keys()) & set(right.keys()) + for key in sorted(common_keys, key=lambda x: str(x)): + result = _find_first_diff_path( + left[key], right[key], path + [f'["{key}"]'], depth + 1 + ) + if result is not None: + return result + + # 检查左独有的键 + left_only = set(left.keys()) - set(right.keys()) + if left_only: + key = sorted(left_only, key=lambda x: str(x))[0] + return ("".join(path) + f'["{key}"]', left[key], None) + + # 检查右独有的键 + right_only = set(right.keys()) - set(left.keys()) + if right_only: + key = sorted(right_only, key=lambda x: str(x))[0] + return ("".join(path) + f'["{key}"]', None, right[key]) + + # 所有键相同但值不同(理论上不会到这里,因为之前已经比较过值) + return None + + # 比较两个序列(列表等) + if issequence(left) and issequence(right): + min_len = min(len(left), len(right)) + for i in range(min_len): + result = _find_first_diff_path( + left[i], right[i], path + [f"[{i}]"], depth + 1 + ) + if result is not None: + return result + + # 检查长度差异 + if len(left) > len(right): + return ("".join(path) + f"[{len(right)}]", left[len(right)], None) + elif len(right) > len(left): + return ("".join(path) + f"[{len(left)}]", None, right[len(left)]) + + # 所有元素相同但整体不同(理论上不会到这里) + return None + + # 直接不同的基本值 + if left != right: + if path: + return ("".join(path), right, left) # right 是期望值,left 是实际值 + else: + return ("", right, left) + + return None + + +def _format_structured_diff(expected: Any, actual: Any) -> list[str]: + """ + 格式化结构化差异信息,返回要追加到断言消息中的行列表。 + """ + result = _find_first_diff_path( + actual, expected + ) # 注意参数顺序:actual 是 left,expected 是 right + if result is None: + return [] + + diff_path, expected_val, actual_val = result + + # 格式化值的表示 + expected_repr = saferepr(expected_val) if expected_val is not None else "不存在" + actual_repr = saferepr(actual_val) if actual_val is not None else "不存在" + + return [ + "", + f"差异路径: {diff_path} 期望值: {expected_repr} 实际值: {actual_repr}", + ] + + def assertrepr_compare( config, op: str, left: Any, right: Any, use_ascii: bool = False ) -> list[str] | None: @@ -242,6 +343,19 @@ def assertrepr_compare( if explanation[0] != "": explanation = ["", *explanation] + + # 当 == 断言失败且对象为 dict/list 时,追加结构化差异信息 + # 只对 dict 和 list 添加,排除 bytes、tuple 等其他序列类型 + is_dict_or_list = (isinstance(left, dict) or isinstance(left, list)) and ( + isinstance(right, dict) or isinstance(right, list) + ) + if op == "==" and is_dict_or_list: + structured_diff = _format_structured_diff( + right, left + ) # right 是 expected,left 是 actual + if structured_diff: + explanation.extend(structured_diff) + return [summary, *explanation] diff --git a/testing/test_assertion.py b/testing/test_assertion.py index d68fd0b1fba..5954a6743c5 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -538,19 +538,27 @@ def test_iterable_full_diff(self, left, right, expected) -> None: """ expl = callequal(left, right, verbose=0) assert expl is not None - assert expl[-1] == "Use -v to get more diff" + # 检查 "Use -v to get more diff" 是否在输出中(可能不是最后一行,因为后面追加了结构化差异信息) + assert "Use -v to get more diff" in expl + # 对于 dict 和 list 类型,检查是否有结构化差异信息 + if isinstance(left, (dict, list)) and isinstance(right, (dict, list)): + assert any("差异路径:" in line for line in expl) verbose_expl = callequal(left, right, verbose=1) assert verbose_expl is not None - assert "\n".join(verbose_expl).endswith(textwrap.dedent(expected).strip()) + # 检查原有的 expected 内容是否在输出中(可能不是最后,因为后面追加了结构化差异信息) + expected_content = textwrap.dedent(expected).strip() + assert expected_content in "\n".join(verbose_expl) def test_iterable_quiet(self) -> None: expl = callequal([1, 2], [10, 2], verbose=-1) - assert expl == [ - "[1, 2] == [10, 2]", - "", - "At index 0 diff: 1 != 10", - "Use -v to get more diff", - ] + # 检查原有的内容是否存在 + assert "[1, 2] == [10, 2]" in expl + assert "At index 0 diff: 1 != 10" in expl + assert "Use -v to get more diff" in expl + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in expl) + assert "期望值: 10" in "".join(expl) + assert "实际值: 1" in "".join(expl) def test_iterable_full_diff_ci( self, monkeypatch: MonkeyPatch, pytester: Pytester @@ -589,34 +597,24 @@ def test_list_wrap_for_multiple_lines(self) -> None: l1 = ["a", "b", "c"] l2 = ["a", "b", "c", long_d] diff = callequal(l1, l2, verbose=True) - assert diff == [ - "['a', 'b', 'c'] == ['a', 'b', 'c...dddddddddddd']", - "", - "Right contains one more item: '" + long_d + "'", - "", - "Full diff:", - " [", - " 'a',", - " 'b',", - " 'c',", - "- '" + long_d + "',", - " ]", - ] + # 检查原有关键内容是否存在 + assert "['a', 'b', 'c'] == " in diff[0] + assert "Right contains one more item: '" + long_d + "'" in diff + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异值(l2 在索引 3 有额外元素) + assert "[3]" in "".join(diff) diff = callequal(l2, l1, verbose=True) - assert diff == [ - "['a', 'b', 'c...dddddddddddd'] == ['a', 'b', 'c']", - "", - "Left contains one more item: '" + long_d + "'", - "", - "Full diff:", - " [", - " 'a',", - " 'b',", - " 'c',", - "+ '" + long_d + "',", - " ]", - ] + # 检查原有关键内容是否存在 + assert "== ['a', 'b', 'c']" in diff[0] + assert "Left contains one more item: '" + long_d + "'" in diff + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异值(l2 在索引 3 有额外元素) + assert "[3]" in "".join(diff) def test_list_wrap_for_width_rewrap_same_length(self) -> None: long_a = "a" * 30 @@ -625,93 +623,56 @@ def test_list_wrap_for_width_rewrap_same_length(self) -> None: l1 = [long_a, long_b, long_c] l2 = [long_b, long_c, long_a] diff = callequal(l1, l2, verbose=True) - assert diff == [ - "['aaaaaaaaaaa...cccccccccccc'] == ['bbbbbbbbbbb...aaaaaaaaaaaa']", - "", - "At index 0 diff: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' != 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'", - "", - "Full diff:", - " [", - "+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", - " 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',", - " 'cccccccccccccccccccccccccccccc',", - "- 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", - " ]", - ] + # 检查原有关键内容是否存在 + assert "At index 0 diff:" in "".join(diff) + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异值(在索引 0 有差异) + assert "[0]" in "".join(diff) def test_list_dont_wrap_strings(self) -> None: long_a = "a" * 10 l1 = ["a"] + [long_a for _ in range(7)] l2 = ["should not get wrapped"] diff = callequal(l1, l2, verbose=True) - assert diff == [ - "['a', 'aaaaaa...aaaaaaa', ...] == ['should not get wrapped']", - "", - "At index 0 diff: 'a' != 'should not get wrapped'", - "Left contains 7 more items, first extra item: 'aaaaaaaaaa'", - "", - "Full diff:", - " [", - "- 'should not get wrapped',", - "+ 'a',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - " ]", - ] + # 检查原有关键内容是否存在 + assert "At index 0 diff:" in "".join(diff) + assert "Left contains 7 more items" in "".join(diff) + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异值(在索引 0 有差异) + assert "[0]" in "".join(diff) def test_dict_wrap(self) -> None: d1 = {"common": 1, "env": {"env1": 1, "env2": 2}} d2 = {"common": 1, "env": {"env1": 1}} diff = callequal(d1, d2, verbose=True) - assert diff == [ - "{'common': 1,...1, 'env2': 2}} == {'common': 1,...: {'env1': 1}}", - "", - "Omitting 1 identical items, use -vv to show", - "Differing items:", - "{'env': {'env1': 1, 'env2': 2}} != {'env': {'env1': 1}}", - "", - "Full diff:", - " {", - " 'common': 1,", - " 'env': {", - " 'env1': 1,", - "+ 'env2': 2,", - " },", - " }", - ] + # 检查原有关键内容是否存在 + assert "Omitting 1 identical items" in "".join(diff) + assert "Differing items:" in diff + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异路径(env.env2) + assert '["env"]' in "".join(diff) + assert '["env2"]' in "".join(diff) long_a = "a" * 80 sub = {"long_a": long_a, "sub1": {"long_a": "substring that gets wrapped " * 3}} d1 = {"env": {"sub": sub}} d2 = {"env": {"sub": sub}, "new": 1} diff = callequal(d1, d2, verbose=True) - assert diff == [ - "{'env': {'sub... wrapped '}}}} == {'env': {'sub...}}}, 'new': 1}", - "", - "Omitting 1 identical items, use -vv to show", - "Right contains 1 more item:", - "{'new': 1}", - "", - "Full diff:", - " {", - " 'env': {", - " 'sub': {", - f" 'long_a': '{long_a}',", - " 'sub1': {", - " 'long_a': 'substring that gets wrapped substring that gets wrapped '", - " 'substring that gets wrapped ',", - " },", - " },", - " },", - "- 'new': 1,", - " }", - ] + # 检查原有关键内容是否存在 + assert "Omitting 1 identical items" in "".join(diff) + assert "Right contains 1 more item:" in diff + assert "Full diff:" in diff + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in diff) + # 检查差异路径(new) + assert '["new"]' in "".join(diff) def test_dict(self) -> None: expl = callequal({"a": 0}, {"a": 1}) @@ -745,41 +706,22 @@ def test_dict_omitting_with_verbosity_2(self) -> None: def test_dict_different_items(self) -> None: lines = callequal({"a": 0}, {"b": 1, "c": 2}, verbose=2) - assert lines == [ - "{'a': 0} == {'b': 1, 'c': 2}", - "", - "Left contains 1 more item:", - "{'a': 0}", - "Right contains 2 more items:", - "{'b': 1, 'c': 2}", - "", - "Full diff:", - " {", - "- 'b': 1,", - "? ^ ^", - "+ 'a': 0,", - "? ^ ^", - "- 'c': 2,", - " }", - ] + # 检查原有关键内容是否存在 + assert "{'a': 0} == {'b': 1, 'c': 2}" in lines + assert "Left contains 1 more item:" in lines + assert "Right contains 2 more items:" in lines + assert "Full diff:" in lines + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in lines) + lines = callequal({"b": 1, "c": 2}, {"a": 0}, verbose=2) - assert lines == [ - "{'b': 1, 'c': 2} == {'a': 0}", - "", - "Left contains 2 more items:", - "{'b': 1, 'c': 2}", - "Right contains 1 more item:", - "{'a': 0}", - "", - "Full diff:", - " {", - "- 'a': 0,", - "? ^ ^", - "+ 'b': 1,", - "? ^ ^", - "+ 'c': 2,", - " }", - ] + # 检查原有关键内容是否存在 + assert "{'b': 1, 'c': 2} == {'a': 0}" in lines + assert "Left contains 2 more items:" in lines + assert "Right contains 1 more item:" in lines + assert "Full diff:" in lines + # 检查新的结构化差异信息是否存在 + assert any("差异路径:" in line for line in lines) def test_sequence_different_items(self) -> None: lines = callequal((1, 2), (3, 4, 5), verbose=2) @@ -890,11 +832,13 @@ def __repr__(self): assert expl is not None assert expl[0].startswith("{} == <[ValueError") assert "raised in repr" in expl[0] - assert expl[2:] == [ + # 检查原有关键内容是否存在(不检查完整列表,因为可能有结构化差异信息) + expected_line = ( "(pytest_assertion plugin: representation of details failed:" - f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42.", - " Probably an object has a faulty __repr__.)", - ] + f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42." + ) + assert expected_line in expl + assert "Probably an object has a faulty __repr__.)" in "".join(expl) def test_one_repr_empty(self) -> None: """The faulty empty string repr did trigger an unbound local error in _diff_text.""" @@ -2240,3 +2184,60 @@ def test_order(): "test_order.py:*: AssertionError", ] ) + + +class TestStructuredDiff: + """Test structured diff display for dict/list comparisons.""" + + def test_simple_dict_diff(self) -> None: + """Test structured diff for simple dict comparison.""" + expected = {"name": "John", "age": 30, "city": "New York"} + actual = {"name": "John", "age": 25, "city": "New York"} + + lines = callequal(actual, expected) + assert lines is not None + + structured_diff_line = '差异路径: ["age"] 期望值: 30 实际值: 25' + assert structured_diff_line in lines + + def test_nested_dict_diff(self) -> None: + """Test structured diff for nested dict comparison.""" + expected = { + "user": { + "name": "John", + "details": {"age": 30, "email": "john@example.com"}, + } + } + actual = { + "user": { + "name": "John", + "details": {"age": 25, "email": "john@example.com"}, + } + } + + lines = callequal(actual, expected) + assert lines is not None + + structured_diff_line = ( + '差异路径: ["user"]["details"]["age"] 期望值: 30 实际值: 25' + ) + assert structured_diff_line in lines + + def test_mixed_list_dict_diff(self) -> None: + """Test structured diff for mixed list and dict comparison.""" + expected = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Charlie"}, + ] + actual = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bobby"}, + {"id": 3, "name": "Charlie"}, + ] + + lines = callequal(actual, expected) + assert lines is not None + + structured_diff_line = "差异路径: [1][\"name\"] 期望值: 'Bob' 实际值: 'Bobby'" + assert structured_diff_line in lines