编写插件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    • 对于每个测试路径,如果存在,则加载相对于测试路径目录部分的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 在启动时发现插件的方式。有关详细信息,请参阅每个钩子的文档。

编写您自己的插件

如果您想编写一个插件,有很多真实的例子可以借鉴:

所有这些插件都实现了钩子和/或fixture来扩展和添加功能。

注意

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

该模板提供了一个极佳的起点,包括一个可工作的插件、使用 tox 运行的测试、一个全面的 README 文件以及一个预配置的入口点。

一旦您的插件有了一些除您之外的满意用户,也请考虑向 pytest-dev 贡献您的插件

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

如果您希望您的插件对外可用,您可以为您的分发定义一个所谓的入口点,以便pytest能够找到您的插件模块。入口点是打包工具提供的一个功能。

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 编译为字节码之前对其进行修改。这是通过在pytest启动时早期安装的PEP 302导入钩子完成的,该钩子将在模块导入时执行此重写。然而,由于我们不想测试与您在生产中运行的字节码不同的字节码,此钩子仅重写测试模块本身(由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将被重写。如果 helper 模块也包含需要重写的断言语句,则需要在此模块导入之前对其进行标记。最简单的方法是在__init__.py模块中将其标记为重写,当导入包中的模块时,该模块将始终首先被导入。这样,plugin.py仍然可以正常导入helper.pypytest_foo/__init__.py的内容将需要如下所示:

import pytest

pytest.register_assert_rewrite("pytest_foo.helper")

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

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

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已被弃用,并将引发警告。

这种机制使得在应用程序甚至外部应用程序中共享夹具变得容易,而无需使用入口点打包元数据技术创建外部插件。

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 来测试您的插件代码。

让我们通过一个例子来演示这个插件的功能。假设我们开发了一个插件,它提供了一个夹具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):
    """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)

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

请注意,要使pytester.copy_example工作,我们需要在配置文件中设置pytester_example_dir,以告诉 pytest 在哪里查找示例文件。

# content of pytest.toml
[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-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
configfile: pytest.toml
collected 2 items

test_example.py ..                                                   [100%]

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

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