pytest中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 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 的执行上下文时出现了问题

将属性绑定到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
2
3
4
5
6
============================== 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 设置和访问都在同一个对象上。

1
2
3
4
5
6
7
8
9
10
11
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
2
3
4
============================== 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 是类级别的共享状态,可以将其设置为类属性,而不是实例属性:

1
2
3
4
5
6
7
8
9
10
11
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 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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') # 这行会失败,先注释掉

运行这个代码:

1
2
3
4
5
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 未被设置。
  • 通过调整为类属性,可以解决这个问题。