Builtin Fixtures
第四章:内置夹具¶
在前一章中,我们学习了夹具是什么,如何编写它们,以及如何将它们用于测试数据以及前置setup和后置teardown代码。还使用conftest.py 在多个测试文件中的测试文件之间共享夹具。
复用通用的夹具是一个很好的主意,pytest 开发者在pytest中包含了一些通用的夹具,之前在Task项目中已经使用了像 tmpdir
和 tmpdir_factory
这样的内置夹具,我们在本章中来详细地了解它们。
与pytest一起预先打包的内置夹具,可以帮助我们在测试中轻松且一致地完成工作。例如,除了处理临时文件之外,pytest 还包括用于访问命令行选项、在测试会话之间进行通信、验证输出流、修改环境变量和询问警告的内置夹具。内置夹具是pytest 核心功能的扩展。
现在让我们逐一看看几个最常用的内置夹具。
查看内置夹具清单:
$ pytest --fixtures
内置夹具清单(7.1版本,共18个):
capfd | Capture, as text, output to file descriptors 1 and 2. |
---|---|
capfdbinary | Capture, as bytes, output to file descriptors 1 and 2. |
caplog | Control logging and access log entries. |
capsys | Capture, as text, output to sys.stdout and sys.stderr. |
capsysbinary | Capture, as bytes, output to sys.stdout and sys.stderr. |
cache | Store and retrieve values across pytest runs. |
doctest_namespace | Provide a dict injected into the docstests namespace. |
monkeypatch | Temporarily modify classes, functions, dictionaries, os.environ, and other objects. |
pytestconfig | Access to configuration values, pluginmanager and plugin hooks. |
record_property | Add extra properties to the test. |
record_testsuite_property | Add extra properties to the test suite. |
recwarn | Record warnings emitted by test functions. |
request | Provide information on the executing test function. |
testdir | Provide a temporary test directory to aid in running, and testing, pytest plugins. |
tmp_path | Provide a pathlib.Path object to a temporary directory which is unique to each test function. |
tmp_path_factory | Make session-scoped temporary directories and return pathlib.Path objects. |
tmpdir | Provide a py.path.local object to a temporary directory which is unique to each test function; replaced by tmp_path. |
tmpdir_factory | Make session-scoped temporary directories and return py.path.local objects; replaced by tmp_path_factory. |
4.1 临时目录¶
temdir
、tempdir_factory
tmpdir
夹具和tmpdir_factory
夹具用来在测试运行之前创建一个临时文件系统目录,并在测试结束时删除该目录。
在 Tasks 项目中,我们需要一个目录来存储 MongoDB 和 TinyDB使用的临时数据库文件。由于我们希望使用在测试会话之后就消失的临时数据库进行测试,因此我们使用 tmpdir
和 tmpdir_factory
来为我们创建和清理目录。
如果要测试读取、写入或修改文件的内容,可以使用tmpdir
创建单个测试使用的文件或目录,并且可以使用 tmpdir_factory
为许多测试用例准备临时目录。
tmpdir
夹具拥有函数范围的作用域,tmpdir_factory
夹具则拥有会话范围的作用域。任何需要一个临时目录或文件的单独测试函数都可以使用tmpdir。对于那些为每个测试函数创建目录或文件的夹具来说也是如此。
下面是一个使用tmpdir夹具的简单例子。
💡 在ch4
目录中,添加新的测试模块test_tempdir.py
,添加新的测试函数:
# ch4/test_tmpdir.py
def test_tmpdir(tmpdir):
# tmpdir already has a path name associated with it
# join() extends the path to include a filename
# the file is created when it's written to
a_file = tmpdir.join('something.txt')
# you can create directories
a_sub_dir = tmpdir.mkdir('anything')
# you can create files in directories (created when written)
another_file = a_sub_dir.join('something_else.txt')
# this write creates 'something.txt'
a_file.write('contents may settle during shipping')
# this write creates 'anything/something_else.txt'
another_file.write('something different')
# 可以读取文件
assert a_file.read() == 'contents may settle during shipping'
assert another_file.read() == 'something different'
tmpdir
夹具的返回的值是py.path.local
类型的对象。这个看起来像是为了得到临时目录和文件所需的一切。但是,有一个问题。因为tmpdir
夹具被的作用域是函数范围,所以不能使用tmpdir创建能够跨越多个测试函数之间的文件夹或文件。tmpdir_factory
夹具对于除了函数作用域以外的作用域(类,模块,会话)则是可用的。
关于py.path文档:https://py.readthedocs.io/en/latest/path.html
tmpdir_factory
夹具很像tmpdir
夹具,但是它有一个不同的接口。
函数作用域的夹具只在每个测试函数运行一次,模块作用域的夹具只在每个模块运行一次,类作用域的夹具在每个类运行一次,会话作用域的夹具在每个会话运行一次。因此,在会话范围的夹具中创建的资源具有整个会话的生命周期。
为了看看tmpdir 和 tmpdir_factory 有多相似,修改 tmpdir 示例,使用 tmpdir_factory 夹具代替。
💡 在ch4
目录的测试模块test_tempdir.py
中,添加新的测试函数test_tmpdir_factory()
:
# ch4/test_tmpdir.py
# 代码片段
# ...
# 测试函数
def test_tmpdir_factory(tmpdir_factory):
# you should start with making a directory
# a_dir acts like the object returned from the tmpdir fixture
a_dir = tmpdir_factory.mktemp('mydir')
# base_temp will be the parent dir of 'mydir'
# you don't have to use getbasetemp()
# using it here just to show that it's available
base_temp = tmpdir_factory.getbasetemp()
print('base:', base_temp)
# the rest of this test looks the same as the 'test_tmpdir()'
# example except I'm using a_dir instead of tmpdir
a_file = a_dir.join('something.txt')
a_sub_dir = a_dir.mkdir('anything')
another_file = a_sub_dir.join('something_else.txt')
a_file.write('contents may settle during shipping')
another_file.write('something different')
assert a_file.read() == 'contents may settle during shipping'
assert another_file.read() == 'something different'
tmpdir_factory.mktemp('mydir')
创建了一个目录并保存为a_dir
变量 。在测试函数的其余部分,可以使用这个a_dir
对象 ,就像tmpdir
夹具返回的tmpdir
对象一样。第15行中,调用tmpdir_factory.getbasetemp()
函数,返回用于此会话的基本目录base_temp
。在这个例子中的print()
语句让你可以看到这个目录在你的系统中的位置。
让我们运行看看,执行命令:
$ pytest test_tmpdir.py::test_tmpdir_factory -v -s
base: C:\Users\Clifford\AppData\Local\Temp\pytest-of-Clifford\pytest-81
.
1 passed in 0.01s
base
目录是依赖于系统和用户的,pytest-NUM
后两位的序号会随着每个会话的NUM 数值的增加而改变。会话结束后,基本目录保持不变,但是pytest 会将它们清理干净,系统上只剩下最近几个临时基本目录,如果在测试运行后需要检查文件,这就很不错了。
如有需要,还可以使用pytest -- basetemp=xxxdir
选项自定义基本目录。
我们从tmpdir_factory 获得会话级别的临时目录和文件,从tmpdir 夹具获得函数级别的目录和文件。但是其他作用域呢?如果我们需要一个模块级别,或者一个类作用域范围的临时目录呢?为了做到这一点,我们创建一个想要的作用域的夹具,并使用tmpdir_factory。
例如,假设我们有一个包含很多测试函数的测试模块,其中许多函数需要从json文件中读取一些测试数据。我们可以在测试模块中,也可以在本地插件文件conftest.py
中,编写一个专门来读取数据文件的模块范围作用域的夹具。
💡 在ch4
目录创建新的目录authors
,并在其中添加新的插件文件conftest.py
,添加新的夹具author_file_json
:
# ch4/authors/conftest.py
"""Demonstrate tmpdir_factory."""
import json
import pytest
@pytest.fixture(scope='module')
def author_file_json(tmpdir_factory):
"""像一个数据文件中写入一些作者信息"""
python_author_data = {
'Xiao Zhou': {'City': 'Suzhou'},
'Xiao Fo': {'City': 'Shenzhen'},
'Xiao Yi': {'City': 'Shenzhen'}
}
file = tmpdir_factory.mktemp('data').join('author_file.json')
print('file:{}'.format(str(file)))
with file.open('w') as f:
json.dump(python_author_data, f)
return file
author_file_json
夹具中,第17行,创建了一个名为data
的临时目录,并在其中创建一个名为author_file.json
的文件。然后它把python_author_data
字典写如到json文件中。因为这是一个模块作用域的fixture夹具,所以json文件在进行测试的模块中只创建一次。
💡 在ch4/authors
目录中添加新的测试模块test_authors.py
:
"""Some tests that use temp data files."""
import json
def test_brian_in_portland(author_file_json):
"""A test that uses a data file."""
with author_file_json.open() as f:
authors = json.load(f)
assert authors['Xiao Fo']['City'] == 'Shenzhen'
def test_all_have_cities(author_file_json):
"""Same file is used for both tests."""
with author_file_json.open() as f:
authors = json.load(f)
for a in authors:
assert len(authors[a]['City']) > 0
执行命令:
$ pytest author/test_authors.py -v -s
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 2 items
author/test_authors.py::test_brian_in_portland file:C:\Users\Clifford\App
Data\Local\Temp\pytest-of-Clifford\pytest-85\data0\author_file.json
PASSED
author/test_authors.py::test_all_have_cities PASSED
========================== 2 passed in 0.02s ===========================
4.2 配置选项¶
pytestconfig
通过内置的pytestconfig
夹具 ,可以控制pytest如何通过命令行参数和选项、配置文件、插件以及pytest启动运行的目录。pytestconfig
夹具是request.config
的快捷方式,在pytest文档中也被称为pytest配置对象。
要了解pytestconfig的工作原理,则需要了解如何添加自定义的命令行选项,并在测试函数中读取该选项值。可以直接从pytestconfig中读取命令行选项的值,但是要添加该选项,并让 pytest 解析它,则需要添加一个钩子函数(hook function)。
钩子函数是另一种控制pytest 行为的方法,常用于插件中,详见【第5章:插件】
添加一个自定义的命令行选项,并从pytestconfig中读取它很容易。我们将使用pytest的钩子函数pytest_addoption
为pytest命令行中已有的选项添加几个选项。
💡 在ch4
目录创建新的目录pytestconfig
,并在其中添加新的插件文件conftest.py
。
# ch4/pytestconfig/conftest.py
def pytest_addoption(parser):
parser.addoption("--myopt", action="store_true",
help="some boolean option")
parser.addoption("--foo", action="store", default="bar",
help="foo: bar or baz")
pytest_addoption
添加命令行选项应该通过插件,或在项目根目录的conftest.py
文件中实现。不要在一个测试子目录中这样做。
运行命令:
$ cd ch4/pytestconfig
$ pytest --help
usage: pytest [options] [file_or_dir] [file_or_dir] [...]
......省略一万字
custom options:
--myopt some boolean option
--foo=FOO foo: bar or baz
......省略一万字
现在,我们可以在测试函数中访问这些选项。
💡 在ch4/pytestconfig
目录中添加新的测试模块test_config.py
:
def test_option(pytestconfig):
print('"foo" set to:', pytestconfig.getoption('foo'))
print('"myopt" set to:', pytestconfig.getoption('myopt'))
让我们看看效果:
$ pytest pytestconfig/test_config.py -s -q
"foo" set to: bar
"myopt" set to: False
.
1 passed in 0.00s
执行命令:
$ pytest pytestconfig/test_config.py -s -q --myopt
"foo" set to: bar
"myopt" set to: True
.
1 passed in 0.00s
执行命令:
$ pytest pytestconfig/test_config.py -s -q --myopt --foo shenzhen
"foo" set to: shenzhen
"myopt" set to: True
.
1 passed in 0.01s
pytestconfig
是一个夹具,所以也可以从其他夹具访问它。如果需要的话,可以为选项名称制作夹具。
💡 在ch4/pytestconfig
目录的test_config.py
中添加新的夹具和测试函数:
import pytest
# 夹具
@pytest.fixture()
def foo(pytestconfig):
return pytestconfig.option.foo
# 夹具
@pytest.fixture()
def myopt(pytestconfig):
return pytestconfig.option.myopt
# 测试函数
def test_fixtures_for_options(foo, myopt):
print('"foo" set to:', foo)
print('"myopt" set to:', myopt)
pytest pytestconfig/test_config.py::test_fixtures_for_options -q -s
"foo" set to: bar
"myopt" set to: False
.
1 passed in 0.01s
还可以访问内置选项,以及有关pytest如何启动的信息(目录、参数等),而不仅仅是添加选项。下面是一些配置值和选项的例子。
💡 在ch4/pytestconfig
目录的test_config.py
中添加新的夹具和测试函数:
# ch4/pytestconfig/test_config.py
# 代码片段
# ...
def test_pytestconfig(pytestconfig):
print('args :', pytestconfig.args)
print('inifile :', pytestconfig.inifile)
print('invocation_dir :', pytestconfig.invocation_dir)
print('rootdir :', pytestconfig.rootdir)
print('-k EXPRESSION :', pytestconfig.getoption('keyword'))
print('-v, --verbose :', pytestconfig.getoption('verbose'))
print('-q, --quiet :', pytestconfig.getoption('quiet'))
print('-l, --showlocals:', pytestconfig.getoption('showlocals'))
print('--tb=style :', pytestconfig.getoption('tbstyle'))
$ pytest pytestconfig/test_config.py::test_pytestconfig -q -s
args : ['pytestconfig/test_config.py::test_pytestconfig']
inifile : None
invocation_dir : D:\Coding\Gitees\studypytest\ch4
rootdir : D:\Coding\Gitees\studypytest\ch4
-k EXPRESSION :
-v, --verbose : -1
-q, --quiet : 1
-l, --showlocals: False
--tb=style : auto
.
1 passed in 0.01s
在后面的第6章中,演示ini配置文件时,我们还会跟pytestconfig
夹具见面。
4.3 缓存信息¶
cache
通常我们测试人员喜欢让每个测试用例都尽可能独立于其他测试用例。我们希望确保不会莫名出现顺序耦合。我们希望能够以任何顺序运行或重复运行任何测试用例,并得到相同的结果。我们也希望测试会话是可重复的,并且不会基于以前的测试会话改变行为。
然而,有时将数据又很重要,需要从一个测试会话传递到下一个测试会话。当我们确实想要将信息传递给未来的测试会话时,我们可以使用内置的cache
夹具。
这个cache 夹具就是存储一个测试会话的信息,然后在下一个测试会话中获取它。有一个很好的使用cache缓存功能的例子就是内置的--last-failed
和--failed-first
功能。
让我们看看这些标志的数据是如何使用缓存存储的。
下面是--last-failed
和--failed-first
以及几个cache缓存选项信息。
选项 | 简写 | 说明 |
---|---|---|
--last-failed |
--lf |
只执行上一次执行失败的用例 |
--failed-first |
--ff |
先执行上一次失败的用例,再执行剩下的其他用例 |
--cache-show |
只展示缓存内容,不会执行测试用例集 | |
--cache-clear |
在测试用例开始执行的时候清理素有的缓存内容 |
我们来进行两次测试,看看效果。
💡 在ch4/cache
目录中,添加新的测试模块test_pass_fail.py
:
def test_this_passes():
assert 1 == 1
def test_this_fails():
assert 1 == 2
执行命令:
$ pytest cache/test_pass_fail.py -v --tb=no
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 2 items
cache/test_pass_fail.py::test_this_passes PASSED [ 50%]
cache/test_pass_fail.py::test_this_fails FAILED [100%]
======================= short test summary info ========================
FAILED cache/test_pass_fail.py::test_this_fails - assert 1 == 2
===================== 1 failed, 1 passed in 0.01s ======================
如果你加上--ff
或 --failed-first
选项再次运行它们,那么先前失败的测试将首先运行,然后是其余部分。
$ pytest cache/test_pass_fail.py -v --tb=no --ff
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 2 items
run-last-failure: rerun previous 1 failure first
cache/test_pass_fail.py::test_this_fails FAILED [ 50%]
cache/test_pass_fail.py::test_this_passes PASSED [100%]
======================= short test summary info ========================
FAILED cache/test_pass_fail.py::test_this_fails - assert 1 == 2
===================== 1 failed, 1 passed in 0.01s ======================
现在使用--lf
或 --last-failed
选项来只运行上次失败的测试。
$ pytest cache/test_pass_fail.py -v --tb=no --lf
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 2 items / 1 deselected / 1 selected
run-last-failure: rerun previous 1 failure
cache/test_pass_fail.py::test_this_fails FAILED [100%]
======================= short test summary info ========================
FAILED cache/test_pass_fail.py::test_this_fails - assert 1 == 2
=================== 1 failed, 1 deselected in 0.01s ====================
test_this_fails()
了。
在讨论如何保存失败数据,以及如何借鉴类似机制之前,让我们先看看另一个更加明显的使用 --lf和--ff 值示例。
下面是
💡 在ch4/cache
目录中,添加新的测试模块test_few_failures.py
,添加一个参数化测试函数,其中有一条用例数据会失败。
# ch4/cache/test_few_failures.py
"""Demonstrate -lf and -ff with failing tests."""
import pytest
from pytest import approx
testdata = [
# x, y, expected
(1.01, 2.01, 3.02),
(1e25, 1e23, 1.1e25),
(1.23, 3.21, 4.44),
(0.1, 0.2, 0.3),
(1e25, 1e24, 1.1e25)
]
@pytest.mark.parametrize("x,y,expected", testdata)
def test_a(x, y, expected):
"""Demo approx()."""
sum_ = x + y
assert sum_ == approx(expected)
$ pytest cache/test_few_failures.py --tb=no -q
.F... [100%]
======================= short test summary info ========================
FAILED cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25] - asser...
1 failed, 4 passed in 0.01s
如果像看看到底哪里出错了,那么可以在命令行中单独运行这个失败的测试用例:
$ pytest "cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25]" --tb=no -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 1 item
cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25] FAILED [100%]
======================= short test summary info ========================
FAILED cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25] - asser...
========================== 1 failed in 0.01s ===========================
--lf
选项显然更方便。
如果需要调试一个测试失败函数,另一个可能会让找bug变得更简单是 --showlocals
选项,或者简写为-l
。
$ pytest cache/test_few_failures.py --lf -l -v
--lf
表示只运行上次失败的测试函数;-l
表示展示测试函数的局部变量;-v
是展示详细结果信息。
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 5 items / 4 deselected / 1 selected
run-last-failure: rerun previous 1 failure
cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25] FAILED [100%]
=============================== FAILURES ===============================
_____________________ test_a[1e+25-1e+23-1.1e+25] ______________________
x = 1e+25, y = 1e+23, expected = 1.1e+25
@pytest.mark.parametrize("x,y,expected", testdata)
def test_a(x, y, expected):
"""Demo approx()."""
sum_ = x + y
> assert sum_ == approx(expected)
E assert 1.01e+25 == 1.1e+25 ± 1.1e+19
E comparison failed
E Obtained: 1.01e+25
E Expected: 1.1e+25 ± 1.1e+19
expected = 1.1e+25
sum_ = 1.01e+25
x = 1e+25
y = 1e+23
cache\test_few_failures.py:19: AssertionError
======================= short test summary info ========================
FAILED cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25] - asser...
=================== 1 failed, 4 deselected in 0.08s ====================
为了实现记住上次失败的测试的秘诀在于,pytest把上次测试会话的测试失败信息,存储在本地文件中。可以看到ch4
目录中有一个.pytest_cache
的目录。Windows用户可以进入该目录查看.cache/v/cache/lastfailed
文件内容。
macOS系统用户可以执行命令查看:
$ cat .cache/v/cache/lastfailed
{
"cache/test_pass_fail.py::test_this_fails": true,
"cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25]": true
}
另外,我们也可以通过--cache-show
选项查看缓存的信息。
$ pytest --cache-show
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch4
cachedir: D:\Coding\Gitees\studypytest\ch4\.pytest_cache
------------------------- cache values for '*' -------------------------
cache\lastfailed contains:
{'cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25]': True,
'cache/test_pass_fail.py::test_this_fails': True}
cache\nodeids contains:
['author/test_authors.py::test_all_have_cities',
'author/test_authors.py::test_brian_in_portland',
'cache/test_few_failures.py::test_a[0.1-0.2-0.3]',
'cache/test_few_failures.py::test_a[1.01-2.01-3.02]',
'cache/test_few_failures.py::test_a[1.23-3.21-4.44]',
'cache/test_few_failures.py::test_a[1e+25-1e+23-1.1e+25]',
'cache/test_few_failures.py::test_a[1e+25-1e+24-1.1e+25]',
'cache/test_pass_fail.py::test_this_fails',
'cache/test_pass_fail.py::test_this_passes',
'pytestconfig/test_config.py::test_fixtures_for_options',
'pytestconfig/test_config.py::test_option',
'pytestconfig/test_config.py::test_pytestconfig',
'test_tempdir.py::test_tmpdir_factory']
cache\stepwise contains:
[]
======================== no tests ran in 0.00s =========================
另外,还可以在会话之前传入--clear-cache
来清除缓存信息。比较简单,这里就不做演示了。
当然,缓存不仅仅可以用来实现--lf
和--ff
选项的功能。比如,让我们制作一个记录测试所需时间、保存时间的夹具,希望能在下一次运行时,展示一个测试所需时间比上次长两倍的异常信息。
缓存cache
夹具的接口很简单:
cache.get(key, default)
cache.set(key, value)
.pytest_cache
目录所展示的样子,key的名称以应用程序或插件的名称开始,后跟一个/
,然后继续用/
分隔key的各个部分。存储的value值可以是任何可以转换为json的对象。
💡 在ch4/cache
目录中,添加新的测试模块test_slower.py
,在其中添加一个夹具。
import pytest
import datetime
@pytest.fixture(autouse=True)
def check_duration(request, cache):
# 文件名(将可能含有冒号节点标识符中的冒号替换成下划线)
key = 'duration/' + request.node.nodeid.replace(':', '_')
start_time = datetime.datetime.now()
yield
stop_time = datetime.datetime.now()
this_duration = (stop_time - start_time).total_seconds()
last_duration = cache.get(key, None)
cache.set(key, this_duration)
if last_duration is not None:
error_string = "test duration over 2x last duration"
assert this_duration <= last_duration * 2, error_string
duration/
来表示一个缓存。yield 之前的代码在测试函数之前运行, yield之后的代码在测试函数之后运行。
现在,我们需要编写一些耗时不同的测试函数。
💡 在ch4/cache
目录的测试模块test_slower.py
中,在其中参数化测试函数。
# ch4/cache/test_slower.py
# 代码片段
# ...
import random
import time
@pytest.mark.parametrize('i', range(5))
def test_slow_stuff(i):
time.sleep(random.random())
random
随机模块配合参数化,轻松地生成了一些随机休眠时长的测试函数,所有的时间都短于一秒钟。
让我们看看它会运行几次:
$ pytest cache/test_slower.py --cache-clear -v
--cache-clear
表示清空缓存信息;-v
表示展示详细的结果。
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 5 items
cache/test_slower.py::test_slow_stuff[0] PASSED [ 20%]
cache/test_slower.py::test_slow_stuff[1] PASSED [ 40%]
cache/test_slower.py::test_slow_stuff[2] PASSED [ 60%]
cache/test_slower.py::test_slow_stuff[3] PASSED [ 80%]
cache/test_slower.py::test_slow_stuff[4] PASSED [100%]
========================== 5 passed in 3.38s ===========================
换个选项,再执行一次:
$ pytest cache/test_slower.py --tb=line
--tb=line
表示按行展示报错信息。
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 5 items
cache\test_slower.py ...E.E. [100%]
================================ ERRORS ================================
_______________ ERROR at teardown of test_slow_stuff[2] ________________
E AssertionError: test duration over 2x last duration
assert 0.799198 <= (0.334876 * 2)
_______________ ERROR at teardown of test_slow_stuff[3] ________________
E AssertionError: test duration over 2x last duration
assert 0.542112 <= (0.09996 * 2)
======================= short test summary info ========================
ERROR cache/test_slower.py::test_slow_stuff[2] - AssertionError: test ...
ERROR cache/test_slower.py::test_slow_stuff[3] - AssertionError: test ...
===================== 5 passed, 2 errors in 2.16s ======================
让我们看看缓存里有什么。
$ pytest --cache-show -q
cachedir: D:\Coding\Gitees\studypytest\ch4\.pytest_cache
------------------------- cache values for '*' -------------------------
cache\lastfailed contains:
{'cache/test_slower.py::test_slow_stuff[2]': True,
'cache/test_slower.py::test_slow_stuff[3]': True}
cache\nodeids contains:
['cache/test_slower.py::test_slow_stuff[0]',
'cache/test_slower.py::test_slow_stuff[1]',
'cache/test_slower.py::test_slow_stuff[2]',
'cache/test_slower.py::test_slow_stuff[3]',
'cache/test_slower.py::test_slow_stuff[4]']
cache\stepwise contains:
[]
duration\cache\test_slower.py__test_slow_stuff[0] contains:
0.176376
duration\cache\test_slower.py__test_slow_stuff[1] contains:
0.276813
duration\cache\test_slower.py__test_slow_stuff[2] contains:
0.799198
duration\cache\test_slower.py__test_slow_stuff[3] contains:
0.542112
duration\cache\test_slower.py__test_slow_stuff[4] contains:
0.279912
no tests ran in 0.01s
duration
数据与其他cache
缓存数据。
然而,有意思的是,lastfailed
功能还可以操作一条缓存记录。我们的duration时间记录数据在每个测试中占用了一个缓存条目。让我们跟随lastfailed的脚步,将数据放入一个条目中。
我们正在为每个测试读写缓存。我们可以把夹具分成一个个函数作用域的夹具来测量持续时间,一个会话作用域夹具来读写缓存。然而,如果我们这样做,我们就不能使用cache夹具了,因为它是函数级别作用域。幸运的是,cache夹具返回request.config.cache。这在任何作用域都可以使用。
这里有一个可能的重构相同的功能。
💡 在ch4/cache
目录中,添加新的测试模块test_slower_2.py
,添加夹具和参数化测试函数。
# ch4/cache/test_slower_2.py
import pytest
import random
import time
import datetime
from collections import namedtuple
Duration = namedtuple('Duration', ['current', 'last'])
@pytest.fixture(scope='session')
def duration_cache(request):
key = 'duration/testdurations'
d = Duration({}, request.config.cache.get(key, {}))
yield d
request.config.cache.set(key, d.current)
@pytest.fixture(autouse=True)
def check_duration(request, duration_cache):
d = duration_cache
nodeid = request.node.nodeid
start_time = datetime.datetime.now()
yield
duration = (datetime.datetime.now() - start_time).total_seconds()
d.current[nodeid] = duration
if d.last.get(nodeid, None) is not None:
errorstring = "test duration over 2x last duration"
assert duration <= (d.last[nodeid] * 2), errorstring
@pytest.mark.parametrize('i', range(5))
def test_slow_stuff(i):
time.sleep(random.random())
current
和 last。然后我们将这个具名元组传递给函数作用域范围的check_duration
夹具,它在每个测试函数中都会运行。当测试函数运行时,相同的具名元组传递给每个测试,当前测试运行的时间存储在d.current 字典中。在测试会话结束时,收集到的current字典被保存在缓存中。
在运行了几次之后,让我们看看保存的缓存:
$ pytest cache/test_slower_2.py --cache-clear -q
..... [100%]
5 passed in 2.75s
执行命令:
$ pytest cache/test_slower_2.py --tb=no -q
..E.E.. [100%]
======================= short test summary info ========================
ERROR cache/test_slower_2.py::test_slow_stuff[1] - AssertionError: tes...
ERROR cache/test_slower_2.py::test_slow_stuff[2] - AssertionError: tes...
5 passed, 2 errors in 3.00s
查看缓存,执行命令:
$ pytest --cache-show -q
------------------------- cache values for '*' -------------------------
cache\lastfailed contains:
{'cache/test_slower_2.py::test_slow_stuff[1]': True,
'cache/test_slower_2.py::test_slow_stuff[2]': True}
cache\nodeids contains:
['cache/test_slower_2.py::test_slow_stuff[0]',
'cache/test_slower_2.py::test_slow_stuff[1]',
'cache/test_slower_2.py::test_slow_stuff[2]',
'cache/test_slower_2.py::test_slow_stuff[3]',
'cache/test_slower_2.py::test_slow_stuff[4]']
cache\stepwise contains:
[]
duration\testdurations contains:
{'cache/test_slower_2.py::test_slow_stuff[0]': 0.364412,
'cache/test_slower_2.py::test_slow_stuff[1]': 0.337831,
'cache/test_slower_2.py::test_slow_stuff[2]': 0.981515,
'cache/test_slower_2.py::test_slow_stuff[3]': 0.702638,
'cache/test_slower_2.py::test_slow_stuff[4]': 0.587005}
no tests ran in 0.00s
4.4 捕获输出¶
capsys
内置capsys
夹具提供两个功能:它允许你查看一些代码中的 stdout 和 stderr流信息,和暂时禁用捕获输出。
让我们来看看如何查看标准输出流stdout
和 标准错误流stderr
。假设现在有一个输出问候语到标准输出流stdout
的函数。
💡 在ch4
目录中,添加新的目录cap
,再其中添加新的测试模块test_capsys.py
:
# ch4/cap/test_capsys.py
def greeting(name):
print('Hi, {}'.format(name))
由于被测函数greeting()
没有返回值,所以无法通过检查返回值来测试它,但是我们直到,print()
打印的信息会写入输出流stdout
并默认会被pytest捕获,因此必须以某种方式来测试stdout
的内容。
我们可以使用内置的capsys
夹具来访问和测试输出信息。
💡 在ch4/cap
目录的测试模块test_capsys.py
中,添加新的测试函数:
# ch4/cap/test_capsys.py
def test_greeting(capsys):
greeting('Earthling')
out, err = capsys.readouterr()
assert out == 'Hi, Earthling\n'
assert err == ''
greeting('Brian')
greeting('Nerd')
out, err = capsys.readouterr()
assert out == 'Hi, Brian\nHi, Nerd\n'
assert err == ''
stdout
和stderr
信息可以通过capsys.redouterr()
获取。返回值是自动从函数执行开始,或者自从最后一次调用的任何被捕捉到的内容。
前面的例子只用到了stdout,我们再看一个stderr的例子。
💡 在ch4/cap
目录的测试模块test_capsys.py
中,添加新的测试函数:
# ch4/cap/test_capsys.py
# 代码片段
import sys
def yikes(problem):
print('YIKES! {}'.format(problem), file=sys.stderr)
def test_yikes(capsys):
yikes('Out of coffee!')
out, err = capsys.readouterr()
assert out == ''
assert 'Out of coffee!' in err
$ pytest cap/test_capsys.py::test_yikes -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 1 item
cap/test_capsys.py::test_yikes PASSED [100%]
========================== 1 passed in 0.01s ===========================
pytest的 -s
选项可以关闭这个捕获功能,当测试运行时输出被发送到stdout。通常情况下这个捕获功能很有用,因为它是失败测试的输出,我们需要看到它来调试失败的测试信息。然而,有些时候可能又想让一部分输出信息被pytest捕获,一些又不想要被捕获。你可以用capsys
夹具做到这一点,例如使用 capsys.disabled()
暂时让输出信息绕过捕获机制。
💡 在ch4/cap
目录的测试模块test_capsys.py
中,添加新的测试函数:
# ch4/cap/test_capsys.py
# 代码片段
def test_capsys_disabled(capsys):
with capsys.disabled():
print('\nalways print this')
print('normal print, usually captured')
$ pytest cap/test_capsys.py::test_capsys_disabled -q
always print this
. [100%]
1 passed in 0.01s
关闭捕获输出,执行命令:
$ pytest cap/test_capsys.py::test_capsys_disabled -q -s
always print this
normal print, usually captured
.
1 passed in 0.00s
always print this
这句,因为它是从capsys.disabled()
代码中打印的,所以拥有特权,pytest捕获机制对它就不起作用了。另一个打印语句只是一个普通的print语句,所以normal print, usually captured
这句,只有在命令中带有-s
选项,关闭输出捕获时才会出现在输出信息中,否则被捕获了就看不到了。
4.5 猴子补丁¶
monkeypatch
猴子补丁(monkeypatch
)是在运行时对类或模块的动态修改。在测试期间,猴子补丁是一种方便的方法,可以接管被测代码的部分,执行期函式库环境,并用对测试更为方便的对象或函数替换输入依赖项或输出依赖项。内置的monkeypatch夹具允许你在单个测试的上下文中这样做。当测试结束时,不管是通过还是失败,最初的未修补程序都会由补丁恢复,撤销所有改变。
在看完API之后,我们将看看猴子补丁是如何在测试代码中使用的。
猴子补丁夹具提供以下api函数:
• setattr(target, name, value=<notset>, raising=True)
设置一个属性
• delattr(target, name=<notset>, raising=True)
删除一个属性
• setitem(dic, name, value)
设置一个字典记录
• delitem(dic, name, raising=True)
删除一个字典记录
• setenv(name, value, prepend=None)
设置一个环境变量
• delenv(name, raising=True)
删除一个环境变量
• syspath_prepend(path)
把path预置到 sys.path中。
• chdir(path)
更改当前的工作目录。
这里的raising
参数告诉pytest如果条目不存在是否引发异常。setenv()
函数中的prepend
参数可以是一个字符。如果设置的话,环境变量的值将改为value+prepend+< old value >
。
要查看monkeypatch
夹具的运行情况,让我们看看编写配置文件的代码。一些程序的行为可以通过在用户主目录的点文件中设置偏好和值来改变。
下面是一段读/写文件的示例。
💡 在ch4
目录中,添加新的包monkey
,再其中添加新的功能模块cheese.py
:
import os
import json
_default_prefs = {
'slicing': ['manchego', 'sharp cheddar'],
'spreadable': ['Saint Andre', 'camembert',
'bucheron', 'goat', 'humbolt fog', 'cambozola'],
'salads': ['crumbled feta']
}
def read_cheese_preferences():
full_path = os.path.expanduser('~/.cheese.json')
with open(full_path, 'r') as f:
prefs = json.load(f)
return prefs
def write_cheese_preferences(prefs):
full_path = os.path.expanduser('~/.cheese.json')
with open(full_path, 'w') as f:
json.dump(prefs, f, indent=4)
def write_default_cheese_preferences():
write_cheese_preferences(_default_prefs)
write_default_cheese_preferences()
函数,它不接受任何参数,也不返回任何值。但它确实有一个我们可以测试的副作用:它会将一个.cheese.json
文件写入当前用户的home目录。
一种方法是让它正常运行并检查副作用。假设我已经有了read_cheese_preferences()
的测试函数,并且我信任它们,所以我可以在write_default_cheese_preferences()
的测试函数中使用它们。
💡 在ch4/monkey
包中,添加新的测试模块test_cheese.py
。
from ch4.monkey import cheese
def test_def_prefs_full():
cheese.write_default_cheese_preferences()
expected = cheese._default_prefs
actual = cheese.read_cheese_preferences()
assert expected == actual
$ pytest monkey/test_cheese.py -v
C:\Users\xxx
中,macOS系统一般在~/
目录中),可以看到已经出现了一个.chees.json
文件了。
这样做存在一个问题,就是任何运行这个测试代码的人都会覆盖他们自己的.cheese.json配置文件。这可不妙。如果用户已经设置了HOME
环境变量,则 os.path.expanduser()
将用户的HOME
中的内容替换为 ~
。让我们创建一个临时目录,并重定向HOME
指向新的临时目录。
setenv()¶
💡 在ch4/monkey
包的测试模块test_cheese.py
中,添加新的测试函数。
# ch4/monkey/test_cheese.py
# 代码片段
def test_def_prefs_change_home(tmpdir, monkeypatch):
monkeypatch.setenv('HOME', tmpdir.mkdir('home'))
cheese.write_default_cheese_preferences()
expected = cheese._default_prefs
actual = cheese.read_cheese_preferences()
assert expected == actual
不要修补HOME 环境变量,让我们来修改expanduser
。
setattr()¶
💡 在ch4/monkey
包的测试模块test_cheese.py
中,添加新的测试函数。
# ch4/monkey/test_cheese.py
# 代码片段
def test_def_prefs_change_expanduser(tmpdir, monkeypatch):
fake_home_dir = tmpdir.mkdir('home')
monkeypatch.setattr(cheese.os.path, 'expanduser',
(lambda x: x.replace('~', str(fake_home_dir))))
cheese.write_default_cheese_preferences()
expected = cheese._default_prefs
actual = cheese.read_cheese_preferences()
assert expected == actual
~
替换为我们新的临时目录。
现在我们已经使用setenv()
和setattr()
来修补环境变量和属性,下一个是setitem()
。
setitem()¶
假设我们担心,如果文件已经存在会发生什么。当write_default_cheese_preferences()
被调用时,我们希望确保它被默认覆盖。
💡 在ch4/monkey
包的测试模块test_cheese.py
中,添加新的测试函数。
# ch4/monkey/test_cheese.py
# 代码片段
import copy
def test_def_prefs_change_defaults(tmpdir, monkeypatch):
# write the file once
fake_home_dir = tmpdir.mkdir('home')
monkeypatch.setattr(cheese.os.path, 'expanduser',
(lambda x: x.replace('~', str(fake_home_dir))))
cheese.write_default_cheese_preferences()
defaults_before = copy.deepcopy(cheese._default_prefs)
# change the defaults
monkeypatch.setitem(cheese._default_prefs, 'slicing', ['provolone'])
monkeypatch.setitem(cheese._default_prefs, 'spreadable', ['brie'])
monkeypatch.setitem(cheese._default_prefs, 'salads', ['pepper jack'])
defaults_modified = cheese._default_prefs
# write it again with modified defaults
cheese.write_default_cheese_preferences()
# read, and check
actual = cheese.read_cheese_preferences()
assert defaults_modified == actual
assert defaults_modified != defaults_before
_default_prefs
是一个字典对象,我们可以使用monkeypatch.setitem()
,在测试期间修改字典条目。
我们前面演示了setenv()
、 setattr()
和 setitem()
的用法。猴子补丁的del系列的方法delenv()
、delattr()
、delitem()
其实非常类似,只不过它们是是用来删除一个环境变量、属性或字典项,而不是设置。
monkeypatch
夹具的最后两个方法是关于路径的。
syspath_prepend(path)
为sys.path 提供一个路径,其作用是将新路径放在模块导入目录的行首。这样做的一个用途是用stub版本替换系统范围内的模块或包。然后你可以使用monkeypatch.syspath_prepend()
来预置stub版本的目录,测试中的代码会首先找到stub版本。
chdir(path)
在测试期间更改当前工作目录。这对于测试命令行脚本和其他依赖于当前工作目录的实用程序很有用。你可以设置一个临时目录,包含任何对你的脚本有意义的内容,然后使用monkey-patch.chdir(the_tmpdir)
。
也可以结合unittest.mock
使用monkeypatch
夹具函数来临时替换模拟对象的属性,详见第7章。
4.6 文档信息¶
doctest_namespace
doctest
模块是标准Python 库的一部分,允许在docstring
中放入函数的小代码示例,并对它们进行测试,以确保它们能够工作。可以使用 --doctest-modules
选项在Python 代码中查找并运行 doctest
测试。
使用内置的doctest_namespace
夹具,可以构建自动使用的夹具来添加符号到 pytest 使用的命名空间中,同时运行 doctest 测试。这使得文档更具可读性。doctest_namespace夹具通常用于将模块导入添加到命名空间中,尤其是当Python 约定要缩短模块或包的名称时。例如,numpy 经常以 import numpy as np语句导入。
我们来举个例子。假设我们有一个名为unnecessary_math.py
的模块,其中包含multiply()和divide()方法,我们真的希望确保每个人都能清楚地理解它。因此,我们在文件文档和函数的文档中列举一些用法示例。
💡 在ch4
目录中,添加新的目录doc/one
,在其中添加新的模块unnecessary_math.py
:
# ch4/doc/unnecessary_math.py
$ pytest doc/one/unnecessary_math.py --doctest-modules -v --tb=short
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 3 items
doc/unnecessary_math.py::unnecessary_math FAILED [ 33%]
doc/unnecessary_math.py::unnecessary_math.divide FAILED [ 66%]
doc/unnecessary_math.py::unnecessary_math.multiply FAILED [100%]
=============================== FAILURES ===============================
______________________ [doctest] unnecessary_math ______________________
001
002 This module defines multiply(a, b) and divide(a, b).
003 >>> import unnecessary_math as um
Expected:
Here's how you use multiply:
Got nothing
D:\Coding\Gitees\studypytest\ch4\doc\unnecessary_math.py:3: DocTestFailur
e
__________________ [doctest] unnecessary_math.divide ___________________
027
028 Returns a divided by b.
029 >>> um.divide(10, 5)
UNEXPECTED EXCEPTION: NameError("name 'um' is not defined")
Traceback (most recent call last):
File "D:\Programs\Python3\lib\doctest.py", line 1336, in __run
exec(compile(example.source, filename, "single",
File "<doctest unnecessary_math.divide[0]>", line 1, in <module>
NameError: name 'um' is not defined
D:\Coding\Gitees\studypytest\ch4\doc\unnecessary_math.py:29: UnexpectedEx
ception
_________________ [doctest] unnecessary_math.multiply __________________
016
017 Returns a multiplied by b.
018 >>> um.multiply(4, 3)
UNEXPECTED EXCEPTION: NameError("name 'um' is not defined")
Traceback (most recent call last):
File "D:\Programs\Python3\lib\doctest.py", line 1336, in __run
exec(compile(example.source, filename, "single",
File "<doctest unnecessary_math.multiply[0]>", line 1, in <module>
NameError: name 'um' is not defined
D:\Coding\Gitees\studypytest\ch4\doc\unnecessary_math.py:18: UnexpectedEx
ception
======================= short test summary info ========================
FAILED doc/unnecessary_math.py::unnecessary_math
FAILED doc/unnecessary_math.py::unnecessary_math.divide
FAILED doc/unnecessary_math.py::unnecessary_math.multiply
========================== 3 failed in 0.05s ===========================
💡 在ch4
目录中,添加新的目录doc/two
,在其中添加新的模块unnecessary_math.py
:
# ch4/doc/two/unnecessary_math.py
def multiply(a, b):
"""
Returns a multiplied by b.
>>> import unnecessary_math as um
>>> um.multiply(4, 3)
12
>>> um.multiply('a', 3)
'aaa'
"""
return a * b
def divide(a, b):
"""
Returns a divided by b.
>>> import unnecessary_math as um
>>> um.divide(10, 5)
2.0
"""
return a / b
执行命令,看看效果:
$ pytest doc/two/unnecessary_math.py --doctest-modules -v --tb=short
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch4
collected 2 items
doc/two/unnecessary_math.py::unnecessary_math.divide PASSED [ 50%]
doc/two/unnecessary_math.py::unnecessary_math.multiply PASSED [100%]
========================== 2 passed in 0.05s ===========================
内置的doctest_namespace夹具,用在一个顶层目录的自动使用的插件文件conftest.py
,可以在不改变源代码的情况下解决这个问题。
💡 在ch4
目录中,添加新的目录doc/three
,在其中添加新的插件文件conftest.py
:
# ch4/doc/three/conftest.py
import pytest
import unnecessary_math
@pytest.fixture(autouse=True)
def add_um(doctest_namespace):
doctest_namespace['um'] = unnecessary_math
pytest
将um
名称添加到doctest_namespace
,并使其成为导入的unnecessary_math
模块的值。有了conftest.py
的这部分代码,凡是在conftest.py
文件作用范围内找到的 doctests
都会拥有 um
符号的定义了。
更多关于doctest的内容,详见【第七章:配套工具】
4.7 警告信息¶
recwarn
内置recwarn
夹具用于检查被测代码生成的警告。在Python 中,你可以添加像断言一样工作的警告,但是用于不需要停止执行的事情。例如,假设我们不想继续维护一个函数,即便我们多么希望从来没有把它放到这个包中,但是实际上已经发布给其他人在使用了。我们可以在代码中添加一个警告,然后在未来一两个版本中保留这个函数。
💡 在ch4
目录中,添加新的目录warn
,在其中添加新的测试模块test_warnings.py
:
import warnings
def lame_function():
warnings.warn("Please stop using this", DeprecationWarning)
# 函数的其他代码
def test_lame_function(recwarn):
lame_function()
assert len(recwarn) == 1
w = recwarn.pop()
assert w.category == DeprecationWarning
assert str(w.message) == 'Please stop using this'
recwarn
值的作用类似于警告列表,列表中的每个警告都定义了类别category
、消息message
、文件名filename
和行号lineno
。
警告是在测试开始时收集的。如果因为你关心警告的测试部分接近结尾而不方便,那么你可以使用recwarn.clear()
在确实关心收集警告的测试块之前清除列表。
其实,除了rewarn
之外,pytest 还可以使用 pytest.warns()
检查警告:
💡 在ch4/warn
目录中的测试模块test_warnings.py
,在其中添加新的测试函数。
# ch4/warn/test_warnings.py
# 代码片段
import pytest
def test_lame_function_2():
with pytest.warns(None) as warning_list:
lame_function()
assert len(warning_list) == 1
w = warning_list.pop()
assert w.category == DeprecationWarning
assert str(w.message) == 'Please stop using this'
pytest.warns()
上下文管理器提供了一种优雅的方式来标记检查警告的代码的那部分。recwarn
夹具和它提供了类似的功能,所以选择使用哪一个纯粹是个人喜好的问题。
在这一章中,你看到了许多pytest 的内置固定夹具。接下来,你将进一步了解插件。编写大型插件的本身可以成为一本书; 然而,小型定制插件是pytest生态系统中的一个常规部分。