diff --git a/changelog/14079.bugfix.rst b/changelog/14079.bugfix.rst new file mode 100644 index 00000000000..cf65868238c --- /dev/null +++ b/changelog/14079.bugfix.rst @@ -0,0 +1,6 @@ +Fix assertion diff output to preserve dictionary insertion order. + +When comparing dictionaries with extra keys, pytest could incorrectly inject +those keys into the structured diff output, producing misleading results. +The assertion diff now correctly preserves insertion order and reports extra +keys separately. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..13a7f1e8c5a 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -511,6 +511,7 @@ def _compare_eq_dict( elif same: explanation += ["Common items:"] explanation += highlighter(pprint.pformat(same)).splitlines() + diff = {k for k in common if left[k] != right[k]} if diff: explanation += ["Differing items:"] @@ -520,24 +521,25 @@ def _compare_eq_dict( + " != " + highlighter(saferepr({k: right[k]})) ] + extra_left = set_left - set_right - len_extra_left = len(extra_left) - if len_extra_left: + if extra_left: explanation.append( - f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:" + f"Left contains {len(extra_left)} more item{'' if len(extra_left) == 1 else 's'}:" ) explanation.extend( - highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines() + highlighter(saferepr({k: left[k] for k in extra_left})).splitlines() ) + extra_right = set_right - set_left - len_extra_right = len(extra_right) - if len_extra_right: + if extra_right: explanation.append( - f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:" + f"Right contains {len(extra_right)} more item{'' if len(extra_right) == 1 else 's'}:" ) explanation.extend( - highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines() + highlighter(saferepr({k: right[k] for k in extra_right})).splitlines() ) + return explanation diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 5179b13b0e9..2e30be655dd 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -209,8 +209,10 @@ def test_pytest_plugins_rewrite_module_names_correctly( "hamster.py": "", "test_foo.py": """\ def test_foo(pytestconfig): - assert pytestconfig.pluginmanager.rewrite_hook.find_spec('ham') is not None - assert pytestconfig.pluginmanager.rewrite_hook.find_spec('hamster') is None + assert pytestconfig.pluginmanager.rewrite_hook.find_spec( + 'ham') is not None + assert pytestconfig.pluginmanager.rewrite_hook.find_spec( + 'hamster') is None """, } pytester.makepyfile(**contents) @@ -417,6 +419,45 @@ class TestAssert_reprcompare: def test_different_types(self) -> None: assert callequal([0, 1], "foo") is None + def test_dict_preserves_insertion_order_regression(self) -> None: + # Regression test for issue #14079 + + long_a = "a" * 80 + sub = { + "long_a": long_a, + "sub1": { + "long_a": "substring that gets wrapped " * 3, + }, + } + + left = {"env": {"sub": sub}} + right = {"env": {"sub": sub}, "new": 1} + + diff = callequal(left, right, verbose=True) + assert diff is not None + + # extra key is reported + assert "{'new': 1}" in diff + + # inspect only structured diff + assert "Full diff:" in diff + start = diff.index("Full diff:") + 1 + diff_block = diff[start:] + + # 'new' must not appear as a structural key + assert all( + "'new': 1" not in line or line.lstrip().startswith("-") + for line in diff_block + ) + + # insertion order preserved inside left dict + env_index = next(i for i, line in enumerate(diff_block) if "'env': {" in line) + closing_index = next( + i for i, line in enumerate(diff_block) if line.strip() == "}" + ) + + assert env_index < closing_index + def test_summary(self) -> None: lines = callequal([0, 1], [0, 2]) assert lines is not None