pytest04 fixtures explicit, modular, scalable

Pytest 固件:显式、模块化、可扩展

purpose of test fixtures 是提供一个固定的基线,在此基础上测试可以可靠地重复执行。Pytest 固件比传统的XUnit 的setup/teardown功能提供了显著的改进:

  • 固件有明确的名称,通过声明它们在测试函数、模块、类或整个项目中的使用来激活。
  • 固件以模块化的方式实现,因为每个固件名称触发一个 固件功能 , 可以使用其他固件。
  • 固件管理规模从简单的单元扩展到复杂的功能测试,允许根据配置和组件选项参数化固件和测试,或者跨功能、类、模块或整个测试会话范围重复使用固件。

此外,pytest继续支持经典的Xunit-style setup. 你可以混合这两种样式,根据喜好,逐步从经典样式转移到新样式。你也可以从现有的 unittest.TestCase style 或 nose based 项目开始。


Fixtures 作为函数参数

Fixtures as function arguments

测试函数可以通过将fixture对象命名为输入参数来接收它们。对于每个参数名,具有该名称的fixture函数提供fixture对象。通过用@pytest.fixture标记fixture函数来注册fixture函数 . 让我们来看一个简单的独立测试模块,它包含一个fixture和一个使用fixture的测试函数:

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

@pytest.fixture
def smtp_connection():
import smtplib
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
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
$ pytest test_smtpsimple.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-4.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item

test_smtpsimple.py F [100%]

================================= FAILURES =================================
________________________________ test_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
> assert 0 # for demo purposes
E assert 0

test_smtpsimple.py:11: AssertionError
========================= 1 failed in 0.12 seconds =========================

在失败的回溯中,我们看到测试函数是用 smtp_connection 参数、 fixture函数创建的实例smtplib.SMTP()来调用的。测试功能失败是故意使用了 assert 0 . pytest 以这种方式来调用测试函数:

  1. pytest找到这个 test_ehlo 因为 test_ 前缀。这个测试函数需要一个名为 smtp_connection . 通过查找名为 smtp_connection 标记的固件函数,找到一个匹配的固件函数.
  2. smtp_connection() 通过创建实例来调用。
  3. test_ehlo(<smtp_connection instance>) 在测试函数的最后一行调用并失败。

请注意,如果你拼错了一个函数参数,或者希望使用一个不可用的参数,你将看到一个错误,其中包含一个可用函数参数列表。

注解

你可以随时发布:

1
pytest --fixtures test_simplefactory.py

查看可用的固件(带引线的固件 _ 仅当添加 -v 选择权。


固件:依赖注入的主要示例

fixtures:a prime example of dependency injection

fixturepytest 中的一个强大功能,它通过提供预初始化的对象来实现依赖注入。依赖注入是一种设计模式,用于减少代码之间的耦合,使代码更加模块化和可测试。

fixture 可以定义一些预初始化的对象环境设置,这些对象或设置可以在多个测试函数中复用。测试函数可以通过参数自动接收这些 fixture,而不需要关心这些对象是如何导入、初始化和清理的

在依赖注入中,依赖关系(即所需的对象或资源)是由外部提供的,而不是由对象自己创建的。fixture 函数在这里扮演了 注入器(injector)的角色,负责创建和提供这些依赖对象。

测试函数是 消费者(consumers),它们通过参数接收这些依赖对象,并使用它们进行测试。

小结:

  • fixturepytest 中实现依赖注入的一种方式。fixture 函数负责创建和管理依赖对象,测试函数通过参数接收这些对象。
  • 使用 fixture 可以减少测试代码中的重复,提高代码的可维护性和可测试性。
  • 通过 yield 关键字,fixture 可以在测试前后执行设置和清理操作,确保每个测试都在干净的环境中运行。

conftest.py 共享固件功能

conftest.py: sharing fixture functions

如果在实现测试的过程中,你意识到要使用来自多个测试文件的fixture函数,可以将其移动到 conftest.py 文件。你不需要导入要在测试中使用的固件,Pytest会发现并自动获取它。fixture函数的发现从测试类开始,然后是测试模块,然后 conftest.py 文件,最后是内置插件和第三方插件。

你也可以使用 conftest.py 文件去实现 本地目录级插件 local per-directory plugins。

  • Local:这些插件仅影响定义它们的目录及其子目录。
  • Per-Directory:每个目录可以有自己的 conftest.py 文件,从而实现不同目录间的独立配置。
  • Plugins:这些插件可以包含 fixture、hook 实现、自定义命令行选项等,用于扩展 pytest 的功能。

共享测试数据

Sharing test data

如果你想让来自文件的测试数据对你的测试可用,一个很好的方法是将这些数据加载到一个固件中,供测试使用。这利用了pytest的自动缓存机制。

另一个好方法是将数据文件添加到 tests 文件夹中. 这里也有可用的插件社区可以用来帮助管理这方面的testing e.g. pytest-datadir 和 pytest-datafiles (感觉不怎么会用到,不仅需要安装包,这种路径、文件名的配置,不适合做大型项目吧)。


scope:在类、模块或会话中跨测试共享一个fixture实例

Scope: sharing a fixture instance across tests in a class, module or session

依赖于连接性的需要访问网络的fixtures,通常创建成本很高。扩展前面的示例,我们可以给 @pytest.fixture 调用添加一个 scope="module" 参数,引起被装饰的 smtp_connection 固件函数在每个测试模块中只调用一次(默认情况下,每个测试调用一次 function)因此,一个测试模块中的多个测试功能将接收相同的 smtp_connection 固件实例(不必每次都实例化,也就是不用每次都去创建连接访问网络),节省时间提高效率。 对于scope可能值是: functionclassmodulepackagesession .

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

1
2
3
4
5
6
7
# content of conftest.py
import pytest
import smtplib

@pytest.fixture(scope="module")
def smtp_connection():
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

固件的名称同样是 smtp_connection ,你可以通过列出名字smtp_connection作为一个在任何测试或fixture函数的输入参数(在 conftest.py 所在的目录中,或者所在的目录下)来访问它的结果 :

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

我们故意插入失败 assert 0 语句以检查正在进行的操作,现在可以运行测试:

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
$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-4.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 2 items

test_module.py FF [100%]

================================= FAILURES =================================
________________________________ test_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

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:6: AssertionError
________________________________ test_noop _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0

test_module.py:11: AssertionError
========================= 2 failed in 0.12 seconds =========================

你看这两个 assert 0 失败,更重要的是,你也可以看到相同的(module-scoped模块范围) smtp_connection对象被传递到两个测试函数中,因为pytest在回溯中显示了传入的参数值。因此,两个测试函数使用 smtp_connection 像单个实例一样快速运行,因为它们重用同一个实例。

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

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

最后, class 作用域将在每个测试class中调用fixture一次 .

注解

pytest一次只缓存一个fixture实例。这意味着当使用参数化固件时,pytest可以在给定的范围内多次调用固件。


首先实例化更大范围的固件

Higher-scoped fixtures are instantiated first

在特性的功能请求中,更高范围的固件(例如 session )先实例化,然后再实例化范围较低的固件(例如 functionclass )。相同范围内固件的相对顺序遵循测试函数中声明的顺序,并尊重固件之间的依赖关系。

考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@pytest.fixture(scope="session")
def s1():
pass

@pytest.fixture(scope="module")
def m1():
pass

@pytest.fixture
def f1(tmpdir):
pass

@pytest.fixture
def f2():
pass

def test_foo(f1, m1, f2, s1):
...

test_foo 所请求的固件将按以下顺序实例化:

  1. s1 :是范围最高的固件 (session
  2. m1 :是第二高范围固件 (module
  3. tmpdir 是一个 function 范围固件, f1需要 :因为它是f1的一个依赖项,此时它需要实例化 .
  4. f1 是第一个在 test_foo 参数列表内的 function -范围固件。
  5. f2 是最后一个在 test_foo 参数列表内的 function -范围固件。

固件定型/执行拆卸代码

Fixture finalization / executing teardown code

当fixture超出范围时,pytest支持fixture特定的定稿代码的执行。通过使用 yield 语句而不是 returnyield语句之后的所有代码当做是teardown代码:

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

import smtplib
import pytest

@pytest.fixture(scope="module")
def smtp_connection():
smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
yield smtp_connection # provide the fixture value
print("teardown smtp")
smtp_connection.close()

这个 printsmtp.close() 语句将在模块中的最后一个测试完成执行后执行,而不管测试的异常状态如何。

让我们执行它:

1
2
3
4
$ pytest -s -q --tb=no
FFteardown smtp

2 failed in 0.12 seconds

我们看到了 smtp_connection 实例在两个测试完成执行后完成。请注意,如果我们用 scope='function'来声明固件函数, 然后在每个测试周围进行fixture setup和clearup。无论哪种情况,测试模块本身都不需要更改或了解这些固件setup的细节。

请注意,我们还可以无缝地使用 yield 语法与 with 声明:

1
2
3
4
5
6
7
8
9
# content of test_yield2.py

import smtplib
import pytest

@pytest.fixture(scope="module")
def smtp_connection():
with smtplib.SMTP("smtp.gmail.com", 587, timeout=5) as smtp_connection:
yield smtp_connection # provide the fixture value

这个 smtp_connection 连接将在测试完成执行后关闭,因为当 with 语句结束时 smtp_connection 对象会自动关闭。

请注意,如果一个异常在 setup 代码(在 yield 关键字前)中发生,那么 teardown 代码(在 yield 后)不会被调用。

执行 teardown 代码的替代选项是利用 addfinalizer 方法(用request-context对象注册终结函数)。

这里是 smtp_connection 固件变化地使用addfinalizer 清理:

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

@pytest.fixture(scope="module")
def smtp_connection(request):
smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

def fin():
print("teardown smtp_connection")
smtp_connection.close()

request.addfinalizer(fin)
return smtp_connection # provide the fixture value

相似地, yieldaddfinalizer 方法都是通过在测试结束后调用它们的代码来工作,但是 addfinalizer 有两个关键差异点区别于 yield

  1. 可以注册多个终结器函数。

  2. 无论 fixture 的 setup 代码是否引发异常,finalizers 终结器都会被调用(面试题常问)。这对于正确关闭由固件创建的所有资源非常方便,即使其中一个资源未能创建/获取:

    1
    2
    3
    4
    5
    6
    7
    8
    @pytest.fixture
    def equipments(request):
    r = []
    for port in ('C1', 'C3', 'C28'):
    equip = connect(port)
    request.addfinalizer(equip.disconnect)
    r.append(equip)
    return r

    在上面的示例中,如果 "C28" 异常失败, "C1""C3" 仍将正确关闭。

    当然,如果在 addfinalizer 函数之前发生异常,则不会执行它


Fixtures 可以检查请求的测试上下文

Fixtures can introspect the requesting test context

固件函数可以接受 request 对象内省“requesting”测试函数、类或模块上下文。进一步扩展前一个 smtp_connection fixture示例,让我们从使用fixture的测试模块中读取一个可选的服务器URL:

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

@pytest.fixture(scope="module")
def smtp_connection(request):
server = getattr(request.module, "smtpserver", "smtp.gmail.com")
smtp_connection = smtplib.SMTP(server, 587, timeout=5)
yield smtp_connection
print("finalizing %s (%s)" % (smtp_connection, server))
smtp_connection.close()

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

1
2
3
4
$ pytest -s -q --tb=no
FFfinalizing <smtplib.SMTP object at 0xdeadbeef> (smtp.gmail.com)

2 failed in 0.12 seconds

让我们快速创建另一个测试模块,该模块在其模块命名空间中实际设置服务器URL:

1
2
3
4
5
6
# 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()

运行它:

1
2
3
4
5
6
7
8
9
10
$ pytest -qq --tb=short test_anothersmtp.py
F [100%]
================================= FAILURES =================================
______________________________ test_showhelo _______________________________
test_anothersmtp.py:5: 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 0xdeadbeef> (mail.python.org)

这个 smtp_connection fixture函数从模块名称空间中获取邮件服务器名称。


工厂函数作为固件

Factories as fixtures

“工厂作为固件”模式有助于在单个测试中多次需要固件结果的情况下,固件不直接返回数据,而是返回一个生成数据的函数,然后可以在测试中多次调用此函数。(工厂函数:返回一个生成数据对象的函数,而不是直接返回数据对象。)

工厂可以根据需要设置参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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")

如果工厂创建的数据需要管理,则固件可以处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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")

为什么要使用工厂作为固件?

  • 灵活性:可以在测试中多次调用工厂函数,每次生成新的数据对象。
  • 参数化:工厂函数可以根据传入的参数生成不同的数据对象。
  • 资源管理:如果生成的数据对象需要管理(例如,需要在测试结束后销毁),固件可以处理这些管理任务。

参数化固件

Parametrizing fixtures

fixture函数可以参数化,在这种情况下,它们将被多次调用,每次执行一组相关的测试,即测试依赖于该fixture。测试函数通常不需要知道它们的重新运行。固件参数化有助于为组件编写详尽的功能测试,这些组件本身可以通过多种方式进行配置。

扩展前面的示例,我们可以标记fixture以创建两个 smtp_connection fixture实例,它将导致使用fixture的所有测试运行两次。fixture函数通过特殊的 request 对象来获取每个参数:

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

@pytest.fixture(scope="module",
params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
yield smtp_connection
print("finalizing %s" % smtp_connection)
smtp_connection.close()

主要变化是 params 具有 @pytest.fixture ,fixture函数将执行的每个值的列表,可以通过 request.param访问每一个值 . 无需更改测试函数代码。让我们再运行一次:

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
$ pytest -q test_module.py
FFFF [100%]
================================= FAILURES =================================
________________________ test_ehlo[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

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:6: AssertionError
________________________ test_noop[smtp.gmail.com] _________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0

test_module.py:11: AssertionError
________________________ test_ehlo[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

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:5: AssertionError
-------------------------- Captured stdout setup ---------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef>
________________________ test_noop[mail.python.org] ________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0

test_module.py:11: AssertionError
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef>
4 failed in 0.12 seconds

我们看到我们的两个测试函数分别运行两次,针对的是不同的 smtp_connection 实例。还要注意的是, 在test_ehlomail.python.org连接第二次测试失败,因为预期的服务器字符串与实际获取的字符串不同。

pytest将构建一个字符串,该字符串是参数化fixture中每个fixture值的测试ID,例如在在上面的例子中的test_ehlo[smtp.gmail.com]test_ehlo[mail.python.org] 。这些ID可用于 -k 选择要运行的特定案例,当某个案例失败时,它们还将识别该特定案例。使用pytest --collect-only 运行将显示生成的ID。

关键字 ids

数字、字符串、布尔值和None将在测试ID中使用它们通常的字符串表示形式。对于其他对象,pytest将根据参数名生成字符串。在一个测试ID中,可以通过使用 ids 关键字参数来为一个确定的fixture值定制字符串:

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_ids.py
import pytest

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

def test_a(a):
pass

def idfn(fixture_value):
if fixture_value == 0:
return "eggs"
else:
return None

@pytest.fixture(params=[0, 1], ids=idfn)
def b(request):
return request.param

def test_b(b):
pass

上面显示了如何 ids 可以是要使用的字符串列表,也可以是将使用fixture值调用的函数,然后必须返回要使用的字符串。在后一种情况下,如果函数返回 None 然后将使用pytest的自动生成的ID。

运行上述测试将导致使用以下测试ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
python -m pytest .\test_ids.py  --collect-only
============================================================================== test session starts ===============================================================================
platform win32 -- Python 3.12.0, pytest-7.0.1, pluggy-1.0.0
rootdir: D:\Developer\pytest_learning\src\4.pytest fixtures explicit, modular, scalable
plugins: allure-pytest-2.9.43, Faker-13.3.0, assume-2.4.3
collected 4 items

<Package 4.pytest fixtures explicit, modular, scalable>
<Module test_ids.py>
<Function test_a[spam]>
<Function test_a[ham]>
<Function test_b[eggs]>
<Function test_b[1]

如果没有配置 ids,那么执行显示:

1
2
3
4
5
6
<Package 4.pytest fixtures explicit, modular, scalable>
<Module test_ids.py>
<Function test_a[0]>
<Function test_a[1]>
<Function test_b[0]>
<Function test_b[1]>

使用 -k 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python -m pytest .\test_ids.py  --collect-only  -k "test_a[spam]"
============================================================================== test session starts ===============================================================================
platform win32 -- Python 3.12.0, pytest-7.0.1, pluggy-1.0.0
rootdir: D:\Developer\pytest_learning\src\4.pytest fixtures explicit, modular, scalable
plugins: allure-pytest-2.9.43, Faker-13.3.0, assume-2.4.3
collected 4 items / 3 deselected / 1 selected

<Package 4.pytest fixtures explicit, modular, scalable>
<Module test_ids.py>
<Function test_a[spam]>

================================================================== 1/4 tests collected (3 deselected) in 0.01s ===================================================================


> python -m pytest .\test_ids.py --collect-only -k "test_a[spam] or test_b[eggs]"

<Package 4.pytest fixtures explicit, modular, scalable>
<Module test_ids.py>
<Function test_a[spam]>
<Function test_b[eggs]>

对参数化固件使用标记

Using marks with parametrized fixtures

pytest.param()可在参数化固件的值集里应用于标记,与它们可用于@pytest.mark.parametrize 的方法相同 .

例子:

1
2
3
4
5
6
7
8
9
# content of test_fixture_marks.py
@pytest.fixture(params=[0, 1,
pytest.param(2, marks=pytest.mark.skip),
pytest.param(3, marks=pytest.mark.xfail(reason="This is expected to fail"))])
def data_set(request):
return request.param

def test_data(data_set):
assert data_set < 3
  • @pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)]):定义了一个参数化固件 data_set,参数值为 [0, 1, pytest.param(2, marks=pytest.mark.skip)]
  • pytest.param(2, marks=pytest.mark.skip):使用 pytest.param 包装参数 2,并标记为 skip
  • 使用 pytest.param 包装参数 3 ,标记为 xfail,测试预期失败。
1
2
3
4
5
6
7
8
9
10
11
 python -m pytest .\test_fixture_marks.py -v
============================ test session starts==============================
platform win32 -- Python 3.12.0, pytest-7.0.1, pluggy-1.0.0 -- D:\software\Python\Python312\python.exe
cachedir: .pytest_cache
rootdir: D:\Developer\pytest_learning\src\4.pytest fixtures explicit, modular, scalable
plugins: allure-pytest-2.9.43, Faker-13.3.0, assume-2.4.3
collected 4 items
test_fixture_marks.py::test_data[0] PASSED [ 25%]
test_fixture_marks.py::test_data[1] PASSED [ 50%]
test_fixture_marks.py::test_data[2] SKIPPED (unconditional skip) [ 75%]
test_fixture_marks.py::test_data[3] XFAIL (This is expected to fail)

模块化:使用fixture函数中的fixtures

Modularity: using fixtures from a fixture function

不仅可以在测试函数中使用fixture,fixture函数还可以使用其他fixture本身。这有助于固件fixtures的模块化设计,并允许在许多项目中重用特定框架的固件。作为一个简单的示例,我们可以扩展前面的示例并实例化一个对象 app ,我们把已经定义好的 smtp_connection 资源加入进去了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# content of test_appsetup.py

import pytest

class App(object):
def __init__(self, smtp_connection):
self.smtp_connection = smtp_connection

@pytest.fixture(scope="module")
def app(smtp_connection):
return App(smtp_connection)

def test_smtp_connection_exists(app):
assert app.smtp_connection

我们在此声明 app 接收先前定义的 smtp_connection fixture并实例化 App 对象。让我们运行它:

1
2
3
4
5
6
7
8
9
10
11
$ pytest -v test_appsetup.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-4.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
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.12 seconds =========================

由于参数化 smtp_connection ,测试将运行两次 App 实例和相应的SMTP服务器。 app 固件没有必要注意 smtp_connection 参数化,因为 Pytest 将充分分析固件依赖关系图。

请注意 app 固件的 module 范围,并使用 smtp_connection 模块范围固件。如果 smtp_connection被缓存在 session 范围也是可行的:fixture可以使用“更广”范围的fixture,但不能使用另一种方式:会话session范围的fixture不能以有意义的方式使用模块module范围的fixture。


按 fixture 实例自动分组测试

Automatic grouping of tests by fixture instances

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

下面的示例使用两个参数化的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
import pytest

@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
param = request.param
print(" SETUP modarg %s" % param)
yield param
print(" TEARDOWN modarg %s" % param)

@pytest.fixture(scope="function", params=[1,2])
def otherarg(request):
param = request.param
print(" SETUP otherarg %s" % param)
yield param
print(" TEARDOWN otherarg %s" % param)

def test_0(otherarg):
print(" RUN test0 with otherarg %s" % otherarg)
def test_1(modarg):
print(" RUN test1 with modarg %s" % modarg)
def test_2(otherarg, modarg):
print(" RUN test2 with otherarg %s and modarg %s" % (otherarg, modarg))

这个示例展示了 pytest 如何管理和优化参数化固件的生命周期。

让我们在详细模式下运行测试,并查看打印输出:

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
$ pytest -v -s test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-4.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
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.12 seconds =========================

可以看到参数化模块作用域 modarg 资源导致测试执行的顺序,从而导致可能的“活动”资源最少。为 mod1 参数化的资源的终结器在 mod2 资源setup前被执行。

特别要注意,test_0是完全独立的,最先完成。然后是使用 mod1 的test_1执行,接着是使用 mod1的test_2 ,然后是用 mod2的test_1, 最后是用 mod2 的test_2.

这个 otherarg 参数化资源(具有函数范围)在使用它的每个测试之前setup,然后在使用它的每个测试之后teardown。


使用类、模块或项目中的 fixture

Using fixtures from classes, modules or projects

有时测试函数不需要直接访问fixture对象。例如,测试可能需要使用空目录作为当前工作目录进行操作,否则不关心具体目录。以下是如何使用标准 tempfile 和pytest fixtures 来实现它。我们将创建的fixture分入conftest.py文件:

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

import pytest
import tempfile
import os

@pytest.fixture()
def cleandir():
newpath = tempfile.mkdtemp()
os.chdir(newpath)

并通过一个 usefixtures 标记,在测试module中声明它的使用:

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

@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit(object):
def test_cwd_starts_empty(self):
assert os.listdir(os.getcwd()) == []
with open("myfile", "w") as f:
f.write("hello")

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

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

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

可以这样指定多个fixtures:

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

可以使用标记机制的通用特性,在测试模块级别指定fixture使用:

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

注意分配的变量 必须 被称为 pytestmark ,例如 foomark 不会激活fixtures。

也可以将项目中所有测试所需的fixtures放入一个ini文件中:

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

警告

注意这个标记在 fixture functions里没有作用 . 例如,这个 无法按预期工作

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

目前,这不会产生任何错误或警告.


自动固定装置 autouse

Autouse fixtures (xUnit setup on steroids)

有时,你可能希望在不显式声明函数参数或 usefixtures 装饰器的情况下自动调用fixtures。作为一个实际的例子,假设我们有一个数据库设备,它有一个 begin/rollback/commit 体系结构,并且我们希望通过一个 transaction 和一个 rollback 自动包围每个测试方法。下面是这个想法的一个虚拟的独立实现:

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
# content of test_db_transact.py

import pytest

class DB(object):
def __init__(self):
self.intransaction = []
def begin(self, name):
self.intransaction.append(name)
def rollback(self):
self.intransaction.pop()

@pytest.fixture(scope="module")
def db():
return DB()

class TestClass(object):
@pytest.fixture(autouse=True)
def transact(self, request, db):
db.begin(request.function.__name__)
yield
db.rollback()

def test_method1(self, db):
assert db.intransaction == ["test_method1"]

def test_method2(self, db):
assert db.intransaction == ["test_method2"]

类级别 transact 固件标有 autouse=true 这意味着类中的所有测试方法都将使用这个fixture,而不需要在测试函数签名或类级别的usefixture装饰器中声明它。

如果我们运行它,我们会得到两个通过的测试:

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

以下是Autouse 固件在其他范围中的工作方式:

  • autouse fixtures遵守 scope= 关键字参数:如果一个autouse fixture具有 scope='session' ,无论它在何处被定义,它将只运行一次。而 scope='class' 意味着它将每个类运行一次,等等。
  • 如果在一个测试模块中定义了一个autouse fixture,那么它的所有测试函数都会自动使用它。
  • 如果在conftest.py文件中定义了一个autouse fixture,那么其目录下的所有测试模块中的所有测试都将调用该fixture。
  • 最后, 请小心使用 :如果你在一个插件中定义了一个autouse fixture,它将对安装该插件的所有项目中的所有测试进行调用。如果一个fixture只在某些设置(例如在ini文件中)下工作,这是有用的。这样一个全局fixture应该总是快速确定它是否应该做任何工作,并避免其他耗费的导入或计算。

注意上面 transact fixture很可能是你希望在项目中可用的一个fixture,而通常不需要它处于活动状态。实现这一点的规范方法是将transact的定义放入没有使用 autouse 的conftest.py 中:

1
2
3
4
5
6
# content of conftest.py
@pytest.fixture
def transact(request, db):
db.begin()
yield
db.rollback()

然后,例如,通过声明需求,让一个testclass使用它:

1
2
3
4
@pytest.mark.usefixtures("transact")
class TestClass(object):
def test_method1(self):
...

此TestClass中的所有测试方法都将使用transact fixture,而模块中的其他测试类或函数将不使用它,除非它们还添加了 transact 参考。


覆盖不同级别的设备

Overriding fixtures on various levels

在相对较大的测试套中,你很可能需要 override 一个 globalroot fixture与 locally 定义一个,用来保持测试代码的可读性和可维护性。

覆盖文件夹(conftest)级别的fixture

Override a fixture on a folder (conftest) level

假设测试文件结构为:

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():
return 'username'

test_something.py
# content of tests/test_something.py
def test_username(username):
assert username == 'username'

subfolder/
__init__.py

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

@pytest.fixture
def username(username):
return 'overridden-' + username

test_something.py
# content of tests/subfolder/test_something.py
def test_username(username):
assert username == 'overridden-username'

以上示例,具有相同名称的fixture可以被某些测试文件夹级别的覆盖。请注意 basesuper fixture可从 在上面的例子中很容易使用的overriding fixture中获取。

在测试模块级别上覆盖fixture

Override a fixture on a test module level

假设测试文件结构为:

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
tests/
__init__.py

conftest.py
# content of tests/conftest.py
@pytest.fixture
def username():
return 'username'

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

@pytest.fixture
def username(username):
return 'overridden-' + username

def test_username(username):
assert username == 'overridden-username'

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

@pytest.fixture
def username(username):
return 'overridden-else-' + username

def test_username(username):
assert username == 'overridden-else-username'

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

通过直接测试参数化覆盖夹具

Override a fixture with direct test parametrization

假设测试文件结构为:

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'

在上面的示例中,fixture值被测试参数值覆盖。注意,即使测试没有直接使用fixture的值(在函数原型中没有提到),也可以用这种方式覆盖fixture的值。

用非参数化固件替代参数化固件

Override a parametrized fixture with non-parametrized one and vice versa

假设测试文件结构为:

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():
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'

在上面的示例中,参数化固件被非参数化版本覆盖,而非参数化固件被某些测试模块的参数化版本覆盖。显然,这同样适用于测试文件夹级别。