如何使用夹具

另请参阅

关于夹具

另请参阅

夹具参考

“请求”夹具

在基本层面上,测试函数通过将夹具声明为参数来请求它们所需的夹具。

当 pytest 运行测试时,它会查看该测试函数签名中的参数,然后搜索具有与这些参数相同的名称的夹具。一旦 pytest 找到它们,它就会运行这些夹具,捕获它们返回的内容(如果有的话),并将这些对象作为参数传递到测试函数中。

快速示例

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 夹具函数并将它返回的对象传递到 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)

夹具可以请求其他夹具

pytest 最大的优势之一是其极其灵活的夹具系统。它允许我们将测试的复杂要求分解为更简单、更井然有序的函数,我们只需要让每个函数描述它们所依赖的事物即可。我们将在后面进一步深入探讨,但现在,这里有一个快速示例来演示夹具如何使用其他夹具

# 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 中的夹具请求夹具就像测试一样。所有相同的请求规则都适用于夹具,也适用于测试。如果我们手动执行,此示例将如何工作

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)

夹具是可重用的

使 pytest 的夹具系统如此强大的原因之一是,它使我们能够定义一个通用的设置步骤,该步骤可以像使用普通函数一样反复重用。两个不同的测试可以请求相同的夹具,并让 pytest 为每个测试提供该夹具的结果。

这对于确保测试不受彼此影响非常有用。我们可以使用此系统来确保每个测试都获得自己的新鲜数据批次,并从干净的状态开始,以便它可以提供一致、可重复的结果。

以下是如何派上用场的示例

# 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 固定装置是自动使用固定装置。因为它自动发生,所以两个测试都受到它的影响,即使这两个测试都没有请求它。但这并不意味着它们不能请求;只是它没有必要

范围:跨类、模块、包或会话共享固定装置

需要网络访问的夹具依赖于连接,并且通常创建起来很耗时。扩展前一个示例,我们可以向 @pytest.fixture 调用中添加一个 scope="module" 参数,以导致 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 将发现并调用标记为 smtp_connection@pytest.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 的两个测试函数运行得像一个一样快,因为它们重复使用了同一个实例。

如果您决定宁愿拥有一个会话范围的 smtp_connection 实例,您可以简单地声明它

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

夹具范围

当测试首次请求时创建夹具,并根据其 scope 销毁夹具

  • function:默认范围,夹具在测试结束时销毁。

  • class:夹具在类中最后一个测试的拆除期间销毁。

  • module:夹具在模块中最后一个测试的拆除期间销毁。

  • package:夹具在定义夹具的包(包括其中的子包和子目录)中最后一个测试的拆除期间销毁。

  • session:夹具在测试会话结束时销毁。

注意

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 固定装置类似的结果,但需要更多冗长内容。

为了使用此方法,我们必须在需要添加拆除代码的固定装置中请求 请求上下文 对象(就像我们请求另一个固定装置一样),然后传递一个包含该拆除代码的可调用对象,将其 addfinalizer 方法。

我们必须小心,因为一旦添加了终结器,pytest 就会运行该终结器,即使该固定装置在添加终结器后引发异常。因此,为了确保我们在不需要时不运行终结器代码,我们只会在固定装置执行需要拆除的操作后才添加终结器。

以下是如何使用 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

它比生成器夹具稍长且复杂一些,但它确实提供了一些细微差别,以备不时之需。

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

关于终结器顺序的说明

终结器按先进后出的顺序执行。对于生成器夹具,第一个运行的清理代码来自最右边的夹具,即最后一个测试参数。

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

对于终结器,第一个运行的夹具是 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 =============================

这是因为生成器夹具在幕后使用 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 步骤定义为 autouse 固定装置,最后,确保所有固定装置都针对该更高级别的范围。

让我们从 上面的示例 中提取,并对其进行一些调整。假设除了检查标题中的欢迎消息外,我们还希望检查注销按钮和指向用户个人资料的链接。

让我们看看如何构建它,以便我们可以在不重复所有这些步骤的情况下运行多个断言。

注意

对于此示例,某些固定装置(即 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 固定装置是 autouse 固定装置,它确保所有其他固定装置在它之前执行。不再需要进行状态更改,因此测试可以自由地进行任意数量的非状态更改查询,而无需冒着踩到其他测试的风险。

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)

固定装置可以内省请求的测试上下文

固定装置函数可以接受 request 对象以内省“请求的”测试函数、类或模块上下文。进一步扩展前面的 smtp_connection 固定装置示例,让我们从使用我们固定装置的测试模块中读取可选的服务器 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 固定装置函数从模块命名空间中获取了我们的邮件服务器名称。

使用标记将数据传递给固定装置

使用 request 对象,固定装置还可以访问应用于测试函数的标记。这对于从测试将数据传递到固定装置很有用

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

工厂作为固定装置

“工厂作为固定装置”模式有助于在单个测试中多次需要固定装置结果的情况下。固定装置不会直接返回数据,而是返回一个生成数据的函数。然后可以在测试中多次调用此函数。

工厂可以根据需要具有参数

@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")

如果工厂创建的数据需要管理,固定装置可以负责管理

@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")

参数化固定装置

可以对固定装置函数进行参数化,在这种情况下,它们将被多次调用,每次执行一组依赖测试,即依赖此固定装置的测试。测试函数通常不需要意识到它们的重新运行。固定装置参数化有助于为组件编写详尽的功能测试,而组件本身可以通过多种方式进行配置。

扩展前面的示例,我们可以标记固定装置以创建两个 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_connectionsession 范围内缓存,该示例仍然有效:夹具使用“更广泛”范围的夹具是可以的,但反过来则不行:会话范围的夹具不能以有意义的方式使用模块范围的夹具。

按夹具实例自动分组测试

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 完全独立,并且首先完成。然后,使用 mod1 执行 test_1,然后使用 mod1 执行 test_2,然后使用 mod2 执行 test_1,最后使用 mod2 执行 test_2。

在使用它的每个测试之前设置,并在之后销毁 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 中成为错误。

在不同级别覆盖固定装置

在相对较大的测试套件中,您很可能需要使用 locally 定义的固定装置来 override globalroot 固定装置,以保持测试代码的可读性和可维护性。

在文件夹(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'

如您所见,可以为某些测试文件夹级别覆盖具有相同名称的固定装置。请注意,可以从 overriding 固定装置轻松访问 basesuper 固定装置 - 在上面的示例中使用。

在测试模块级别覆盖固定装置

给定测试文件结构为

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 支持的项目将使用 入口点,因此只需将这些项目安装到环境中,即可使用这些 fixture。

如果您想使用不使用入口点的项目的 fixture,则可以在顶部 conftest.py 文件中定义 pytest_plugins 以将该模块注册为插件。

假设您在 mylibrary.fixtures 中有一些 fixture,并且您想将它们重新用于 app/tests 目录中。

您需要做的就是定义 pytest_plugins,在 app/tests/conftest.py 中指向该模块。

pytest_plugins = "mylibrary.fixtures"

这会有效地将 mylibrary.fixtures 注册为插件,使其所有 fixture 和钩子都可用于 app/tests 中的测试。

注意

有时用户会从其他项目导入 fixture 以供使用,但这是不推荐的:将 fixture 导入模块会将它们注册到 pytest 中,作为在该模块中定义的 fixture。

这会产生一些轻微的后果,例如在 pytest --help 中多次出现,但不推荐这样做,因为此行为可能会在未来版本中发生更改/停止工作。