如何使用 fixtures¶
参见
“Requesting” fixtures¶
在基本层面上,测试函数通过将它们声明为参数来请求它们所需要的 fixture。
当 pytest 运行一个测试时,它会查看该测试函数签名中的参数,然后搜索与这些参数具有相同名称的 fixture。一旦 pytest 找到它们,它就运行这些 fixture,捕获它们返回的内容(如果有的话),并将这些对象作为参数传递给 test 函数。
上手样例¶
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
“requests” 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)
fixtures 可以 request 其他 fixtures¶
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 像 tests 一样 request fixture。所有相同的 request 规则都适用于用于测试的 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)
Fixtures 是可重用的¶
pytest 的 fixture 系统如此强大的原因之一是,它使我们能够定义一个通用的 setup 步骤,可以像使用普通函数一样重复使用。两个不同的测试可以请求相同的 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)
一个 test/fixture 可以一次 request 多个 fixture¶
测试和 fixtures 不限于 requesting 一个 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
每个测试可以 requested 多次 fixture(返回值被缓存)¶
在同一测试中,fixtures 也可以被 requested 多次,pytest 不会为该测试再次执行它们。这意味着我们可以在多个依赖于它们的 fixture中 requested 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]
如果 requested fixture 是每次执行一次 requested 的测试期间,这个测试会失败,因为 append_first
和 test_string_only
将 order
作为一个空列表(例如 []
),但由于 order
是缓存的返回值(连同任何副作用执行它可能有)它被称为第一次后,测试和 append_first
都引用同一个对象,和测试看到 append_first
对该对象的影响。
Autouse fixture(不必请求的 fixture)¶
有时,您可能希望拥有一个(甚至几个)您知道所有测试都将依赖的 fixture。”Autouse” fixtures 是使所有测试自动 requests 它们的一种方便方法。这可以减少大量冗余的 requests,甚至可以提供更高级的 fixture 使用(后面会详细介绍)。
可以通过将 autouse=True
传递给 fixture 的装饰器来使一个 fixture 成为 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
fixture 是 auuse fixture。因为它是自动发生的,所以两个测试都受到它的影响,即使没有一个测试请求它。但这并不意味着它们不能被要求;只是说这没有必要。
范围:跨类、模块、包或会话共享 fixture¶
需要网络访问的 fixture 依赖于连接性,通常需要花费大量时间来创建。扩展前面的例子,我们可以向 @pytest.fixture
添加 scope="module"
参数,以使 smtp_connection
函数(负责创建到现有 SMTP 服务器的连接)在每个测试模块中只被调用一次(默认是每个测试函数调用一次)。因此,测试模块中的多个测试函数都将接收相同的 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-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
:在类中最后一个测试的 teardown 过程中,fixture 被销毁。module
:在拆卸模块中最后一次测试时,fixture 被销毁。package
: the fixture is destroyed during teardown of the last test in the package.session
: the fixture is destroyed at the end of the test session.
备注
Pytest 一次只缓存一个 fixture 的实例,这意味着当使用参数化的 fixture 时,pytest 可以在给定的范围内多次调用一个 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()
Teardown/Cleanup(又名 Fixture 终结)¶
当我们运行我们的测试时,我们想要确保它们自己清理,这样它们就不会干扰任何其他测试(也不会留下如山的测试数据,使系统膨胀)。pytest 中的夹具提供了一个非常有用的拆卸系统,它允许我们定义每个夹具的具体清理步骤。
可以通过两种方式利用这个系统。
1. yield
fixtures(推荐)¶
“Yield” fixtures 使用 yield
而不是 return
。使用这些 fixture,我们可以运行一些代码并将对象传递回请求的 fixture/test,就像使用其他 fixture 一样。唯一的区别是:
return
被替换为yield
。该 fixture 的任何拆卸代码都放在
yield
之后。
一旦 pytest 找出固定装置的线性顺序,它将运行每一个,直到它返回或产生,然后移动到列表中的下一个固定装置做同样的事情。
一旦测试完成,pytest 将返回 fixture 列表,但是以 相反的顺序,取每个产生结果的 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
是在 setup 过程中运行的最后一个 fixture,所以它是在拆卸过程中首先运行的。
有一个风险是,即使在拆卸方面有正确的 order,也不能保证安全清理。这在 安全拆解 中有更详细的介绍。
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
yield fixture 的处理错误¶
如果 yield fixture 在 yield 之前引发异常,pytest 将不会在 yield fixture 的 yield
语句之后尝试运行拆卸代码。但是,对于每一个已经在测试中成功运行的装置,pytest 仍然会试图像往常一样将其拆除。
2. Adding finalizers directly¶
While yield fixtures are considered to be the cleaner and more straightforward option, there is another choice, and that is to add “finalizer” functions directly to the test’s request-context object. It brings a similar result as yield fixtures, but requires a bit more verbosity.
In order to use this approach, we have to request the request-context object
(just like we would request another fixture) in the fixture we need to add
teardown code for, and then pass a callable, containing that teardown code, to
its addfinalizer
method.
We have to be careful though, because pytest will run that finalizer once it’s been added, even if that fixture raises an exception after adding the finalizer. So to make sure we don’t run the finalizer code when we wouldn’t need to, we would only add the finalizer once the fixture would have done something that we’d need to teardown.
Here’s how the previous example would look using the addfinalizer
method:
# 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
It’s a bit longer than yield fixtures and a bit more complex, but it does offer some nuances for when you’re in a pinch.
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
Note on finalizer order¶
Finalizers are executed in a first-in-last-out order. For yield fixtures, the first teardown code to run is from the right-most fixture, i.e. the last test parameter.
# 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 =============================
This is so because yield fixtures use addfinalizer
behind the scenes: when the fixture executes, addfinalizer
registers a function that resumes the generator, which in turn calls the teardown code.
安全拆解¶
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 可以轻松重用。
还有一个更严重的问题,那就是如果安装过程中的任何一个步骤引发了异常,那么任何一个拆卸代码都不会运行。
One option might be to go with the addfinalizer
method instead of yield
fixtures, but that might get pretty complex and difficult to maintain (and it
wouldn’t be compact anymore).
$ pytest -q test_emaillib.py
. [100%]
1 passed in 0.12s
Safe fixture structure¶
The safest and simplest fixture structure requires limiting fixtures to only making one state-changing action each, and then bundling them together with their teardown code, as the email examples above showed.
The chance that a state-changing operation can fail but still modify state is negligible, as most of these operations tend to be transaction-based (at least at the level of testing where state could be left behind). So if we make sure that any successful state-changing action gets torn down by moving it to a separate fixture function and separating it from other, potentially failing state-changing actions, then our tests will stand the best chance at leaving the test environment the way they found it.
For an example, let’s say we have a website with a login page, and we have access to an admin API where we can generate users. For our test, we want to:
Create a user through that admin API
Launch a browser using Selenium
Go to the login page of our site
Log in as the user we created
Assert that their name is in the header of the landing page
We wouldn’t want to leave that user in the system, nor would we want to leave that browser session running, so we’ll want to make sure the fixtures that create those things clean up after themselves.
下面是它可能的样子:
备注
For this example, certain fixtures (i.e. base_url
and
admin_credentials
) are implied to exist elsewhere. So for now, let’s
assume they exist, and we’re just not looking at them.
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}!"
The way the dependencies are laid out means it’s unclear if the user
fixture would execute before the driver
fixture. But that’s ok, because
those are atomic operations, and so it doesn’t matter which one runs first
because the sequence of events for the test is still linearizable. But what does matter is
that, no matter which one runs first, if the one raises an exception while the
other would not have, neither will have left anything behind. If driver
executes before user
, and user
raises an exception, the driver will
still quit, and the user was never made. And if driver
was the one to raise
the exception, then the driver would never have been started and the user would
never have been made.
Running multiple assert
statements safely¶
Sometimes you may want to run multiple asserts after doing all that setup, which makes sense as, in more complex systems, a single action can kick off multiple behaviors. pytest has a convenient way of handling this and it combines a bunch of what we’ve gone over so far.
All that’s needed is stepping up to a larger scope, then having the act step defined as an autouse fixture, and finally, making sure all the fixtures are targeting that higher level scope.
Let’s pull an example from above, and tweak it a bit. Let’s say that in addition to checking for a welcome message in the header, we also want to check for a sign out button, and a link to the user’s profile.
Let’s take a look at how we can structure that so we can run multiple asserts without having to repeat all those steps again.
备注
For this example, certain fixtures (i.e. base_url
and
admin_credentials
) are implied to exist elsewhere. So for now, let’s
assume they exist, and we’re just not looking at them.
# 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
Notice that the methods are only referencing self
in the signature as a
formality. No state is tied to the actual test class as it might be in the
unittest.TestCase
framework. Everything is managed by the pytest fixture
system.
Each method only has to request the fixtures that it actually needs without worrying about order. This is because the act fixture is an autouse fixture, and it made sure all the other fixtures executed before it. There’s no more changes of state that need to take place, so the tests are free to make as many non-state-changing queries as they want without risking stepping on the toes of the other tests.
The login
fixture is defined inside the class as well, because not every one
of the other tests in the module will be expecting a successful login, and the act may need to
be handled a little differently for another test class. For example, if we
wanted to write another test scenario around submitting bad credentials, we
could handle it by adding something like this to the test file:
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 functions can accept the request
object
to introspect the “requesting” test function, class or module context.
Further extending the previous smtp_connection
fixture example, let’s
read an optional server URL from the test module which uses our fixture:
# 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()
We use the request.module
attribute to optionally obtain an
smtpserver
attribute from the test module. If we just execute
again, nothing much has changed:
$ 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
Let’s quickly create another test module that actually sets the server URL in its module namespace:
# 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....
voila! The smtp_connection
fixture function picked up our mail server name
from the module namespace.
使用标记将数据传递给 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
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 参数化有助于编写对组件进行详尽的功能测试以多种方式进行配置。
Extending the previous example, we can flag the fixture to create two
smtp_connection
fixture instances which will cause all tests using the fixture
to run twice. The fixture function gets access to each parameter
through the special request
object:
# 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()
The main change is the declaration of params
with
@pytest.fixture
, a list of values
for each of which the fixture function will execute and can access
a value via request.param
. No test function code needs to change.
So let’s just do another run:
$ 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
We see that our two test functions each ran twice, against the different
smtp_connection
instances. Note also, that with the mail.python.org
connection the second test fails in test_ehlo
because a
different server string is expected than what arrived.
pytest will build a string that is the test ID for each fixture value
in a parametrized fixture, e.g. test_ehlo[smtp.gmail.com]
and
test_ehlo[mail.python.org]
in the above examples. These IDs can
be used with -k
to select specific cases to run, and they will
also identify the specific case when one is failing. Running pytest
with --collect-only
will show the generated IDs.
Numbers, strings, booleans and None
will have their usual string
representation used in the test ID. For other objects, pytest will
make a string based on the argument name. It is possible to customise
the string used in a test ID for a certain fixture value by using the
ids
keyword argument:
# 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
The above shows how ids
can be either a list of strings to use or
a function which will be called with the fixture value and then
has to return a string to use. In the latter case if the function
returns None
then pytest’s auto-generated ID will be used.
Running the above tests results in the following test IDs being used:
$ 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 ========================
Using marks with parametrized fixtures¶
pytest.param()
can be used to apply marks in values sets of parametrized fixtures in the same way
that they can be used with @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
Running this test will skip the invocation of data_set
with value 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 =======================
Modularity: using fixtures from a fixture function¶
In addition to using fixtures in test functions, fixture functions
can use other fixtures themselves. This contributes to a modular design
of your fixtures and allows re-use of framework-specific fixtures across
many projects. As a simple example, we can extend the previous example
and instantiate an object app
where we stick the already defined
smtp_connection
resource into it:
# 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
Here we declare an app
fixture which receives the previously defined
smtp_connection
fixture and instantiates an App
object with it. Let’s run it:
$ 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 =============================
Due to the parametrization of smtp_connection
, the test will run twice with two
different App
instances and respective smtp servers. There is no
need for the app
fixture to be aware of the smtp_connection
parametrization because pytest will fully analyse the fixture dependency graph.
Note that the app
fixture has a scope of module
and uses a
module-scoped smtp_connection
fixture. The example would still work if
smtp_connection
was cached on a session
scope: it is fine for fixtures to use
“broader” scoped fixtures but not the other way round:
A session-scoped fixture could not use a module-scoped one in a
meaningful way.
Automatic grouping of tests by fixture instances¶
pytest minimizes the number of active fixtures during test runs. If you have a parametrized fixture, then all the tests using it will first execute with one instance and then finalizers are called before the next fixture instance is created. Among other things, this eases testing of applications which create and use global state.
The following example uses two parametrized fixtures, one of which is
scoped on a per-module basis, and all the functions perform print
calls
to show the setup/teardown flow:
# 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}")
Let’s run the tests in verbose mode and with looking at the print-output:
$ 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 =============================
You can see that the parametrized module-scoped modarg
resource caused an
ordering of test execution that lead to the fewest possible “active” resources.
The finalizer for the mod1
parametrized resource was executed before the
mod2
resource was setup.
In particular notice that test_0 is completely independent and finishes first.
Then test_1 is executed with mod1
, then test_2 with mod1
, then test_1
with mod2
and finally test_2 with mod2
.
The otherarg
parametrized resource (having function scope) was set up before
and teared down after every test that used it.
Use fixtures in classes and modules with usefixtures
¶
Sometimes test functions do not directly need access to a fixture object.
For example, tests may require to operate with an empty directory as the
current working directory but otherwise do not care for the concrete
directory. Here is how you can use the standard tempfile
and pytest fixtures to
achieve it. We separate the creation of the fixture into a conftest.py
file:
# 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)
and declare its use in a test module via a usefixtures
marker:
# 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()) == []
Due to the usefixtures
marker, the cleandir
fixture
will be required for the execution of each test method, just as if
you specified a “cleandir” function argument to each of them. Let’s run it
to verify our fixture is activated and the tests pass:
$ pytest -q
.. [100%]
2 passed in 0.12s
You can specify multiple fixtures like this:
@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
...
and you may specify fixture usage at the test module level using pytestmark
:
pytestmark = pytest.mark.usefixtures("cleandir")
It is also possible to put fixtures required by all tests in your project into an ini-file:
# content of pytest.ini
[pytest]
usefixtures = cleandir
警告
Note this mark has no effect in fixture functions. For example, this will not work as expected:
@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture():
...
Currently this will not generate any error or warning, but this is intended to be handled by issue #3664.
Overriding fixtures on various levels¶
In relatively large test suite, you most likely need to override
a global
or root
fixture with a locally
defined one, keeping the test code readable and maintainable.
Override a fixture on a folder (conftest) level¶
Given the tests file structure is:
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'
As you can see, a fixture with the same name can be overridden for certain test folder level.
Note that the base
or super
fixture can be accessed from the overriding
fixture easily - used in the example above.
Override a fixture on a test module level¶
Given the tests file structure is:
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'
In the example above, a fixture with the same name can be overridden for certain test module.
Override a fixture with direct test parametrization¶
Given the tests file structure is:
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'
In the example above, a fixture value is overridden by the test parameter value. Note that the value of the fixture can be overridden this way even if the test doesn’t use it directly (doesn’t mention it in the function prototype).
Override a parametrized fixture with non-parametrized one and vice versa¶
Given the tests file structure is:
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'
In the example above, a parametrized fixture is overridden with a non-parametrized version, and a non-parametrized fixture is overridden with a parametrized version for certain test module. The same applies for the test folder level obviously.
Using fixtures from other projects¶
Usually projects that provide pytest support will use entry points, so just installing those projects into an environment will make those fixtures available for use.
In case you want to use fixtures from a project that does not use entry points, you can
define pytest_plugins
in your top conftest.py
file to register that module
as a plugin.
Suppose you have some fixtures in mylibrary.fixtures
and you want to reuse them into your
app/tests
directory.
All you need to do is to define pytest_plugins
in app/tests/conftest.py
pointing to that module.
pytest_plugins = "mylibrary.fixtures"
This effectively registers mylibrary.fixtures
as a plugin, making all its fixtures and
hooks available to tests in app/tests
.
备注
Sometimes users will import fixtures from other projects for use, however this is not recommended: importing fixtures into a module will register them in pytest as defined in that module.
This has minor consequences, such as appearing multiple times in pytest --help
,
but it is not recommended because this behavior might change/stop working
in future versions.