目录
“Requesting” fixtures-“请求”夹具
在基本层面上,测试函数通过将它们声明为参数来请求所需的夹具。
当pytest运行一个测试时,它会查看该测试函数签名中的参数,然后搜索与这些参数同名的夹具。pytest找到这些夹具后,会运行它们,捕获它们返回的内容(如果有的话),并将这些对象作为参数传递给测试函数。
简单的例子
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)
在这个例子中,首先,我们定义了两个类:Fruit
和 FruitSalad
。
-
Fruit
类表示一种水果,它有一个name
属性来存储水果的名称,以及一个cubed
属性来标记水果是否已被“切块”(在这个上下文中,我们将其视为一个布尔值,但实际上它可能表示水果的某种状态变化)。Fruit
类还有一个cube
方法,用于将cubed
属性设置为True
。 -
FruitSalad
类表示一份水果沙拉,它接受任意数量的Fruit
对象作为输入(通过*fruit_bowl
参数),并在初始化时调用_cube_fruit
方法来处理这些水果。_cube_fruit
方法遍历所有的水果,并对每个水果调用cube
方法。
然后,我们定义了一个 pytest 夹具 fruit_bowl
。这个夹具函数创建了一个包含两个 Fruit
对象(苹果和香蕉)的列表,并返回这个列表。
最后,我们编写了一个测试函数 test_fruit_salad
,它请求了 fruit_bowl
夹具。在测试函数内部,我们使用从 fruit_bowl
夹具返回的水果列表来创建一个 FruitSalad
对象。然后,我们使用一个断言来检查 FruitSalad
对象中的所有水果是否都被“切块”了(即它们的 cubed
属性是否都为 True
)。
当 pytest 运行这个测试时,它会自动识别 test_fruit_salad
函数中请求的 fruit_bowl
夹具,并执行该夹具函数来获取所需的水果列表。然后,pytest 将这个列表作为参数传递给 test_fruit_salad
函数,使得测试能够使用这些水果来验证 FruitSalad
类的行为。
如果我们打算手动执行这个测试,大致上会发生以下事情:
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)系统。该系统允许我们将复杂的测试需求简化为更简单、更有组织的函数,我们只需要让每个函数描述它所依赖的事物。稍后我们将更深入地探讨这一点,但现在,让我们通过一个快速示例来演示夹具如何使用其他夹具:
# 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)请求其他夹具的方式与测试请求夹具的方式相同。适用于测试的请求规则同样适用于夹具。如果我们手动执行这个示例,它将按以下方式工作:
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 的夹具系统之所以如此强大,原因之一在于它允许我们定义一个通用的设置步骤,该步骤可以像使用普通函数一样被反复重用。两个不同的测试可以请求相同的夹具,并让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]
在这里,每个测试都被赋予了该列表对象的一个自己的副本,这意味着顺序夹具(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)
一个测试/夹具可以同时请求多个夹具
测试和夹具并不限于一次只请求一个夹具。它们可以请求任意数量的夹具。这些夹具可以是相互独立的,也可以是存在依赖关系的。pytest的夹具系统会负责解析这些依赖关系,并按照正确的顺序来设置和清理这些夹具。
下面是另一个快速演示的例子:
# 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
在同一个测试中,夹具可以被请求多次(返回值会被缓存)
在pytest中,同一个测试过程中也可以多次请求同一个夹具,但pytest不会为那个测试再次执行它。这意味着我们可以在多个依赖于它的夹具中请求同一个夹具(甚至在测试本身中再次请求),而这些夹具不会被执行多次。
pytest通过缓存夹具的返回值来实现这一点。当夹具第一次被请求时,pytest会执行它并缓存其返回值。随后的请求将直接返回缓存的值,而不是重新执行夹具。
这种机制对于需要执行代价高昂的初始化操作的夹具特别有用,因为它可以确保这些操作只执行一次,同时仍然允许在测试的不同部分中使用这些夹具的返回值。
需要注意的是,虽然夹具的返回值被缓存了,但如果夹具本身具有副作用(比如修改全局状态),那么这些副作用仍然会在每次请求夹具时发生。因此,在设计夹具时,需要特别注意避免引入不必要的副作用。
# 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]
如果每次在测试过程中请求一个夹具时都执行它一次,那么这个测试将会失败,因为 append_first
和 test_string_only
都会将 order
视为一个空列表(即 []
)。但是,由于 order
的返回值(以及执行它可能产生的任何副作用)在第一次被调用后被缓存了,所以测试和 append_first
实际上都在引用同一个对象。因此,测试能够看到 append_first
对该对象所产生的影响。
具体来说,在这个例子中,append_first
夹具修改了 order
列表,将其变为包含 "a"
的列表。由于 order
列表的引用(而不是其内容的副本)被缓存了,所以当 test_string_only
访问 order
时,它看到的是已经被 append_first
修改过的列表,而不是原始的空列表。这就是为什么断言 assert order == [first_entry]
能够成功的原因。
自动使用夹具(无需请求即可使用的夹具)
有时,您可能希望有一个或多个夹具,您知道所有测试都将依赖于它们。“自动使用”夹具是一种让所有测试自动请求它们的便捷方式。这可以省去许多冗余的请求,甚至还可以提供更高级的夹具使用方法(稍后会详细介绍)。
我们可以通过将autouse=True
传递给夹具的装饰器来使夹具成为自动使用夹具。以下是一个简单的示例,展示了如何使用它们:
# 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
夹具是一个自动使用夹具。因为它是自动发生的,所以两个测试都受到了它的影响,尽管这两个测试都没有请求它。但这并不意味着它们不能请求它;只是这样做不是必要的。
作用域(Scope):跨类、模块、包或会话共享夹具
需要网络访问的夹具依赖于网络连接,并且通常创建起来比较耗时。以前面的示例为基础进行扩展,我们可以在@pytest.fixture
调用中添加scope="module"
参数,这样smtp_connection
夹具函数(负责创建一个到已存在的SMTP服务器的连接)就只会在每个测试模块中被调用一次(默认是每个测试函数被调用一次)。因此,在同一个测试模块中的多个测试函数将接收相同的smtp_connection
夹具实例,从而节省时间。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
夹具的值。pytest 会发现并调用标记有 @pytest.fixture
的 smtp_connection
夹具函数。运行测试看起来像这样:
$ 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():
# 返回的夹具值将被所有请求它的测试共享
...
夹具作用域
夹具是在首次被测试请求时创建的,并且根据它们的作用域被销毁:
-
function:默认作用域,夹具在测试结束时被销毁。
-
class:夹具在类中最后一个测试的清理阶段被销毁。
-
module:夹具在模块中最后一个测试的清理阶段被销毁。
-
package(注意:pytest本身不直接支持包级作用域,但这里的描述可能是在假设或模拟该行为):如果假设支持包级作用域,则夹具在定义它的包(包括其子包和子目录)中最后一个测试的清理阶段被销毁。然而,重要的是要注意,这不是pytest官方直接支持的功能,可能需要通过其他方式(如使用
pytest_configure
和pytest_unconfigure
钩子)来模拟。 -
session:夹具在测试会话结束时被销毁。
注意
pytest 一次只缓存一个夹具的实例,这意味着当使用参数化夹具时,pytest 可能会在给定作用域内多次调用该夹具。
动态作用域
从版本5.2开始增加。
在某些情况下,您可能想要在不更改代码的情况下更改夹具的作用域。为了做到这一点,您可以将一个可调用的对象传递给作用域(scope)参数。这个可调用的对象必须返回一个包含有效作用域名称的字符串,并且它只会在夹具定义期间执行一次。它将被调用时带有两个关键字参数:fixture_name
(一个字符串,表示夹具的名称)和config
(一个配置对象)。
您可以编写一个可调用对象,它根据传递给pytest的命令行参数或环境变量来决定夹具的作用域。这样,您就可以在不修改夹具代码的情况下,通过简单地更改命令行参数或环境变量来调整夹具的作用域,从而适应不同的测试环境或需求。
这在处理需要时间来设置的夹具时特别有用,比如启动一个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()
determine_scope
函数:- 这个函数接收两个参数:
fixture_name
和config
。尽管在这个具体的实现中fixture_name
没有被使用,但在更复杂的情况下,你可以根据fixture的名称来做出不同的决策。 config
是Pytest的配置对象,它允许你访问命令行选项、插件配置等信息。- 函数内部,它检查是否通过命令行选项
--keep-containers
传递了值(在这个例子中,不关心值的具体内容,只关心是否传递了此选项)。如果传递了--keep-containers
选项,则函数返回"session"
,意味着docker_container
fixture 将具有会话作用域(即在整个测试会话期间只创建和销毁一次容器)。 - 如果没有传递
--keep-containers
选项,则函数返回"function"
,表示docker_container
fixture 将具有函数作用域(即在每个测试函数之前创建容器,在每个测试函数之后销毁容器)。
- 这个函数接收两个参数:
docker_container
fixture:- 使用
@pytest.fixture
装饰器来定义fixture。 scope=determine_scope
:这里将fixture的作用域设置为determine_scope
函数的返回值。这意味着docker_container
fixture 的作用域将基于测试运行时的配置动态确定。- 在fixture体内,
yield spawn_container()
被使用。spawn_container()
应该是一个函数,负责启动并返回一个Docker容器实例(尽管这个函数没有在代码片段中定义)。使用yield
语句使得fixture成为一个生成器,允许在测试前执行spawn_container()
来启动容器,并在测试后自动执行yield
之后的清理代码(尽管在这个例子中,并没有直接显示清理代码)。
- 使用
通过这种方式,你可以灵活地控制Docker容器的创建和销毁行为,以适应不同的测试需求。例如,在开发过程中,你可能希望每个测试函数都使用一个新的容器来避免潜在的副作用,而在CI/CD流程中,为了节省时间和资源,你可能希望整个测试会话只使用一个容器。
拆解/清理(也称为Fixture终结)
当我们运行测试时,我们希望确保它们能够自我清理,以免干扰其他测试(同时也避免在系统中留下大量测试数据导致系统膨胀)。pytest中的Fixture提供了一个非常有用的拆解系统,允许我们为每个Fixture定义必要的具体步骤,以便在其自身之后进行清理。
这个系统可以通过两种方式加以利用。
-
使用
yield
的Fixture(推荐)
使用yield
的Fixture通过yield
而不是return
来返回。这些Fixture可以运行一些代码,并将一个对象传回给请求它的Fixture/测试,就像其他Fixture一样。唯一的不同之处在于:
- 使用
yield
代替了return
。 - 该Fixture的任何拆解代码都放在
yield
之后。
一旦pytest确定了Fixture的线性顺序,它就会依次运行每个Fixture,直到它return
或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 # 使用yield在这里可以让我们在测试完成后执行清理代码
mail_admin.delete_user(user) # 测试完成后清理用户
@pytest.fixture
def receiving_user(mail_admin):
user = mail_admin.create_user()
yield user # 同样使用yield来支持测试后的清理
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,所以在拆解过程中它会首先运行。需要注意的是,即使拆解的顺序是正确的,也不能保证清理过程是绝对安全的。关于安全拆解的更多细节将在后文介绍。
处理带有 yield 的 fixture 的错误
如果在使用 yield 的 fixture 中,在 yield 语句之前抛出了异常,pytest 将不会尝试运行该 yield fixture 的 yield 语句之后的拆解(teardown)代码。但是,对于该测试已经成功运行的每一个其他 fixture,pytest 仍然会尝试像平常一样拆解它们。
这意味着,如果某个 yield fixture 在其 yield 语句之前失败(比如,在创建资源时出错),那么该 fixture 的拆解代码将不会被执行,但 pytest 会尽力拆解那些已经成功运行的 fixture,以确保测试环境的清理。然而,这也可能导致一些资源(如数据库连接、文件句柄等)在测试失败后被遗漏,因此编写健壮的拆解代码和错误处理逻辑是很重要的。
直接添加终结器
虽然使用 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 要长一些,也稍微复杂一些,但在你遇到棘手问题时,它确实提供了一些细微的差别和解决方案。
关于终结器顺序的说明
终结器(finalizers)是按照后进先出(LIFO,Last In First Out)的顺序执行的。对于使用 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
对于终结器,首先运行的 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
这是因为 yield fixtures 在幕后使用了 addfinalizer:当 fixture 执行时,addfinalizer 会注册一个函数来恢复生成器,该生成器进而调用拆解代码。这样确保了即使在发生异常时,拆解代码也能被执行。