pytest 中 fixture 与类继承交互导致的问题
文章目录
- 问题
- 分析
- 将属性绑定到 **类** 上
- 使用 `scope='function'`
- 解决方法
- 为什么有两个不同的对象
- 核心原因:fixture 的执行上下文
- `scope='function'` 的情况
- `scope='class'` 的情况
- 为什么 pytest 要这样做?
- 这是 pytest 的设计局限
- 总结
本文探讨 Pytest 中 fixture 作用域与类继承的交互问题,介绍其执行顺序规则。
以 TestBase 类和 TestDerived 子类为例,指出当 init 函数的 fixture 作用域设为 class 时会出现子类无法使用 self.base 的情况,原因是 fixture 和测试方法在不同实例对象上执行。
在 pytest 中,通常情况下,fixture 的执行顺序主要由 scope 决定,但并非简单地"高级别先执行"。实际上,pytest 按照一种"由外到内"的方式执行不同 scope 的 fixture。
具体来说,fixture 执行顺序遵循以下规则:
- 首先按照 scope 从大到小的顺序执行:session > package > module > class > function
- 同一 scope 级别的 fixture 按照依赖关系执行:如果一个 fixture 依赖于另一个 fixture(通过参数引用),则先执行被依赖的 fixture
- 同一 scope 级别且无依赖关系的 fixture 按照它们在代码中的声明顺序执行
问题
下面是 pytest 中 fixture 作用域(scope)与 Python 类继承之间的交互方式导致的一个问题。
这个代码 TestBase 中,如果将 init 函数使用级别为 function 的scope 运行没问题,但是改成 class 级别后,子类中的方法就没使用 self.base 了。
import pytestclass 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 pytestclass 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
可以发现:
- fixture 确实执行了:
Init fixture executing
说明scope='class'
的 fixture 被正确触发 - 执行顺序没问题:fixture 先执行,然后才是测试方法
- 对象实例不同:注意两个关键的内存地址
- fixture 中的
self
:0x0000026EA6DE32E0
- 测试方法中的
self
:0x0000026EA6E50760
- fixture 中的
这就解释了为什么原始代码会失败,当使用类内部定义的 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'
的情况
- pytest 为每个测试方法创建一个新的
TestDerived
实例 - 在这个实例上调用
init
fixture - 然后在同一个实例上调用测试方法
- 流程:创建实例 → 调用 fixture → 调用测试方法(都在同一个对象上)
scope='class'
的情况
- pytest 需要在类级别执行 fixture,但 fixture 仍然是一个实例方法
- pytest 创建一个
TestDerived
实例来调用init
fixture - 但当执行具体的测试方法时,pytest 又创建了另一个新的实例
- 流程:创建实例A → 调用 fixture → 创建实例B → 调用测试方法
为什么 pytest 要这样做?
这实际上是 pytest 设计的一个特点(或者说是限制),打印对象 id :
import pytestclass 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 时,无法很好地协调实例的生命周期。这就是为什么通常建议:
- 避免在类内部定义 class 级别的 fixture
- 将 class 级别的 fixture 定义在 conftest.py 中
- 或者使用
request.cls
来操作类属性而不是实例属性
所以看到的"两个对象"现象是 pytest 内部机制导致的,而不是 Python 或测试逻辑的问题。这也解释了为什么这种用法容易出现意想不到的行为。
总结
scope='function'
有效是因为init
方法在每个测试函数运行时都会为当前实例设置self.base
。scope='class'
失败是因为init
方法的self
没有正确绑定到TestDerived
的实例上,导致self.base
未被设置。- 通过调整为类属性,可以解决这个问题。