编写钩子函数

钩子函数验证与执行

pytest 为任何给定的钩子规范调用来自已注册插件的钩子函数。让我们看看 pytest_collection_modifyitems(session, config, items) 钩子的一个典型钩子函数,pytest 在所有测试项收集完成后调用它。

当我们在插件中实现 pytest_collection_modifyitems 函数时,pytest 在注册期间会验证您使用的参数名称是否与规范匹配,如果不匹配则会退出。

让我们看一个可能的实现

def pytest_collection_modifyitems(config, items):
    # called after collection is completed
    # you can modify the ``items`` list
    ...

在这里,pytest 将传入 config(pytest 配置对象)和 items(已收集的测试项列表),但不会传入 session 参数,因为我们没有在函数签名中列出它。这种参数的动态“剪枝”使得 pytest 能够“面向未来兼容”:我们可以引入新的钩子命名参数,而不会破坏现有钩子实现的签名。这也是 pytest 插件长期兼容性的原因之一。

请注意,除了 pytest_runtest_* 之外的钩子函数不允许引发异常。这样做会中断 pytest 的运行。

firstresult: 在第一个非 None 结果处停止

pytest 钩子的大多数调用都会产生一个结果列表,其中包含所有被调用的钩子函数的非 None 结果。

有些钩子规范使用 firstresult=True 选项,这样钩子调用只执行到 N 个已注册函数中的第一个返回非 None 结果,该结果随后被视为整个钩子调用的结果。在这种情况下,其余的钩子函数将不会被调用。

钩子包装器:围绕其他钩子执行

pytest 插件可以实现钩子包装器,它们包装其他钩子实现的执行。钩子包装器是一个恰好只 yield 一次的生成器函数。当 pytest 调用钩子时,它首先执行钩子包装器并传递与常规钩子相同的参数。

在钩子包装器的 yield 点,pytest 将执行下一个钩子实现,并将其结果返回到 yield 点,如果它们引发异常,则会传播异常。

这是一个钩子包装器的示例定义

import pytest


@pytest.hookimpl(wrapper=True)
def pytest_pyfunc_call(pyfuncitem):
    do_something_before_next_hook_executes()

    # If the outcome is an exception, will raise the exception.
    res = yield

    new_res = post_process_result(res)

    # Override the return value to the plugin system.
    return new_res

钩子包装器需要为钩子返回一个结果,或者引发一个异常。

在许多情况下,包装器只需要在实际的钩子实现周围执行跟踪或其他副作用,在这种情况下,它可以返回 yield 的结果值。最简单(但无用)的钩子包装器是 return (yield)

在其他情况下,包装器希望调整或适应结果,在这种情况下,它可以返回一个新值。如果底层钩子的结果是一个可变对象,包装器可以修改该结果,但最好避免这样做。

如果钩子实现因异常而失败,包装器可以使用 try-catch-finally 围绕 yield 来处理该异常,通过传播它、抑制它或完全引发不同的异常。

欲了解更多信息,请查阅 pluggy 关于钩子包装器的文档

钩子函数排序/调用示例

对于任何给定的钩子规范,可能存在多个实现,因此我们通常将 hook 执行视为 1:N 函数调用,其中 N 是已注册函数的数量。有多种方法可以影响钩子实现是先于还是后于其他实现,即在 N 大小的函数列表中的位置

# Plugin 1
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items):
    # will execute as early as possible
    ...


# Plugin 2
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(items):
    # will execute as late as possible
    ...


# Plugin 3
@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(items):
    # will execute even before the tryfirst one above!
    try:
        return (yield)
    finally:
        # will execute after all non-wrappers executed
        ...

执行顺序如下

  1. Plugin3 的 pytest_collection_modifyitems 被调用直到 yield 点,因为它是一个钩子包装器。

  2. Plugin1 的 pytest_collection_modifyitems 被调用,因为它被标记为 tryfirst=True

  3. Plugin2 的 pytest_collection_modifyitems 被调用,因为它被标记为 trylast=True(但即使没有这个标记,它也会在 Plugin1 之后)。

  4. 然后 Plugin3 的 pytest_collection_modifyitems 执行 yield 点之后的代码。yield 接收调用非包装器的结果,如果非包装器引发异常则会引发异常。

也可以在钩子包装器上使用 tryfirsttrylast,这将影响钩子包装器之间的排序。

声明新钩子

注意

这是关于如何添加新钩子以及它们如何工作的快速概述,但更完整的概述可以在 pluggy 文档中找到。

插件和 conftest.py 文件可以声明新的钩子,然后其他插件可以实现这些钩子以改变行为或与新插件交互

pytest_addhooks(pluginmanager)[source]

在插件注册时调用,允许通过调用 pluginmanager.add_hookspecs(module_or_class, prefix) 添加新钩子。

参数:

pluginmanager (PytestPluginManager) – pytest 插件管理器。

注意

此钩子与钩子包装器不兼容。

在 conftest 插件中使用

如果 conftest 插件实现了此钩子,它将在 conftest 注册时立即被调用。

钩子通常被声明为无操作函数,只包含描述钩子何时被调用以及期望返回值的文档。函数的名称必须以 pytest_ 开头,否则 pytest 将无法识别它们。

这是一个例子。假设这段代码在 sample_hook.py 模块中。

def pytest_my_hook(config):
    """
    Receives the pytest config and does things with it
    """

为了向 pytest 注册钩子,它们需要被组织在自己的模块或类中。然后,这个类或模块可以通过 pytest_addhooks 函数(它本身是 pytest 暴露的一个钩子)传递给 pluginmanager

def pytest_addhooks(pluginmanager):
    """This example assumes the hooks are grouped in the 'sample_hook' module."""
    from my_app.tests import sample_hook

    pluginmanager.add_hookspecs(sample_hook)

有关实际示例,请参阅 xdist 中的 newhooks.py

钩子可以从 fixture 或其他钩子中调用。在这两种情况下,钩子都通过 hook 对象调用,该对象在 config 对象中可用。大多数钩子直接接收 config 对象,而 fixture 可以使用提供相同对象的 pytestconfig fixture。

@pytest.fixture()
def my_fixture(pytestconfig):
    # call the hook called "pytest_my_hook"
    # 'result' will be a list of return values from all registered functions.
    result = pytestconfig.hook.pytest_my_hook(config=pytestconfig)

注意

钩子仅使用关键字参数接收参数。

现在你的钩子已准备好使用。要在一个钩子上注册一个函数,其他插件或用户现在只需在他们的 conftest.py 中定义具有正确签名的函数 pytest_my_hook

示例

def pytest_my_hook(config):
    """
    Print all active hooks to the screen.
    """
    print(config.hook)

在 pytest_addoption 中使用钩子

有时,需要根据一个插件中的钩子来改变另一个插件定义命令行选项的方式。例如,一个插件可能暴露一个命令行选项,而另一个插件需要定义其默认值。可以使用 pluginmanager 来安装和使用钩子来完成此操作。该插件将定义并添加钩子,并按如下方式使用 pytest_addoption

# contents of hooks.py


# Use firstresult=True because we only want one plugin to define this
# default value
@hookspec(firstresult=True)
def pytest_config_file_default_value():
    """Return the default value for the config file command line option."""


# contents of myplugin.py


def pytest_addhooks(pluginmanager):
    """This example assumes the hooks are grouped in the 'hooks' module."""
    from . import hooks

    pluginmanager.add_hookspecs(hooks)


def pytest_addoption(parser, pluginmanager):
    default_value = pluginmanager.hook.pytest_config_file_default_value()
    parser.addoption(
        "--config-file",
        help="Config file to use, defaults to %(default)s",
        default=default_value,
    )

使用 myplugin 的 conftest.py 将简单地按如下方式定义钩子

def pytest_config_file_default_value():
    return "config.yaml"

可选地使用第三方插件的钩子

如上所述,使用来自插件的新钩子可能有点棘手,因为标准验证机制:如果您依赖的插件未安装,验证将失败,并且错误消息对您的用户来说意义不大。

一种方法是将钩子实现推迟到新插件中,而不是直接在您的插件模块中声明钩子函数,例如

# contents of myplugin.py


class DeferPlugin:
    """Simple plugin to defer pytest-xdist hook functions."""

    def pytest_testnodedown(self, node, error):
        """standard xdist hook function."""


def pytest_configure(config):
    if config.pluginmanager.hasplugin("xdist"):
        config.pluginmanager.register(DeferPlugin())

这还有一个额外的好处,即允许您根据安装的插件有条件地安装钩子。

在钩子函数之间存储数据到项中

插件通常需要在某个钩子实现中将数据存储在 Item 上,并在另一个钩子实现中访问它。一种常见的解决方案是直接在 item 上分配一些私有属性,但这会被 mypy 等类型检查器所不喜,并且也可能与其他插件产生冲突。因此 pytest 提供了一种更好的方法来实现这一点,即 item.stash

要在插件中使用“stash”,首先在插件的顶层某处创建“stash 键”

been_there_key = pytest.StashKey[bool]()
done_that_key = pytest.StashKey[str]()

然后在某个点使用这些键来暂存您的数据

def pytest_runtest_setup(item: pytest.Item) -> None:
    item.stash[been_there_key] = True
    item.stash[done_that_key] = "no"

并在另一个点检索它们

def pytest_runtest_teardown(item: pytest.Item) -> None:
    if not item.stash[been_there_key]:
        print("Oh?")
    item.stash[done_that_key] = "yes!"

暂存(Stashes)可用于所有节点类型(如 ClassSession),如果需要,也可用于 Config