第 4 章 内置夹具

在上一章中,您了解了夹具是什么、如何编写它们以及如何将它们用于测试数据以及设置和拆卸代码。 您还使用 conftest.py 在多个测试文件中的测试之间共享固定装置。

重用常见的固定装置是一个好主意,因此 pytest 开发人员在 pytest 中包含了一些常用的固定装置。 与 pytest 一起预打包的内置装置可以帮助您在测试中轻松一致地完成一些非常有用的事情。 例如,pytest 包含内置装置,可以处理临时目录和文件、访问命令行选项、在测试会话之间进行通信、验证输出流、修改环境变量以及询问警告。 内置装置是 pytest 核心功能的扩展。

我们将在本章中介绍一些内置装置:

  • tmp_path 和 tmp_path_factory — 用于临时目录

  • capsys — 用于捕获输出

  • Monkeypatch — 用于更改环境或应用程序代码,如轻量级的模拟形式

这是一个很好的组合,向您展示了通过创造性的夹具使用可以获得的一些额外功能。 我鼓励您通过阅读 pytest --fixtures 的输出来阅读其他内置装置。

使用 tmp_path 和 tmp_path_factory

tmp_path 和 tmp_path_factory 固定装置用于创建临时目录。 tmp_path 函数作用域固定装置返回一个 pathlib.Path 实例,该实例指向一个临时目录,该目录在测试期间会保留一段时间,并且会更长一些。 tmp_path_factory 会话范围固定装置返回 TempPathFactory 对象。该对象具有返回 Path 对象的 mktemp() 函数。 您可以使用 mktemp() 创建多个临时目录。

你像这样使用它们:

ch4/test_tmp.py
def test_tmp_path(tmp_path):
    file = tmp_path / "file.txt"
    file.write_text("Hello")
    assert file.read_text() == "Hello"


def test_tmp_path_factory(tmp_path_factory):
    path = tmp_path_factory.mktemp("sub")
    file = path / "file.txt"
    file.write_text("Hello")
    assert file.read_text() == "Hello"

除了以下内容之外,它们的用法几乎相同:

  • 对于 tmp_path_factory,您必须调用 mktemp() 来获取目录。

  • tmp_path_factory 是会话范围。

  • tmp_path 是函数作用域。

在上一章中,我们使用标准库 tempfile.TemporaryDirectory 作为我们的 db 固定装置:

ch4/conftest_from_ch3.py
from pathlib import Path
from tempfile import TemporaryDirectory


@pytest.fixture(scope="session")
def db():
    """CardsDB object connected to a temporary database"""
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db_ = cards.CardsDB(db_path)
        yield db_
        db_.close()

让我们使用一个新的内置函数来代替。 因为我们的 db 固定装置是会话范围的,所以我们不能使用 tmp_path,因为会话范围固定装置不能使用函数作用域固定装置。 我们可以使用 tmp_path_factory:

ch4/conftest.py
@pytest.fixture(scope="session")
def db(tmp_path_factory):
    """CardsDB object connected to a temporary database"""
    db_path = tmp_path_factory.mktemp("cards_db")
    db_ = cards.CardsDB(db_path)
    yield db_
    db_.close()

好的。请注意,这还允许我们删除两个 import 语句,因为我们不需要导入 pathlib 或 tempfile。

以下是两个相关的内置夹具:

  • *tmpdir—与 tmp_path 类似,但返回 py.path.local 对象。 该装置早在 tmp_path 之前就已在 pytest 中可用。 py.path.local 早于 pathlib,后者是在 Python 3.4 中添加的。 py.path.local 在 pytest 中正在慢慢淘汰,取而代之的是 stdlib pathlib 版本。 因此,我建议使用 tmp_path。

  • tmpdir_factory - 与 tmp_path_factory 类似,但其 mktemp 函数返回 py.path.local 对象而不是 pathlib.Path 对象。

所有 pytest 临时目录夹具的基本目录都是系统和用户相关的,并且包含 pytest-NUM 部分,其中 NUM 会针对每个会话递增。 会话结束后,基本目录会立即保留,以便您在测试失败时进行检查。 pytest 最终会清理它们。 系统上仅保留最近的几个临时基目录。

如果需要,您还可以使用 pytest --basetemp=mydir 指定自己的基目录。

使用 capsys

有时应用程序代码应该向 stdout、stderr 等输出一些内容。 碰巧的是,Cards 示例项目有一个应该进行测试的命令行界面。

命令 cards version 应该输出版本:

$ cards version
1.0.0

该版本也可从 API 获取:

$ python -i
>>> import cards
>>> cards.__version__
'1.0.0'

测试这一点的一种方法是使用 subprocess.run() 实际运行命令,获取输出,并将其与 API 中的版本进行比较:

/ch4/test_version.py
def test_version_v1():
    process = subprocess.run(
        ["cards", "version"], capture_output=True, text=True
    )
    output = process.stdout.rstrip()
    assert output == cards.__version__

rstrip() 用于删除换行符。(我从这个示例开始,因为有时调用子进程并读取输出是您唯一的选择。但是,它是一个糟糕的 capsys 示例。)

capsys 夹具能够捕获对 stdout 和 stderr 的写入。 我们可以直接在 CLI 中调用实现此功能的方法,并使用 capsys 读取输出:

/ch4/test_version.py
import cards


def test_version_v2(capsys):
    cards.cli.version()
    output = capsys.readouterr().out.rstrip()
    assert output == cards.__version__

capsys.readouterr() 方法返回一个包含 out 和 err 的命名元组。 我们只是读取 out 部分,然后使用 rstrip() 去除换行符。

capsys 的另一个功能是能够暂时禁用 pytest 的正常输出捕获。 pytest 通常捕获测试和应用程序代码的输出。 这包括打印语句。

这是一个小例子:

ch4/test_print.py
def test_normal():
    print("\nnormal print")

如果我们运行它,我们看不到任何输出:

$ cd /path/to/code/ch4
$ pytest test_print.py::test_normal
======================= test session starts =======================
collected 1 item
test_print.py . [100%]
======================== 1 passed in 0.00s ========================

pytest 捕获所有输出。它有助于保持命令行会话的整洁。

然而,有时我们可能希望看到所有输出,即使是通过测试。 我们可以使用 -s--capture=no 标志:

$ pytest -s test_print.py::test_normal
======================= test session starts =======================
collected 1 item
test_print.py
➤ normal print
.
======================== 1 passed in 0.00s ========================

pytest 最后将显示失败测试的输出。

这是一个简单的失败测试:

ch4/test_print.py
def test_fail():
    print("\nprint in failing test")
    assert False

输出显示:

$ pytest test_print.py::test_fail
======================= test session starts =======================
collected 1 item
test_print.py F [100%]
============================ FAILURES =============================
____________________________ test_fail ____________________________
def test_fail():
print("\nprint in failing test")
> assert False
E assert False
test_print.py:9: AssertionError
➤ ---------------------- Captured stdout call -----------------------
➤
➤ print in failing test
===================== short test summary info =====================
FAILED test_print.py::test_fail - assert False
======================== 1 failed in 0.04s ========================

始终包含输出的另一种方法是使用 capsys.disabled():

ch4/test_print.py
def test_disabled(capsys):
    with capsys.disabled():
        print("\ncapsys disabled print")

即使没有 -s 标志,with 块中的输出也将始终显示:

$ pytest test_print.py::test_disabled
======================= test session starts =======================
collected 1 item
test_print.py
capsys disabled print
.                                                [100%]
======================== 1 passed in 0.00s ========================

以下是相关的内置夹具:

  • capfd — 与 capsys 类似,但捕获文件描述符 1 和 2,通常与 stdout 和 stderr 相同。

  • capsysbinary — capsys 捕获文本,而 capsysbinary 捕获字节。

  • capfdbinary — 捕获文件描述符 1 和 2 上的字节。

  • caplog — 捕获使用日志记录包写入的输出。

使用 monkeypatch

在之前对 capsys 的讨论中,我们使用以下代码来测试 Cards 项目的输出:

ch4/test_version.py
def test_version_v2(capsys):
    cards.cli.version()
    output = capsys.readouterr().out.rstrip()
    assert output == cards.__version__

这为如何使用 capsys 提供了一个很好的示例,但它仍然不是我更喜欢测试 CLI 的方式。 Cards 应用程序使用一个名为 Typer 的库,该库包含一个运行程序功能,允许我们测试更多代码,使其看起来更像命令行测试,保持在进程中,并为我们提供输出挂钩。它的使用方式如下:

ch4/test_version.py
def test_version_v3():
    runner = CliRunner()
    result = runner.invoke(cards.app, ["version"])
    output = result.output.rstrip()
    assert output == cards.__version__

我们将使用这种输出测试方法作为我们对 Cards CLI 进行的其余测试的起点。

我通过测试 cards 版本开始 CLI 测试。 从 cards 版本开始很好,因为它不使用数据库。 为了测试 CLI 的其余部分,我们需要将数据库重定向到临时目录,就像我们在第 33 页的使用夹具进行安装和拆卸中测试 API 时所做的那样。为此,我们将使用 Monkeypatch。

Monkey patch 是在运行时对类或模块的动态修改。 在测试过程中,Monkey patch 是一种便捷的方法,可以接管应用程序代码的部分运行时环境,并将输入依赖项或输出依赖项替换为更方便测试的对象或函数。 Monkeypatch 内置夹具允许您在单个测试的上下文中执行此操作。 它用于修改对象、字典、环境变量、python 搜索路径或当前目录。 这就像迷你版的嘲笑。 当测试结束时,无论通过还是失败,原始未打补丁的代码都会被恢复,撤消补丁更改的所有内容。

在我们进入一些例子之前,这一切都非常棘手。 看完 API 后,我们将看看monkeypatch 是如何在测试代码中使用的。

Monkeypatch 夹具提供以下功能:

  • setattr(target, name, value, raise=True)——设置属性

  • delattr(target, name, raise=True)——删除属性

  • setitem(dic, name, value)——设置字典条目

  • delitem(dic, name, raise=True)——删除字典条目

  • setenv(name, value, prepend=None)——设置环境变量

  • delenv(name, raise=True)——删除环境变量

  • syspath_prepend(path)——将路径添加到 sys.path 之前,这是 Python 的导入位置列表

  • chdir(path)——更改当前工作目录

raise 参数告诉 pytest 如果该项尚不存在是否引发异常。 setenv() 的前置参数可以是一个字符。 如果设置了,环境变量的值将更改为 value + prepend + <old value>

我们可以使用 Monkeypatch 通过多种方式将 CLI 重定向到数据库的临时目录。 两种方法都涉及应用程序代码的知识。 我们看一下 cli.get_path() 方法:

cards_proj/src/cards/cli.py
Unresolved include directive in modules/ROOT/pages/section01/ch04/ch4.adoc - include::example$cards_proj/src/cards/cli.py[]

该方法告诉 CLI 代码的其余部分数据库的位置。 我们可以修补整个函数,修补pathlib.Path().home(),或者设置环境变量 CARDS_DB_DIR

我们将使用 cards config 命令测试这些修改,该命令可以方便地返回数据库位置:

$ cards config
/Users/okken/cards_db

在开始之前,我们将多次调用 runner.invoke() 来调用 cards,因此我们将该代码放入名为 run_cards() 的辅助函数中:

ch4/test_config.py
from typer.testing import CliRunner
import cards


def run_cards(*params):
    runner = CliRunner()
    result = runner.invoke(cards.app, params)
    return result.output.rstrip()


def test_run_cards():
    assert run_cards("version") == cards.__version__

请注意,我为我们的辅助函数添加了一个测试函数,只是为了确保我做得正确。

首先,让我们尝试修补整个 get_path 函数:

ch4/test_config.py
def test_patch_get_path(monkeypatch, tmp_path):
    def fake_get_path():
        return tmp_path

    monkeypatch.setattr(cards.cli, "get_path", fake_get_path)
    assert run_cards("config") == str(tmp_path)

就像 mocking 一样,monkey-patching 需要一些思维转变才能让一切设置正确。 函数 get_path 是 cards.cli 的一个属性。 我们想用 fake_get_path 替换它。 因为 get_path 是一个可调用函数,所以我们必须将其替换为另一个可调用函数。 我们不能只用 tmp_path 替换它,tmp_path 是一个不可调用的 pathlib.Path 对象。

如果我们想替换 pathlib.Path 中的 home() 方法,它是一个类似的补丁:

ch4/test_config.py
def test_patch_home(monkeypatch, tmp_path):
    full_cards_dir = tmp_path / "cards_db"

    def fake_home():
        return tmp_path

    monkeypatch.setattr(cards.cli.pathlib.Path, "home", fake_home)
    assert run_cards("config") == str(full_cards_dir)

因为 cards.cli 正在导入 pathlib,所以我们必须修补 cards.cli.pathlib.Path 的 home 属性。

说真的,如果您开始更多地使用 monkey-patching 和/或 mocking,将会发生一些事情:

  • 您将开始理解这一点。

  • 您将开始尽可能避免 mocking 和 monkey-patching。 希望环境变量补丁不那么复杂。

希望环境变量补丁不那么复杂:

ch4/test_config.py
def test_patch_env_var(monkeypatch, tmp_path):
    monkeypatch.setenv("CARDS_DB_DIR", str(tmp_path))
    assert run_cards("config") == str(tmp_path)

嗯,看看那个。它不太复杂。然而,我作弊了。 我已经设置了代码,使该环境变量本质上是 Cards API 的一部分,以便我可以在测试期间使用它。

可测试性设计

可测试性设计是从硬件设计师,特别是开发集成电路的设计师那里借鉴来的概念。这个概念简单地说,就是在软件中添加功能,使其更易于测试。在某些情况下,这可能意味着未注明的应用程序接口或应用程序接口的某些部分在发布时被关闭。在其他情况下,API 会被扩展并公开。

在卡配置命令返回数据库位置和支持 CARDS_DB_DIR 环境变量的情况下,这些都是为了使代码更易于测试而添加的。它们对最终用户也可能有用。至少,让用户知道这些内容并不会造成危害,因此它们被保留为公共 API 的一部分。

其余内置装置

在本章中,我们研究了 tmp_path、tmp_path_factory、capsys 和 Monkey-patch 内置装置。 还有不少。 我们将在本书的其他部分讨论其中一些。 其他的则作为练习,供读者在需要时进行研究。

以下是截至本版本撰写时 pytest 附带的剩余内置装置的列表:

  • capfd、capfdbinary、capsysbinary — 使用文件描述符和/或二进制输出的 capsys 变体。

  • caplog — 与 capsys 等类似; 用于使用 Python 日志系统创建的消息。

  • cache — 用于在 pytest 运行中存储和检索值。 该装置最有用的部分是它允许使用 --last-failed、--failed-first 和类似的标志。

  • doctest_namespace — 如果您喜欢使用 pytest 运行 doctest 风格的测试,则非常有用。

  • pytestconfig — 用于访问配置值、pluginmanager 和插件挂钩

  • record_property、record_testsuite_property — 用于向测试或测试套件添加额外的属性。 对于向持续集成工具使用的 XML 报告添加数据特别有用

  • rewarn — 用于测试警告消息

  • request — 用于提供有关执行测试功能的信息。 在夹具参数化过程中最常用

  • pytester、testdir—用于提供临时测试目录以帮助运行和测试pytest 插件。 pytester 是基于 pathlib 的替代品,用于替代基于 py.path 的 testdir。

  • tmpdir、tmpdir_factory—类似于tmp_path 和tmp_path_factory; 用于返回 py.path.local 对象而不是 pathlib.Path 对象

我们将在剩下的章节中了解其中的许多夹具。 您可以通过运行 pytest --fixtures 找到内置夹具的完整列表,它也提供了很好的描述。 您还可以在在线 pytest 文档中找到更多信息。

审查

在本章中,我们研究了 tmp_path、tmp_path_factory、capsys 和 monkeypatch 内置装置:

  • tmp_path 和tmp_path_factory 装置用于临时目录。 tmp_path 是函数作用域,tmp_path_factory 是会话作用域。 本章中未涉及的相关装置是 tmpdir 和 tmpdir_factory。

  • capsys 可用于捕获 stdout 和 stderr。 它还可用于暂时关闭输出捕获。 相关装置包括 capsysbinary、capfd、capfdbinary 和 caplog。

  • monkeypatch 可用于更改应用程序代码或环境。 我们将它与 Cards 应用程序一起使用,将数据库位置重定向到使用 tmp_path 创建的临时目录。

  • 您可以使用 pytest --fixtures 阅读有关这些和其他夹具的信息。

练习

尽可能使用内置装置是简化您自己的测试代码的好方法。 下面的练习旨在让您体验使用 tmp_path 和 Monkeypatch 这两个超级方便且常见的内置装置。

看一下这个写入文件的脚本:

ch4/hello_world.py
def hello():
    with open("hello.txt", "w") as f:
        f.write("Hello World!\n")


if __name__ == "__main__":
    hello()
  1. 编写一个不带固定装置的测试,验证 hello() 是否将正确的内容写入 hello.txt。

  2. 使用利用临时目录和 monkeypatch.chdir() 的夹具编写第二个测试。

  3. 添加一条打印语句来查看临时目录所在的位置。 测试运行后手动检查 hello.txt 文件。 pytest 在测试运行后将临时目录保留一段时间以帮助调试。

  4. 注释掉两个测试中对 hello() 的调用并重新运行。 他们都失败了吗? 如果没有,为什么不呢?

下一步

到目前为止,我们使用的所有测试函数都只运行一次。 在下一章中,我们将探索几种让测试函数在不同数据或不同环境下运行多次的方法。 这是一种无需编写更多测试即可进行更彻底测试的绝佳方法。