如何使用 fixture

另请参阅

关于 fixture

另请参阅

Fixture 参考

“请求” fixture

在基本层面,测试函数通过将其声明为参数来请求所需的 fixture。

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

快速示例

import pytest


class Fruit:
    def __init__(self, name):
        self.name = name
        self.cubed = False

    def cube(self):
        self.cubed = True


class FruitSalad:
    def __init__(self, *fruit_bowl):
        self.fruit = fruit_bowl
        self._cube_fruit()

    def _cube_fruit(self):
        for fruit in self.fruit:
            fruit.cube()


# Arrange
@pytest.fixture
def fruit_bowl():
    return [Fruit("apple"), Fruit("banana")]


def test_fruit_salad(fruit_bowl):
    # Act
    fruit_salad = FruitSalad(*fruit_bowl)

    # Assert
    assert all(fruit.cubed for fruit in fruit_salad.fruit)

在此示例中,test_fruit_salad请求fruit_bowl (即 def test_fruit_salad(fruit_bowl):),当 pytest 看到这一点时,它将执行 fruit_bowl fixture 函数,并将它返回的对象作为 fruit_bowl 参数传递给 test_fruit_salad

如果我们手动操作,大致会发生以下情况

def fruit_bowl():
    return [Fruit("apple"), Fruit("banana")]


def test_fruit_salad(fruit_bowl):
    # Act
    fruit_salad = FruitSalad(*fruit_bowl)

    # Assert
    assert all(fruit.cubed for fruit in fruit_salad.fruit)


# Arrange
bowl = fruit_bowl()
test_fruit_salad(fruit_bowl=bowl)

Fixture 可以请求其他 fixture

pytest 最强大的优势之一是其极其灵活的 fixture 系统。它使我们能够将复杂的测试需求归结为更简单、更有组织性的函数,我们只需要让每个函数描述它们所依赖的东西。我们将在后面更深入地探讨这一点,但现在,这里有一个快速示例来演示 fixture 如何使用其他 fixture

# contents of test_append.py
import pytest


# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]

请注意,这与上面的示例相同,但变化很小。pytest 中的 fixture 请求 fixture,就像测试请求一样。所有相同的请求规则都适用于 fixture 和测试。如果我们手动操作,此示例将按以下方式工作

def first_entry():
    return "a"


def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]


entry = first_entry()
the_list = order(first_entry=entry)
test_string(order=the_list)

Fixture 是可重用的

pytest 的 fixture 系统之所以如此强大,其中一个原因是它使我们能够定义一个通用的设置步骤,该步骤可以像普通函数一样重复使用。两个不同的测试可以请求同一个 fixture,并且 pytest 会给每个测试提供该 fixture 自己的结果。

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

以下是一个演示其如何派上用场的示例

# contents of test_append.py
import pytest


# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]


def test_int(order):
    # Act
    order.append(2)

    # Assert
    assert order == ["a", 2]

这里的每个测试都获得了 list 对象的副本,这意味着 order fixture 被执行了两次(first_entry fixture 也是如此)。如果我们也手动操作,它看起来会像这样

def first_entry():
    return "a"


def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]


def test_int(order):
    # Act
    order.append(2)

    # Assert
    assert order == ["a", 2]


entry = first_entry()
the_list = order(first_entry=entry)
test_string(order=the_list)

entry = first_entry()
the_list = order(first_entry=entry)
test_int(order=the_list)

一个测试/fixture 可以一次请求多个 fixture

测试和 fixture 不限于一次请求一个 fixture。它们可以请求任意数量的 fixture。这里有另一个快速示例来演示

# contents of test_append.py
import pytest


# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def second_entry():
    return 2


# Arrange
@pytest.fixture
def order(first_entry, second_entry):
    return [first_entry, second_entry]


# Arrange
@pytest.fixture
def expected_list():
    return ["a", 2, 3.0]


def test_string(order, expected_list):
    # Act
    order.append(3.0)

    # Assert
    assert order == expected_list

每个测试可以多次请求 fixture(返回值会被缓存)

在同一个测试中,fixture 也可以被多次请求,pytest 不会为该测试再次执行它们。这意味着我们可以在依赖它们的多个 fixture 中(甚至在测试本身中)请求 fixture,而这些 fixture 不会被多次执行。

# contents of test_append.py
import pytest


# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order():
    return []


# Act
@pytest.fixture
def append_first(order, first_entry):
    return order.append(first_entry)


def test_string_only(append_first, order, first_entry):
    # Assert
    assert order == [first_entry]

如果一个被请求的 fixture 在测试中每次被请求时都执行一次,那么这个测试将会失败,因为 append_firsttest_string_only 都会将 order 看作一个空列表(即 []),但由于 order 的返回值(以及执行它可能产生的任何副作用)在第一次调用后被缓存,因此测试和 append_first 都引用了同一个对象,测试看到了 append_first 对该对象的影响。

自动使用 fixture(无需请求的 fixture)

有时你可能希望有一个(或几个)你确定所有测试都将依赖的 fixture。“自动使用” fixture 是一种方便的方法,可以使所有测试自动请求它们。这可以减少大量冗余的请求,甚至可以提供更高级的 fixture 用法(稍后将详细介绍)。

我们可以通过将 autouse=True 传递给 fixture 的装饰器来使其成为自动使用 fixture。下面是一个简单的使用示例

# contents of test_append.py
import pytest


@pytest.fixture
def first_entry():
    return "a"


@pytest.fixture
def order(first_entry):
    return []


@pytest.fixture(autouse=True)
def append_first(order, first_entry):
    return order.append(first_entry)


def test_string_only(order, first_entry):
    assert order == [first_entry]


def test_string_and_int(order, first_entry):
    order.append(2)
    assert order == [first_entry, 2]

在此示例中,append_first fixture 是一个自动使用 fixture。因为它自动发生,所以两个测试都受到它的影响,即使没有一个测试请求它。但这并不意味着它们不能请求;只是没有必要

作用域:在类、模块、包或会话之间共享 fixture

需要网络访问的 fixture 依赖于连接性,并且通常创建耗时。扩展前面的示例,我们可以在调用 @pytest.fixture 时添加 scope="module" 参数,以使负责创建与现有 SMTP 服务器连接的 smtp_connection fixture 函数在每个测试模块中只被调用一次(默认是在每个测试函数中调用一次)。测试模块中的多个测试函数因此将分别接收相同的 smtp_connection fixture 实例,从而节省时间。scope 的可能值有:functionclassmodulepackagesession

下一个示例将 fixture 函数放入一个单独的 conftest.py 文件中,以便目录中的多个测试模块可以访问该 fixture 函数

# content of conftest.py
import smtplib

import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
# content of test_module.py


def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert b"smtp.gmail.com" in msg
    assert 0  # for demo purposes


def test_noop(smtp_connection):
    response, msg = smtp_connection.noop()
    assert response == 250
    assert 0  # for demo purposes

这里,test_ehlo 需要 smtp_connection fixture 值。pytest 将发现并调用标记为 @pytest.fixturesmtp_connection fixture 函数。运行测试如下所示

$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items

test_module.py FF                                                    [100%]

================================= FAILURES =================================
________________________________ test_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
        assert b"smtp.gmail.com" in msg
>       assert 0  # for demo purposes
        ^^^^^^^^
E       assert 0

test_module.py:7: AssertionError
________________________________ test_noop _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
        ^^^^^^^^
E       assert 0

test_module.py:13: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
============================ 2 failed in 0.12s =============================

你会看到两个 assert 0 失败了,更重要的是,你还可以看到完全相同smtp_connection 对象被传递到了这两个测试函数中,因为 pytest 在回溯中显示了传入的参数值。因此,两个使用 smtp_connection 的测试函数运行速度与单个测试函数一样快,因为它们重用了同一个实例。

如果你决定要一个会话范围的 smtp_connection 实例,你可以简单地声明它

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

Fixture 作用域

Fixture 在首次被测试请求时创建,并根据其 scope 销毁

  • function:默认作用域,fixture 在测试结束时销毁。

  • class:fixture 在类中最后一个测试的 teardown 期间销毁。

  • module:fixture 在模块中最后一个测试的 teardown 期间销毁。

  • package:fixture 在定义 fixture 的包中最后一个测试的 teardown 期间销毁,包括其中的子包和子目录。

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

注意

Pytest 一次只缓存一个 fixture 实例,这意味着在使用参数化 fixture 时,pytest 可能会在给定作用域内多次调用 fixture。

动态作用域

5.2 版本新增。

在某些情况下,你可能希望更改 fixture 的作用域而无需更改代码。为此,可以将一个可调用对象传递给 scope。该可调用对象必须返回一个包含有效作用域的字符串,并且只会在 fixture 定义期间执行一次。它将与两个关键字参数一起调用——fixture_name 作为字符串,以及 config 作为配置对象。

这对于需要设置时间(例如生成 docker 容器)的 fixture 特别有用。你可以使用命令行参数来控制在不同环境中生成的容器的作用域。请参见下面的示例。

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

拆卸/清理(又称 Fixture 终结)

当我们运行测试时,我们会希望确保它们在运行后进行清理,以免影响其他测试(也为了不留下大量测试数据来膨胀系统)。pytest 中的 fixture 提供了一个非常有用的拆卸系统,它允许我们定义每个 fixture 清理自身所需的特定步骤。

该系统可以通过两种方式利用。

2. 直接添加终结器

虽然 yield fixture 被认为是更清晰、更直接的选择,但还有另一种选择,那就是直接将“终结器”函数添加到测试的 请求上下文 对象中。它会带来与 yield fixture 类似的结果,但需要更多的冗余代码。

为了使用这种方法,我们必须在需要添加拆卸代码的 fixture 中请求 请求上下文 对象(就像我们请求另一个 fixture 一样),然后将一个包含拆卸代码的可调用对象传递给它的 addfinalizer 方法。

不过我们必须小心,因为 pytest 一旦添加了终结器,就会运行它,即使该 fixture 在添加终结器之后引发异常也是如此。因此,为了确保我们不会在不需要时运行终结器代码,我们只会在 fixture 做了需要拆卸的事情之后才添加终结器。

使用 addfinalizer 方法,前面的示例将如下所示

# content of test_emaillib.py
from emaillib import Email, MailAdminClient

import pytest


@pytest.fixture
def mail_admin():
    return MailAdminClient()


@pytest.fixture
def sending_user(mail_admin):
    user = mail_admin.create_user()
    yield user
    mail_admin.delete_user(user)


@pytest.fixture
def receiving_user(mail_admin, request):
    user = mail_admin.create_user()

    def delete_user():
        mail_admin.delete_user(user)

    request.addfinalizer(delete_user)
    return user


@pytest.fixture
def email(sending_user, receiving_user, request):
    _email = Email(subject="Hey!", body="How's it going?")
    sending_user.send_email(_email, receiving_user)

    def empty_mailbox():
        receiving_user.clear_mailbox()

    request.addfinalizer(empty_mailbox)
    return _email


def test_email_received(receiving_user, email):
    assert email in receiving_user.inbox

它比 yield fixture 稍长且更复杂,但在你遇到困难时,它确实提供了一些细微的差别。

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

关于终结器顺序的说明

终结器以先进后出的顺序执行。对于 yield fixture,第一个运行的拆卸代码来自最右侧的 fixture,即最后一个测试参数。

# content of test_finalizers.py
import pytest


def test_bar(fix_w_yield1, fix_w_yield2):
    print("test_bar")


@pytest.fixture
def fix_w_yield1():
    yield
    print("after_yield_1")


@pytest.fixture
def fix_w_yield2():
    yield
    print("after_yield_2")
$ pytest -s test_finalizers.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item

test_finalizers.py test_bar
.after_yield_2
after_yield_1


============================ 1 passed in 0.12s =============================

对于终结器,第一个运行的 fixture 是对 request.addfinalizer 的最后一次调用。

# content of test_finalizers.py
from functools import partial
import pytest


@pytest.fixture
def fix_w_finalizers(request):
    request.addfinalizer(partial(print, "finalizer_2"))
    request.addfinalizer(partial(print, "finalizer_1"))


def test_bar(fix_w_finalizers):
    print("test_bar")
$ pytest -s test_finalizers.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item

test_finalizers.py test_bar
.finalizer_1
finalizer_2


============================ 1 passed in 0.12s =============================

这是因为 yield fixture 在幕后使用了 addfinalizer:当 fixture 执行时,addfinalizer 会注册一个恢复生成器的函数,该函数又会调用拆卸代码。

安全拆卸

pytest 的 fixture 系统非常强大,但它仍然由计算机运行,因此无法自行找出如何安全地拆卸我们抛给它的一切。如果我们不小心,错误的错误位置可能会留下测试的残余,这会很快导致进一步的问题。

例如,考虑以下测试(基于上面的邮件示例)

# content of test_emaillib.py
from emaillib import Email, MailAdminClient

import pytest


@pytest.fixture
def setup():
    mail_admin = MailAdminClient()
    sending_user = mail_admin.create_user()
    receiving_user = mail_admin.create_user()
    email = Email(subject="Hey!", body="How's it going?")
    sending_user.send_email(email, receiving_user)
    yield receiving_user, email
    receiving_user.clear_mailbox()
    mail_admin.delete_user(sending_user)
    mail_admin.delete_user(receiving_user)


def test_email_received(setup):
    receiving_user, email = setup
    assert email in receiving_user.inbox

这个版本更加紧凑,但也更难阅读,没有非常描述性的 fixture 名称,而且任何 fixture 都不能轻易重用。

还有一个更严重的问题是,如果设置中的任何步骤引发异常,则所有拆卸代码都不会运行。

一种选择可能是使用 addfinalizer 方法而不是 yield fixture,但这可能会变得相当复杂且难以维护(并且不再紧凑)。

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

安全的 fixture 结构

最安全和最简单的 fixture 结构要求将 fixture 限制为每个只执行一个状态更改操作,然后将它们与各自的拆卸代码捆绑在一起,如上面的电子邮件示例所示。

状态更改操作失败但仍能修改状态的可能性微乎其微,因为这些操作大多是基于事务的(至少在可能留下状态的测试级别)。因此,如果我们确保任何成功的状态更改操作通过将其移动到单独的 fixture 函数并将其与其他可能失败的状态更改操作分开来拆卸,那么我们的测试将最有机会使测试环境保持原样。

举个例子,假设我们有一个带有登录页面的网站,并且我们可以访问一个管理 API 来生成用户。对于我们的测试,我们希望

  1. 通过该管理 API 创建一个用户

  2. 使用 Selenium 启动浏览器

  3. 访问我们站点的登录页面

  4. 以我们创建的用户身份登录

  5. 断言他们的名字显示在登录页面的标题中

我们不希望将该用户留在系统中,也不希望让该浏览器会话继续运行,因此我们需要确保创建这些事物的 fixture 在完成后进行清理。

这可能看起来像这样

注意

对于此示例,某些 fixture(即 base_urladmin_credentials)被暗示存在于其他地方。所以现在,我们假设它们存在,并且我们只是不查看它们。

from uuid import uuid4
from urllib.parse import urljoin

from selenium.webdriver import Chrome
import pytest

from src.utils.pages import LoginPage, LandingPage
from src.utils import AdminApiClient
from src.utils.data_types import User


@pytest.fixture
def admin_client(base_url, admin_credentials):
    return AdminApiClient(base_url, **admin_credentials)


@pytest.fixture
def user(admin_client):
    _user = User(name="Susan", username=f"testuser-{uuid4()}", password="P4$$word")
    admin_client.create_user(_user)
    yield _user
    admin_client.delete_user(_user)


@pytest.fixture
def driver():
    _driver = Chrome()
    yield _driver
    _driver.quit()


@pytest.fixture
def login(driver, base_url, user):
    driver.get(urljoin(base_url, "/login"))
    page = LoginPage(driver)
    page.login(user)


@pytest.fixture
def landing_page(driver, login):
    return LandingPage(driver)


def test_name_on_landing_page_after_login(landing_page, user):
    assert landing_page.header == f"Welcome, {user.name}!"

依赖关系的设置方式意味着不清楚 user fixture 是否会在 driver fixture 之前执行。但这没关系,因为它们是原子操作,所以哪个先运行并不重要,因为测试的事件序列仍然是可线性化的。但真正重要的是,无论哪个先运行,如果其中一个引发了异常而另一个没有,那么两者都不会留下任何残余。如果 driveruser 之前执行,并且 user 引发异常,驱动程序仍将退出,并且用户从未被创建。如果 driver 是引发异常的那个,那么驱动程序就永远不会启动,用户也永远不会被创建。

安全地运行多个 assert 语句

有时你可能希望在完成所有设置后运行多个断言,这很有意义,因为在更复杂的系统中,单个操作可以触发多种行为。pytest 提供了一种方便的处理方式,它结合了我们目前所学到的许多内容。

所需要做的只是将作用域提升到更大的范围,然后将操作步骤定义为自动使用 fixture,最后,确保所有 fixture 都以该更高级别的作用域为目标。

让我们借鉴上面的一个示例,并稍作调整。假设除了检查标题中的欢迎消息外,我们还想检查注销按钮和用户个人资料的链接。

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

注意

对于此示例,某些 fixture(即 base_urladmin_credentials)被暗示存在于其他地方。所以现在,我们假设它们存在,并且我们只是不查看它们。

# contents of tests/end_to_end/test_login.py
from uuid import uuid4
from urllib.parse import urljoin

from selenium.webdriver import Chrome
import pytest

from src.utils.pages import LoginPage, LandingPage
from src.utils import AdminApiClient
from src.utils.data_types import User


@pytest.fixture(scope="class")
def admin_client(base_url, admin_credentials):
    return AdminApiClient(base_url, **admin_credentials)


@pytest.fixture(scope="class")
def user(admin_client):
    _user = User(name="Susan", username=f"testuser-{uuid4()}", password="P4$$word")
    admin_client.create_user(_user)
    yield _user
    admin_client.delete_user(_user)


@pytest.fixture(scope="class")
def driver():
    _driver = Chrome()
    yield _driver
    _driver.quit()


@pytest.fixture(scope="class")
def landing_page(driver, login):
    return LandingPage(driver)


class TestLandingPageSuccess:
    @pytest.fixture(scope="class", autouse=True)
    def login(self, driver, base_url, user):
        driver.get(urljoin(base_url, "/login"))
        page = LoginPage(driver)
        page.login(user)

    def test_name_in_header(self, landing_page, user):
        assert landing_page.header == f"Welcome, {user.name}!"

    def test_sign_out_button(self, landing_page):
        assert landing_page.sign_out_button.is_displayed()

    def test_profile_link(self, landing_page, user):
        profile_href = urljoin(base_url, f"/profile?id={user.profile_id}")
        assert landing_page.profile_link.get_attribute("href") == profile_href

请注意,方法签名中引用 self 只是形式。与 unittest.TestCase 框架中可能存在的情况不同,实际测试类没有绑定任何状态。一切都由 pytest fixture 系统管理。

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

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

class TestLandingPageBadCredentials:
    @pytest.fixture(scope="class")
    def faux_user(self, user):
        _user = deepcopy(user)
        _user.password = "badpass"
        return _user

    def test_raises_bad_credentials_exception(self, login_page, faux_user):
        with pytest.raises(BadCredentialsException):
            login_page.login(faux_user)

Fixture 可以内省请求的测试上下文

fixture 函数可以接受 request 对象来内省“请求”测试函数、类或模块上下文。进一步扩展前面的 smtp_connection fixture 示例,让我们从使用我们 fixture 的测试模块中读取一个可选的服务器 URL

# content of conftest.py
import smtplib

import pytest


@pytest.fixture(scope="module")
def smtp_connection(request):
    server = getattr(request.module, "smtpserver", "smtp.gmail.com")
    smtp_connection = smtplib.SMTP(server, 587, timeout=5)
    yield smtp_connection
    print(f"finalizing {smtp_connection} ({server})")
    smtp_connection.close()

我们使用 request.module 属性来可选地从测试模块中获取 smtpserver 属性。如果我们再次执行,变化不大

$ pytest -s -q --tb=no test_module.py
FFfinalizing <smtplib.SMTP object at 0xdeadbeef0002> (smtp.gmail.com)

========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
2 failed in 0.12s

让我们快速创建另一个实际在其模块命名空间中设置服务器 URL 的测试模块

# content of test_anothersmtp.py

smtpserver = "mail.python.org"  # will be read by smtp fixture


def test_showhelo(smtp_connection):
    assert 0, smtp_connection.helo()

运行它

$ pytest -qq --tb=short test_anothersmtp.py
F                                                                    [100%]
================================= FAILURES =================================
______________________________ test_showhelo _______________________________
test_anothersmtp.py:6: in test_showhelo
    assert 0, smtp_connection.helo()
E   AssertionError: (250, b'mail.python.org')
E   assert 0
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0003> (mail.python.org)
========================= short test summary info ==========================
FAILED test_anothersmtp.py::test_showhelo - AssertionError: (250, b'mail....

瞧!smtp_connection fixture 函数从模块命名空间中获取了我们的邮件服务器名称。

使用标记将数据传递给 fixture

使用 request 对象,fixture 还可以访问应用于测试函数的标记。这对于从测试中向 fixture 传递数据很有用

import pytest


@pytest.fixture
def fixt(request):
    marker = request.node.get_closest_marker("fixt_data")
    if marker is None:
        # Handle missing marker in some way...
        data = None
    else:
        data = marker.args[0]

    # Do something with the data
    return data


@pytest.mark.fixt_data(42)
def test_fixt(fixt):
    assert fixt == 42

将工厂作为 fixture

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

工厂可以根据需要包含参数

@pytest.fixture
def make_customer_record():
    def _make_customer_record(name):
        return {"name": name, "orders": []}

    return _make_customer_record


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

如果工厂创建的数据需要管理,fixture 可以处理

@pytest.fixture
def make_customer_record():
    created_records = []

    def _make_customer_record(name):
        record = models.Customer(name=name, orders=[])
        created_records.append(record)
        return record

    yield _make_customer_record

    for record in created_records:
        record.destroy()


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

参数化 fixture

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

扩展前面的示例,我们可以标记 fixture 以创建两个 smtp_connection fixture 实例,这将导致所有使用该 fixture 的测试运行两次。fixture 函数通过特殊的 request 对象访问每个参数

# content of conftest.py
import smtplib

import pytest


@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
    smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
    yield smtp_connection
    print(f"finalizing {smtp_connection}")
    smtp_connection.close()

主要变化是在 @pytest.fixture 中声明 params,它是一个值列表,fixture 函数将为其中的每个值执行,并且可以通过 request.param 访问一个值。不需要更改任何测试函数代码。所以我们再运行一次

$ pytest -q test_module.py
FFFF                                                                 [100%]
================================= FAILURES =================================
________________________ test_ehlo[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
        assert b"smtp.gmail.com" in msg
>       assert 0  # for demo purposes
        ^^^^^^^^
E       assert 0

test_module.py:7: AssertionError
________________________ test_noop[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
        ^^^^^^^^
E       assert 0

test_module.py:13: AssertionError
________________________ test_ehlo[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
>       assert b"smtp.gmail.com" in msg
E       AssertionError: assert b'smtp.gmail.com' in b'mail.python.org\nPIPELINING\nSIZE 51200000\nETRN\nSTARTTLS\nAUTH DIGEST-MD5 NTLM CRAM-MD5\nENHANCEDSTATUSCODES\n8BITMIME\nDSN\nSMTPUTF8\nCHUNKING'

test_module.py:6: AssertionError
-------------------------- Captured stdout setup ---------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0004>
________________________ test_noop[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
        ^^^^^^^^
E       assert 0

test_module.py:13: AssertionError
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0005>
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo[smtp.gmail.com] - assert 0
FAILED test_module.py::test_noop[smtp.gmail.com] - assert 0
FAILED test_module.py::test_ehlo[mail.python.org] - AssertionError: asser...
FAILED test_module.py::test_noop[mail.python.org] - assert 0
4 failed in 0.12s

我们看到我们的两个测试函数都运行了两次,分别针对不同的 smtp_connection 实例。另外请注意,对于 mail.python.org 连接,第二个测试在 test_ehlo 中失败了,因为预期的服务器字符串与实际收到的不同。

pytest 将为参数化 fixture 中每个 fixture 值构建一个作为测试 ID 的字符串,例如上面示例中的 test_ehlo[smtp.gmail.com]test_ehlo[mail.python.org]。这些 ID 可以与 -k 一起使用来选择要运行的特定用例,它们还将在某个用例失败时标识该特定用例。使用 --collect-only 运行 pytest 将显示生成的 ID。

数字、字符串、布尔值和 None 将使用其常规字符串表示形式作为测试 ID。对于其他对象,pytest 将根据参数名称生成一个字符串。通过使用 ids 关键字参数,可以自定义用于特定 fixture 值的测试 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 可以是要使用的字符串列表,也可以是一个函数,该函数将与 fixture 值一起调用,然后必须返回一个要使用的字符串。在后一种情况下,如果函数返回 None,则将使用 pytest 自动生成的 ID。

运行上述测试会使用以下测试 ID

$ pytest --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 12 items

<Dir fixtures.rst-228>
  <Module test_anothersmtp.py>
    <Function test_showhelo[smtp.gmail.com]>
    <Function test_showhelo[mail.python.org]>
  <Module test_emaillib.py>
    <Function test_email_received>
  <Module test_finalizers.py>
    <Function test_bar>
  <Module test_ids.py>
    <Function test_a[spam]>
    <Function test_a[ham]>
    <Function test_b[eggs]>
    <Function test_b[1]>
  <Module test_module.py>
    <Function test_ehlo[smtp.gmail.com]>
    <Function test_noop[smtp.gmail.com]>
    <Function test_ehlo[mail.python.org]>
    <Function test_noop[mail.python.org]>

======================= 12 tests collected in 0.12s ========================

将标记与参数化 fixture 一起使用

pytest.param() 可以用于在参数化 fixture 的值集中应用标记,就像它们可以与 @pytest.mark.parametrize 一起使用一样。

示例

# content of test_fixture_marks.py
import pytest


@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)])
def data_set(request):
    return request.param


def test_data(data_set):
    pass

运行此测试将跳过值为 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 =======================

模块化:从 fixture 函数中使用 fixture

除了在测试函数中使用 fixture 外,fixture 函数本身也可以使用其他 fixture。这有助于实现 fixture 的模块化设计,并允许在许多项目中重用框架特定的 fixture。作为一个简单的示例,我们可以扩展前面的示例并实例化一个 app 对象,将已定义的 smtp_connection 资源放入其中

# content of test_appsetup.py

import pytest


class App:
    def __init__(self, smtp_connection):
        self.smtp_connection = smtp_connection


@pytest.fixture(scope="module")
def app(smtp_connection):
    return App(smtp_connection)


def test_smtp_connection_exists(app):
    assert app.smtp_connection

这里我们声明了一个 app fixture,它接收先前定义的 smtp_connection fixture 并用它实例化一个 App 对象。我们来运行它

$ pytest -v test_appsetup.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 2 items

test_appsetup.py::test_smtp_connection_exists[smtp.gmail.com] PASSED [ 50%]
test_appsetup.py::test_smtp_connection_exists[mail.python.org] PASSED [100%]

============================ 2 passed in 0.12s =============================

由于 smtp_connection 的参数化,测试将运行两次,分别使用两个不同的 App 实例和各自的 smtp 服务器。由于 pytest 会完全分析 fixture 依赖图,因此 app fixture 无需知道 smtp_connection 的参数化。

请注意,app fixture 具有 module 作用域,并使用模块作用域的 smtp_connection fixture。如果 smtp_connectionsession 作用域中缓存,示例仍然会有效:fixture 可以使用“更广”作用域的 fixture,但反之则不行:会话作用域的 fixture 无法有意义地使用模块作用域的 fixture。

按 fixture 实例自动分组测试

pytest 在测试运行期间最大限度地减少了活动 fixture 的数量。如果你有一个参数化的 fixture,那么所有使用它的测试将首先使用一个实例执行,然后才会在创建下一个 fixture 实例之前调用终结器。除此之外,这还简化了对创建和使用全局状态的应用程序的测试。

以下示例使用两个参数化 fixture,其中一个以模块为范围,所有函数都执行 print 调用以显示设置/拆卸流程

# content of test_module.py
import pytest


@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
    param = request.param
    print("  SETUP modarg", param)
    yield param
    print("  TEARDOWN modarg", param)


@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
    param = request.param
    print("  SETUP otherarg", param)
    yield param
    print("  TEARDOWN otherarg", param)


def test_0(otherarg):
    print("  RUN test0 with otherarg", otherarg)


def test_1(modarg):
    print("  RUN test1 with modarg", modarg)


def test_2(otherarg, modarg):
    print(f"  RUN test2 with otherarg {otherarg} and modarg {modarg}")

让我们在详细模式下运行测试并查看打印输出

$ pytest -v -s test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 8 items

test_module.py::test_0[1]   SETUP otherarg 1
  RUN test0 with otherarg 1
PASSED  TEARDOWN otherarg 1

test_module.py::test_0[2]   SETUP otherarg 2
  RUN test0 with otherarg 2
PASSED  TEARDOWN otherarg 2

test_module.py::test_1[mod1]   SETUP modarg mod1
  RUN test1 with modarg mod1
PASSED
test_module.py::test_2[mod1-1]   SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod1
PASSED  TEARDOWN otherarg 1

test_module.py::test_2[mod1-2]   SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod1
PASSED  TEARDOWN otherarg 2

test_module.py::test_1[mod2]   TEARDOWN modarg mod1
  SETUP modarg mod2
  RUN test1 with modarg mod2
PASSED
test_module.py::test_2[mod2-1]   SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod2
PASSED  TEARDOWN otherarg 1

test_module.py::test_2[mod2-2]   SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod2
PASSED  TEARDOWN otherarg 2
  TEARDOWN modarg mod2


============================ 8 passed in 0.12s =============================

你可以看到参数化模块作用域的 modarg 资源导致了测试执行顺序,从而最大限度地减少了“活动”资源。参数化资源 mod1 的终结器在 mod2 资源设置之前执行。

特别注意,test_0 完全独立并首先完成。然后 test_1 使用 mod1 执行,然后 test_2 使用 mod1 执行,然后 test_1 使用 mod2 执行,最后 test_2 使用 mod2 执行。

参数化资源 otherarg(具有函数作用域)在使用它的每个测试之前设置并之后拆卸。

在类和模块中使用 usefixtures

有时测试函数不需要直接访问 fixture 对象。例如,测试可能需要以空目录作为当前工作目录来操作,但否则不关心具体目录。以下是如何使用标准 tempfile 和 pytest fixture 来实现它。我们将 fixture 的创建分离到一个 conftest.py 文件中

# content of conftest.py

import os
import tempfile

import pytest


@pytest.fixture
def cleandir():
    with tempfile.TemporaryDirectory() as newpath:
        old_cwd = os.getcwd()
        os.chdir(newpath)
        yield
        os.chdir(old_cwd)

并通过 usefixtures 标记在测试模块中声明其使用

# content of test_setenv.py
import os

import pytest


@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
    def test_cwd_starts_empty(self):
        assert os.listdir(os.getcwd()) == []
        with open("myfile", "w", encoding="utf-8") as f:
            f.write("hello")

    def test_cwd_again_starts_empty(self):
        assert os.listdir(os.getcwd()) == []

由于 usefixtures 标记,每个测试方法的执行都需要 cleandir fixture,就好像你为每个方法指定了一个“cleandir”函数参数一样。我们来运行它以验证我们的 fixture 是否已激活并且测试通过

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

你可以像这样指定多个 fixture

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

你还可以使用 pytestmark 在测试模块级别指定 fixture 的使用

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

还可以将项目中所有测试所需的 fixture 放入 ini 文件中

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

警告

请注意,此标记在fixture 函数中不起作用。例如,这不会按预期工作

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

这将生成一个弃用警告,并将在 Pytest 8 中成为错误。

在不同级别覆盖 fixture

在相对大型的测试套件中,你很可能需要使用本地定义的 fixture 覆盖全局或根 fixture,以保持测试代码的可读性和可维护性。

在文件夹 (conftest) 级别覆盖 fixture

假设测试文件结构为

tests/
    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        def test_username(username):
            assert username == 'username'

    subfolder/
        conftest.py
            # content of tests/subfolder/conftest.py
            import pytest

            @pytest.fixture
            def username(username):
                return 'overridden-' + username

        test_something_else.py
            # content of tests/subfolder/test_something_else.py
            def test_username(username):
                assert username == 'overridden-username'

如你所见,对于某些测试文件夹级别,同名的 fixture 可以被覆盖。请注意,basesuper fixture 可以轻松地从 overriding fixture 中访问——如上面的示例所示。

在测试模块级别覆盖 fixture

假设测试文件结构为

tests/
    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-' + username

        def test_username(username):
            assert username == 'overridden-username'

    test_something_else.py
        # content of tests/test_something_else.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-else-' + username

        def test_username(username):
            assert username == 'overridden-else-username'

在上面的示例中,同名的 fixture 可以针对某些测试模块进行覆盖。

通过直接测试参数化覆盖 fixture

假设测试文件结构为

tests/
    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

        @pytest.fixture
        def other_username(username):
            return 'other-' + username

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.mark.parametrize('username', ['directly-overridden-username'])
        def test_username(username):
            assert username == 'directly-overridden-username'

        @pytest.mark.parametrize('username', ['directly-overridden-username-other'])
        def test_username_other(other_username):
            assert other_username == 'other-directly-overridden-username-other'

在上面的示例中,fixture 值被测试参数值覆盖。请注意,即使测试没有直接使用它(在函数原型中没有提及),也可以通过这种方式覆盖 fixture 的值。

用非参数化 fixture 覆盖参数化 fixture,反之亦然

假设测试文件结构为

tests/
    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture(params=['one', 'two', 'three'])
        def parametrized_username(request):
            return request.param

        @pytest.fixture
        def non_parametrized_username(request):
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def parametrized_username():
            return 'overridden-username'

        @pytest.fixture(params=['one', 'two', 'three'])
        def non_parametrized_username(request):
            return request.param

        def test_username(parametrized_username):
            assert parametrized_username == 'overridden-username'

        def test_parametrized_username(non_parametrized_username):
            assert non_parametrized_username in ['one', 'two', 'three']

    test_something_else.py
        # content of tests/test_something_else.py
        def test_username(parametrized_username):
            assert parametrized_username in ['one', 'two', 'three']

        def test_username(non_parametrized_username):
            assert non_parametrized_username == 'username'

在上面的示例中,参数化 fixture 被非参数化版本覆盖,非参数化 fixture 被参数化版本覆盖,适用于特定的测试模块。显然,这也适用于测试文件夹级别。

使用来自其他项目的 fixture

通常,提供 pytest 支持的项目将使用入口点,因此只需将这些项目安装到环境中,即可使这些 fixture 可供使用。

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

假设你的 mylibrary.fixtures 中有一些 fixture,你想在 app/tests 目录中重用它们。

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

pytest_plugins = "mylibrary.fixtures"

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

注意

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

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