如何使用 Fixture

另請參閱

關於 Fixture

另請參閱

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_firsttest_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 的可能值為:functionclassmodulepackagesession

下一個範例將 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 定義必要的特定步驟,以便在完成後自行清理。

這個系統可以透過兩種方式利用。

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,我們可以在其中產生使用者。對於我們的測試,我們想要

  1. 透過該管理員 API 建立使用者

  2. 使用 Selenium 啟動瀏覽器

  3. 前往我們網站的登入頁面

  4. 以我們建立的使用者身份登入

  5. 斷言其名稱在登陸頁面的標頭中

我們不希望將該使用者留在系統中,也不希望讓該瀏覽器 Session 繼續運行,因此我們要確保建立這些事物的 Fixture 在完成後自行清理。

以下是它可能看起來的樣子

注意

對於此範例,某些 Fixture(即 base_urladmin_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 之前執行。但這沒關係,因為這些是原子操作,因此哪個先運行並不重要,因為測試的事件順序仍然是 線性化的。但是,重要的是,無論哪個先運行,如果一個在不會引發例外的情況下引發例外,則兩者都不會留下任何東西。如果 driveruser 之前執行,並且 user 引發例外,則驅動程式仍將退出,並且永遠不會建立使用者。如果 driver 是引發例外的那個,那麼驅動程式將永遠不會啟動,並且永遠不會建立使用者。

安全地運行多個 assert 語句

有時您可能希望在完成所有設定後運行多個斷言,這是有道理的,因為在更複雜的系統中,單個操作可以觸發多種行為。pytest 提供了一種方便的方法來處理這個問題,它結合了我們到目前為止所討論的大部分內容。

所有需要的只是提升到更大的 Scope,然後將 act 步驟定義為 autouse Fixture,最後,確保所有 Fixture 都以該更高層級的 Scope 為目標。

讓我們提取上面的範例,並稍微調整一下。假設除了檢查標頭中的歡迎訊息外,我們還想檢查登出按鈕和使用者個人資料的連結。

讓我們看看如何建構它,以便我們可以運行多個斷言,而無需再次重複所有這些步驟。

注意

對於此範例,某些 Fixture(即 base_urladmin_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 globalroot 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 輕鬆存取 basesuper 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 中多次出現,但不建議這樣做,因為此行為在未來版本中可能會變更/停止運作。