如何使用 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_first
和 test_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
的可能值有:function
、class
、module
、package
或 session
。
下一个示例将 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.fixture
的 smtp_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 清理自身所需的特定步骤。
该系统可以通过两种方式利用。
1. yield
fixture(推荐)¶
“yield” fixture 使用 yield
而不是 return
。使用这些 fixture,我们可以运行一些代码并将一个对象传递回请求的 fixture/测试,就像使用其他 fixture 一样。唯一的区别是
return
被替换为yield
。该 fixture 的任何拆卸代码都放在
yield
之后。
一旦 pytest 确定了 fixture 的线性顺序,它将运行每个 fixture 直到它返回或 yield,然后继续列表中的下一个 fixture 进行相同的操作。
测试完成后,pytest 将按相反顺序返回 fixture 列表,取出每个已 yield 的 fixture,并运行其中在 yield
语句之后的代码。
作为一个简单的例子,考虑这个基本的电子邮件模块
# content of emaillib.py
class MailAdminClient:
def create_user(self):
return MailUser()
def delete_user(self, user):
# do some cleanup
pass
class MailUser:
def __init__(self):
self.inbox = []
def send_email(self, email, other):
other.inbox.append(email)
def clear_mailbox(self):
self.inbox.clear()
class Email:
def __init__(self, subject, body):
self.subject = subject
self.body = body
假设我们要测试从一个用户向另一个用户发送电子邮件。我们必须首先创建每个用户,然后从一个用户向另一个用户发送电子邮件,最后断言另一个用户在其收件箱中收到了该消息。如果要在测试运行后进行清理,我们可能需要确保在删除该用户之前清空另一个用户的邮箱,否则系统可能会报错。
这可能看起来像这样
# 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):
user = mail_admin.create_user()
yield user
user.clear_mailbox()
mail_admin.delete_user(user)
def test_email_received(sending_user, receiving_user):
email = Email(subject="Hey!", body="How's it going?")
sending_user.send_email(email, receiving_user)
assert email in receiving_user.inbox
因为 receiving_user
是设置期间最后一个运行的 fixture,所以它是拆卸期间第一个运行的。
存在一种风险,即使在拆卸方面顺序正确,也无法保证安全清理。这将在安全拆卸中更详细地介绍。
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
处理 yield fixture 的错误¶
如果一个 yield fixture 在 yield 之前引发异常,pytest 不会尝试运行该 yield fixture 的 yield
语句之后的拆卸代码。但是,对于该测试中已经成功运行的每个 fixture,pytest 仍然会像往常一样尝试拆卸它们。
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 来生成用户。对于我们的测试,我们希望
通过该管理 API 创建一个用户
使用 Selenium 启动浏览器
访问我们站点的登录页面
以我们创建的用户身份登录
断言他们的名字显示在登录页面的标题中
我们不希望将该用户留在系统中,也不希望让该浏览器会话继续运行,因此我们需要确保创建这些事物的 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
fixture 是否会在 driver
fixture 之前执行。但这没关系,因为它们是原子操作,所以哪个先运行并不重要,因为测试的事件序列仍然是可线性化的。但真正重要的是,无论哪个先运行,如果其中一个引发了异常而另一个没有,那么两者都不会留下任何残余。如果 driver
在 user
之前执行,并且 user
引发异常,驱动程序仍将退出,并且用户从未被创建。如果 driver
是引发异常的那个,那么驱动程序就永远不会启动,用户也永远不会被创建。
安全地运行多个 assert
语句¶
有时你可能希望在完成所有设置后运行多个断言,这很有意义,因为在更复杂的系统中,单个操作可以触发多种行为。pytest 提供了一种方便的处理方式,它结合了我们目前所学到的许多内容。
所需要做的只是将作用域提升到更大的范围,然后将操作步骤定义为自动使用 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)
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
运行此测试将跳过值为 2
的 data_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_connection
在 session
作用域中缓存,示例仍然会有效: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 可以被覆盖。请注意,base
或 super
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
中多次出现,但不推荐这样做,因为此行为在未来版本中可能会更改/停止工作。