如何使用fixtures

另請參閱

關於fixtures

另請參閱

Fixtures參考

「要求」fixtures

在基本層級,測試函式會透過宣告fixtures為引數來要求所需的fixtures。

當pytest執行測試時,它會查看該測試函式簽章中的參數,然後搜尋與這些參數名稱相同的fixtures。一旦pytest找到它們,它就會執行這些fixtures,擷取它們回傳的內容(如果有),並將這些物件作為引數傳遞給測試函式。

快速範例

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_bowlfixtures函式,並將它回傳的物件傳遞給test_fruit_salad作為fruit_bowl引數。

以下大致說明我們如何手動執行這項操作

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)

Fixtures可以「要求」其他fixtures

pytest最棒的優點之一就是它極具彈性的fixtures系統。它讓我們可以將測試的複雜需求簡化為更簡單且有組織的函式,我們只需要讓每個函式描述它們所依賴的事物即可。我們稍後會進一步深入探討這一點,但現在先提供一個快速範例來說明fixtures如何使用其他fixtures

# 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中的fixtures「要求」fixtures,就像測試一樣。所有相同的「要求」規則都適用於fixtures和測試。以下是我們手動執行此範例的方式

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)

Fixtures可以重複使用

讓pytest的fixtures系統如此強大的原因之一,就是它讓我們可以定義一個通用設定步驟,這個步驟可以一再重複使用,就像一般函式一樣。兩個不同的測試可以要求相同的fixtures,並讓pytest為每個測試提供該fixtures的結果。

這對於確保測試不受彼此影響非常有用。我們可以使用此系統來確保每個測試都能取得自己的一批新鮮資料,並從乾淨的狀態開始,以便提供一致且可重複的結果。

以下是一個範例,說明這一點如何派上用場

# 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 固定裝置會執行兩次(first_entry 固定裝置也是如此)。如果我們手動執行,會類似以下內容

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)

測試/固定裝置可以一次要求多個固定裝置

測試和固定裝置不限於一次要求單一固定裝置。他們可以要求任意數量的固定裝置。以下提供另一個快速範例來說明

# 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

每個測試可以要求固定裝置多次(快取傳回值)

固定裝置也可以在同一個測試中要求多次,而 pytest 也不會再次為該測試執行這些固定裝置。這表示我們可以在多個依賴這些固定裝置的固定裝置中要求固定裝置(甚至在測試本身中再次要求),而這些固定裝置不會執行超過一次。

# 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]

如果要求的固定裝置在測試期間每次要求時都執行一次,那麼這個測試就會失敗,因為 append_firsttest_string_only 都會將 order 視為一個空清單(即 []),但由於 order 的傳回值在第一次呼叫後已快取(以及可能產生的任何副作用),因此測試和 append_first 都會參照同一個物件,而測試也會看到 append_first 對該物件產生的影響。

自動使用固定裝置(您不必要求的固定裝置)

有時您可能希望有一個固定裝置(甚至多個),而您知道所有測試都會依賴這些固定裝置。「自動使用」固定裝置是一種讓所有測試自動要求這些固定裝置的便利方式。這可以減少許多重複的要求,甚至可以提供更進階的固定裝置使用方式(稍後會詳細說明)。

我們可以透過將 autouse=True 傳遞到固定裝置的裝飾器,來讓固定裝置成為自動使用固定裝置。以下是一個簡單的範例,說明如何使用它們

# 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 固定裝置是一個自動使用固定裝置。因為它會自動發生,所以即使沒有任何測試要求它,兩個測試都會受到它的影響。這並不表示它們不能要求;只是它並不需要

範圍:跨類別、模組、套件或工作階段共用固定裝置

需要網路存取的固定裝置仰賴連線,而且通常建立起來很耗時。延伸前一個範例,我們可以新增一個 scope="module" 參數到 @pytest.fixture 呼叫中,以造成 smtp_connection 固定裝置函式,負責建立與已存在的 SMTP 伺服器的連線,只會在每個測試模組中呼叫一次(預設是在每個測試函式中呼叫一次)。因此,測試模組中的多個測試函式會收到相同的 smtp_connection 固定裝置執行個體,因此可以節省時間。 scope 的可能值為: functionclassmodulepackagesession

下一個範例將固定裝置函式放入一個獨立的 conftest.py 檔案中,以便目錄中多個測試模組的測試可以存取固定裝置函式

# 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 固定裝置值。pytest 會發現並呼叫 @pytest.fixture 標記的 smtp_connection 固定裝置函式。執行測試看起來像這樣

$ 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 的兩個測試函式執行得像單一函式一樣快,因為它們重複使用相同的執行個體。

如果您決定您比較想要有一個工作階段範圍的 smtp_connection 執行個體,您可以簡單地宣告它

@pytest.fixture(scope="session")
def smtp_connection():
    # the returned fixture value will be shared for
    # all tests requesting it
    ...

固定裝置範圍

當測試第一次要求時,固定裝置會被建立,並根據它們的 scope 銷毀

  • 函數:預設範圍,測試結束時會銷毀固定裝置。

  • 類別:在類別中最後一個測試的清除作業期間會銷毀固定裝置。

  • 模組:在模組中最後一個測試的清除作業期間會銷毀固定裝置。

  • 套件:在定義固定裝置的套件中最後一個測試的清除作業期間會銷毀固定裝置,包括其中的子套件和子目錄。

  • 工作階段:在測試工作階段結束時會銷毀固定裝置。

注意

Pytest 一次只快取一個固定裝置實例,這表示在使用參數化固定裝置時,pytest 可能在給定的範圍內多次呼叫固定裝置。

動態範圍

在 5.2 版中新增。

在某些情況下,您可能想變更固定裝置的範圍,而不變更程式碼。為此,請將可呼叫物件傳遞給 scope。可呼叫物件必須傳回包含有效範圍的字串,而且只會執行一次,也就是在固定裝置定義期間。它會以兩個關鍵字引數呼叫,也就是字串 fixture_name 和包含組態物件的 config

在處理需要設定時間的固定裝置(例如產生 docker 容器)時,這可能會特別有用。您可以使用命令列引數來控制不同環境中已產生容器的範圍。請參閱以下範例。

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()

清除/清理(又稱為固定裝置最後處理)

當我們執行測試時,我們希望確保它們在執行後會自行清理,以免它們與其他測試混淆(而且這樣我們也不會留下大量的測試資料來膨脹系統)。pytest 中的固定裝置提供了一個非常有用的終止系統,它讓我們可以定義每個固定裝置在執行後自行清理所需的特定步驟。

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

2. 直接新增終結器

雖然 yield 固定裝置被認為是更簡潔、更直接的選項,但還有另一種選擇,那就是直接將「終結器」函式新增到測試的 請求內容 物件。它帶來的結果與 yield 固定裝置類似,但需要更多冗餘。

為了使用此方法,我們必須在需要新增卸載程式碼的固定裝置中要求 request-context 物件(就像我們會要求另一個固定裝置一樣),然後傳遞一個包含該卸載程式碼的可呼叫函式給它的 addfinalizer 方法。

不過,我們必須小心,因為 pytest 會在新增 finalizer 之後執行它,即使該固定裝置在新增 finalizer 之後引發例外狀況也是如此。因此,為了確保我們不會在不需要時執行 finalizer 程式碼,我們只會在固定裝置執行我們需要卸載的動作後才新增 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 固定裝置長一點,也複雜一點,但當你遇到困難時,它確實提供了一些細微差別。

$ pytest -q test_emaillib.py
.                                                                    [100%]
1 passed in 0.12s

關於 finalizer 順序的注意事項

finalizer 會以先進後出的順序執行。對於 yield 固定裝置,第一個執行的卸載程式碼來自最右邊的固定裝置,也就是最後一個測試參數。

# 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,第一個執行的固定裝置是最後一次呼叫 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 固定裝置在幕後使用 addfinalizer:當固定裝置執行時,addfinalizer 會註冊一個函式來繼續執行產生器,而產生器會呼叫卸載程式碼。

安全的卸載

pytest 的固定裝置系統非常強大,但它仍然是由電腦執行,因此無法找出如何安全卸載我們投入的所有內容。如果我們不小心,錯誤出現在錯誤的位置可能會遺留測試中的東西,而這可能會很快導致進一步的問題。

例如,考慮以下測試(根據上述郵件範例):

# 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

這個版本更為精簡,但它也更難閱讀,沒有很具描述性的固定裝置名稱,而且沒有任何固定裝置可以輕易地重複使用。

還有一個更嚴重的問題,那就是如果設定中的任何步驟引發例外,則不會執行任何清除程式碼。

一個選項可能是使用addfinalizer方法,而不是使用產量固定裝置,但這可能會變得相當複雜且難以維護(而且它不再精簡)。

$ pytest -q test_emaillib.py
.                                                                    [100%]
1 passed in 0.12s

安全固定裝置結構

最安全、最簡單的固定裝置結構需要將固定裝置限制為每次只執行一個狀態變更動作,然後將它們與其清除程式碼綑綁在一起,如上述的電子郵件範例所示。

狀態變更操作失敗但仍會修改狀態的機率微乎其微,因為這些操作大多傾向於基於交易(至少在狀態可能會被遺留的測試層級)。因此,如果我們確保任何成功的狀態變更動作會被移至一個獨立的固定裝置函數並與其他可能失敗的狀態變更動作分開,然後清除,那麼我們的測試將有最大的機會讓測試環境保持在它們找到它的狀態。

舉例來說,假設我們有一個網站,其中有一個登入頁面,而且我們可以存取一個管理員 API,我們可以在其中產生使用者。對於我們的測試,我們想要

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

  2. 使用 Selenium 啟動一個瀏覽器

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

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

  5. 斷言他們的姓名在登入頁面的標題中

我們不希望將該使用者留在系統中,也不希望讓該瀏覽器工作階段繼續執行,因此我們需要確保建立這些項目的固定裝置會自行清除。

以下是它的可能樣貌

注意

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

安全地執行多個assert陳述

有時你可能想要在執行所有這些設定後執行多個斷言,這很有道理,因為在更複雜的系統中,單一動作可以啟動多種行為。pytest 有個處理此問題的便利方式,它結合了我們到目前為止討論過的許多內容。

所有需要做的就是擴大範圍,然後將act步驟定義為自動使用固定裝置,最後,確保所有固定裝置都針對較高層級的範圍。

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

讓我們看看如何建構它,以便我們可以在不重複所有這些步驟的情況下執行多個斷言。

注意

對於這個範例,某些固定裝置(例如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 固定裝置系統管理。

每種方法只需請求它實際需要的固定裝置,而不用擔心順序。這是因為act固定裝置是自動使用固定裝置,並且它確保所有其他固定裝置在它之前執行。不再需要進行狀態變更,因此測試可以自由地進行任意多個非狀態變更查詢,而不會冒著踩到其他測試的風險。

login固定裝置也在類別中定義,因為模組中並非每個其他測試都會預期成功登入,並且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 函式可以接受 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 函式從模組命名空間中擷取我們的郵件伺服器名稱。

使用標記將資料傳遞給 fixtures

使用 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

工廠作為 fixtures

「工廠作為 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")

參數化 fixtures

固定裝置函式可以參數化,在這種情況下,它們將被呼叫多次,每次執行一組依賴的測試,也就是依賴這個固定裝置的測試。測試函式通常不需要知道它們會重新執行。固定裝置參數化有助於為可以以多種方式配置的元件撰寫詳盡的功能測試。

擴充前一個範例,我們可以標記固定裝置,以建立兩個 smtp_connection 固定裝置執行個體,這將導致所有使用固定裝置的測試執行兩次。固定裝置函式會透過特殊 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,其中包含一串值,固定裝置函式將針對每個值執行,並能透過 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 將為參數化固定裝置中的每個固定裝置值建立一個字串作為測試 ID,例如 test_ehlo[smtp.gmail.com]test_ehlo[mail.python.org] 在上述範例中。這些 ID 可搭配 -k 使用,以選取要執行的特定案例,且當其中一個案例失敗時,它們也會識別出該特定案例。使用 --collect-only 執行 pytest 將顯示產生的 ID。

數字、字串、布林值和 None 會在測試 ID 中使用它們通常的字串表示法。對於其他物件,pytest 會根據引數名稱建立一個字串。可以透過使用 ids 關鍵字引數,自訂在特定固定裝置值中用於測試 ID 的字串

# 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 可以是字串清單或函式,函式會搭配固定裝置值呼叫,然後必須傳回一個要使用的字串。在後一種情況下,如果函式傳回 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-217>
  <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 ========================

搭配參數化固定裝置使用標記

pytest.param() 可用於在參數化固定裝置的值集中套用標記,其方式與搭配 @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

執行此測試將略過呼叫值為 2data_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 =======================

模組化:從固定裝置函式使用固定裝置

除了在測試函式中使用固定裝置外,固定裝置函式本身也可以使用其他固定裝置。這有助於您的固定裝置採用模組化設計,並允許在多個專案中重複使用特定於架構的固定裝置。舉一個簡單的範例,我們可以延伸前一個範例,並實例化一個物件 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 固定裝置,它接收先前定義的 smtp_connection 固定裝置,並使用它實例化一個 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 固定裝置不需要知道 smtp_connection 參數化,因為 pytest 會完整分析固定裝置相依性圖表。

請注意, app 固定裝置的範圍是 module,並使用模組範圍的 smtp_connection 固定裝置。如果 smtp_connection 緩存在 session 範圍中,範例仍然會運作:固定裝置使用「較廣泛」範圍的固定裝置是可以的,但反過來則不行:會期範圍的固定裝置無法有意義地使用模組範圍的固定裝置。

依固定裝置實例自動分組測試

pytest 會在測試執行期間將活動固定裝置的數量減至最少。如果您有一個參數化固定裝置,則使用它的所有測試將先使用一個實例執行,然後在建立下一個固定裝置實例之前呼叫完成函式。這可以簡化測試建立和使用全域狀態的應用程式。

以下範例使用兩個參數化固定裝置,其中一個的範圍是每個模組,而所有函式都會執行 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}")

我們在詳細模式下執行測試,並查看列印輸出

$ 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 設定固定裝置

有時測試函式不需要直接存取固定裝置物件。例如,測試可能需要以目前工作目錄的空目錄進行操作,但不會在意具體目錄。以下是使用標準 tempfile 和 pytest 固定裝置來達成此目的的方法。我們將固定裝置的建立分開到 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 固定裝置會在執行每個測試方法時需要,就像您為每個方法指定「cleandir」函式引數一樣。讓我們執行它來驗證我們的固定裝置已啟用,且測試通過

$ pytest -q
..                                                                   [100%]
2 passed in 0.12s

您可以像這樣指定多個固定裝置

@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test(): ...

您可以在測試模組層級使用 pytestmark 指定固定裝置用途

pytestmark = pytest.mark.usefixtures("cleandir")

也可以將專案中所有測試所需的固定裝置放入 ini 檔案中

# content of pytest.ini
[pytest]
usefixtures = cleandir

警告

請注意,此標記在固定裝置函式中沒有作用。例如,這不會如預期般運作

@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture(): ...

這會產生不建議使用的警告,並會在 Pytest 8 中變成錯誤。

在不同層級覆寫固定裝置

在相對較大的測試套件中,您很可能需要使用局部定義的固定裝置來覆寫全域固定裝置,以保持測試程式碼的可讀性和可維護性。

覆寫資料夾層級 (conftest) 的固定裝置

假設測試檔案結構為

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'

如您所見,可以針對特定測試資料夾層級覆寫同名的固定裝置。請注意,basesuper 固定裝置可以輕鬆地從 overriding 固定裝置存取 - 如上方的範例所示。

覆寫測試模組層級的固定裝置

假設測試檔案結構為

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'

在上方的範例中,可以針對特定測試模組覆寫同名的固定裝置。

使用直接測試參數化覆寫固定裝置

假設測試檔案結構為

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'

在上方的範例中,固定裝置值會被測試參數值覆寫。請注意,即使測試並未直接使用固定裝置(在函式原型中未提及),也可以用這種方式覆寫固定裝置的值。

使用非參數化固定裝置覆寫參數化固定裝置,反之亦然

假設測試檔案結構為

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'

在上方的範例中,使用非參數化版本覆寫參數化固定裝置,並使用參數化版本覆寫特定測試模組的非參數化固定裝置。顯然地,測試資料夾層級也適用相同的原則。

使用其他專案的固定裝置

通常提供 pytest 支援的專案會使用 進入點,因此只要將這些專案安裝到環境中,即可使用這些固定裝置。

如果您想使用未採用進入點的專案的固定裝置,可以在頂端的 conftest.py 檔案中定義 pytest_plugins,以將該模組註冊為外掛程式。

假設您在 mylibrary.fixtures 中有一些固定裝置,而且您想要在 app/tests 目錄中重複使用它們。

您只需要在指向該模組的 app/tests/conftest.py 中定義 pytest_plugins 即可。

pytest_plugins = "mylibrary.fixtures"

這會有效地將 mylibrary.fixtures 註冊為一個外掛程式,讓所有固定裝置和掛勾都可以在 app/tests 中的測試中使用。

注意

有時使用者會從其他專案匯入固定裝置以供使用,不過這並不建議:將固定裝置匯入模組會在 pytest 中將它們註冊為在該模組中定義

這會造成一些小後果,例如在 pytest --help 中出現多次,但不建議這樣做,因為此行為可能會在未來版本中變更/停止運作。