编写插件

为自己的项目实现 本地 conftest 插件 或可在许多项目(包括第三方项目)中使用的 可通过 pip 安装的插件 非常容易。如果您只想使用插件而不想编写插件,请参阅 如何安装和使用插件

一个插件包含一个或多个钩子函数。 编写钩子 解释了如何自己编写钩子函数的基本知识和详细信息。 pytest 通过调用以下插件的 明确指定的钩子 来实现配置、收集、运行和报告的所有方面

原则上,每个钩子调用都是 1:N Python 函数调用,其中 N 是针对给定规范注册的实现函数的数量。所有规范和实现都遵循 pytest_ 前缀命名约定,这使得它们易于区分和查找。

工具启动时的插件发现顺序

pytest 在工具启动时按以下方式加载插件模块

  1. 通过扫描命令行以查找 -p no:name 选项并阻止加载该插件(甚至内置插件也可以通过这种方式被阻止)。这发生在正常命令行解析之前。

  2. 通过加载所有内置插件。

  3. 通过扫描命令行以查找 -p name 选项并加载指定的插件。这发生在正常命令行解析之前。

  4. 通过加载所有通过 setuptools 入口点 注册的插件。

  5. 通过加载所有通过 PYTEST_PLUGINS 环境变量指定的插件。

  6. 通过加载所有“初始” conftest.py 文件

    • 确定测试路径:在命令行上指定,否则在 testpaths 中指定(如果已定义并且从 rootdir 运行),否则在当前目录中

    • 对于每个测试路径,加载 conftest.pytest*/conftest.py(相对于测试路径的目录部分,如果存在)。在加载 conftest.py 文件之前,加载其所有父目录中的 conftest.py 文件。在加载 conftest.py 文件之后,如果存在,递归加载其 pytest_plugins 变量中指定的所有插件。

conftest.py:每个目录的本地插件

本地 conftest.py 插件包含特定于目录的挂钩实现。挂钩会话和测试运行活动将调用所有定义在 conftest.py 文件中的挂钩,这些文件更接近文件系统的根目录。实现 pytest_runtest_setup 挂钩的示例,以便对 a 子目录中的测试调用该挂钩,但不对其他目录中的测试调用该挂钩

a/conftest.py:
    def pytest_runtest_setup(item):
        # called for running each test in 'a' directory
        print("setting up", item)

a/test_sub.py:
    def test_sub():
        pass

test_flat.py:
    def test_flat():
        pass

以下是如何运行它的方法

pytest test_flat.py --capture=no  # will not show "setting up"
pytest a/test_sub.py --capture=no  # will show "setting up"

注意

如果您有 conftest.py 文件,它们不驻留在 Python 包目录中(即包含 __init__.py 的目录),那么“import conftest”可能是模棱两可的,因为在您的 PYTHONPATHsys.path 上可能还有其他 conftest.py 文件。因此,对于项目来说,最好将 conftest.py 放在包作用域下,或者从不从 conftest.py 文件中导入任何内容。

另请参见:pytest 导入机制和 sys.path/PYTHONPATH

注意

某些挂钩无法在不是 初始 的 conftest.py 文件中实现,这是因为 pytest 在启动期间发现插件的方式。有关详细信息,请参阅每个挂钩的文档。

编写您自己的插件

如果您想编写插件,有很多实际示例供您复制

所有这些插件都实现 挂钩 和/或 fixtures 以扩展和添加功能。

注意

务必查看优秀的 cookiecutter-pytest-plugin 项目,这是一个用于编写插件的 cookiecutter 模板

该模板提供了一个优秀的起点,其中包含一个正在工作的插件、使用 tox 运行的测试、一个全面的 README 文件以及一个预先配置的入口点。

一旦您的插件除了您自己之外还有其他满意的用户,请考虑 将您的插件贡献给 pytest-dev

使您的插件可供他人安装

如果您想使您的插件在外部可用,您可以为您的发行版定义一个所谓的入口点,以便 pytest 找到您的插件模块。入口点是 setuptools 提供的一项功能。

pytest 查找 pytest11 入口点来发现其插件,因此您可以通过在 pyproject.toml 文件中定义它来使您的插件可用。

# sample ./pyproject.toml file
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "myproject"
classifiers = [
    "Framework :: Pytest",
]

[project.entry-points.pytest11]
myproject = "myproject.pluginmodule"

如果按照这种方式安装包,pytest 将加载 myproject.pluginmodule 作为插件,该插件可以定义 钩子。使用 pytest --trace-config 确认注册

注意

请务必在 PyPI 分类器 列表中包含 Framework :: Pytest,以便用户轻松找到您的插件。

断言重写

pytest 的主要功能之一是使用简单的断言语句,并在断言失败时详细检查表达式。这是通过“断言重写”提供的,它在将解析的 AST 编译为字节码之前对其进行修改。这是通过 PEP 302 导入钩子完成的,该钩子在 pytest 启动时会尽早安装,并在导入模块时执行此重写。但是,由于我们不想测试与您在生产中运行的不同的字节码,因此此钩子仅重写测试模块本身(如 python_files 配置选项所定义),以及作为插件一部分的任何模块。任何其他导入的模块都不会被重写,并且会发生正常的断言行为。

如果您在需要启用断言重写的其他模块中具有断言帮助器,则需要明确要求 pytest 在导入该模块之前重写该模块。

register_assert_rewrite(*names)[source]

注册一个或多个将在导入时重写的模块名称。

此函数将确保此模块或包内的所有模块的断言语句都将被重写。因此,您应该确保在实际导入模块之前调用此函数,如果您使用包的插件,通常在 __init__.py 中调用。

参数:

names (str) – 要注册的模块名称。

当您使用包创建 pytest 插件时,这一点尤其重要。导入钩子只处理 conftest.py 文件和 pytest11 入口点中列为插件的任何模块。例如,考虑以下包

pytest_foo/__init__.py
pytest_foo/plugin.py
pytest_foo/helper.py

具有以下典型的 setup.py 提取

setup(..., entry_points={"pytest11": ["foo = pytest_foo.plugin"]}, ...)

在这种情况下,只有 pytest_foo/plugin.py 将被重写。如果帮助器模块还包含需要重写的断言语句,则需要在导入之前将其标记为需要重写。最简单的方法是在 __init__.py 模块中标记它进行重写,该模块在导入包中的模块时始终首先导入。这样,plugin.py 仍然可以正常导入 helper.pypytest_foo/__init__.py 的内容将需要如下所示

import pytest

pytest.register_assert_rewrite("pytest_foo.helper")

在测试模块或 conftest 文件中要求/加载插件

您可以在测试模块或 conftest.py 文件中使用 pytest_plugins 来要求插件

pytest_plugins = ["name1", "name2"]

当加载测试模块或 conftest 插件时,指定的插件也将被加载。任何模块都可以被指定为插件,包括内部应用程序模块

pytest_plugins = "myapp.testsupport.myplugin"

pytest_plugins 以递归方式进行处理,因此请注意,在上面的示例中,如果 myapp.testsupport.myplugin 也声明了 pytest_plugins,则该变量的内容也将作为插件加载,依此类推。

注意

在非根 conftest.py 文件中使用 pytest_plugins 变量来要求插件已被弃用。

这一点很重要,因为 conftest.py 文件实现了每个目录的挂钩实现,但一旦导入插件,它将影响整个目录树。为了避免混淆,在位于测试根目录以外的任何 conftest.py 文件中定义 pytest_plugins 已被弃用,并且会引发警告。

这种机制使得在应用程序中甚至外部应用程序中轻松共享固定装置成为可能,而无需使用 setuptools 的入口点技术创建外部插件。

pytest_plugins 导入的插件也将自动标记为断言重写(请参见 pytest.register_assert_rewrite())。但是,要产生任何效果,该模块一定不能被导入;如果在处理 pytest_plugins 语句时该模块已被导入,则会产生警告,并且插件内的断言不会被重写。要修复此问题,您可以在导入模块之前调用 pytest.register_assert_rewrite(),或者您可以安排代码在注册插件之后再进行导入。

按名称访问另一个插件

如果一个插件希望与另一个插件中的代码协作,它可以通过插件管理器获取一个引用,如下所示

plugin = config.pluginmanager.get_plugin("name_of_plugin")

如果您想查看现有插件的名称,请使用 --trace-config 选项。

注册自定义标记

如果您的插件使用任何标记,您应该注册它们,以便它们出现在 pytest 的帮助文本中,并且不会 导致虚假警告。例如,以下插件将为所有用户注册 cool_markermark_with

def pytest_configure(config):
    config.addinivalue_line("markers", "cool_marker: this one is for cool tests.")
    config.addinivalue_line(
        "markers", "mark_with(arg, arg2): this marker takes arguments."
    )

测试插件

pytest 附带了一个名为 pytester 的插件,它可以帮助您为插件代码编写测试。该插件在默认情况下处于禁用状态,因此您必须在使用它之前启用它。

您可以通过在测试目录中的 conftest.py 文件中添加以下行来实现

# content of conftest.py

pytest_plugins = ["pytester"]

或者,您可以使用 -p pytester 命令行选项调用 pytest。

这将允许您使用 pytester fixture 来测试您的插件代码。

让我们通过一个示例来演示您可以使用该插件做什么。假设我们开发了一个插件,该插件提供了一个 fixture hello,它会生成一个函数,我们可以使用一个可选参数调用此函数。如果我们不提供值,它将返回 Hello World! 的字符串值;如果我们提供字符串值,它将返回 Hello {value}!

import pytest


def pytest_addoption(parser):
    group = parser.getgroup("helloworld")
    group.addoption(
        "--name",
        action="store",
        dest="name",
        default="World",
        help='Default "name" for hello().',
    )


@pytest.fixture
def hello(request):
    name = request.config.getoption("name")

    def _hello(name=None):
        if not name:
            name = request.config.getoption("name")
        return f"Hello {name}!"

    return _hello

现在,pytester fixture 提供了一个便捷的 API,用于创建临时 conftest.py 文件和测试文件。它还允许我们运行测试并返回一个结果对象,我们可以用它来断言测试结果。

def test_hello(pytester):
    """Make sure that our plugin works."""

    # create a temporary conftest.py file
    pytester.makeconftest(
        """
        import pytest

        @pytest.fixture(params=[
            "Brianna",
            "Andreas",
            "Floris",
        ])
        def name(request):
            return request.param
    """
    )

    # create a temporary pytest test file
    pytester.makepyfile(
        """
        def test_hello_default(hello):
            assert hello() == "Hello World!"

        def test_hello_name(hello, name):
            assert hello(name) == "Hello {0}!".format(name)
    """
    )

    # run all tests with pytest
    result = pytester.runpytest()

    # check that all 4 tests passed
    result.assert_outcomes(passed=4)

此外,在对 pytester 运行 pytest 之前,可以将示例复制到 pytester 的隔离环境中。通过这种方式,我们可以将被测逻辑抽象到单独的文件中,这对于较长的测试和/或较长的 conftest.py 文件特别有用。

请注意,为了让 pytester.copy_example 正常工作,我们需要在 pytest.ini 中设置 pytester_example_dir,以告诉 pytest 在哪里查找示例文件。

# content of pytest.ini
[pytest]
pytester_example_dir = .
# content of test_example.py


def test_plugin(pytester):
    pytester.copy_example("test_example.py")
    pytester.runpytest("-k", "test_example")


def test_example():
    pass
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
configfile: pytest.ini
collected 2 items

test_example.py ..                                                   [100%]

============================ 2 passed in 0.12s =============================

有关 runpytest() 返回的结果对象及其提供的方法的更多信息,请查看 RunResult 文档。