Skip to content

Builtin Fixtures

第4章:内置夹具

在前一章中,我们学习了夹具是什么,如何编写它们,以及如何将它们用于测试数据以及前置setup和后置teardown代码。还使用conftest.py 在多个测试文件中的测试文件之间共享夹具。

复用通用的夹具是一个很好的主意,pytest 开发者在pytest中包含了一些通用的夹具,之前在Task项目中已经使用了像 tmpdirtmpdir_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 临时目录

temdirtempdir_factory

tmpdir夹具和tmpdir_factory夹具用来在测试运行之前创建一个临时文件系统目录,并在测试结束时删除该目录。

在 Tasks 项目中,我们需要一个目录来存储 MongoDB 和 TinyDB使用的临时数据库文件。由于我们希望使用在测试会话之后就消失的临时数据库进行测试,因此我们使用 tmpdirtmpdir_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'
代码解释:第10行,调用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
两个测试函数都将使用同一个json文件。如果一个测试数据文件适用于多个测试函数,那么重新创建就没有意义了。

执行命令:

$ 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 ===========================
OK,虽然做到了,但是如果有多个失败的案例,像这样一个个复制/粘贴来运行,显然太麻烦了。此时使用--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
这个fixture设置成了autouse,因此会自动调用,不需要在测试中引用它。request 对象是用来抓取nodeid 以便在缓存key中使用。nodeid 是一个唯一标识符,即使在参数化测试中也能正常工作。我们在键前面加上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())
duration_cache夹具的作用域是会话范围。它会读取的先前的条目或者一个空字典。我们将查询到的字典和一个空字典保存在一个名为Duration的具名元组中,其访问符为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 == ''
捕获到的stdoutstderr信息可以通过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 通常自动捕获来自于测试用例和被测代码的输出,包括print()语句。只有在完整的测试会话完成之后,捕获的输出才会显示失败的测试信息。

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
打开当前用户的本地目录(Windows系统一般在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()在线文档的深入研究后,发现了有一些令人担忧的信息:在windows系统上,HOME 和 USERPROFILE会被使用....之类。这对于在 Windows 上运行测试的人来说可能不太好。也许我们应该采取不同的方法。

不要修补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
在测试期间,cheese模块中调用os.path.expanduser() 的任何内容都会得到我们的lambda表达式。这个小函数使用正则表达式re模块的函数re.sub将 ~ 替换为我们新的临时目录。

现在我们已经使用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
因为unnecessary_math 这个名字很长,所以我们决定使用um来代替,将import unnecessary_math as um 放在文档字符串的顶部。函数的文档字符串中的代码不包含import 语句,而是继续使用 um 约定。问题是 pytest 把每个文档字符串和代码看作是一个不同的测试。顶部文档字符串中的导入允许第一部分通过,但函数的文档字符串中的代码会失败:
$ 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 ===========================
解决这个问题的一种方法是在每个文档字符串中放入import 语句。

💡 在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
代码解释:这里告诉pytestum名称添加到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生态系统中的一个常规部分。