如何使用 Fixture¶
另請參閱
另請參閱
「請求」Fixture¶
在基本層面上,測試函數透過將所需的 Fixture 宣告為參數來請求它們。
當 pytest 要執行測試時,它會查看該測試函數簽名中的參數,然後搜尋與這些參數名稱相同的 Fixture。一旦 pytest 找到它們,它就會執行這些 Fixture,捕獲它們返回的內容(如果有的話),並將這些物件作為參數傳遞到測試函數中。
快速範例¶
import pytest
class Fruit:
def __init__(self, name):
self.name = name
self.cubed = False
def cube(self):
self.cubed = True
class FruitSalad:
def __init__(self, *fruit_bowl):
self.fruit = fruit_bowl
self._cube_fruit()
def _cube_fruit(self):
for fruit in self.fruit:
fruit.cube()
# Arrange
@pytest.fixture
def fruit_bowl():
return [Fruit("apple"), Fruit("banana")]
def test_fruit_salad(fruit_bowl):
# Act
fruit_salad = FruitSalad(*fruit_bowl)
# Assert
assert all(fruit.cubed for fruit in fruit_salad.fruit)
在此範例中,test_fruit_salad
「請求」 fruit_bowl
(即 def test_fruit_salad(fruit_bowl):
),當 pytest 看到這個時,它將執行 fruit_bowl
Fixture 函數,並將其返回的物件作為 fruit_bowl
參數傳遞到 test_fruit_salad
中。
如果我們手動執行,這大致是發生的事情
def fruit_bowl():
return [Fruit("apple"), Fruit("banana")]
def test_fruit_salad(fruit_bowl):
# Act
fruit_salad = FruitSalad(*fruit_bowl)
# Assert
assert all(fruit.cubed for fruit in fruit_salad.fruit)
# Arrange
bowl = fruit_bowl()
test_fruit_salad(fruit_bowl=bowl)
Fixture 可以請求其他 Fixture¶
pytest 最強大的優勢之一是其極其靈活的 Fixture 系統。它允許我們將複雜的測試需求簡化為更簡單且更有組織的函數,我們只需要讓每個函數描述它們所依賴的事物。我們將在稍後更深入地探討這一點,但現在,這是一個快速範例,展示 Fixture 如何使用其他 Fixture
# contents of test_append.py
import pytest
# Arrange
@pytest.fixture
def first_entry():
return "a"
# Arrange
@pytest.fixture
def order(first_entry):
return [first_entry]
def test_string(order):
# Act
order.append("b")
# Assert
assert order == ["a", "b"]
請注意,這與上面的範例相同,但幾乎沒有任何變化。pytest 中的 Fixture 就像測試一樣請求 Fixture。所有相同的請求規則都適用於測試的 Fixture。以下是如果我們手動執行此範例的方式
def first_entry():
return "a"
def order(first_entry):
return [first_entry]
def test_string(order):
# Act
order.append("b")
# Assert
assert order == ["a", "b"]
entry = first_entry()
the_list = order(first_entry=entry)
test_string(order=the_list)
Fixture 是可重複使用的¶
使 pytest 的 Fixture 系統如此強大的原因之一是,它使我們能夠定義一個通用的設定步驟,可以一遍又一遍地重複使用,就像使用普通函數一樣。兩個不同的測試可以請求相同的 Fixture,並且 pytest 會為每個測試提供來自該 Fixture 的自己的結果。
這對於確保測試不受彼此影響非常有用。我們可以使用此系統來確保每個測試都獲得自己的一批新數據,並從乾淨的狀態開始,以便它可以提供一致、可重複的結果。
以下是如何派上用場的範例
# contents of test_append.py
import pytest
# Arrange
@pytest.fixture
def first_entry():
return "a"
# Arrange
@pytest.fixture
def order(first_entry):
return [first_entry]
def test_string(order):
# Act
order.append("b")
# Assert
assert order == ["a", "b"]
def test_int(order):
# Act
order.append(2)
# Assert
assert order == ["a", 2]
此處的每個測試都獲得了該 list
物件的自己的副本,這表示 order
Fixture 被執行了兩次(對於 first_entry
Fixture 也是如此)。如果我們也手動執行此操作,它看起來會像這樣
def first_entry():
return "a"
def order(first_entry):
return [first_entry]
def test_string(order):
# Act
order.append("b")
# Assert
assert order == ["a", "b"]
def test_int(order):
# Act
order.append(2)
# Assert
assert order == ["a", 2]
entry = first_entry()
the_list = order(first_entry=entry)
test_string(order=the_list)
entry = first_entry()
the_list = order(first_entry=entry)
test_int(order=the_list)
一個測試/Fixture 可以一次請求多個 Fixture¶
測試和 Fixture 不限於一次請求單個 Fixture。它們可以根據需要請求任意多個。這是另一個快速範例來示範
# contents of test_append.py
import pytest
# Arrange
@pytest.fixture
def first_entry():
return "a"
# Arrange
@pytest.fixture
def second_entry():
return 2
# Arrange
@pytest.fixture
def order(first_entry, second_entry):
return [first_entry, second_entry]
# Arrange
@pytest.fixture
def expected_list():
return ["a", 2, 3.0]
def test_string(order, expected_list):
# Act
order.append(3.0)
# Assert
assert order == expected_list
Fixture 可以在每個測試中被請求多次(傳回值會被快取)¶
Fixture 也可以在同一個測試期間被請求多次,而 pytest 不會為該測試再次執行它們。這表示我們可以在多個依賴於它們的 Fixture 中請求 Fixture(甚至在測試本身中再次請求),而這些 Fixture 不會被執行超過一次。
# contents of test_append.py
import pytest
# Arrange
@pytest.fixture
def first_entry():
return "a"
# Arrange
@pytest.fixture
def order():
return []
# Act
@pytest.fixture
def append_first(order, first_entry):
return order.append(first_entry)
def test_string_only(append_first, order, first_entry):
# Assert
assert order == [first_entry]
如果一個被請求的 Fixture 在測試期間每次被請求時都執行一次,那麼這個測試將會失敗,因為 append_first
和 test_string_only
都會將 order
視為空列表(即 []
),但由於 order
的傳回值在第一次呼叫後被快取(以及執行它可能產生的任何副作用),因此測試和 append_first
都參考相同的物件,並且測試看到了 append_first
對該物件的影響。
Autouse Fixture (您不必請求的 Fixture)¶
有時您可能希望擁有一個(甚至多個)您知道所有測試都將依賴的 Fixture。「Autouse」Fixture 是一種方便的方式,可以使所有測試自動請求它們。這可以減少許多冗餘的請求,甚至可以提供更進階的 Fixture 用法(稍後會詳細介紹)。
我們可以透過將 autouse=True
傳遞到 Fixture 的裝飾器,使 Fixture 成為 autouse Fixture。這是一個關於如何使用它們的簡單範例
# contents of test_append.py
import pytest
@pytest.fixture
def first_entry():
return "a"
@pytest.fixture
def order(first_entry):
return []
@pytest.fixture(autouse=True)
def append_first(order, first_entry):
return order.append(first_entry)
def test_string_only(order, first_entry):
assert order == [first_entry]
def test_string_and_int(order, first_entry):
order.append(2)
assert order == [first_entry, 2]
在此範例中,append_first
Fixture 是一個 autouse Fixture。由於它是自動發生的,因此兩個測試都受到它的影響,即使兩個測試都沒有請求它。但這並不表示它們不能被請求;只是沒有必要。
Scope:跨類別、模組、套件或 Session 共用 Fixture¶
需要網路存取的 Fixture 依賴於連線能力,並且通常建立起來很耗時。擴展先前的範例,我們可以將 scope="module"
參數新增到 @pytest.fixture
調用中,以使負責建立與現有 SMTP 伺服器連線的 smtp_connection
Fixture 函數僅在每個測試模組中調用一次(預設值是每個測試函數調用一次)。因此,測試模組中的多個測試函數將各自接收相同的 smtp_connection
Fixture 實例,從而節省時間。scope
的可能值為:function
、class
、module
、package
或 session
。
下一個範例將 Fixture 函數放入單獨的 conftest.py
檔案中,以便目錄中多個測試模組中的測試可以存取 Fixture 函數
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection():
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
# content of test_module.py
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
assert 0 # for demo purposes
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
assert 0 # for demo purposes
在這裡,test_ehlo
需要 smtp_connection
Fixture 值。pytest 將發現並呼叫 @pytest.fixture
標記的 smtp_connection
Fixture 函數。執行測試看起來像這樣
$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items
test_module.py FF [100%]
================================= FAILURES =================================
________________________________ test_ehlo _________________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
> assert 0 # for demo purposes
E assert 0
test_module.py:7: AssertionError
________________________________ test_noop _________________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0
test_module.py:13: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
============================ 2 failed in 0.12s =============================
您會看到兩個 assert 0
失敗,更重要的是,您還可以看見完全相同的 smtp_connection
物件被傳遞到兩個測試函數中,因為 pytest 在追蹤中顯示了傳入的參數值。因此,使用 smtp_connection
的兩個測試函數的執行速度與單個測試函數一樣快,因為它們重複使用了相同的實例。
如果您決定想要擁有一個 Session 範圍的 smtp_connection
實例,您可以簡單地宣告它
@pytest.fixture(scope="session")
def smtp_connection():
# the returned fixture value will be shared for
# all tests requesting it
...
Fixture Scope¶
Fixture 在首次被測試請求時建立,並根據其 scope
銷毀
function
:預設 Scope,Fixture 在測試結束時銷毀。class
:Fixture 在類別中最後一個測試的 TearDown 期間銷毀。module
:Fixture 在模組中最後一個測試的 TearDown 期間銷毀。package
:Fixture 在定義 Fixture 的套件(包括其中的子套件和子目錄)中最後一個測試的 TearDown 期間銷毀。session
:Fixture 在測試 Session 結束時銷毀。
注意
Pytest 一次僅快取一個 Fixture 實例,這表示當使用參數化的 Fixture 時,pytest 可能會在給定的 Scope 中多次調用 Fixture。
動態 Scope¶
版本 5.2 新增。
在某些情況下,您可能希望在不變更程式碼的情況下變更 Fixture 的 Scope。為此,請將可呼叫物件傳遞給 scope
。可呼叫物件必須傳回帶有有效 Scope 的字串,並且只會執行一次 - 在 Fixture 定義期間。它將使用兩個關鍵字參數呼叫 - 字串 fixture_name
和帶有設定物件的 config
。
當處理需要時間設定的 Fixture 時,這可能特別有用,例如產生 Docker 容器。您可以使用命令行參數來控制不同環境中產生的容器的 Scope。請參閱下面的範例。
def determine_scope(fixture_name, config):
if config.getoption("--keep-containers", None):
return "session"
return "function"
@pytest.fixture(scope=determine_scope)
def docker_container():
yield spawn_container()
TearDown/清理(又名 Fixture 最終化)¶
當我們執行測試時,我們會希望確保它們在完成後清理,以免影響任何其他測試(並且也避免留下大量的測試數據來膨脹系統)。pytest 中的 Fixture 提供了一個非常有用的 TearDown 系統,它允許我們為每個 Fixture 定義必要的特定步驟,以便在完成後自行清理。
這個系統可以透過兩種方式利用。
1. yield
Fixture(推薦)¶
「Yield」Fixture 使用 yield
而不是 return
。使用這些 Fixture,我們可以執行一些程式碼並將物件傳回給請求的 Fixture/測試,就像其他 Fixture 一樣。唯一的區別是
return
被替換為yield
。該 Fixture 的任何 TearDown 程式碼都放在
yield
之後。
一旦 pytest 找出 Fixture 的線性順序,它將執行每個 Fixture,直到它傳回或 Yield,然後繼續列表中的下一個 Fixture 執行相同的操作。
測試完成後,pytest 將沿著 Fixture 列表往回走,但順序相反,取得每個 Yield 的 Fixture,並執行其中在 yield
語句之後的程式碼。
作為一個簡單的範例,請考慮這個基本的電子郵件模組
# content of emaillib.py
class MailAdminClient:
def create_user(self):
return MailUser()
def delete_user(self, user):
# do some cleanup
pass
class MailUser:
def __init__(self):
self.inbox = []
def send_email(self, email, other):
other.inbox.append(email)
def clear_mailbox(self):
self.inbox.clear()
class Email:
def __init__(self, subject, body):
self.subject = subject
self.body = body
假設我們要測試從一個使用者向另一個使用者發送電子郵件。我們首先必須建立每個使用者,然後從一個使用者向另一個使用者發送電子郵件,最後斷言另一個使用者在其收件匣中收到了該訊息。如果我們想在測試執行後進行清理,我們可能必須確保在刪除該使用者之前清空另一個使用者的信箱,否則系統可能會抱怨。
以下是它可能看起來的樣子
# content of test_emaillib.py
from emaillib import Email, MailAdminClient
import pytest
@pytest.fixture
def mail_admin():
return MailAdminClient()
@pytest.fixture
def sending_user(mail_admin):
user = mail_admin.create_user()
yield user
mail_admin.delete_user(user)
@pytest.fixture
def receiving_user(mail_admin):
user = mail_admin.create_user()
yield user
user.clear_mailbox()
mail_admin.delete_user(user)
def test_email_received(sending_user, receiving_user):
email = Email(subject="Hey!", body="How's it going?")
sending_user.send_email(email, receiving_user)
assert email in receiving_user.inbox
由於 receiving_user
是設定期間最後一個運行的 Fixture,因此它是 TearDown 期間第一個運行的 Fixture。
即使 TearDown 端的順序正確,也存在無法保證安全清理的風險。這在 安全 TearDown 中有更詳細的介紹。
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
處理 Yield Fixture 的錯誤¶
如果 Yield Fixture 在 Yield 之前引發例外,pytest 將不會嘗試在該 Yield Fixture 的 yield
語句之後執行 TearDown 程式碼。但是,對於已經為該測試成功運行的每個 Fixture,pytest 仍將嘗試像往常一樣 TearDown 它們。
2. 直接新增 Finalizer¶
雖然 Yield Fixture 被認為是更簡潔和更直接的選項,但還有另一種選擇,那就是直接將「Finalizer」函數新增到測試的 請求上下文 物件。它帶來與 Yield Fixture 相似的結果,但需要更多的冗長性。
為了使用這種方法,我們必須在需要新增 TearDown 程式碼的 Fixture 中請求 請求上下文 物件(就像我們請求另一個 Fixture 一樣),然後將一個可呼叫物件(包含該 TearDown 程式碼)傳遞給其 addfinalizer
方法。
但是我們必須小心,因為即使 Fixture 在新增 Finalizer 後引發例外,pytest 也會在新增 Finalizer 後立即執行該 Finalizer。因此,為了確保我們在不需要時不執行 Finalizer 程式碼,我們只會在 Fixture 完成了我們需要 TearDown 的操作後才新增 Finalizer。
以下是使用 addfinalizer
方法的先前範例的外觀
# content of test_emaillib.py
from emaillib import Email, MailAdminClient
import pytest
@pytest.fixture
def mail_admin():
return MailAdminClient()
@pytest.fixture
def sending_user(mail_admin):
user = mail_admin.create_user()
yield user
mail_admin.delete_user(user)
@pytest.fixture
def receiving_user(mail_admin, request):
user = mail_admin.create_user()
def delete_user():
mail_admin.delete_user(user)
request.addfinalizer(delete_user)
return user
@pytest.fixture
def email(sending_user, receiving_user, request):
_email = Email(subject="Hey!", body="How's it going?")
sending_user.send_email(_email, receiving_user)
def empty_mailbox():
receiving_user.clear_mailbox()
request.addfinalizer(empty_mailbox)
return _email
def test_email_received(receiving_user, email):
assert email in receiving_user.inbox
它比 Yield Fixture 更長且更複雜,但它確實提供了一些在您處於困境時的細微差別。
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
關於 Finalizer 順序的注意事項¶
Finalizer 以先進後出的順序執行。對於 Yield Fixture,第一個要運行的 TearDown 程式碼來自最右邊的 Fixture,即最後一個測試參數。
# content of test_finalizers.py
import pytest
def test_bar(fix_w_yield1, fix_w_yield2):
print("test_bar")
@pytest.fixture
def fix_w_yield1():
yield
print("after_yield_1")
@pytest.fixture
def fix_w_yield2():
yield
print("after_yield_2")
$ pytest -s test_finalizers.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_finalizers.py test_bar
.after_yield_2
after_yield_1
============================ 1 passed in 0.12s =============================
對於 Finalizer,第一個要運行的 Fixture 是最後一次呼叫 request.addfinalizer
。
# content of test_finalizers.py
from functools import partial
import pytest
@pytest.fixture
def fix_w_finalizers(request):
request.addfinalizer(partial(print, "finalizer_2"))
request.addfinalizer(partial(print, "finalizer_1"))
def test_bar(fix_w_finalizers):
print("test_bar")
$ pytest -s test_finalizers.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_finalizers.py test_bar
.finalizer_1
finalizer_2
============================ 1 passed in 0.12s =============================
這是因為 Yield Fixture 在幕後使用 addfinalizer
:當 Fixture 執行時,addfinalizer
會註冊一個函數,該函數會恢復產生器,進而呼叫 TearDown 程式碼。
安全 TearDown¶
pytest 的 Fixture 系統非常強大,但它仍然由電腦運行,因此它無法弄清楚如何安全地 TearDown 我們扔給它的所有東西。如果我們不小心,錯誤位置的錯誤可能會留下我們測試中的東西,這可能會很快導致進一步的問題。
例如,請考慮以下測試(基於上面的郵件範例)
# content of test_emaillib.py
from emaillib import Email, MailAdminClient
import pytest
@pytest.fixture
def setup():
mail_admin = MailAdminClient()
sending_user = mail_admin.create_user()
receiving_user = mail_admin.create_user()
email = Email(subject="Hey!", body="How's it going?")
sending_user.send_email(email, receiving_user)
yield receiving_user, email
receiving_user.clear_mailbox()
mail_admin.delete_user(sending_user)
mail_admin.delete_user(receiving_user)
def test_email_received(setup):
receiving_user, email = setup
assert email in receiving_user.inbox
這個版本更緊湊,但也更難以閱讀,沒有非常描述性的 Fixture 名稱,並且沒有任何 Fixture 可以輕鬆重複使用。
還有一個更嚴重的問題,那就是如果設定中的任何步驟引發例外,則不會運行任何 TearDown 程式碼。
一種選擇可能是使用 addfinalizer
方法而不是 Yield Fixture,但這可能會變得非常複雜且難以維護(並且不再緊湊)。
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
安全 Fixture 結構¶
最安全和最簡單的 Fixture 結構要求將 Fixture 限制為每個 Fixture 僅執行一個更改狀態的操作,然後將它們與其 TearDown 程式碼捆綁在一起,如上面的電子郵件範例所示。
狀態變更操作可能失敗但仍修改狀態的可能性微乎其微,因為大多數這些操作傾向於基於 交易(至少在可能留下狀態的測試級別)。因此,如果我們確保任何成功的狀態變更操作都透過將其移動到單獨的 Fixture 函數並將其與其他可能失敗的狀態變更操作分開來進行 TearDown,那麼我們的測試將有最大的機會使測試環境保持在它們找到它的方式。
舉例來說,假設我們有一個帶有登入頁面的網站,並且我們可以存取一個管理員 API,我們可以在其中產生使用者。對於我們的測試,我們想要
透過該管理員 API 建立使用者
使用 Selenium 啟動瀏覽器
前往我們網站的登入頁面
以我們建立的使用者身份登入
斷言其名稱在登陸頁面的標頭中
我們不希望將該使用者留在系統中,也不希望讓該瀏覽器 Session 繼續運行,因此我們要確保建立這些事物的 Fixture 在完成後自行清理。
以下是它可能看起來的樣子
注意
對於此範例,某些 Fixture(即 base_url
和 admin_credentials
)被暗示存在於其他地方。因此,就目前而言,讓我們假設它們存在,而我們只是沒有查看它們。
from uuid import uuid4
from urllib.parse import urljoin
from selenium.webdriver import Chrome
import pytest
from src.utils.pages import LoginPage, LandingPage
from src.utils import AdminApiClient
from src.utils.data_types import User
@pytest.fixture
def admin_client(base_url, admin_credentials):
return AdminApiClient(base_url, **admin_credentials)
@pytest.fixture
def user(admin_client):
_user = User(name="Susan", username=f"testuser-{uuid4()}", password="P4$$word")
admin_client.create_user(_user)
yield _user
admin_client.delete_user(_user)
@pytest.fixture
def driver():
_driver = Chrome()
yield _driver
_driver.quit()
@pytest.fixture
def login(driver, base_url, user):
driver.get(urljoin(base_url, "/login"))
page = LoginPage(driver)
page.login(user)
@pytest.fixture
def landing_page(driver, login):
return LandingPage(driver)
def test_name_on_landing_page_after_login(landing_page, user):
assert landing_page.header == f"Welcome, {user.name}!"
依賴項的佈局方式表示不清楚 user
Fixture 是否會在 driver
Fixture 之前執行。但這沒關係,因為這些是原子操作,因此哪個先運行並不重要,因為測試的事件順序仍然是 線性化的。但是,重要的是,無論哪個先運行,如果一個在不會引發例外的情況下引發例外,則兩者都不會留下任何東西。如果 driver
在 user
之前執行,並且 user
引發例外,則驅動程式仍將退出,並且永遠不會建立使用者。如果 driver
是引發例外的那個,那麼驅動程式將永遠不會啟動,並且永遠不會建立使用者。
安全地運行多個 assert
語句¶
有時您可能希望在完成所有設定後運行多個斷言,這是有道理的,因為在更複雜的系統中,單個操作可以觸發多種行為。pytest 提供了一種方便的方法來處理這個問題,它結合了我們到目前為止所討論的大部分內容。
所有需要的只是提升到更大的 Scope,然後將 act 步驟定義為 autouse Fixture,最後,確保所有 Fixture 都以該更高層級的 Scope 為目標。
讓我們提取上面的範例,並稍微調整一下。假設除了檢查標頭中的歡迎訊息外,我們還想檢查登出按鈕和使用者個人資料的連結。
讓我們看看如何建構它,以便我們可以運行多個斷言,而無需再次重複所有這些步驟。
注意
對於此範例,某些 Fixture(即 base_url
和 admin_credentials
)被暗示存在於其他地方。因此,就目前而言,讓我們假設它們存在,而我們只是沒有查看它們。
# contents of tests/end_to_end/test_login.py
from uuid import uuid4
from urllib.parse import urljoin
from selenium.webdriver import Chrome
import pytest
from src.utils.pages import LoginPage, LandingPage
from src.utils import AdminApiClient
from src.utils.data_types import User
@pytest.fixture(scope="class")
def admin_client(base_url, admin_credentials):
return AdminApiClient(base_url, **admin_credentials)
@pytest.fixture(scope="class")
def user(admin_client):
_user = User(name="Susan", username=f"testuser-{uuid4()}", password="P4$$word")
admin_client.create_user(_user)
yield _user
admin_client.delete_user(_user)
@pytest.fixture(scope="class")
def driver():
_driver = Chrome()
yield _driver
_driver.quit()
@pytest.fixture(scope="class")
def landing_page(driver, login):
return LandingPage(driver)
class TestLandingPageSuccess:
@pytest.fixture(scope="class", autouse=True)
def login(self, driver, base_url, user):
driver.get(urljoin(base_url, "/login"))
page = LoginPage(driver)
page.login(user)
def test_name_in_header(self, landing_page, user):
assert landing_page.header == f"Welcome, {user.name}!"
def test_sign_out_button(self, landing_page):
assert landing_page.sign_out_button.is_displayed()
def test_profile_link(self, landing_page, user):
profile_href = urljoin(base_url, f"/profile?id={user.profile_id}")
assert landing_page.profile_link.get_attribute("href") == profile_href
請注意,這些方法僅在簽名中將 self
作為形式參考。沒有狀態與實際的測試類別綁定,就像在 unittest.TestCase
框架中一樣。一切都由 pytest Fixture 系統管理。
每個方法只需要請求它實際需要的 Fixture,而無需擔心順序。這是因為 act Fixture 是一個 autouse Fixture,它確保所有其他 Fixture 在它之前執行。不再需要發生狀態變更,因此測試可以自由地進行任意多次非狀態變更的查詢,而不會有踩到其他測試腳趾的風險。
login
Fixture 也定義在類別內部,因為模組中的其他測試並非每個都期望成功登入,並且 act 可能需要針對另一個測試類別進行稍微不同的處理。例如,如果我們想編寫另一個關於提交錯誤憑證的測試情境,我們可以透過在測試檔案中新增類似以下的內容來處理它
class TestLandingPageBadCredentials:
@pytest.fixture(scope="class")
def faux_user(self, user):
_user = deepcopy(user)
_user.password = "badpass"
return _user
def test_raises_bad_credentials_exception(self, login_page, faux_user):
with pytest.raises(BadCredentialsException):
login_page.login(faux_user)
Fixture 可以內省請求測試上下文¶
Fixture 函數可以接受 request
物件,以內省「請求」測試函數、類別或模組上下文。進一步擴展先前的 smtp_connection
Fixture 範例,讓我們從使用我們 Fixture 的測試模組中讀取可選的伺服器 URL
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection(request):
server = getattr(request.module, "smtpserver", "smtp.gmail.com")
smtp_connection = smtplib.SMTP(server, 587, timeout=5)
yield smtp_connection
print(f"finalizing {smtp_connection} ({server})")
smtp_connection.close()
我們使用 request.module
屬性從測試模組中選擇性地取得 smtpserver
屬性。如果我們再次執行,則沒有太多變化
$ pytest -s -q --tb=no test_module.py
FFfinalizing <smtplib.SMTP object at 0xdeadbeef0002> (smtp.gmail.com)
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
2 failed in 0.12s
讓我們快速建立另一個測試模組,該模組實際上在其模組命名空間中設定了伺服器 URL
# content of test_anothersmtp.py
smtpserver = "mail.python.org" # will be read by smtp fixture
def test_showhelo(smtp_connection):
assert 0, smtp_connection.helo()
運行它
$ pytest -qq --tb=short test_anothersmtp.py
F [100%]
================================= FAILURES =================================
______________________________ test_showhelo _______________________________
test_anothersmtp.py:6: in test_showhelo
assert 0, smtp_connection.helo()
E AssertionError: (250, b'mail.python.org')
E assert 0
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0003> (mail.python.org)
========================= short test summary info ==========================
FAILED test_anothersmtp.py::test_showhelo - AssertionError: (250, b'mail....
瞧!smtp_connection
Fixture 函數從模組命名空間中擷取了我們的郵件伺服器名稱。
使用標記將資料傳遞給 Fixture¶
使用 request
物件,Fixture 也可以存取應用於測試函數的標記。這對於從測試中將資料傳遞到 Fixture 中非常有用
import pytest
@pytest.fixture
def fixt(request):
marker = request.node.get_closest_marker("fixt_data")
if marker is None:
# Handle missing marker in some way...
data = None
else:
data = marker.args[0]
# Do something with the data
return data
@pytest.mark.fixt_data(42)
def test_fixt(fixt):
assert fixt == 42
工廠作為 Fixture¶
「工廠作為 Fixture」模式可以在單個測試中多次需要 Fixture 結果的情況下提供幫助。Fixture 不是直接傳回資料,而是傳回一個產生資料的函數。然後可以在測試中多次呼叫此函數。
工廠可以根據需要設定參數
@pytest.fixture
def make_customer_record():
def _make_customer_record(name):
return {"name": name, "orders": []}
return _make_customer_record
def test_customer_records(make_customer_record):
customer_1 = make_customer_record("Lisa")
customer_2 = make_customer_record("Mike")
customer_3 = make_customer_record("Meredith")
如果工廠建立的資料需要管理,Fixture 可以負責處理
@pytest.fixture
def make_customer_record():
created_records = []
def _make_customer_record(name):
record = models.Customer(name=name, orders=[])
created_records.append(record)
return record
yield _make_customer_record
for record in created_records:
record.destroy()
def test_customer_records(make_customer_record):
customer_1 = make_customer_record("Lisa")
customer_2 = make_customer_record("Mike")
customer_3 = make_customer_record("Meredith")
參數化 Fixture¶
Fixture 函數可以被參數化,在這種情況下,它們將被多次呼叫,每次執行一組依賴的測試,即依賴於此 Fixture 的測試。測試函數通常不需要知道它們的重新運行。Fixture 參數化有助於為本身可以透過多種方式配置的元件編寫詳盡的功能測試。
擴展先前的範例,我們可以標記 Fixture 以建立兩個 smtp_connection
Fixture 實例,這將導致所有使用該 Fixture 的測試運行兩次。Fixture 函數可以透過特殊的 request
物件存取每個參數
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
yield smtp_connection
print(f"finalizing {smtp_connection}")
smtp_connection.close()
主要變更是使用 @pytest.fixture
宣告 params
,這是值列表,Fixture 函數將針對每個值執行,並且可以透過 request.param
存取值。無需變更任何測試函數程式碼。因此,讓我們再次執行
$ pytest -q test_module.py
FFFF [100%]
================================= FAILURES =================================
________________________ test_ehlo[smtp.gmail.com] _________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
> assert 0 # for demo purposes
E assert 0
test_module.py:7: AssertionError
________________________ test_noop[smtp.gmail.com] _________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0
test_module.py:13: AssertionError
________________________ test_ehlo[mail.python.org] ________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
> assert b"smtp.gmail.com" in msg
E AssertionError: assert b'smtp.gmail.com' in b'mail.python.org\nPIPELINING\nSIZE 51200000\nETRN\nSTARTTLS\nAUTH DIGEST-MD5 NTLM CRAM-MD5\nENHANCEDSTATUSCODES\n8BITMIME\nDSN\nSMTPUTF8\nCHUNKING'
test_module.py:6: AssertionError
-------------------------- Captured stdout setup ---------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0004>
________________________ test_noop[mail.python.org] ________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0
test_module.py:13: AssertionError
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0005>
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo[smtp.gmail.com] - assert 0
FAILED test_module.py::test_noop[smtp.gmail.com] - assert 0
FAILED test_module.py::test_ehlo[mail.python.org] - AssertionError: asser...
FAILED test_module.py::test_noop[mail.python.org] - assert 0
4 failed in 0.12s
我們看到我們的兩個測試函數針對不同的 smtp_connection
實例各運行了兩次。另請注意,使用 mail.python.org
連線,第二個測試在 test_ehlo
中失敗,因為預期的伺服器字串與到達的伺服器字串不同。
pytest 將建構一個字串,該字串是參數化 Fixture 中每個 Fixture 值的測試 ID,例如上述範例中的 test_ehlo[smtp.gmail.com]
和 test_ehlo[mail.python.org]
。這些 ID 可以與 -k
一起使用,以選擇要運行的特定案例,並且當案例失敗時,它們也會識別特定案例。使用 --collect-only
運行 pytest 將顯示產生的 ID。
數字、字串、布林值和 None
將在測試 ID 中使用它們常用的字串表示形式。對於其他物件,pytest 將根據參數名稱建立字串。可以透過使用 ids
關鍵字參數來自訂測試 ID 中用於特定 Fixture 值的字串
# content of test_ids.py
import pytest
@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
def a(request):
return request.param
def test_a(a):
pass
def idfn(fixture_value):
if fixture_value == 0:
return "eggs"
else:
return None
@pytest.fixture(params=[0, 1], ids=idfn)
def b(request):
return request.param
def test_b(b):
pass
以上說明了 ids
可以是字串列表以供使用,或者是一個函式,該函式將使用 fixture 值呼叫,然後必須傳回要使用的字串。在後一種情況下,如果該函式傳回 None
,則將使用 pytest 的自動產生 ID。
執行上述測試會產生以下使用的測試 ID
$ pytest --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 12 items
<Dir fixtures.rst-227>
<Module test_anothersmtp.py>
<Function test_showhelo[smtp.gmail.com]>
<Function test_showhelo[mail.python.org]>
<Module test_emaillib.py>
<Function test_email_received>
<Module test_finalizers.py>
<Function test_bar>
<Module test_ids.py>
<Function test_a[spam]>
<Function test_a[ham]>
<Function test_b[eggs]>
<Function test_b[1]>
<Module test_module.py>
<Function test_ehlo[smtp.gmail.com]>
<Function test_noop[smtp.gmail.com]>
<Function test_ehlo[mail.python.org]>
<Function test_noop[mail.python.org]>
======================= 12 tests collected in 0.12s ========================
將標記與參數化的 fixture 一起使用¶
pytest.param()
可用於在參數化 fixture 的值集合中套用標記,就像它們可以用於 @pytest.mark.parametrize 一樣。
範例
# content of test_fixture_marks.py
import pytest
@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)])
def data_set(request):
return request.param
def test_data(data_set):
pass
執行此測試將會略過使用值 2
呼叫 data_set
$ pytest test_fixture_marks.py -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 3 items
test_fixture_marks.py::test_data[0] PASSED [ 33%]
test_fixture_marks.py::test_data[1] PASSED [ 66%]
test_fixture_marks.py::test_data[2] SKIPPED (unconditional skip) [100%]
======================= 2 passed, 1 skipped in 0.12s =======================
模組化:從 fixture 函式使用 fixture¶
除了在測試函式中使用 fixture 外,fixture 函式本身也可以使用其他 fixture。這有助於 fixture 的模組化設計,並允許跨多個專案重複使用特定於框架的 fixture。作為一個簡單的範例,我們可以擴展先前的範例並實例化一個物件 app
,我們將已定義的 smtp_connection
資源放入其中
# content of test_appsetup.py
import pytest
class App:
def __init__(self, smtp_connection):
self.smtp_connection = smtp_connection
@pytest.fixture(scope="module")
def app(smtp_connection):
return App(smtp_connection)
def test_smtp_connection_exists(app):
assert app.smtp_connection
在這裡,我們宣告一個 app
fixture,它接收先前定義的 smtp_connection
fixture,並使用它來實例化一個 App
物件。讓我們執行它
$ pytest -v test_appsetup.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 2 items
test_appsetup.py::test_smtp_connection_exists[smtp.gmail.com] PASSED [ 50%]
test_appsetup.py::test_smtp_connection_exists[mail.python.org] PASSED [100%]
============================ 2 passed in 0.12s =============================
由於 smtp_connection
的參數化,測試將使用兩個不同的 App
實例和各自的 smtp 伺服器執行兩次。 app
fixture 不需要知道 smtp_connection
參數化,因為 pytest 將完全分析 fixture 依賴關係圖。
請注意,app
fixture 的作用域為 module
,並使用模組作用域的 smtp_connection
fixture。 如果 smtp_connection
快取在 session
作用域上,此範例仍然可以運作:fixture 可以使用「更廣泛」作用域的 fixture,但反之則不然:會話作用域的 fixture 無法以有意義的方式使用模組作用域的 fixture。
依 fixture 實例自動分組測試¶
pytest 在測試執行期間會盡可能減少作用中的 fixture 數量。 如果您有一個參數化的 fixture,則所有使用它的測試將首先使用一個實例執行,然後在建立下一個 fixture 實例之前呼叫終結器。 除此之外,這簡化了建立和使用全域狀態的應用程式的測試。
以下範例使用兩個參數化的 fixture,其中一個的作用域是每個模組,並且所有函式都執行 print
呼叫以顯示設定/拆解流程
# content of test_module.py
import pytest
@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
param = request.param
print(" SETUP modarg", param)
yield param
print(" TEARDOWN modarg", param)
@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
param = request.param
print(" SETUP otherarg", param)
yield param
print(" TEARDOWN otherarg", param)
def test_0(otherarg):
print(" RUN test0 with otherarg", otherarg)
def test_1(modarg):
print(" RUN test1 with modarg", modarg)
def test_2(otherarg, modarg):
print(f" RUN test2 with otherarg {otherarg} and modarg {modarg}")
讓我們在 verbose 模式下執行測試,並查看 print 輸出
$ pytest -v -s test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 8 items
test_module.py::test_0[1] SETUP otherarg 1
RUN test0 with otherarg 1
PASSED TEARDOWN otherarg 1
test_module.py::test_0[2] SETUP otherarg 2
RUN test0 with otherarg 2
PASSED TEARDOWN otherarg 2
test_module.py::test_1[mod1] SETUP modarg mod1
RUN test1 with modarg mod1
PASSED
test_module.py::test_2[mod1-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod1
PASSED TEARDOWN otherarg 1
test_module.py::test_2[mod1-2] SETUP otherarg 2
RUN test2 with otherarg 2 and modarg mod1
PASSED TEARDOWN otherarg 2
test_module.py::test_1[mod2] TEARDOWN modarg mod1
SETUP modarg mod2
RUN test1 with modarg mod2
PASSED
test_module.py::test_2[mod2-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod2
PASSED TEARDOWN otherarg 1
test_module.py::test_2[mod2-2] SETUP otherarg 2
RUN test2 with otherarg 2 and modarg mod2
PASSED TEARDOWN otherarg 2
TEARDOWN modarg mod2
============================ 8 passed in 0.12s =============================
您可以看到,參數化的模組作用域 modarg
資源導致測試執行的順序,從而導致最少的「作用中」資源。 mod1
參數化資源的終結器在 mod2
資源設定之前執行。
特別注意 test_0 是完全獨立的,並且首先完成。 然後 test_1 使用 mod1
執行,然後 test_2 使用 mod1
執行,然後 test_1 使用 mod2
執行,最後 test_2 使用 mod2
執行。
otherarg
參數化資源(具有函式作用域)在每個使用它的測試之前設定,並在之後拆解。
在類別和模組中使用帶有 usefixtures
的 fixture¶
有時測試函式不需要直接存取 fixture 物件。 例如,測試可能需要以空目錄作為目前的工作目錄來操作,但除此之外不關心具體的目錄。 以下是如何使用標準 tempfile
和 pytest fixture 來實現它。 我們將 fixture 的建立分隔到一個 conftest.py
檔案中
# content of conftest.py
import os
import tempfile
import pytest
@pytest.fixture
def cleandir():
with tempfile.TemporaryDirectory() as newpath:
old_cwd = os.getcwd()
os.chdir(newpath)
yield
os.chdir(old_cwd)
並透過 usefixtures
標記在測試模組中宣告其使用
# content of test_setenv.py
import os
import pytest
@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
def test_cwd_starts_empty(self):
assert os.listdir(os.getcwd()) == []
with open("myfile", "w", encoding="utf-8") as f:
f.write("hello")
def test_cwd_again_starts_empty(self):
assert os.listdir(os.getcwd()) == []
由於 usefixtures
標記,每個測試方法的執行都需要 cleandir
fixture,就像您為每個測試方法指定了 “cleandir” 函式引數一樣。 讓我們執行它以驗證我們的 fixture 已啟動並且測試通過
$ pytest -q
.. [100%]
2 passed in 0.12s
您可以像這樣指定多個 fixture
@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test(): ...
您可以使用 pytestmark
在測試模組層級指定 fixture 的使用方式
pytestmark = pytest.mark.usefixtures("cleandir")
也可以將專案中所有測試所需的 fixture 放入 ini 檔案中
# content of pytest.ini
[pytest]
usefixtures = cleandir
警告
請注意,此標記在 fixture 函式中無效。 例如,這將無法如預期般運作
@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture(): ...
這會產生棄用警告,並將在 Pytest 8 中變成錯誤。
在不同層級覆寫 fixture¶
在相對較大的測試套件中,您很可能需要使用 locally
定義的 fixture override
global
或 root
fixture,以保持測試程式碼的可讀性和可維護性。
在資料夾 (conftest) 層級覆寫 fixture¶
假設測試檔案結構是
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
test_something.py
# content of tests/test_something.py
def test_username(username):
assert username == 'username'
subfolder/
conftest.py
# content of tests/subfolder/conftest.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-' + username
test_something_else.py
# content of tests/subfolder/test_something_else.py
def test_username(username):
assert username == 'overridden-username'
如您所見,可以在特定測試資料夾層級覆寫具有相同名稱的 fixture。 請注意,可以從 overriding
fixture 輕鬆存取 base
或 super
fixture - 如上面的範例所示。
在測試模組層級覆寫 fixture¶
假設測試檔案結構是
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
test_something.py
# content of tests/test_something.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-' + username
def test_username(username):
assert username == 'overridden-username'
test_something_else.py
# content of tests/test_something_else.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-else-' + username
def test_username(username):
assert username == 'overridden-else-username'
在上面的範例中,可以在特定測試模組中覆寫具有相同名稱的 fixture。
使用直接測試參數化覆寫 fixture¶
假設測試檔案結構是
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
@pytest.fixture
def other_username(username):
return 'other-' + username
test_something.py
# content of tests/test_something.py
import pytest
@pytest.mark.parametrize('username', ['directly-overridden-username'])
def test_username(username):
assert username == 'directly-overridden-username'
@pytest.mark.parametrize('username', ['directly-overridden-username-other'])
def test_username_other(other_username):
assert other_username == 'other-directly-overridden-username-other'
在上面的範例中,fixture 值會被測試參數值覆寫。 請注意,即使測試未直接使用 fixture 的值(未在函式原型中提及),也可以透過這種方式覆寫 fixture 的值。
用非參數化的 fixture 覆寫參數化的 fixture,反之亦然¶
假設測試檔案結構是
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture(params=['one', 'two', 'three'])
def parametrized_username(request):
return request.param
@pytest.fixture
def non_parametrized_username(request):
return 'username'
test_something.py
# content of tests/test_something.py
import pytest
@pytest.fixture
def parametrized_username():
return 'overridden-username'
@pytest.fixture(params=['one', 'two', 'three'])
def non_parametrized_username(request):
return request.param
def test_username(parametrized_username):
assert parametrized_username == 'overridden-username'
def test_parametrized_username(non_parametrized_username):
assert non_parametrized_username in ['one', 'two', 'three']
test_something_else.py
# content of tests/test_something_else.py
def test_username(parametrized_username):
assert parametrized_username in ['one', 'two', 'three']
def test_username(non_parametrized_username):
assert non_parametrized_username == 'username'
在上面的範例中,參數化的 fixture 被非參數化的版本覆寫,而非參數化的 fixture 被特定測試模組的參數化版本覆寫。 顯然,這同樣適用於測試資料夾層級。
從其他專案使用 fixture¶
通常,提供 pytest 支援的專案將使用 entry points,因此只需將這些專案安裝到環境中,即可使用這些 fixture。
如果您想從未使用 entry points 的專案中使用 fixture,您可以在頂層 conftest.py
檔案中定義 pytest_plugins
,以將該模組註冊為外掛程式。
假設您在 mylibrary.fixtures
中有一些 fixture,並且您想在 app/tests
目錄中重複使用它們。
您只需要在 app/tests/conftest.py
中定義指向該模組的 pytest_plugins
即可。
pytest_plugins = "mylibrary.fixtures"
這有效地將 mylibrary.fixtures
註冊為外掛程式,使其所有 fixture 和鉤子都可用於 app/tests
中的測試。
注意
有時,使用者會從其他專案匯入 fixture 以供使用,但不建議這樣做:將 fixture 匯入到模組中會將它們在 pytest 中註冊為在該模組中定義。
這會產生一些小的後果,例如在 pytest --help
中多次出現,但不建議這樣做,因為此行為在未來版本中可能會變更/停止運作。