编写插件

Writing plugins

实现适用于您项目的 本地 conftest 插件 或者适用于多个项目的 pip 可安装插件 非常简单,这些插件还可以用于第三方项目。如只需使用而不编写插件,请参阅 如何安装和使用插件

一个插件包含一个或多个钩子函数。Writing hooks 讲解了如何编写钩子函数的基本内容和详细信息。pytest 通过调用以下插件的 明确定义的钩子 实现配置、收集、运行和报告的各个方面:

  • 内置插件:从 pytest 的内部 _pytest 目录加载。

  • 外部插件:通过其打包元数据中的 入口点 发现已安装的第三方模块。

  • conftest.py 插件 :在测试目录中自动发现的模块。

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

工具启动时插件发现顺序

Plugin discovery order at tool startup

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

  1. 扫描命令行中的 -p no:name 选项,并*阻止*该插件的加载(即使是内置插件也可以通过这种方式阻止加载)。此过程在正常的命令行解析之前执行。

  2. 加载所有内置插件。

  3. 扫描命令行中的 -p name 选项并加载指定插件。此过程在正常的命令行解析之前执行。

  4. 加载通过安装的第三方包中的 入口点 注册的所有插件,除非设置了 PYTEST_DISABLE_PLUGIN_AUTOLOAD 环境变量。

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

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

  • 确定测试路径:在命令行中指定的路径,否则使用 testpaths (如果在 rootdir 中定义并运行),否则为当前目录。

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

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

conftest.py: local per-directory plugins

本地 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  # 不会显示 "setting up"
pytest a/test_sub.py --capture=no  # 会显示 "setting up"

Note

如果您的 conftest.py 文件不位于 Python 包目录中(即包含 __init__.py 的目录),那么 import conftest 可能会出现歧义,因为您的 PYTHONPATHsys.path 中可能还存在其他 conftest.py 文件。因此,建议项目将 conftest.py 文件放在包作用域中,或者从不在 conftest.py 文件中导入任何内容。

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

Note

由于 pytest 在启动时的插件发现机制,某些钩子无法在非 initial 的 conftest.py 文件中实现。具体细节请参阅每个钩子的文档。

编写您自己的插件

Writing your own plugin

如果您想编写一个插件,可以从以下多个实际示例中进行参考:

以上插件均通过实现 hooks 和/或 fixtures 来扩展和增强功能。

Note

强烈建议查看出色的 cookiecutter-pytest-plugin 项目,这是用于编写插件的 cookiecutter 模板

此模板提供了一个出色的起点,包括一个工作插件、tox 运行的测试、详尽的 README 文件以及预配置的入口点。

一旦您的插件除您自己外也拥有了一些满意的用户,请考虑将插件 贡献给 pytest-dev

使其他人可以安装您的插件

Making your plugin installable by others

如果您想让您的插件对外可用,可以为您的分发定义一个所谓的入口点,这样 pytest 就可以找到您的插件模块。入口点是 打包工具 提供的一个特性。

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

# 示例 ./pyproject.toml 文件
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

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

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

如果一个包以这种方式安装, pytest 会将 myproject.pluginmodule 作为插件加载,后者可以定义 hooks 。使用 pytest --trace-config 确认注册。

Note

请确保在 PyPI 分类 列表中包括 Framework :: Pytest ,这样便于用户找到您的插件。

断言重写

Assertion Rewriting

pytest 的主要功能之一是使用简单的 assert 语句,并在断言失败时提供详细的表达式检查。这通过“断言重写”来实现,该功能在代码被编译为字节码之前修改解析的 AST。这是通过 PEP 302 导入钩子实现的,在 pytest 启动时会尽早安装该钩子,并在模块被导入时执行此重写。然而,为了确保测试和生产环境中运行的字节码一致,此钩子仅重写测试模块本身(由 python_files 配置选项定义)和任何插件中的模块。其他被导入的模块不会被重写,而会保持普通的断言行为。

如果您在其他模块中有需要启用断言重写的辅助断言功能,您需要明确要求 pytest 在导入该模块之前对其进行重写。

register_assert_rewrite(*names)[source]

Register one or more module names to be rewritten on import.

This function will make sure that this module or all modules inside the package will get their assert statements rewritten. Thus you should make sure to call this before the module is actually imported, usually in your __init__.py if you are a plugin using a package.

Parameters:

names (str) – The module names to register.

当您编写一个通过包创建的 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 会被重写。如果辅助模块也包含需要重写的 assert 语句,则在导入之前需要将其标记为可重写。最简单的方法是在 __init__.py 模块中标记它,当导入包中的模块时,该模块总是会首先被导入。这样,plugin.py 仍然可以正常导入 helper.py。此时,pytest_foo/__init__.py 的内容应如下所示:

import pytest

pytest.register_assert_rewrite("pytest_foo.helper")

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

Requiring/Loading plugins in a test module or conftest file

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

pytest_plugins = ["name1", "name2"]

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

pytest_plugins = "myapp.testsupport.myplugin"

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

Note

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

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

此机制使在应用程序或甚至外部应用程序中共享 fixtures 变得简单,而无需使用 entry point packaging metadata 技术创建外部插件。

通过 pytest_plugins 导入的插件也会自动标记为断言重写(见 pytest.register_assert_rewrite())。但是,为了使其生效,模块必须尚未被导入;如果在处理 pytest_plugins 语句时该模块已经被导入,将会产生警告,插件中的断言将不会被重写。要解决此问题,您可以在导入模块之前自己调用 pytest.register_assert_rewrite(),或者可以安排代码以延迟导入,直到插件注册之后。

通过名称访问另一个插件

Accessing another plugin by name

如果一个插件想要与另一个插件的代码协作,它可以通过插件管理器获得引用,如下所示:

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

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

注册自定义标记

Registering custom markers

如果您的插件使用了任何标记,您应该注册它们,以便它们出现在 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."
    )

测试插件

Testing plugins

pytest 附带一个名为 pytester 的插件,帮助您为插件代码编写测试。该插件默认情况下是禁用的,因此在使用之前需要先启用它。

您可以通过在测试目录中的 conftest.py 文件中添加以下行来做到这一点:

# content of conftest.py

pytest_plugins = ["pytester"]

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

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

让我们用一个示例演示您可以使用该插件做什么。假设我们开发了一个提供 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 夹具提供了一个方便的 API,用于创建临时的 conftest.py 文件和测试文件。它还允许我们运行测试并返回结果对象,通过该对象我们可以断言测试的结果。

def test_hello(pytester):
    """确保我们的插件正常工作。"""

    # 创建一个临时 conftest.py 文件
    pytester.makeconftest(
        """
        import pytest

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

    # 创建一个临时 pytest 测试文件
    pytester.makepyfile(
        """
        def test_hello_default(hello):
            assert hello() == "Hello World!"

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

    # 使用 pytest 运行所有测试
    result = pytester.runpytest()

    # 检查所有 4 个测试是否通过
    result.assert_outcomes(passed=4)

此外,在运行 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 文档。