如何使用 fixtures
"请求" fixtures
在基本层面上,测试函数通过将所需的 fixtures 声明为参数来请求它们。
当 pytest 要运行一个测试时,它会查看测试函数签名中的参数,然后搜索具有与这些参数相同名称的 fixtures。一旦 pytest 找到它们,它会运行这些 fixtures,捕获它们返回的内容(如果有的话),并将这些对象作为参数传递给测试函数。这样,测试函数就可以使用 fixtures 提供的对象来执行测试。这种机制使得测试函数能够方便地访问和利用各种测试上下文和资源。
快速示例
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 函数,并将其返回的对象传递给test_fruit_salad
作为 fruit_bowl
参数。这样,测试函数可以使用 fruit_bowl
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)
# Arrange
bowl = fruit_bowl()
test_fruit_salad(fruit_bowl=bowl)
Fixtures 可以请求其他 fixtures。
pytest的一个最大优势之一是其极其灵活的 fixture 系统。它允许我们将复杂的测试需求简化成更简单和有组织的函数,其中每个函数只需描述它们所依赖的事物。我们将在后面更深入地讨论这一点,但现在,这里有一个快速示例来演示 fixtures 如何使用其他 fixtures:
# 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 中的 fixtures 就像测试一样请求 fixtures,所有相同的请求规则也适用于fixtures。以下是如果我们手动执行这个示例会发生的情况:
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)
Fixtures 可重用
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。
测试和fixtures不限于一次请求一个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
fixtures可以在同一个测试中被多次请求(返回值会被缓存)。
在同一个测试中,fixtures 也可以被多次请求,pytest 不会再次执行它们。这意味着我们可以在多个依赖它们的 fixtures 中(甚至在测试本身中)请求 fixtures,而这些 fixtures 不会被执行多次。这提供了更大的灵活性和复用性。
# 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
对该对象产生的影响。这就是为什么pytest会缓存fixture的返回值,以防止重复执行。
自动使用的 fixtures(无需显式请求的 fixtures)
有时,您可能希望有一个fixture(甚至多个fixture),所有的测试都会依赖它们。"自动使用" fixtures 是一种方便的方法,可以使所有测试自动请求它们。这可以减少很多冗余的请求,甚至可以提供更高级的 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。由于它会自动执行,因此两个测试都会受到它的影响,即使没有一个测试显式请求它。这并不意味着它们不能被请求,只是不必要。
作用域:在类、模块、包或会话之间共享 fixtures
需要网络访问的 fixtures 依赖于网络连接,通常需要花费较长时间来创建。在前面的示例中,我们可以通过在 @pytest.fixture
装饰器的调用中添加一个scope="module"
参数来控制 smtp_connection
fixture 函数的作用范围。这个 fixture 函数负责创建到现有 SMTP 服务器的连接,通过设置scope="module"
,它将仅在每个测试模块中调用一次(默认情况下是在每个测试函数中调用一次)。这意味着同一测试模块中的多个测试函数将共享相同的 smtp_connection
fixture 实例,从而节省了连接的创建时间。scope
参数可以取以下可能的值:function
、class
、module
、package
或 session
。
下面的示例将 fixture 函数放入一个单独的 conftest.py
文件中,以便目录中的多个测试模块可以访问这个 fixture 函数:
# content of conftest.py
import pytest
import smtplib
@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会发现并调用被标记为smtp_connection
的@pytest.fixture
的fixture函数。运行测试的过程如下:
$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.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 在类中的最后一个测试被拆卸时销毁。module
:fixture 在模块中的最后一个测试被拆卸时销毁。package
:fixture 在包中的最后一个测试被拆卸时销毁。session
:fixture 在测试会话结束时销毁。
pytest一次只缓存一个fixture实例,这意味着当使用参数化的fixture时,pytest可能会在给定的作用域内调用fixture多次。
动态作用域
在某些情况下,您可能希望在不更改代码的情况下更改 fixture 的作用域。要做到这一点,请将一个可调用对象传递给 scope
。这个可调用对象必须返回一个有效作用域的字符串,并且只会在 fixture 定义期间执行一次。它将被调用时带有两个关键字参数 - fixture_name
(字符串)和 config
(配置对象)。
这在处理需要时间进行设置的 fixtures 时特别有用,比如启动一个 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 中的 fixtures 提供了一个非常有用的拆卸系统,允许我们为每个 fixture 定义必要的特定步骤,以便在 fixture 完成后进行清理。
这个系统可以用两种方式发挥作用。
1. yield
fixtures (推荐)
"Yield" 型 fixtures 使用 yield
而不是 return
。使用这些 fixtures,我们可以运行一些代码并将一个对象传递回请求的 fixture/测试,就像使用其他 fixtures 一样。唯一的区别是:
return
被替换为yield
。- 与该 fixture 相关的任何拆卸代码都放在
yield
之后。
一旦pytest确定了fixture的线性顺序,它会依次运行每个fixture,直到遇到返回或yield,然后继续执行列表中的下一个fixture以执行相同的操作。
一旦测试结束,pytest 会按相反的顺序回到 fixtures 列表,获取每一个已经 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
import pytest
from emaillib import Email, MailAdminClient
@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
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 语句后的拆卸代码。但是,对于已经成功运行的每个 fixture,pytest 仍然会尝试按照正常方式进行拆卸。
2. 直接添加拆卸函数
虽然 yield
型 fixture 被认为是更清晰和更直接的选项,但还有另一种选择,那就是直接将 “finalizer” 函数添加到测试的request-context对象中。这会带来与 yield
型 fixture 类似的结果,但需要更多的冗长代码。
要使用这种方法,我们必须在需要为其添加拆卸代码的 fixture 中请求 request-context对象(就像我们请求其他 fixture 一样),然后将一个包含拆卸代码的可调用对象传递给它的 addfinalizer
方法。
需要小心的是,一旦添加了 finalizer,pytest 会立即运行它,即使在添加 finalizer 之后,fixture 引发了异常。因此,为了确保我们只在需要进行拆卸操作时才运行 finalizer 代码,我们应该在 fixture 执行了需要拆卸的操作之后才添加 finalizer。
以下是使用addfinalizer
方法的先前示例的演示:
# content of test_emaillib.py
import pytest
from emaillib import Email, MailAdminClient
@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-7.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 =============================
For finalizers, the first fixture to run is last call to 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-7.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系统非常强大,但请注意,由于它仍然由计算机执行,所以它无法自动判断如何安全地清理我们的测试环境。如果我们不小心,将 error 的清理操作放在不适当的位置,可能会导致测试中的一些资源未被正确清理,这可能会迅速引发更多问题。
例如,考虑以下测试(基于上面的邮件示例):
# content of test_emaillib.py
import pytest
from emaillib import Email, MailAdminClient
@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结构要求将fixtures限制为仅执行一个状态更改操作,然后将它们与拆卸代码一起捆绑在一起,正如上面的电子邮件示例所示。
状态更改操作可能失败但仍然修改状态的机会微乎其微,因为大多数这些操作通常是基于事务处理的(至少在测试的层次上可能会留下状态)。因此,如果我们确保任何成功的状态更改操作都通过将其移到单独的fixture函数中并将其与其他可能失败的状态更改操作分开来进行拆卸,那么我们的测试将最有可能使测试环境保持原样。简而言之,将每个成功的状态更改操作与其拆卸代码分离,并尽量将其限制在一个fixture函数中,可以确保测试尽可能地保持测试环境不变。
举个例子,假设我们有一个带有登录页面的网站,我们可以访问一个管理员API,在那里我们可以创建用户。对于我们的测试,我们想要:
- 通过管理员API创建一个用户
- 使用Selenium启动一个浏览器
- 转到我们网站的登录页面
- 以我们创建的用户身份登录
- 断言他们的名字出现在登录页面的页眉中
我们不希望将该用户留在系统中,也不希望保留该浏览器会话,因此我们需要确保创建这些资源的fixtures在完成后进行清理。
这可能看起来是这样的:
请注意,在这个示例中,某些fixtures(即
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
仍然会正常退出,因为user
根本没有被创建。而如果driver
是引发异常的一方,那么driver
根本不会启动,因此也不会有用户被创建。
虽然实际上,
user
fixture不一定需要在driver
fixture之前执行,但如果我们让driver
请求user
,那么在创建用户引发异常的情况下,这可能会节省一些时间,因为它不会尝试启动driver
,而这是一个相当耗时的操作。
安全地运行多个assert
语句
有时候,在进行了所有设置之后,您可能希望运行多个断言,这是有道理的,因为在更复杂的系统中,单个操作可能会触发多个行为。pytest有一种方便的处理方法,它结合了我们迄今为止讨论过的许多内容。
所需的只是扩大作用范围,然后将操作步骤定义为自动使用的fixture,最后确保所有fixture都针对较高级别的作用范围。
让我们从上面的示例中提取一个示例,并稍微调整一下。假设除了在页眉中检查欢迎消息之外,我们还想检查是否存在注销按钮和指向用户个人资料的链接。
让我们看看如何构建结构,以便我们可以运行多个断言,而不必再次重复所有这些步骤。
对于这个示例,某些fixtures(例如
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系统管理。
每个方法只需要请求它实际需要的fixtures,而不必担心顺序。这是因为act fixture是一个自动使用的fixture,它确保了所有其他fixture在它之前执行。不再需要进行状态更改,因此测试可以自由地进行任意数量的非状态更改查询,而不会危及其他测试的正常运行。
login
fixture也在类内部定义,因为模块中的不是每个其他测试都会期望成功登录,而且操作可能需要在另一个测试类中进行略微不同的处理。例如,如果我们想围绕提交错误凭据编写另一个测试场景,我们可以通过在测试文件中添加类似以下内容来处理:
假定该页面对象(例如
LoginPage
)在尝试登录后,如果在登录表单上识别到表示错误凭据的文本,则会引发自定义异常BadCredentialsException
。
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)
Fixtures can introspect the requesting test context
Fixture函数可以接受request
对象,以便内省“请求”它的测试函数、类或模块上下文。接下来,我们将进一步扩展之前的smtp_connection
fixture示例,以从使用该fixture的测试模块中读取一个可选的服务器URL:
# content of conftest.py
import pytest
import smtplib
@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("finalizing {} ({})".format(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()
Running it:
$ 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函数从模块命名空间中获取了我们的邮件服务器名称。
使用标记(marker)将数据传递给fixtures
使用request
对象,fixture还可以访问应用于测试函数的标记(marker)。这对于从测试中将数据传递给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
将工厂函数作为fixtures
"工厂作为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")
参数化fixtures
Fixture函数可以被参数化,这样它们将被多次调用,每次执行依赖于此fixture的一组测试,即依赖于这个fixture的测试。测试函数通常不需要知道它们被重新运行。Fixture参数化有助于编写详尽的功能测试,用于测试可以以多种方式配置的组件。
扩展前面的示例,我们可以标记fixture以创建两个smtp_connection
fixture实例,这将导致使用该fixture的所有测试运行两次。Fixture函数通过特殊的request
对象获得对每个参数的访问权限:
# content of conftest.py
import pytest
import smtplib
@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("finalizing {}".format(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-7.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 12 items
<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 ========================
使用参数化的 fixtures 与标记
pytest.param
可以以与 @pytest.mark.parametrize
相同的方式,用于在参数化的 fixtures 的值集合中应用标记。
示例:
# 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-7.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 函数中使用其他 fixtures。
除了在测试函数中使用 fixtures 外,fixture 函数本身也可以使用其他 fixtures。这有助于构建模块化的 fixtures 设计,并允许在许多项目中重用特定于框架的 fixtures。作为一个简单的示例,我们可以扩展前面的示例,并实例化一个名为 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-7.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
fixture 不需要了解 smtp_connection
的参数化,因为 pytest 将完全分析 fixture 依赖图。
请注意,app
fixture 具有 module
作用域并使用了一个具有模块作用域的 smtp_connection
fixture。如果smtp_connection
缓存在 session
作用域上,示例仍然可以工作:fixtures 可以使用具有“更广泛”作用域的 fixtures,但反之则不行:一个 session 作用域的 fixture 无法以有意义的方式使用模块作用域的 fixture。
测试根据 fixture 实例的自动分组
pytest在测试运行过程中会尽量减少活跃的fixture数量。如果你有一个参数化的fixture,那么所有使用它的测试首先会使用一个实例,然后在创建下一个fixture实例之前会调用finalizer。这样做的好处之一是简化了测试应用程序的过程,特别是那些创建和使用全局状态的应用程序。
以下示例中,使用了两个参数化的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(" RUN test2 with otherarg {} and modarg {}".format(otherarg, modarg))
让我们以详细模式运行测试,并查看打印输出:
$ pytest -v -s test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.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 完全独立并且最先完成。然后,test_1 使用 mod1
执行,接着是 test_2 使用 mod1
,然后是 test_1 使用 mod2
,最后是 test_2 使用 mod2
。
具有函数级别作用域的 otherarg
参数化资源在每个使用它的测试之前设置,并在测试结束后拆除。
在类和模块中使用 fixtures,可以使用 usefixtures
。
有时测试函数不需要直接访问 fixture 对象。例如,测试可能需要将空目录设置为当前工作目录,但不关心具体的目录。以下是如何使用标准的 tempfile
和 pytest fixtures 来实现这一点。我们将 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") 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
你可以像这样指定多个 fixtures:
@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
...
你还可以在测试模块级别使用 pytestmark
指定 fixture 的使用方式:
pytestmark = pytest.mark.usefixtures("cleandir")
还可以将所有测试所需的 fixtures 放入项目的 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():
...
目前这不会生成任何错误或警告,但这是由
3664
处理的预期行为。
在不同级别覆盖 fixtures
在相对大型的测试套件中,您很可能需要使用在本地定义的 fixture 来覆盖
全局或根级别的 fixture,以保持测试代码的可读性和可维护性。
在文件夹(conftest)级别覆盖一个 fixture。
假设测试文件结构如下:
tests/
__init__.py
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/
__init__.py
conftest.py
# content of tests/subfolder/conftest.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-' + username
test_something.py
# content of tests/subfolder/test_something.py
def test_username(username):
assert username == 'overridden-username'
正如您所看到的,相同名称的 fixture 可以在某些测试文件夹级别进行覆盖。请注意,base
或 super
fixture 可以轻松地从 overriding
fixture 中访问 - 就像上面的示例中使用的那样。
在测试模块级别覆盖一个 fixture。
假设测试文件结构如下:
tests/
__init__.py
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/
__init__.py
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/
__init__.py
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 被一个参数化版本覆盖,这适用于特定的测试模块。显然,相同的规则也适用于测试文件夹级别。
从其他项目中使用 fixtures。
通常,项目提供了 pytest 支持会使用 "entry points",所以只需将这些项目安装到环境中,就可以使用它们提供的 fixtures。
但是,如果您想要使用来自没有用到 "entry points" 的项目的 fixtures,您可以在顶级 conftest.py
文件中定义 pytest_plugins
,以将该模块注册为插件。
假设您的 fixtures 存在于 mylibrary.fixtures
中,并且您希望在 app/tests
目录中重用它们。那么您只需在 app/tests/conftest.py
中定义 pytest_plugins
,指向该模块即可。
pytest_plugins = "mylibrary.fixtures"
这实际上将 mylibrary.fixtures
注册为插件,使得其中的所有 fixtures 和 hooks 都可供 app/tests
中的测试使用。
有时用户会从其他项目中 导入 fixtures 以供使用,但这并不推荐:将 fixtures 导入到模块中将会在 pytest 中注册它们,就好像它们在该模块中被 定义 一样。
这可能会导致一些次要问题,比如在
pytest --help
中出现多次,但并不 推荐 这样做,因为这种行为可能会在将来的版本中发生变化或停止工作。