【python】之pytest如何使用fixture夹具-四【重】

四、关于fixtures测试夹具的使用【重点】

这篇文章主要介绍fixture夹具,在项目中的使用技巧,代码比较多,文章比较长,请耐心看完

有些官方文档上的代码比价绕或者冗余,就自己写了简单版的便于理解

4.1 “Requesting” fixtures(请求夹具)

在基本层面上,测试函数通过将它们声明为参数来请求它们所需的装置。

当pytest运行测试时,它会查看该测试函数签名中的参数,然后搜索与这些参数名称相同的fixture。一旦pytest找到它们,它就运行这些fixture,捕获它们返回的内容(如果有的话),并将这些对象作为参数传递给test函数。

4.1.1 快速示例Quick example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pytest


class Fruit:
def __init__(self, name):
self.name = name
self.cubed = False

def cube(self):
self.cubed = True # 当调用cube方法时,将cubed属性设置为True

class FruitSalad:
def __init__(self, *fruit_bowl):
self.fruit = fruit_bowl
self._cube_fruit() # 调用私有方法 调用了fruit的cube方法,使其为True

def _cube_fruit(self):
for fruit in self.fruit:
fruit.cube() # 遍历调用fruit的cube方法

# Arrange
@pytest.fixture
def fruit_bowl(): # 这是一个测试用例的fixture函数
return [Fruit("apple"), Fruit("banana")] # 返回了两个Fruit实例对象


def test_fruit_salad(fruit_bowl): # 传进来就可以用
# Act
fruit_salad = FruitSalad(*fruit_bowl) # 实例化FruitSalad 加*是可变参数

# Assert
assert all(fruit.cubed for fruit in fruit_salad.fruit) # 调用了fruit的cubed方法 返回了Ture 断言成功

如果要手动完成,需要这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
ef fruit_bowl(): # 没有加fixture 装饰器,这是一个普通的函数
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) # 需要手动去调用

在这个例子中,test_fruit_salad“请求”了fruit_bowl(即def test_fruit_salad(fruit_bowl):),当pytest看到这个时,它将执行fruit_bowl固定函数,并将它返回的对象作为fruit_bowl参数传递给test_fruit_salad。

官方的这里示例描述的不太好,会把人看晕,看下面的就很简单明了,官方想表达的是,你如何定义一个fixture函数,然后如何使用它,你只需要在测试函数的入参里面加入即可,不需要手动执行你定义的fixture函数foo( )

1
2
3
4
5
6
7
import pytest
@pytest.fixture
def foo():
return 1
def test_foo(foo): # 你可以不用去foo()这样调用foo 只需要传进来即可
a = foo
assert a == 1

4.1.2 Fixtures 也可以请求其他 fixtures【重点】

pytest最大的优势之一是其极其灵活的夹具系统。它允许我们将复杂的测试需求简化为更简单和有组织的功能,我们只需要让每个功能描述它们所依赖的东西。我们将在后面深入讨论这个问题,但是现在,这里有一个快速的示例来演示fixture如何使用其他fixture:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# contents of test_append.py
import pytest

# Arrange
@pytest.fixture
def first_entry(): # 第一个fixture 返回了 "a"
return "a"

# Arrange
@pytest.fixture
def order(first_entry): # order返回了一个列表,里面是first_entry的返回值 就是 [ "a" ]
return [first_entry]

def test_string(order): # 引入order 此时order = [ "a" ]
# Act
order.append("b") # 给order 追加一个元素"b"
# Assert
assert order == ["a", "b"]

如果手动调用就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
def first_entry(): # 没加fixture 就是一个普通函数
return "a"
def order(first_entry): # 没加fixture 就是一个普通函数
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) # 需要手动调用

4.1.3 Fixtures 可以重复使用【重点】

pytestfixture系统如此强大的原因之一就是,它使我们能够定义一个可以重复使用的通用设置步骤,就像使用普通函数一样。两个不同的测试可以请求相同的fixture,并让pytest从该fixture为每个测试提供各自的结果。

这对于确保测试用例不会相互影响非常有用。我们可以使用这个系统来确保每个测试都获得自己的一批新鲜数据,并且从干净的状态开始,这样它就可以提供一致、可重复的结果。

使用的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 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") # order追加了 "b" 但是用例结束后没有改变order,其他用例仍然用order的初始值时使用,order仍然是 [ "a" ]
# Assert
assert order == ["a", "b"]

def test_int(order): # 他用例仍然用order的初始值时使用,order仍然是 [ "a" ]
# Act
order.append(2) # order 追加了一个 "2"
# Assert
assert order == ["a", 2]

这里的每个测试用例都被赋予了该列表对象的自己的副本,这意味着fixture装置函数被执行两次(对于first_entry固定装置也是如此)。

如果我们也手动执行此操作,就是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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() 是因为前面的entry已经被改变了,变成了["a", "b"],不能使用了,需要一个新的entry = first_entry()也就是[ "a" ]
entry = first_entry()
the_list = order(first_entry=entry)
test_int(order=the_list)

4.1.4 一个 test或者fixture可以同时请求多个fixture【重点】

测试用例和fixture装置不限于一次请求一个fixture装置。他们可以要求任意数量的。

这是另一个演示的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# contents of test_append.py
import pytest


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

# Arrange
@pytest.fixture
def second_entry(): # second_entry()= 2
return 2
# Arrange
@pytest.fixture
def order(first_entry, second_entry):
# 重点在这个fixture装置里,这里不仅可以使用其他fixture 还可以使用多个其他fixture
return [first_entry, second_entry] # # 所以order() = ["a", 2]

# Arrange
@pytest.fixture
def expected_list(): # second_entry()= ["a", 2, 3.0]
return ["a", 2, 3.0]

def test_string(order, expected_list):
# Act
order.append(3.0)

# Assert
assert order == expected_list #order本来是["a", 2]追加了一个3.0 所以断言肯定成功

这里讲的几个还是比较重要的

1、如何使用fixture

4.1.5 每次测试可以多次请求fixture夹具(返回值被缓存)

在同一测试用例期间也可以多次请求fixture夹具函数,、

并且 pytest 不会在该测试中再次执行它们,

意味着我们可以在依赖于它们的多个fixture装置中请求其他fixture装置(甚至在测试本身中再次请求fixture装置),而无需多次执行这些fixture装置。—-看着有点绕先不管,接着往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# contents of test_append.py
import pytest


# Arrange
@pytest.fixture
def first_entry():
print(f"如果运行了{first_entry.__name__}就会打印这句话")
return "a"
# Arrange
@pytest.fixture
def order():
print(f"如果运行了{order.__name__}就会打印这句话")
return []
# Act
@pytest.fixture
def append_first(order, first_entry):
print(f"如果运行了{append_first.__name__}就会打印这句话")
return order.append(first_entry)

def test_string_only(append_first, order, first_entry):
# Assert
print(order) # 这个时候order已经被改变了,已经是["a"]了而不是[]
print(first_entry) # a
assert order == [first_entry]
# 所以当fixture在fixture和test_之间混合使用时是会有缓存的,最后汇总到一起

如果在测试期间每次请求时都执行一次所定义的fixture装置的话 ,那么这个测试就会失败,、

因为append_first方法和test_string_only用例都会将order视为一个空列表(如[ ]),

但由于 order 的返回值在第一次调用后被缓存(以及执行它可能产生的任何副作用),测试用例判断了append_first对该对象的影响。

这里官方想表达的是,fixture在fixture和test_之间混合使用不会重复执行,看一下终端输出结果会更清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
================================================================================ test session starts ================================================================================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0

plugins: anyio-4.2.0
collected 1 item

testcases/test_fixturestest.py
如果运行了order就会打印这句话
如果运行了first_entry就会打印这句话
如果运行了append_first就会打印这句话
['a']
a
.

================================================================================= 1 passed in 0.01s =================================================================================

这里的每段print( xxx ),都只被打印了一次,如果会重复执行的话,就会打印多次了。

4.2 自动使用fixtue(不必主动请求的fixture)

测试中有时可能想要有一个(甚至几个)让所有测试用例都依赖的fixture装置,

自动使用fixture装置是一种让所有测试用例都自动请求它们的便捷方法。这可以消除大量冗余的请求,甚至可以提供更高级的夹具使用(更多内容请参见下文)

可以通过将 autouse=True 传递给装饰器pytest.fixture来使fixture成为自动使用的fixture

代码简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# contents of test_append.py
import pytest

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

@pytest.fixture
def order(first_entry): # 这里的first_entry,只是定义了一个形参,可以随便传参,
# 换一个写法就是 def order(argument: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): # 明明没有引用 append_first,却产生了效果,
# 这里就会想呀,order 不是一个空列表吗?使用了 append_first 才会忘order里面添加 first_entry,
# 这就是 autouse=True 的作用 ,隐式的使用了 append_first
print(order) # ['a']
assert order == [first_entry]

def test_string_and_int(order, first_entry):
order.append(2)
# 这里就跟明显了,arder 里面添加了 2,本应该是[2]的,但是打印出来是["a", 2],因为隐方的调用了 append_first往order里面添加了first_entry "a"
print(order) # ['a', 2]
assert order == [first_entry, 2]

在此示例中,append_first 固定装置是自动使用固定装置。因为它是自动发生的,所以两个测试都会受到它的影响,即使两个测试都没有请求它。但这并不意味着他们不能被请求;只是没有必要写罢了—->隐式传递

4.3 作用域:跨类、模块、包或会话共享fixture【重点】

4.3.1理解fixture 作用域【重点】

需要网络访问的fixtures取决于连接性,并且创建起来通常非常耗时。

扩展前面的例子

我们可以在 @pytest.fixture 调用中添加一个 scope="module" 参数来引发 smtp_connection 固定功能,负责创建到预先存在的 SMTP 服务器的连接,

每个测试模块仅调用一次(默认为每个测试函数调用一次)。

因此,测试模块中的多个测试函数将各自接收相同的 smtp_connection 夹具实例,从而节省时间

范围的可能值为:函数、类、模块、包或会话。

下一个示例将fixture函数放入单独的conftest.py文件中,以便目录中多个测试模块的测试可以访问fixture函数:

官方的代码有点看的不是特别清晰,可以直接看下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 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 固定功能。运行测试如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ 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 =============================

您会看到两个断言 0 失败,更重要的是您还可以看到完全相同的 smtp_connection 对象被传递到两个测试函数中,因为 pytest 在回溯中显示传入的参数值。因此,使用 smtp_connection 的两个测试函数运行速度与单个测试函数一样快,因为它们重复使用相同的实例。

两个测试用例,虽然都引入的这个fixture,但是,其实这个fixture是只运行了一次,没有被重复的运行,而且只在模块级别运行,假如这个fixture是函数级别的,那就是每个测试方法单独运行一次,同理,假如是类级别的,那就是每个类单独运行一次,下面会介绍所有级别

看下面的代码可以更清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyClass:
"""这个类,如果是第一次被实例化对象,age的值就是1,如果是第二次,age得值就是2"""
instance_count = 0
def __init__(self):
MyClass.instance_count += 1
self.age = MyClass.instance_count


@pytest.fixture(scope="module") # 模块级别
def module_test():
Tom = MyClass()
return Tom # 返回类的实例化对象的age属性

def test_ehlo(module_test):
TestTom = module_test
assert TestTom.age == 1 # 这个测试用例和下面的测试用例,返回的是同一个实例化对象,所以age的值都是1

# 说白了,就是这两个测试用例,使用的都是同一个实例化对象,

def test_noop(module_test):
TestTom = module_test
assert TestTom.age == 1

如果改一下,改成function 级别的呢?测试用例就不会通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyClass:
"""这个类,如果是第一次被实例化对象,age的值就是1,如果是第二次,age得值就是2"""
instance_count = 0
def __init__(self):
MyClass.instance_count += 1
self.age = MyClass.instance_count


@pytest.fixture(scope="function") # 模块级别
def module_test():
Tom = MyClass()
return Tom # 返回类的实例化对象的age属性

def test_ehlo(module_test):
TestTom = module_test
assert TestTom.age == 1 # 这个测试用例和下面的测试用例,返回的是同一个实例化对象,所以age的值都是1

# 说白了,就是这两个测试用例,使用的都是同一个实例化对象,

def test_noop(module_test):
TestTom = module_test
assert TestTom.age == 1

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
================================ FAILURES =================================================
________________________________ test_noop _________________________________________

module_test = <test_fixturestest.MyClass object at 0x7f0eeb976b60>

def test_noop(module_test):
TestTom = module_test
> assert TestTom.age == 1
E assert 2 == 1
E + where 2 = <test_fixturestest.MyClass object at 0x7f0eeb976b60>.age

testcases/test_fixturestest.py:206: AssertionError
======================= short test summary info ===========================================
FAILED testcases/test_fixturestest.py::test_noop - assert 2 == 1
============================ 1 failed, 1 passed in 0.01s ==============================

如果您决定希望拥有一个会话范围的 smtp_connection 实例,您可以简单地声明它:

1
2
3
4
5
@pytest.fixture(scope="session")
def smtp_connection():
# the returned fixture value will be shared for
# all tests requesting it
...

4.3.2 Fixture 作用域范围【重点】

夹具在测试首次请求时创建,并根据其范围销毁:

  • function: 默认范围,fixture在每个测试用例结束时销毁。.
  • class: 该fixture在class最后一次测试的测试用例时被销毁。
  • module: 模块级别(py文件)
  • package: 包级别(文件夹)
  • session: 会话级别(整场测试)

模块级别、包级别、会话级别也是一样,因为fixture是在conftest.py文件里写的,这个文件是放在项目的根目录下的,你的测试用例,不一定全都是放在一个模块内(py文件)一个包内(文件夹),会话级别的意思就是整场测试用例的级别。

Pytest 一次仅缓存fixture装置的一个实例,这意味着当使用参数化fixture装置时,pytest 可能会在给定范围内多次调用fixture装置。

4.3.3 fixture 动态范围–自定义作用域

在某些情况下,您可能希望在不更改代码的情况下更改夹具的范围。为此,请将可调用对象传递给作用域。

可调用函数必须返回一个具有有效范围的字符串,并且仅在夹具定义期间执行一次。它将使用两个关键字参数进行调用——fixture_name 作为字符串,config 使用配置对象。

当处理需要时间设置的固定装置(例如生成 docker 容器)时,这尤其有用。

可以使用命令行参数来控制不同环境下生成的容器的范围。请参阅下面的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
# determine_scope 函数的定义是受两个参数:fixture_name 和 config。fixture_name 是 fixture 的名称,而 config 是 pytest 的配置对象。
# 函数的逻辑是,如果在 pytest 的命令行选项中存在 --keep-containers 选项(表示保持容器的选项),则返回 "session" 作为 fixture 的作用域;否则,返回 "function" 作为 fixture 的作用域。
def determine_scope(fixture_name, config):
if config.getoption("--keep-containers", None):
return "session"
return "function"

# fixture 的名称是 docker_container。
#scope=determine_scope 将作用域参数设置为 determine_scope 函数的返回值。也就是说,根据 determine_scope 函数的逻辑,这个 fixture 的作用域将根据 --keep-containers 命令行选项的存在与否来确定。
@pytest.fixture(scope=determine_scope) # scope="session"或者"function"
def docker_container():
yield spawn_container()
# yield spawn_container() 表示在 fixture 的设置和清理过程中生成一个值。spawn_container() 是一个函数,它可能在 fixture 的设置阶段创建一个容器,并在 fixture 的清理阶段销毁容器。yield 语句将生成的值暂时提供给使用该 fixture 的测试函数,以便进行测试操作。

4.4 Teardown/Cleanup (又称前置和后置)

当我们运行测试时,我们需要确保它们自行清理,这样它们就不会干扰任何其他测试(同时我们也不会留下大量的测试数据来使系统膨胀)

pytest 中的装置提供了一个非常有用的拆卸系统,它允许我们定义每个装置自行清理所需的特定步骤。

可以通过两种方式利用该系统。

1. yield fixtures 【推荐】

“Yield” fixtures使用 yield 而不是 return.,我们可以运行一些代码并将对象传递回请求的装置/测试,就像其他装置一样。唯一的区别是:

  1. return换成yield
  2. 一些 teardown代码放在yiend的后面

一旦 pytest 计算出fixtures装置的线性顺序,它将运行每个fixtures装置,直到它返回或产生,然后转到列表中的下一个fixtures装置以执行相同的操作。

测试完成后,pytest 将返回fixtures装置列表,但以相反的顺序,获取每个生成的fixtures装置,并运行其中位于yield 语句之后的代码。

举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 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

假设我们要测试从一个用户向另一个用户发送电子邮件。

  1. 我们必须首先创建每个用户,
  2. 然后将电子邮件从一个用户发送到另一个用户,
  3. 最后断言另一个用户在其收件箱中收到了该消息。

如果我们想在测试运行后进行清理,我们可能必须确保在删除其他用户之前清空该用户的邮箱,否则系统可能会有垃圾数据。

看一下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class MailAdminClient:
def create_user(self):
print("create user")
return MailUser()

def delete_user(self, user):
print("delete user")
# do some cleanup
pass


class MailUser:
def __init__(self):
self.inbox = []

def send_email(self, email, other):
print("send email")
other.inbox.append(email)

def clear_mailbox(self):
print("clearing mailbox")
self.inbox.clear()


class Email:
def __init__(self, subject, body):
self.subject = subject
self.body = body


@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

看一下运行的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
============================ test session starts =======================================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0

plugins: anyio-4.2.0
collected 1 item

testcases/test_fixturestest.py
create user # 开始时运行了
create user
send email
.clearing mailbox
delete user # 结束时运行了
delete user


========================= 1 passed in 0.01s =============================================

因为receiving_user是安装过程中最后一个运行的fixture装置,所以它也是拆卸过程中第一个运行的fixture装置。

yield fixture处理错误

如果yield装置在yield之前引发异常,pytest将不会尝试在该yield装置的yield语句之后运行结束代码。

但是,对于已经成功运行该测试的每个fixture装置,pytest 仍会像平常一样尝试将其拆除。

也就是说,如果你在yield之前报错了,就不会运行yield之后的代码了,如果yield之前没报错,就肯定会运行yield之后的代码,哪怕yield之后也有报错

2. 直接添加终结器finalizers

虽然yield夹具被认为是更简单、更直接的选项,但还有另一种选择,那就是将“finalizers”函数直接添加到测试的请求上下文对象request中。

它带来了与yield装置类似的结果,但可能会让代码变得冗余了一点。

为了使用这种方法,我们必须在需要添加拆卸代码的固定装置中请求请求上下文对象(就像我们请求另一个固定装置一样),然后将包含该拆卸代码的可调用对象传递给其 addfinalizer 方法。

但我们必须小心,因为 pytest 在添加finalizers后将运行该终结器,即使该装置在添加finalizers后引发异常

因此,为了确保我们在不需要时不运行终结器代码,只有在夹具完成了我们需要拆卸的操作后,我们才会添加终结器。

这里的目的是,你可以将清理用户数据的方法,放在这个用例执行完成后执行,以下是使用 addfinalizer 方法的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 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

执行的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
====================== test session starts ============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0

configfile: pytest.ini
plugins: anyio-4.2.0
collected 1 item

testcases/test_fixturestest.py
create user
create user
send email
.clearing mailbox
delete user
delete user


======================================= 1 passed in 0.01s ==========

request.addfinalizer() 添加的方法会在当前测试用例执行完成后运行。它会在测试用例的所有代码(包括测试函数、fixture、setup 和 teardown)执行完毕后被调用。

具体来说,request.addfinalizer() 方法用于注册一个清理函数,该函数将在以下情况之一发生时被调用:

  1. 当前测试用例的测试函数执行完成后。
  2. 当前测试用例中的任何 fixture 的 yield 语句之后。
  3. 当前测试用例中的任何 fixture 的 teardown 方法执行完成后。

简化一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@pytest.fixture
def my_fixture(request):
def cleanup():
print("Performing cleanup,这是最后执行的")

request.addfinalizer(cleanup) # 被添加进去的函数,会在测试用例执行完之后执行
print("Setup")
yield
print("Teardown")

def test_example2(my_fixture):
print("Running test")

# 你还可以写 lambda 匿名方法传进去,例如:
@pytest.fixture
def my_fixture(request):
request.addfinalizer(lambda: print("Performing cleanup,这是最后执行的")) # 被添加进去的函数,会在测试用例执行完之后执行
print("Setup")
yield
print("Teardown")
# 你也可以将方法定义在外面:
def cleanup():
print("Performing cleanup,这是最后执行的")
def my_fixture(request):
request.addfinalizer(cleanup) # 被添加进去的函数,会在测试用例执行完之后执行
print("Setup")
yield
print("Teardown")

执行的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
====================== test session starts =================================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0

configfile: pytest.ini
plugins: anyio-4.2.0
collected 1 item

testcases/test_fixturestest.py
Setup # yield 前执行的
Running test # 测试用例执行的
.Teardown # yield 后执行的
Performing cleanup,这是最后执行的 # 终结器执行的


======================== 1 passed in 0.01s =========

request.addfinalizer()fixture函数里面添加yield的区别在于:

  • request.addfinalizer()的函数是在测试结束后执行,
  • request.addfinalizer()可以在程序发生报错后仍然执行,而使用yield的话,yield前面的代码在执行时如果报错,那yield后面的代码就不会执行,

另关于对于request的使用,后续开一篇文章讲解

关于finalizer顺序的注意事项【重点】

finalizer按照先进后出的顺序执行。对于yield fixture,要运行的第一个teardown代码来自最右侧的fixture装置,即最后一个测试参数(入参)。

  • 从右往左顺序执行yieldfixture

看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 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():
print("after_yield_1,start")
yield
print("after_yield_1,end")

@pytest.fixture
def fix_w_yield2():
print("after_yield_2,start")
yield
print("after_yield_2,end")

$ 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 # 先执行的2
after_yield_1 # 再执行的1
yield_1,start # 开始时先执行的1的开始
after_yield_2,start # 再执行2的开始
test_bar # 再执行测试函数
.after_yield_2,end # 结束时先执行2的结束
after_yield_1,end # 再执行1的结束

============================ 1 passed in 0.12s =============================

对于finalizers内注册的函数,优先运行最后一个添加进去的函数,依次往前运行。代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# content of test_finalizers.py
from functools import partial
import pytest


@pytest.fixture
def fix_w_finalizers(request):
request.addfinalizer(partial(print, "finalizer_2")) # 后运行2
request.addfinalizer(partial(print, "finalizer_1")) # 先运行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 # 先运行1
finalizer_2 # 后运行2


============================ 1 passed in 0.12s =============================

之所以如此,是因为产量yield fixtures装置在幕后使用了 addfinalizer:当fixture装置执行时,addfinalizer 注册一个恢复生成器的函数,该函数又调用teardown代码。

所以一个测试用例内的fixture的执行顺序是,入参中的最后一个先执行,addfinalizer注册的函数最后执行

4.5 teardowns安全【注意】

pytest 的固定系统非常强大,但它仍然由计算机运行,因此它无法弄清楚如何安全地拆除我们扔给它的所有东西

如果我们不小心,错误位置的错误可能会留下测试中的内容,这可能很快会导致进一步的问题。

例如,考虑以下测试(基于上面的邮件示例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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名称,一点也不见名知意,并且也无法做到轻松的重复使用这个fixture,复用性差。

  • 这里还有一个更严重的问题,即如果在运行代码的时候有任何步骤引发了程序异常报错,则任何代码都不会运行。

  • 所以有另一种选择就是使用 addfinalizer 注册方法而不是全部都堆在fixture里面,但是用addfinalizer的话可能会是代码变得相当复杂且难以维护(并且它不再紧凑)。有利有弊吧

4.5.1 fixture 安全结构

最安全和最简单的fixture结构要求限制一个fixture仅进行一个状态的更改操作(意思就是一个fixture只做一件事情,逻辑不可以写多了),

然后将多个fixture与其他fixture代码捆绑在一起,如上面的电子邮件示例所示。

状态更改操作失败但仍然修改状态的可能性可以忽略不计,因为大多数这些操作往往是基于事务的(至少在状态可能被抛在后面的测试级别)。

因此,如果我们通过将任何成功的状态更改操作移动到单独的fixture并将其与其他有可能失败的状态更改操作分开来确保将其最后执行,那么我们的测试将有最多的可能离开测试环境

这里的意思,将必定成功的操作,和可能失败的操作拆分开来,将成功率大的和成功率大的操作放在一起,失败率大的放一起或者拆开来放,避免一头成功一头失败的,造成其他安全隐患

举个例子,假设我们有一个带有登录页面的网站,并且我们可以访问可以生成用户的管理 API。 对于我们的测试,我们想要:

  1. 通过该API 创建管理用户
  2. 使用 Selenium 启动浏览器
  3. 转到我们网站的登录页面
  4. 以我们创建的用户身份登录
  5. 在登录的标题中断言我们的用户名称

我们不想让该用户留在系统数据中,避免脏数据,也不想让浏览器会话保持运行,因此我们希望确保创建这些东西的装置能够自行清理脏数据和关闭浏览器页面。

示例看起来是这样的:

对于此示例,某些固定装置(即 base_url 和 admin_credentials)暗示存在于其他地方。所以现在,我们假设它们存在,但我们只是不去关注它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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): # 这个fixture只负责连接Url
return AdminApiClient(base_url, **admin_credentials)


@pytest.fixture
def user(admin_client): # 这个fixture只负责创建管理员用户,和结束时删除用户
_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(): # 这个fixture,只负责定义浏览器驱动打开和关闭浏览器
_driver = Chrome()
yield _driver
_driver.quit()

@pytest.fixture
def login(driver, base_url, user): # 这个fixute只负责用户的页面的打开和登录用户
driver.get(urljoin(base_url, "/login")) # 用户登录程序
page = LoginPage(driver)
page.login(user)

@pytest.fixture
def landing_page(driver, login): # 这个fixture 值负责将驱动和用户登录页面组合在一起,意思就是负责打开浏览器、进入用户登录页面登录
return LandingPage(driver)


def test_name_on_landing_page_after_login(landing_page, user): # 测试代码,将所有fixtue组合
assert landing_page.header == f"Welcome, {user.name}!"

依赖关系的布置方式意味着不清楚用户fixture是否会在驱动程序fixture之前执行。

但这没关系,因为这些都是原子操作,所以哪个先运行并不重要,因为测试的事件序列仍然是线性化的。

但重要的是,无论哪一个先运行,如果某一个抛出异常,而另一个没有抛出异常,那么两个都不会留下任何东西。

如果驱动程序fixture在用户fixture之前执行,并且用户fixture引发异常,驱动程序fixture仍然会退出,并且用户永远不会被创建。 如果驱动程序是引发异常的人,那么驱动程序将永远不会启动,用户也永远不会被创建。

这里的意思还是做拆分,就像代码分层一样,原子层的操作和业务层的操作分开来,这里的代码就是将登录的操作和创建用户的操作分了层,打开浏览器的操作

  • admin_client(base_url, admin_credentials) :这个fixture只负责连接对应的 Url
  • user(admin_client):这个fixture会使用到admin_client,只负责创建管理员用户,和结束时删除用户
  • driver() :这个fixture,只负责定义浏览器驱动打开和关闭浏览器,不做其他操作
  • login(driver, base_url, user): 这个fixute是一个组合,将driver 和 base_url 和 user 三个组合到一起,作用是打开浏览器、打开对应的url 执行用户操作,结束时会将所有fixtue的结束操作全部执行

这是很好的一种编码设计思想,一种分层的思想,将用户登录的操作全部分层出来,测试代码只负责最终的组合,不关注它所依赖的fixture 的实现逻辑,只关注进和出。这里很重要

4.6 安全的运行多个assert断言语句

有时,您可能希望在完成所有设置后运行多个断言,这是有道理的,因为在更复杂的系统中,单个操作可以启动多个行为

pytest 有一种方便的方法来处理这个问题,它结合了我们到目前为止所讨论的一些内容。

所需要的只是逐步扩大到更大的范围,然后将行为步骤定义为自动使用固定装置,最后确保所有固定装置都针对更高级别的范围

让我们从上面举一个例子,并稍微调整一下。假设除了检查标题中的欢迎消息之外,我们还想检查注销按钮和用户个人资料的链接。

让我们看一下如何构建它,以便我们可以运行多个断言,而不必再次重复所有这些步骤。

对于此示例,某些固定装置(即 base_url 和 admin_credentials)暗示存在于其他地方。所以现在,我们假设它们存在,但我们只是不去关注它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# contents of tests/end_to_end/test_login.py
from uuid import uuid4
from urllib.parse import urljoin

from selenium.webdriver import Chrome
import pytest

from src.utils.pages import LoginPage, LandingPage
from src.utils import AdminApiClient
from src.utils.data_types import User


@pytest.fixture(scope="class")
def admin_client(base_url, admin_credentials):
return AdminApiClient(base_url, **admin_credentials)


@pytest.fixture(scope="class")
def user(admin_client):
_user = User(name="Susan", username=f"testuser-{uuid4()}", password="P4$$word")
admin_client.create_user(_user)
yield _user
admin_client.delete_user(_user)


@pytest.fixture(scope="class")
def driver():
_driver = Chrome()
yield _driver
_driver.quit()


@pytest.fixture(scope="class")
def landing_page(driver, login):
return LandingPage(driver)


class TestLandingPageSuccess:
@pytest.fixture(scope="class", autouse=True)
def login(self, driver, base_url, user):
driver.get(urljoin(base_url, "/login"))
page = LoginPage(driver)
page.login(user)

def test_name_in_header(self, landing_page, user):
assert landing_page.header == f"Welcome, {user.name}!"

def test_sign_out_button(self, landing_page):
assert landing_page.sign_out_button.is_displayed()

def test_profile_link(self, landing_page, user):
profile_href = urljoin(base_url, f"/profile?id={user.profile_id}")
assert landing_page.profile_link.get_attribute("href") == profile_href

请注意,这些方法只是在签名中引用 self 作为一种形式。没有任何状态与实际的测试类相关,因为它可能位于 unittest.TestCase 框架中。一切都由 pytest 夹具系统管理。

每个方法只需要请求它实际需要的fixture,而不用担心执行顺序。这是因为fixture装置是一个自动使用的fixture,并且它确保所有其他fixture在它之前执行。

不再需要进行状态更改,因此测试可以自由地进行任意数量的非状态更改查询,而不必冒踩到其他测试的风险。

登录装置也在类内部定义,因为并非模块中的每个其他测试都期望成功登录,并且对于另一个测试类,该行为可能需要稍微不同地处理。可以每个类每个登录

这里表达的是,在一个测试类内,运行多个测试用例,多个断言,不会因为某个测试用例的断言失败了导致另一个测试用例无法运行下下去,虽然他们都是使用的同一个fixture夹具,你需要其他的登录前置,那么你可以在另一个类内编写一个fixture 供这个类内的用例去使用,

例如,如果我们想围绕提交错误凭据编写另一个测试场景,我们可以通过在测试文件中添加如下内容来处理它:

1
2
3
4
5
6
7
8
9
10
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)

4.7 Fixtures 可以使用内置的requesting来测试上下文【重点】

fixture函数可以接受requesting对象来内省“request”来测试函数、类或模块上下文。

进一步扩展前面的 smtp_connection 夹具示例,让我们从使用我们的夹具的测试模块中读取可选的服务器 URL:

1
2
3
4
5
6
7
8
9
10
11
12
# content of conftest.py
import smtplib

import pytest

@pytest.fixture(scope="module")
def smtp_connection(request):
server = getattr(request.module, "smtpserver", "smtp.gmail.com") # getattr()获取对象的属性和方法,第一个参数是对象本身,第二个参数是对象的属性,第三个参数是如果没有时的返回默认值
smtp_connection = smtplib.SMTP(server, 587, timeout=5)
yield smtp_connection
print(f"finalizing {smtp_connection} ({server})")
smtp_connection.close()

我们使用request.module属性来选择性地从测试模块获取 smtpserver 属性。如果我们再次执行,没有什么改变:

1
2
3
4
5
6
7
$ 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:

1
2
3
4
5
6
7
 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:

1
2
3
4
5
6
7
8
9
10
11
12
$ 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从模块命名空间中获取我们的邮件服务器名称。

通过request.module获取测试模块对象,request是一个对象,这个对象就是整场测试,

request是一个特俗的fixute用于获取有关当前测试的信息和操作

4.8 使用标记将数据传递到fixtures

使用request对象,夹具还可以访问应用于测试功能的标记。这对于将数据从测试传递到夹具中非常有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pytest

@pytest.fixture
def fixt(request): # 这个fixture的目的是获取被测用例的mark的标记,
marker = request.node.get_closest_marker("fixt_data") # 获取用例的mark标记的fixt_data
if marker is None:
# Handle missing marker in some way...
data = None # 如果没有使用标记,就返回none
else:
data = marker.args[0] # 如果有使用mark标记,就返回标记的值
# Do something with the data
return data

@pytest.mark.fixt_data(42)
def test_fixt(fixt):
assert fixt == 42 # 断言成功

4.9 工厂 fixtures【重点】

“工厂即夹具”模式可以在单次测试中多次需要夹具结果的情况下提供帮助。

夹具不是直接返回数据,而是返回生成数据的函数。然后可以在测试中多次调用该函数。

这里需要了解一下什么是工厂函数,或者说工厂设计模式了,工厂可以根据条件或者参数的不同来创建不同类型的对象

演员可以根据需要拥有参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@pytest.fixture
def make_customer_record():
def _make_customer_record(name):
print(f"Making customer record for {name}")
return {"name": name, "orders": []}

return _make_customer_record #返回的是一个函数


def test_customer_records(make_customer_record):
# 调用3次,获得3个不同的对象,因为这个fixture的返回值就是一个函数
customer_1 = make_customer_record("Lisa") # 调用这个函数,Making customer record for Lisa
customer_2 = make_customer_record("Mike") # Making customer record for Mike
customer_3 = make_customer_record("Meredith") # Making customer record for Meredith

如果工厂创建的数据需要管理,夹具可以处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@pytest.fixture
def make_customer_record():
created_records = []

def _make_customer_record(name):
record = models.Customer(name=name, orders=[])
created_records.append(record)
return record

yield _make_customer_record

for record in created_records: # 这一条在测试用例结束时执行
record.destroy() # 循环销毁的意思,


def test_customer_records(make_customer_record):
customer_1 = make_customer_record("Lisa")
customer_2 = make_customer_record("Mike")
customer_3 = make_customer_record("Meredith")

fixture可以定义成一个工厂方法供测试用例去使用

4.10 参数化 fixtures【重点】

夹具函数可以参数化,在这种情况下,它们将被多次调用,每次执行一组相关测试,即依赖于该夹具的测试。

测试函数通常不需要知道它们的重新运行。夹具参数化有助于为组件编写详尽的功能测试,这些组件本身可以通过多种方式进行配置。

展前面的示例,我们可以标记该固定装置以创建两个 smtp_connection 固定装置实例,这将导致使用该固定装置的所有测试运行两次。

Fixture函数通过特殊的请求对象来访问每个参数:

1
2
3
4
5
6
7
8
9
10
11
# content of conftest.py
import smtplib

import pytest

@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"]) # 注意这里定义的params = [ ] 参数是一个列表,所以会运行两次,如何拿到这个参数呢?
def smtp_connection(request):
smtp_connection = smtplib.SMTP(request.param, 587, timeout=5) # 用request.param去拿即可
yield smtp_connection
print(f"finalizing {smtp_connection}")
smtp_connection.close()

主要需要了解的是使用 @pytest.fixture 声明参数,这是一个值列表,其中的每个值将被执行,并且可以通过 request.param 访问值。

写一个简单的更容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@pytest.fixture(scope="session", params=[ ['2', 'two'],['1', 'one']])
def get_param(request):
return request.param


def test_getParam(get_param):
print(f"第{get_param[0]}次运行,打印{get_param[1]}")
# 运行结果:
============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0
configfile: pytest.ini
plugins: anyio-4.2.0
collected 2 items

testcases/test_fixturestest.py
1次运行,打印one
.第2次运行,打印two
.

============================== 2 passed in 0.01s ======================

这里可以看到,这个测试函数,针对get_param方法,运行了两次,

pytest 可以使用字符串进行标记,这个字符串是参数化fixture夹具中每个夹具值的测试 ID 标签,。这些 ID 可以与 -k 一起使用来选择运行的指定的测试用例,并且它们也支持失败重试,

也可以在命令行执行测试时加入 --collect-only 命令可以在运行测试时展示对应的 ID

数字、字符串、布尔值和 None 将在测试 ID 中使用其常用的字符串表示形式。

对于其他对象,pytest 将根据参数名称创建一个字符串。通过使用 ids 关键字参数,可以为某个固定装置值自定义测试 ID 中使用的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# content of test_ids.py
import pytest


@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
def a(request):
print(request.param)
return request.param

def test_a(a):
pass

========================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 -- /home/wang/code/venvadmin/bin/python
cachedir: .pytest_cache
rootdir: /home/
configfile: pytest.ini
plugins: anyio-4.2.0
collecting ... collected 2 items

testcases/test_fixturestest.py::test_a[ham] 1 # ham 是 id 1 是参数
PASSED
testcases/test_fixturestest.py::test_a[spam] 0 # spam 是 id 1 是参数
PASSED

============================== 2 passed in 0.01s ========
# 也可以支持动态给标签

def idfn(fixture_value): # 动态给标签
if fixture_value == 0:
print("eggs")
return "eggs"
else:
print("执行了 ")
return None


@pytest.fixture(params=[0, 1], ids=idfn) # 支持动态传入标签
def b(request):
print(request.param)
return request.param


def test_b(b):
pass
====================== test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 -- /home/wang/code//venvadmin/bin/python
cachedir: .pytest_cache
rootdir: /home/
configfile: pytest.ini
plugins: anyio-4.2.0
collecting ... eggs
执行了
collected 2 items

testcases/test_fixturestest.py::test_b[1] 1 # id 和 参数化
PASSED
testcases/test_fixturestest.py::test_b[eggs] 0
PASSED

============================== 2 passed in 0.01s ===============

也可以在pytest.ini里面使用-k接字符串来指定标签运行

1
2
3
4
5
6
# content of pytest.ini
[pytest]
addopts = -s -v -k "ham"
# -s 是捕获打印输出,如果有用到print()打印终端输出的话,就必须就加这个
# -v 就是展示标识符,
# -k 就是制定表示符

4.11 使用带标记参数化fixture夹具

pytest.param() 可用于在参数化装置的值集中应用标记,其方式与与 @pytest.mark.parametrize 使用的方式相同。

示例:

1
2
3
4
5
6
7
8
9
10
11
# content of test_fixture_marks.py
import pytest


@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)]) # 使用 pytest.param
def data_set(request):
return request.param


def test_data(data_set):
pass

运行此测试将跳过对值为 2 的 data_set 的调用:

1
2
3
4
5
6
7
8
9
10
11
12
$ pytest test_fixture_marks.py -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 3 items

test_fixture_marks.py::test_data[0] PASSED [ 33%]
test_fixture_marks.py::test_data[1] PASSED [ 66%]
test_fixture_marks.py::test_data[2] SKIPPED (unconditional skip) [100%]

======================= 2 passed, 1 skipped in 0.12s =======================

4.12 模块化:从一个fixutre方法中使用fixture

模块化:使用固定功能中的固定装置

除了在测试函数中使用固定装置之外,固定装置函数还可以使用其他固定装置本身

这有助于设备的模块化设计,并允许在许多项目中重复使用特定于框架的设备。举个简单的例子,

我们可以扩展前面的示例并实例化一个对象应用程序,将已定义的 smtp_connection 资源粘贴到其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 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) # 这个smtp_connection就是之前定义的fixture 在这个被使用了


def test_smtp_connection_exists(app):
assert app.smtp_connection

由于smtp_connection的参数化,测试将使用两个不同的App实例和各自的smtp服务器运行两次。应用程序fixture不需要知道smtp_connection参数化,因为pytest将完全分析fixture依赖关系图。

注意,app fixture的作用域是module,并使用了一个模块作用域的smtp_connection fixture。如果smtp_connection缓存在会话作用域中,这个例子仍然可以工作:fixture可以使用“更广”作用域的fixture,但反之则不行:会话作用域的fixture不能以有意义的方式使用模块作用域的fixture。

4.13 按fixture实例自动对测试进行分组

Pytest在测试运行期间最小化活动fixture的数量。如果您有一个参数化的fixture,那么使用它的所有测试将首先使用一个实例执行,然后在创建下一个fixture实例之前调用finalizers。除此之外,这简化了创建和使用全局状态的应用程序的测试。

下面的例子使用了两个参数化的fixture,其中一个是基于每个模块的,所有的函数都执行print调用来显示setup/teardown流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 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): # 这个用的是otherarg 是独立的执行了2次
print(" RUN test0 with otherarg", otherarg)


def test_1(modarg): # 这个用了modarg 执行了两次
print(" RUN test1 with modarg", modarg)


def test_2(otherarg, modarg): # 这个是2+2次,
print(f" RUN test2 with otherarg {otherarg} and modarg {modarg}")

总共执行了8次,让我们在详细模式下运行测试并查看打印输出:详细模式,就是加了-s的模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
======================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 -- /home/wang/code//venvadmin/bin/python
cachedir: .pytest_cache
rootdir: /home/wang/
configfile: pytest.ini
plugins: anyio-4.2.0
collecting ... collected 8 items

testcases/test_fixturestest.py::test_0[1] SETUP otherarg 1
RUN test0 with otherarg 1
PASSED TEARDOWN otherarg 1

testcases/test_fixturestest.py::test_0[2] SETUP otherarg 2
RUN test0 with otherarg 2
PASSED TEARDOWN otherarg 2

testcases/test_fixturestest.py::test_1[mod1] SETUP modarg mod1
RUN test1 with modarg mod1
PASSED
testcases/test_fixturestest.py::test_2[mod1-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod1
PASSED TEARDOWN otherarg 1

testcases/test_fixturestest.py::test_2[mod1-2] SETUP otherarg 2
RUN test2 with otherarg 2 and modarg mod1
PASSED TEARDOWN otherarg 2

testcases/test_fixturestest.py::test_1[mod2] TEARDOWN modarg mod1
SETUP modarg mod2
RUN test1 with modarg mod2
PASSED
testcases/test_fixturestest.py::test_2[mod2-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod2
PASSED TEARDOWN otherarg 1

testcases/test_fixturestest.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.01s =============

您可以看到,参数化的模块范围的建模资源导致了测试执行的排序,从而导致了最少可能的“活动”资源。mod1参数化资源的终结器在mod2资源被设置之前被执行。

特别要注意的是,test_0是完全独立的,并且首先完成。然后用mod1执行test_1,然后用mod1执行test_2,然后用mod2执行test_1,最后用mod2执行test_2。

其他参数化资源(具有功能范围)在每次使用它的测试之前设置并在测试之后删除。

4.14 通过 usefixtures 在类和模块中使用fixtures装置

有时测试函数不直接需要访问fixture对象。

例如,测试可能需要使用空目录作为当前工作目录,但不关心具体目录。

下面是如何使用标准的tempfile和pytest fixture来实现它。我们将fixture的创建分离到一个conftest.py文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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 标记在测试模块中声明其使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# content of test_setenv.py
import os

import pytest


@pytest.mark.usefixtures("cleandir") # 意思就是可以不传入到test函数中,用usefixture装饰器来使用你的fixture装置,另一种写法而已
class TestDirectoryInit:
def test_cwd_starts_empty(self):
assert os.listdir(os.getcwd()) == []
with open("myfile", "w", encoding="utf-8") as f:
f.write("hello")

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

由于使用了usefixtures标记,每个测试方法的执行都需要cleandir fixture,就像您为每个测试方法指定了一个“cleandir”函数参数一样。让我们运行它来验证我们的夹具是否被激活并且测试通过了:

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

您可以像这样指定多个装置:

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

您可以使用 pytestmark 在测试模块级别指定夹具的使用:

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

还可以将项目中所有测试所需的装置放入 ini 文件中:

1
2
3
# content of pytest.ini
[pytest]
usefixtures = cleandir

Warning

请注意,该标记对夹具功能没有影响。例如,这不会按预期工作

1
2
3
@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture(): ...

This generates a deprecation warning, and will become an error in Pytest 8.

4.15 覆盖各个级别的同名fixtures装置

在相对较大的测试套件中,您很可能需要使用本地定义的fixture覆盖全局fixture或根fixture,以保持测试代码的可读性和可维护性。

4.15.1 覆盖conftest.py文件内定义的文件夹(包)级别的固定装置

结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
tests/
__init__.py

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

@pytest.fixture
def username(): # 这个username 的作用域,可以作用在tests整个目录下
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): # 在test的下级目录subfolder内又重新定义了一个conftest.py文件,在文件内,又重新定义了username的fixture,属于重写了,在test/subfolder目录内的测试用例,都会使用心得username
return 'overridden-' + username

test_something.py
# content of tests/subfolder/test_something.py
def test_username(username):
assert username == 'overridden-username' # 因为是重写后的,所以测试通过

4.15.2 覆盖conftest.py文件内定义的模块(.py)级别的固定装置

结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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): # 测试模块内又单独定义了一个fixture,以当前模块内的为主
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): # 同级目录内又一个模块内又重新定义了一个同名fixute,以这个为准
return 'overridden-else-' + username

def test_username(username):
assert username == 'overridden-else-username' # 测试通过

在上面的示例中,可以为某些测试模块覆盖具有相同名称的夹具。

4.15.3 通过参数化夹具覆盖conftest.py内定义的fixture夹具

目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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'

在上面的示例中,夹具值被测试参数值覆盖。请注意,即使测试不直接使用夹具的值(在函数原型中未提及),也可以通过这种方式覆盖它。

4.15.4 反之亦然,用非参数化夹具覆盖参数化夹具

Given the tests file structure is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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(): # 非参数化夹具,覆盖了conftest.py里的参数化夹具
return 'overridden-username'

@pytest.fixture(params=['one', 'two', 'three'])
def non_parametrized_username(request): # 参数化夹具覆盖了conftest.py里的非参数化夹具
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'

在上面的例子中,一个参数化的夹具被一个非参数化的版本覆盖,而一个非参数化的夹具被一个特定测试模块的参数化的版本覆盖。这同样适用于测试文件夹级别。

4.16 使用其他项目的fixture【不建议】

通常,提供pytest支持的项目将使用入口点,因此只需将这些项目安装到环境中,就可以使用这些fixture。

如果您想使用不使用入口点的项目中的fixture,您可以在顶部conftest.py文件中定义pytest_plugins,以将该模块注册为插件。

假设在mylibrary中有一些fixture。你想在app/tests目录中重用它们。

你所需要做的就是在app/tests/conftest.py中定义pytest_plugins,并指向该模块。

1
pytest_plugins = "mylibrary.fixtures"

这有效地注册了mylibrary。fixture作为一个插件,使其所有fixture和钩子可用于app/tests中的测试。

请注意
有时用户会从其他项目导入fixture以供使用,但不建议这样做:将fixture导入到模块中会将它们注册到pytest中,就像该模块中定义的那样。

这有轻微的后果,比如在pytest——help中出现多次,但不建议这样做,因为这种行为可能会在未来的版本中改变/停止工作。

img


【python】之pytest如何使用fixture夹具-四【重】
http://example.com/2024/01/23/664python之pytest如何使用fixture夹具-四/
作者
Wangxiaowang
发布于
2024年1月23日
许可协议