DEBUG.WIKI
晚安云;香港 CN2 ¥15/月
京东云;让 AI 进入真实业务;零售 · 物流 · 客服 · 硬件;查看方案 →
搬瓦工;先看线路,再选 VPS;CN2 GIA · 对应线路套餐;查看线路 →

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 仍无错误。也要检查失败或跳过时的清理路径。单独运行每个测试都通过,仍可能掩盖只有连续运行才会暴露的状态污染。