文章目錄
- 問題
- 分析
- 將屬性綁定到 **類** 上
- 使用 `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
未被設置。- 通過調整為類屬性,可以解決這個問題。