pytest 中 fixture 与类继承交互导致的问题

发布于:2025-06-03 ⋅ 阅读:(21) ⋅ 点赞:(0)

本文探讨 Pytest 中 fixture 作用域与类继承的交互问题,介绍其执行顺序规则。
以 TestBase 类和 TestDerived 子类为例,指出当 init 函数的 fixture 作用域设为 class 时会出现子类无法使用 self.base 的情况,原因是 fixture 和测试方法在不同实例对象上执行。

在 pytest 中,通常情况下,fixture 的执行顺序主要由 scope 决定,但并非简单地"高级别先执行"。实际上,pytest 按照一种"由外到内"的方式执行不同 scope 的 fixture。

具体来说,fixture 执行顺序遵循以下规则:

  1. 首先按照 scope 从大到小的顺序执行:session > package > module > class > function
  2. 同一 scope 级别的 fixture 按照依赖关系执行:如果一个 fixture 依赖于另一个 fixture(通过参数引用),则先执行被依赖的 fixture
  3. 同一 scope 级别且无依赖关系的 fixture 按照它们在代码中的声明顺序执行

问题

下面是 pytest 中 fixture 作用域(scope)与 Python 类继承之间的交互方式导致的一个问题。

这个代码 TestBase 中,如果将 init 函数使用级别为 function 的scope 运行没问题,但是改成 class 级别后,子类中的方法就没使用 self.base 了。

import pytest

class TestBase:
    # @pytest.fixture(scope='class', autouse=True)  # 在 test_derived 中无法使用 self.base
    @pytest.fixture(scope='function', autouse=True)  # 可行
    def init(self):
        self.base = "base"


class TestDerived(TestBase):
    def test_derived(self):
        assert self.base == "base"

    def test_derived2(self):
        assert self.base == "base"

分析

fixture 的触发机制问题

  • 对于 autouse=True 的 fixture,pytest 需要确定何时以及在哪个对象上执行它
  • 当 fixture 定义在类内部且使用 scope='class' 时,pytest 可能在处理 fixture 的执行上下文时出现了问题
将属性绑定到
import pytest


class TestBase:
    @pytest.fixture(scope='class', autouse=True)
    def init(self, request):
        print(f"Init fixture executing, self is: {self}")
        print(f"Request.cls is: {request.cls}")
        request.cls.base = "base"  # 确保设置在类上而不是实例上


class TestDerived(TestBase):
    def test_derived(self):
        print(f"In test_derived, self is: {self}")
        print(f"self.__class__.base is: {getattr(self.__class__, 'base', 'NOT_FOUND')}")
        assert hasattr(self.__class__, 'base')
        assert self.__class__.base == "base"
        assert self.base == "base"

执行结果:

============================== 1 passed in 0.10s ==============================
Init fixture executing, self is: <src.practice_demo.te.TestDerived object at 0x0000026EA6DE32E0>
Request.cls is: <class 'src.practice_demo.te.TestDerived'>
PASSED                                  [100%]
In test_derived, self is: <src.practice_demo.te.TestDerived object at 0x0000026EA6E50760>
self.__class__.base is: base

可以发现:

  1. fixture 确实执行了Init fixture executing 说明 scope='class' 的 fixture 被正确触发
  2. 执行顺序没问题:fixture 先执行,然后才是测试方法
  3. 对象实例不同:注意两个关键的内存地址
    • fixture 中的 self: 0x0000026EA6DE32E0
    • 测试方法中的 self: 0x0000026EA6E50760

这就解释了为什么原始代码会失败,当使用类内部定义的 scope='class' fixture 时:

  • fixture 在一个 TestDerived 实例上执行(地址 2E0),设置了 self.base = "base"
  • 但测试方法 test_derived 在另一个不同的 TestDerived 实例上执行(地址 760
  • 这两个是完全不同的对象实例

解决:使用 request.cls.base = "base" 将属性设置在上而不是实例上,所以无论哪个实例都能访问到这个类属性。

使用 scope='function'

因为 function 级别的 fixture 会在每个测试方法的同一个实例上执行,所以 self.base 设置和访问都在同一个对象上。

class TestBase:
    @pytest.fixture(scope='function', autouse=True)
    def init(self):
        print(f"Init fixture executing, self is: {self}")
        self.base = 'base'


class TestDerived(TestBase):
    def test_derived(self):
        print(f"In test_derived, self is: {self}")
        assert self.base == "base"

运行结果,地址相同:

============================== 1 passed in 0.10s ==============================
Init fixture executing, self is: <src.practice_demo.t.TestDerived object at 0x00000238DC562A90>
PASSED                                   [100%]
In test_derived, self is: <src.practice_demo.t.TestDerived object at 0x00000238DC562A90>

解决方法

使用类属性代替实例属性

如果 base 是类级别的共享状态,可以将其设置为类属性,而不是实例属性:

class TestBase:
    @pytest.fixture(scope='class', autouse=True)
    def init(self, request):
        request.cls.base = "base"  # 设置类属性

class TestDerived(TestBase):
    def test_derived(self):
        assert self.base == "base"

    def test_derived2(self):
        assert self.base == "base"
  • 在这里,request.cls 指向当前测试类(TestDerived),通过 request.cls.base 设置类属性。
  • 这样,base 成为 TestDerived 的类属性,所有的实例都可以通过 self.base 访问。

或者,保持使用 function 级别,这确实更符合 Python 类实例的工作方式,因为每个测试方法实际上都是在一个新的类实例上运行的。

为什么有两个不同的对象

使用 scope='function'情况下,因为 function 级别的 fixture 会在每个测试方法的同一个实例上执行,所以 self.base 设置和访问都在同一个对象上。

但是,为啥 scope 为 class 时,会出现两个对象呢?

这与 pytest 的 fixture 执行机制Python 类方法调用机制 有关。

核心原因:fixture 的执行上下文

当在类内部定义 fixture 时,pytest 需要在某个对象实例上调用这个 fixture 方法。但是:

scope='function' 的情况
  1. pytest 为每个测试方法创建一个新的 TestDerived 实例
  2. 在这个实例上调用 init fixture
  3. 然后在同一个实例上调用测试方法
  4. 流程:创建实例 → 调用 fixture → 调用测试方法(都在同一个对象上)
scope='class' 的情况
  1. pytest 需要在类级别执行 fixture,但 fixture 仍然是一个实例方法
  2. pytest 创建一个 TestDerived 实例来调用 init fixture
  3. 但当执行具体的测试方法时,pytest 又创建了另一个新的实例
  4. 流程:创建实例A → 调用 fixture → 创建实例B → 调用测试方法
为什么 pytest 要这样做?

这实际上是 pytest 设计的一个特点(或者说是限制),打印对象 id :

import pytest

class TestBase:
    @pytest.fixture(scope='class', autouse=True)
    def init(self):
        print(f"\nFixture executing on instance: {id(self)}")
        self.base = "base"


class TestDerived(TestBase):
    def test_derived(self):
        print(f"\nTest executing on instance: {id(self)}")
        print(f"hasattr(self, 'base'): {hasattr(self, 'base')}")
        if hasattr(self, 'base'):
            print(f"self.base: {self.base}")
        else:
            print("self.base does not exist")
        # assert hasattr(self, 'base')  # 这行会失败,先注释掉

运行这个代码:

Fixture executing on instance: 1523376596880
PASSED                                  [100%]
Test executing on instance: 1523376597312
hasattr(self, 'base'): False
self.base does not exist
这是 pytest 的设计局限

pytest 在处理类内部定义的 scope='class' fixture 时,无法很好地协调实例的生命周期。这就是为什么通常建议:

  1. 避免在类内部定义 class 级别的 fixture
  2. 将 class 级别的 fixture 定义在 conftest.py 中
  3. 或者使用 request.cls 来操作类属性而不是实例属性

所以看到的"两个对象"现象是 pytest 内部机制导致的,而不是 Python 或测试逻辑的问题。这也解释了为什么这种用法容易出现意想不到的行为。

总结

  • scope='function' 有效是因为 init 方法在每个测试函数运行时都会为当前实例设置 self.base
  • scope='class' 失败是因为 init 方法的 self 没有正确绑定到 TestDerived 的实例上,导致 self.base 未被设置。
  • 通过调整为类属性,可以解决这个问题。

网站公告

今日签到

点亮在社区的每一天
去签到