如何使用夹具

另请参见

关于 fixtures

另请参见

Fixtures 参考

“请求”夹具

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

当 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 夹具函数并将返回的对象作为 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)

夹具可以“请求”其他夹具

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 将发现并调用 @pytest.fixture 标记的 smtp_connection 夹具函数。运行测试如下所示

$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.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

它比 yield 夹具长一点,也复杂一点,但它确实在您陷入困境时提供了一些细微的差别。

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

关于终结器顺序的说明

终结器以先进后出的顺序执行。对于 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-9.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-9.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 方法而不是 yield 夹具,但这可能会变得非常复杂且难以维护(并且不再紧凑)。

$ 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 有一种方便的方法来处理这个问题,它结合了我们到目前为止所介绍的许多内容。

所需要的只是提升到更大的作用域,然后将动作步骤定义为自动使用的夹具,最后,确保所有夹具都指向该更高层次的作用域。

我们来引用上面提到的一个例子,并稍作修改。假设除了检查标题中的欢迎消息之外,我们还想检查注销按钮和用户个人资料的链接。

让我们看看如何构建它,以便我们可以运行多个断言,而无需再次重复所有这些步骤。

注意

对于此示例,某些夹具(即 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 夹具系统管理。

每个方法只需请求它实际需要的夹具,而无需担心顺序。这是因为动作夹具是一个自动使用的夹具,它确保所有其他夹具都在它之前执行。不再需要进行状态更改,因此测试可以自由地进行任意数量的非状态更改查询,而不会冒与其他测试发生冲突的风险。

login 夹具也定义在类内部,因为模块中并非所有其他测试都期望成功登录,并且对于另一个测试类,动作可能需要稍作不同的处理。例如,如果我们要编写另一个关于提交错误凭据的测试场景,我们可以通过向测试文件添加类似这样的内容来处理它

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-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 12 items

<Dir fixtures.rst-232>
  <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

运行此测试将跳过2 为值的 data_set 调用

$ pytest test_fixture_marks.py -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.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-9.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-9.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")

也可以将项目所有测试所需的夹具放入配置文件中

# content of pytest.toml
[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'

如您所见,可以为某个测试文件夹级别覆盖同名夹具。请注意, 夹具可以轻松地从 覆盖 夹具中访问——如上例所示。

在测试模块级别覆盖夹具

假设测试文件结构如下

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 中多次出现,但不建议这样做,因为此行为可能会在未来版本中更改/停止工作。