diff --git a/changelog/15287.bugfix.rst b/changelog/15287.bugfix.rst new file mode 100644 index 00000000000..a59b493f4f4 --- /dev/null +++ b/changelog/15287.bugfix.rst @@ -0,0 +1 @@ +Fixed a regression in pytest 9.2.0 where :meth:`MonkeyPatch.setattr ` ``undo()`` deleted child attributes on :class:`unittest.mock.MagicMock` (or other dynamic attributes not present on the class) instead of restoring them. diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9b9bcd3fb95..a19861c6d49 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -266,7 +266,10 @@ def setattr( # class attribute, which permanently freezes descriptors that # resolve dynamically (#10644). target_dict = getattr(target, "__dict__", None) - if isinstance(target_dict, Mapping): + if isinstance(target_dict, Mapping) and ( + name in target_dict + or inspect.getattr_static(type(target), name, NOTSET) is not NOTSET + ): oldval = target_dict.get(name, NOTSET) setattr(target, name, value) self._setattr.append((target, name, oldval)) diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 0d07783b05b..e652eeb9af0 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -607,6 +607,44 @@ def __init__(self) -> None: assert obj.x == 1 +def test_undo_magicmock_child_attribute() -> None: + """Undoing a patch on a MagicMock's child mock must not delete it. + + See #15287. + """ + from unittest.mock import MagicMock + + shared = MagicMock() + orig_child = shared.connect # create child mock + monkeypatch = MonkeyPatch() + monkeypatch.setattr(shared, "connect", MagicMock(return_value=1)) + assert shared.connect() == 1 + + monkeypatch.undo() + assert shared.connect is orig_child + shared.connect.return_value = 2 + assert shared.connect() == 2 + + +def test_undo_dynamic_getattr_attribute() -> None: + """Undoing a patch on an attribute resolved via __getattr__ must restore it.""" + + class Dynamic: + def __getattr__(self, name: str) -> int: + if name == "dyn": + return 42 + raise AttributeError(name) + + d = Dynamic() + assert d.dyn == 42 + monkeypatch = MonkeyPatch() + monkeypatch.setattr(d, "dyn", 100) + assert d.dyn == 100 + + monkeypatch.undo() + assert d.dyn == 42 + + def test_issue1338_name_resolving() -> None: pytest.importorskip("requests") monkeypatch = MonkeyPatch()