如何使用 fixture

另请参阅

关于 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 可以复用

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 一次请求的数量不限于一个,而是可以任意多个。下面再用一个简短示例说明:

# 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 可以请求它,测试本身也可以再次请求,而它只执行一次。

# 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_first 和 test_string_only 看到的 order 都会是空列表 []。不过,order 首次调用后,其返回值以及执行产生的副作用都被缓存;测试和 append_first 引用的是同一对象,因此测试能看到 append_first 对它的影响。

Autouse fixture:无需显式请求的 fixture

有时你知道所有测试都会依赖某个或多个 fixture。Autouse fixture 可以让所有测试自动请求它们,减少重复请求,也能支持更复杂的用法,后文将继续介绍。

向 fixture 装饰器传入 autouse=True,就能把它变成 autouse fixture。下面是一个简单示例:

# contents of test_append.py
import pytest


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


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


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


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


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

本例的 append_first 是 autouse fixture,因此两个测试即使没有请求它,也会自动受其影响。这不意味着不能显式请求它,只意味着没有必要。

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

需要网络访问的 fixture 依赖连接,而且创建通常耗时。延续前面的例子,可以给 @pytest.fixture 加上 scope=”module”,使负责连接已有 SMTP 服务器的 smtp_connection fixture 每个测试模块只执行一次,而默认是每个测试函数执行一次。同一模块内的多个测试函数会获得同一个 smtp_connection 实例,以节约时间。scope 可为 function、class、module、package 或 session。

下一示例把 fixture 函数放到独立的 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 对象。由于复用了实例,使用 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:默认作用域,在测试结束时销毁。

  • class:在类中最后一个测试的清理阶段销毁。

  • module:在模块中最后一个测试的清理阶段销毁。

  • package:在定义 fixture 的包中最后一个测试的清理阶段销毁;包含其中的子包和子目录。

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

注意

pytest 同一时间只缓存一个 fixture 实例;因此,对参数化 fixture 来说,在给定作用域内仍可能执行多次。

动态作用域

版本 5.2 新增。

有时你希望改变 fixture 作用域而不改动代码。可以把可调用对象传给 scope。它必须返回有效作用域名称的字符串,且只在 fixture 定义时执行一次。调用时接收两个关键字参数:字符串 fixture_name,以及配置对象 config。

这对于准备阶段耗时的 fixture 尤其有用,例如启动 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()

拆除与清理:fixture 的终结处理

运行测试时,应确保测试自行清理,既不干扰其他测试,也不在系统里遗留大量测试数据。pytest fixture 提供了有用的清理系统,让每个 fixture 都能定义自身需要的具体清理步骤。

这个系统有两种用法。

2. 直接添加 finalizer

yield fixture 通常更简洁、直观,但也可以直接向测试的 request-context 对象添加 finalizer 函数。效果类似,只是代码更冗长。

要采用这一方式,在需要清理代码的 fixture 中像请求其他 fixture 一样请求 request-context 对象,再把包含清理代码的可调用对象传给其 addfinalizer 方法。

需要小心:finalizer 一旦加入,即使该 fixture 随后抛出异常,pytest 也会执行它。因此,只有在 fixture 已完成确实需要清理的操作后,才应添加 finalizer,避免执行不必要的清理。

使用 addfinalizer 的前一个示例如下:

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

import pytest


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


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


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

    def delete_user():
        mail_admin.delete_user(user)

    request.addfinalizer(delete_user)
    return user


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

    def empty_mailbox():
        receiving_user.clear_mailbox()

    request.addfinalizer(empty_mailbox)
    return _email


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

比 yield fixture 更长,也更复杂,但在遇到特殊情况时,可以提供更细致的控制。

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

关于 finalizer 的执行顺序

Finalizer 采用先进后出顺序。对于 yield fixture,最右侧 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-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 =============================

对于直接添加的 finalizer,最先执行的是最后一次 request.addfinalizer 注册的函数。

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


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


def test_bar(fix_w_finalizers):
    print("test_bar")
$ pytest -s test_finalizers.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-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 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,让它与其他可能失败的状态变更分开,并确保它有对应清理,将最有助于测试把环境恢复到开始时的状态。

例如,有一个带登录页的网站,也有可以创建用户的管理 API。测试需要:

  1. 通过管理 API 创建用户。

  2. 使用 Selenium 启动浏览器。

  3. 访问网站登录页。

  4. 以刚创建的用户登录。

  5. 断言登录后页面的页头中包含该用户的姓名。

我们不希望遗留用户,也不希望浏览器会话一直运行,因此负责创建这些资源的 fixture 应负责清理。

实现可能如下:

注意

此例假定某些 fixture,例如 base_url 与 admin_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 执行。但这没关系,因为它们是原子操作,先后顺序不影响测试事件序列的可线性化性。关键是:不论哪个先执行,只要一个抛出异常、另一个本可成功,都不会留下资源。若 driver 先执行而 user 抛出异常,driver 仍会退出,而用户没有创建;若 driver 抛出异常,浏览器从未启动,用户也从未创建。

安全地执行多个断言

有时完成全部准备后,希望执行多个断言;复杂系统中,一个动作可能触发多个行为。pytest 有一种方便的方法,把前面介绍的机制结合起来。

只需扩大作用域,把动作步骤定义成 autouse fixture,并确保所有 fixture 都使用这个更大的作用域。

修改前面的示例:除检查页头欢迎消息,还要检查退出登录按钮和用户资料链接。

下面看怎样组织结构,以便执行多个断言,而不重复所有准备步骤。

注意

此例假定某些 fixture,例如 base_url 与 admin_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)
    @classmethod
    def login(cls, 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 是 autouse fixture,确保其他 fixture 都已先执行。此后没有更多状态变更,测试可以进行任意数量的不改变状态的查询,不必担心干扰其他测试。

login fixture 也定义在类内,因为模块中的其他测试未必预期登录成功;另一个测试类可能需要不同的动作。例如要测试错误凭据,可在文件中添加如下内容:

class TestLandingPageBadCredentials:
    @pytest.fixture(scope="class")
    @classmethod
    def faux_user(cls, 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 的测试模块中读取可选的服务器 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 传递数据

Fixture 也可以通过 request 对象访问测试函数上的标记,从而把测试中的数据传给 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 创建两个 smtp_connection 实例;所有使用它的测试都会运行两次。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 的每个值生成测试 ID,例如 test_ehlo[smtp.gmail.com] 与 test_ehlo[mail.python.org]。可用 -k 选择特定用例;发生失败时,ID 也会标明是哪一用例。执行 pytest –collect-only 可显示生成的 ID。

数字、字符串、布尔值和 None 使用通常的字符串表示作为测试 ID;其他对象则按参数名生成字符串。可以使用 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 值,并返回用作 ID 的字符串。如果函数返回 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-236>
  <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

运行该测试,会跳过 data_set 值为 2 的调用:

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

模块化:在 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,并用它创建 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 会完整分析 fixture 依赖图。

注意,app 使用 module 作用域,也依赖 module 作用域的 smtp_connection。如果 smtp_connection 缓存在 session 作用域,此例仍能运行。Fixture 可以使用作用域更广的 fixture,反过来不行:会话级 fixture 无法有意义地使用模块级 fixture。

按 fixture 实例自动分组测试

pytest 会尽量减少运行测试时活跃的 fixture 数量。对于参数化 fixture,所有使用它的测试先以一个实例执行;随后调用 finalizer,再创建下一个实例。这也方便测试创建或使用全局状态的应用。

以下示例使用两个参数化 fixture,其中一个作用域是模块。所有函数都打印消息,以展示准备与清理流程:

# 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 资源使测试按尽量减少活跃资源的顺序执行。在准备 mod2 之前,先执行了 mod1 资源的 finalizer。

特别注意,完全独立的 test_0 先完成。随后依次执行使用 mod1 的 test_1 和 test_2,再执行使用 mod2 的 test_1 和 test_2。

另一个参数化资源 otherarg 使用 function 作用域,会在每个使用它的测试之前准备、之后清理。

通过 usefixtures 在类和模块中使用 fixture

有时测试不需要直接访问 fixture 对象。例如,测试需要以空目录作为当前工作目录,却不关心具体目录。可以结合标准库 tempfile 和 pytest fixture 实现,并把 fixture 的创建放进 conftest.py:

# content of conftest.py

import os
import tempfile

import pytest


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

再用 usefixtures 标记在测试模块中声明使用它:

# content of test_setenv.py
import os

import pytest


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

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

因为 usefixtures 标记,执行每个测试方法时都需要 cleandir,效果与为各方法添加 cleandir 参数相同。运行以确认 fixture 生效、测试通过:

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

可以这样指定多个 fixture:

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

也可以通过 pytestmark 在测试模块级别指定:

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

还可以把项目所有测试都需要的 fixture 写入配置文件:

# content of pytest.toml
[pytest]
usefixtures = ["cleandir"]

警告

@pytest.mark.usefixtures 不能用于 fixture 函数。如下用法是错误的:

@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_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'

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

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

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

如上所示,可以在特定测试目录层级覆盖同名 fixture。覆盖版本也能方便地访问基础或父级 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 就可使用。

如果项目没有使用入口点,可在顶层 conftest.py 中定义 pytest_plugins,把该模块注册为插件。

假设 mylibrary.fixtures 中有一些 fixture,希望在 app/tests 目录中复用。

只需在 app/tests/conftest.py 中定义指向该模块的 pytest_plugins。

pytest_plugins = "mylibrary.fixtures"

这实际把 mylibrary.fixtures 注册为插件,让 app/tests 中的测试可以使用其全部 fixture 和 hook。

注意

有些用户直接导入其他项目的 fixture,但不推荐这样做:导入到模块中,会让 pytest 把这些 fixture 注册为定义在该模块内。

它可能产生轻微影响,例如在 pytest –help 中出现多次;更重要的是,这种行为可能在未来版本改变或失效,因此不推荐。

来源:pytest-dev 团队,How to use fixtures。本稿翻译该页面全部文章正文,保留全部 57 段示例、输出和文件结构。文中的终端结果为官方示例,不是本稿在本地执行所得;SMTP、Selenium 和管理 API 示例需要各自的外部环境。

许可:pytest MIT License;本稿包含中文翻译和 HTML 排版变更。

The MIT License (MIT)
Copyright (c) 2004 Holger Krekel and others
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容