pytest 开发版 monkeypatch 撤销报 AttributeError,自定义属性值没有恢复
pytest 9.2 开发路径对实例字典的假设可能破坏自定义 __setattr__ 对象的恢复。区分正式版与开发版,并在自有配置对象的真实存储层恢复测试隔离。
测试里用 monkeypatch.setattr(cfg, "debug", True),测试体通过了,teardown 却报 AttributeError: 'Config' object has no attribute 'debug';下一条测试读到的还是 True。如果 cfg 通过自定义 __setattr__ 把属性存在其他字典中,要检查 pytest 开发版的撤销逻辑。
这个回归明确涉及 pytest 9.2.0 的开发构建,例如 9.2.0.dev345。截至 2026 年 10 月 21 日,可核对的正式 pytest 9.1.1 仍使用此前的读取路径,不能把该回归写成所有正式 pytest 9 都会发生的问题。
属性能读取,不代表它在实例字典里
下面的自有配置对象把 debug 放在 _data 中:
class Config:
def __init__(self):
object.__setattr__(self, "_data", {"debug": False})
def __getattr__(self, name):
try:
return self._data[name]
except KeyError:
raise AttributeError(name) from None
def __setattr__(self, name, value):
self._data[name] = value
受影响开发路径看到对象有 __dict__,就从该字典读取原属性;里面只有 _data,没有直接的 debug。于是它把原值记为“不存在”,撤销时调用 delattr(cfg, "debug"),而不是通过 setattr 恢复 False。删除失败后,修改还留在 _data 中。
这不同于 fixture 本身的 scope 错配,也不同于某个终结钩子报错后复用了旧 fixture 缓存。扩大 fixture 作用域会让污染持续更久,不能修好撤销方式。
自己维护的对象,可以在真实存储层打补丁
对上面这个明确由自己控制的示例,可改为:
cfg = Config()
def test_patch(monkeypatch):
monkeypatch.setitem(cfg._data, "debug", True)
assert cfg.debug is True
def test_restored():
assert cfg.debug is False
setitem() 记录并恢复的是字典条目,不再让属性撤销路径猜测存储位置。这个办法要求存储结构是自己维护且语义清楚的;不能为了规避 pytest 问题,穿透第三方对象的私有存储并绕过必要的属性校验。第三方对象应优先使用它提供的测试接口或替身。
若必须使用属性 setter,还可以编写范围明确的 fixture:先通过 getattr() 读取旧值,在 try/finally 中用同一 setter 恢复。不要继续对同一属性调用有问题的 monkeypatch.setattr(),再手工补一次赋值;原来的错误撤销记录仍会在 teardown 执行。
依赖开发版时,记录到具体构建
确认 CI 是否从源码分支或开发 wheel 安装了 pytest,再决定是否恢复项目兼容的正式版。开发分支的修复和 CI 通过,都不等于已安装的正式包包含修复;应对照实际发行版本确认。
复查时保持原来的逐测试生命周期,让第一条测试修改值,第二条确认已经恢复,并检查 teardown 仍无错误。也要检查失败或跳过时的清理路径。单独运行每个测试都通过,仍可能掩盖只有连续运行才会暴露的状态污染。


