pytest8.x版本 中文使用文档-------16.编写插件

目录

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

conftest.py: 本地目录级插件

编写自己的插件

让你的插件可由他人安装

断言重写

在测试模块或 conftest.py 文件中引入/加载插件

通过名称访问另一个插件

注册自定义标记

测试插件


为你的项目实现本地conftest插件local conftest plugins或编写可通过pip-installable plugins 安装的插件(这些插件可以在多个项目中使用,包括第三方项目)是很简单的。如果你只想使用插件而不打算编写插件,请参考“如何安装和使用插件” How to install and use plugins

编写pytest插件主要涉及到定义钩子函数(hooks)和/或fixtures,这些都可以在pytest的运行过程中被调用或利用。钩子函数允许你插入自定义代码到pytest的各个执行阶段,而fixtures则提供了一种方便的方式来设置测试前的环境和清理测试后的环境。

编写插件时,你需要遵循pytest的插件架构,这通常意味着你需要了解pytest如何发现和加载插件,以及它提供了哪些钩子函数和fixtures的接口。

插件包含一个或多个钩子函数。编写钩子(Writing hooks)部分解释了编写钩子函数的基础知识和详细步骤。pytest通过调用以下插件中定义良好的钩子well specified hooks来实现配置、收集、运行和报告的所有方面:

  • 内置插件(builtin plugins):从pytest的内部_pytest目录加载。这些插件提供了pytest的核心功能。

  • 外部插件(external plugins:通过setuptools的入口点setuptools entry points机制(setuptools entry points)发现的模块。这些插件是由第三方开发者编写的,可以为pytest添加额外的功能或改进现有功能。

  • conftest.py插件(conftest.py plugins):在测试目录中自动发现的模块。这些插件是特定于项目的,通常用于定义测试所需的fixtures和钩子函数,以便在项目的多个测试文件中重用。

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

“规范”指的是 pytest 框架中定义的钩子点(hook points),即预定义的函数签名和触发时机,用于在测试的不同阶段执行特定的代码。而“实现”则是指开发者为这些钩子点提供的具体函数实现,这些函数会在测试运行时被 pytest 调用。

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

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

  1. 扫描命令行中的 -p no:name 选项:首先,pytest 会扫描命令行以查找 -p no:name 选项,并阻止指定的插件被加载(即使是内置插件也可以通过这种方式被阻止)。这一步骤发生在正常的命令行参数解析之前。

  2. 加载所有内置插件:之后,pytest 会加载所有内置的插件。

  3. 扫描命令行中的 -p name 选项:接下来,pytest 会扫描命令行以查找 -p name 选项,并加载指定的插件。这一步骤也发生在正常的命令行参数解析之前。

  4. 加载通过 setuptools 入口点注册的所有插件:随后,pytest 会加载所有通过 setuptools 入口点( setuptools entry points.)注册的插件。

  5. 加载通过 PYTEST_PLUGINS 环境变量指定的所有插件:然后,pytest 会加载通过 PYTEST_PLUGINS 环境变量指定的所有插件。

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

    • 确定测试路径:首先,pytest 会确定测试路径。这些路径可以是命令行上指定的,如果定义了testpaths并且从 rootdir 运行,则使用 testpaths,否则使用当前目录。
    • 加载 conftest.py 文件:对于每个测试路径,pytest 会加载与该测试路径目录部分相对的 conftest.py 文件以及 test*/conftest.py 文件(如果存在)。在加载一个 conftest.py 文件之前,pytest 会先加载其所有父目录中的 conftest.py 文件。加载完一个 conftest.py 文件后,如果该文件定义了 pytest_plugins 变量,pytest 会递归地加载该变量中指定的所有插件。

conftest.py: 本地目录级插件

本地 conftest.py 插件包含特定于目录的钩子实现。会话钩子(Hook Session)和测试运行活动将调用在文件系统中更接近根目录的 conftest.py 文件中定义的所有钩子。以下是一个实现 pytest_runtest_setup 钩子的示例,该钩子仅在子目录中的测试被调用时执行,而在其他目录中的测试则不会调用:

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"

当运行 a/test_sub.py 中的 test_sub 测试时,pytest_runtest_setup 钩子会在测试运行之前被调用,并打印出相应的信息。然而,当运行 test_flat.py 中的 test_flat 测试时,这个钩子不会被调用,因为它定义在 a/conftest.py 中,而 test_flat.py 不在 a 目录下。

注意

如果你拥有不在 Python 包目录(即包含 __init__.py 的目录)中的 conftest.py 文件,那么“import conftest”可能会产生歧义,因为在你的 PYTHONPATH 或 sys.path 上可能还存在其他 conftest.py 文件。因此,项目的一个良好实践是将 conftest.py 放在包作用域下,或者从不从 conftest.py 文件中导入任何内容。

另请参阅:pytest 的导入机制和 sys.path/PYTHONPATH pytest import mechanisms and sys.path/PYTHONPATH

注意

由于 pytest 在启动时发现插件的方式,一些钩子不能在非初始的(initial ) conftest.py 文件中实现。有关详细信息,请参阅每个钩子的文档。

编写自己的插件

如果你想编写一个插件,有很多实际例子可以供你参考:

所有这些插件都通过实现钩子hooks 和/或夹具fixtures 来扩展和增加功能。

注意

请确保查看出色的  cookiecutter-pytest-plugin 项目,这是一个用于编写插件的 cookiecutter template 模板。

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

另外,一旦你的插件除了你自己之外还有一些满意的用户,请考虑将其贡献给 pytest-devcontributing your plugin to pytest-dev 。

让你的插件可由他人安装

如果你想让你的插件对外可用,你可以为你的分发版定义一个所谓的入口点,这样 pytest 就能找到你的插件模块。入口点是 setuptools提供的一个功能。

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"

在这个示例中,myproject 是插件的名称(这只是一个标识符,不必与项目名称相同),而 "myproject.pluginmodule" 是你的插件模块的路径,该模块应该包含 pytest 插件的实现。这样,当 pytest 运行时,它会查找并加载这些定义的插件。

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

注意

请确保在 PyPI 分类器列表(PyPI classifiers )中包含 Framework :: Pytest,以便用户能够轻松找到你的插件。

断言重写

pytest 的主要特性之一是使用普通的 assert 语句以及在断言失败时对表达式进行详细的自省。这是通过“断言重写”实现的,该机制在将解析后的抽象语法树(AST)编译成字节码之前对其进行修改。这是通过 PEP 302导入钩子实现的,该钩子在 pytest 启动时就会安装,并在模块被导入时执行重写。然而,由于我们不希望测试与生产中运行的字节码不同,因此这个钩子只重写测试模块本身(如python_files 配置选项所定义的),以及任何作为插件一部分的模块。其他任何导入的模块都不会被重写,并将保留正常的断言行为。

如果你在其他模块中有断言助手,并且需要在这些模块中启用断言重写,你需要明确告诉 pytest 在这些模块被导入之前重写它们。

register_assert_rewrite(*names)                           [source]

注册一个或多个模块名称,以便在导入时进行重写。

此函数将确保此模块或包内的所有模块都会将其断言语句重写。因此,你应该确保在实际导入模块之前调用此函数,如果你是一个使用包的插件,那么通常在 __init__.py 文件中调用它。

参数:

  • names (str) – 要注册的模块名称。注意这里 *names 表示可以传入多个模块名称作为参数。

当你使用包来编写 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 被列为 pytest11 入口点的一个插件,因此它的断言语句会被重写。但是pytest_foo/helper.py 模块不会自动被视为插件的一部分,如果 helper 模块也包含需要重写的 assert 语句,那么它需要在被导入之前被标记为需要重写。最简单的方法是在 __init__.py 模块中标记它进行重写,因为当导入包内的模块时,__init__.py 总是首先被导入。这样,plugin.py 仍然可以正常导入 helper.py

pytest_foo/__init__.py 的内容将需要像这样:

import pytest

pytest.register_assert_rewrite("pytest_foo.helper")

在测试模块或 conftest.py 文件中引入/加载插件

你可以使用  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 来引入它们,你应该在位于测试根目录(通常是包含所有测试用例和 conftest.py 的顶级目录)的 conftest.py 文件中进行这个操作。这样做可以确保插件被正确地加载,并且只在需要的测试目录中生效,而不会意外地影响到整个项目或测试套件的其他部分。

这个机制使得在应用程序之间甚至外部应用程序之间共享fixture变得容易,而无需使用setuptools的入口点技术来创建外部插件。

通过pytest_plugins引入的插件也将自动被标记为需要断言重写(参见pytest.register_assert_rewrite())。但是,为了产生效果,该模块必须尚未被导入;如果在pytest_plugins语句被处理时模块已经被导入,那么会发出警告,并且插件内的断言将不会被重写。为了解决这个问题,你可以在模块被导入之前自行调用pytest.register_assert_rewrite(),或者你可以调整代码以延迟导入,直到插件被注册之后。

通过名称访问另一个插件

如果一个插件想要与另一个插件的代码协作,它可以通过插件管理器以如下方式获取对该插件的引用:

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

如果你想要查看现有插件的名称,可以使用--trace-config选项。

注册自定义标记

如果你的插件使用了任何标记(marker),你应该注册它们,以便它们能够出现在pytest的帮助文本中,并且不会导致虚假的警告cause spurious warnings。例如,下面的插件将为所有用户注册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_configure钩子函数在pytest配置阶段执行。config.addinivalue_line方法用于向pytest的配置系统添加一条关于标记的说明,这些说明会出现在pytest --markers命令的输出中,帮助用户了解可用的标记及其用途。在上面的例子中,cool_marker被描述为“这个标记用于酷炫的测试”,而mark_with则被描述为“这个标记接受参数”,并展示了如何接受多个参数(虽然示例中只明确提到了argarg2,但实际上它可以接受任意数量的参数,具体取决于标记的实现)。

测试插件

pytest附带了一个名为pytester的插件,它可以帮助你为插件代码编写测试。这个插件默认是禁用的,因此在使用之前你需要启用它。

你可以通过在测试目录中的conftest.py文件中添加以下行来启用它:

# content of conftest.py

pytest_plugins = ["pytester"]

或者,你也可以通过命令行选项-p pytester来调用pytest。

这样,你就可以使用pytester 这个fixture来测试你的插件代码了。pytester提供了许多有用的功能和fixture,可以帮助你模拟pytest的运行环境,并验证你的插件是否按预期工作。

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

首先,我们需要确保插件可以接收命令行选项,以便能够设置默认的“name”值。这可以通过pytest_addoption钩子来实现。然后,定义了hello这个fixture,它依赖于pytest的请求上下文(request),以便能够访问pytest的配置和命令行选项。在这个fixture中,我们定义了一个内部函数_hello,它接受一个名为name的可选参数。如果调用时没有提供name参数,它将使用pytest配置中设置的默认“name”值。最后,我们返回这个内部函数_hello,以便在测试用例中使用。

import pytest  
  
def pytest_addoption(parser):  
    # 创建一个新的命令行选项组,名为"helloworld"  
    group = parser.getgroup("helloworld")  
    # 向该组添加一个选项"--name",其值将被存储在config.option.name中  
    # 如果没有提供值,则默认为"World"  
    group.addoption(  
        "--name",  
        action="store",  
        dest="name",  
        default="World",  
        help='Default "name" for hello().',  
    )  
  
@pytest.fixture  
def hello(request):  
    # 从pytest配置中获取命令行选项"--name"的值  
    name = request.config.getoption("name")  
  
    # 定义一个内部函数_hello,它接受一个可选的name参数  
    def _hello(name=None):  
        # 如果没有提供name参数,则使用命令行选项中的值  
        if not name:  
            name = request.config.getoption("name")  
        # 返回一个格式化的字符串  
        return f"Hello {name}!"  
  
    # 返回内部函数_hello,以便在测试用例中使用  
    return _hello  
  

注:

pytest的插件系统中,parser.getgroup()是一个用于组织命令行选项(arguments and options)的方法。parser.getgroup(name, title=None, description=None)方法允许你获取或创建一个新的选项组(group)。选项组是一种将相关选项组织在一起的方式,以便在帮助文本中更清晰地展示给用户。

  • name:选项组的唯一名称。如果已经存在具有该名称的组,则不会创建新组,而是返回现有的组。
  • title(可选):选项组的标题,通常用于帮助文本中显示组的名称。如果未提供,则可能使用name作为标题。
  • description(可选):选项组的描述,提供更详细的关于该组包含选项的信息。

group.addoption() 方法是在 pytest 的插件系统中,特别是在 pytest_addoption 钩子函数中用于向命令行解析器(通常是通过 argparse 库实现的)添加自定义选项的一个方法。这个方法隶属于通过 parser.getgroup() 方法获取或创建的选项组(group)。

group.addoption(  
    name, action=None, *, default=None, nargs=None, const=None,  
    type=None, choices=None, required=False, help=None,  
    dest=None, metavar=None, group=None,  
)
  • name:命令行选项的名称,包括前缀(如 -- 或 -,尽管在 pytest 的上下文中,通常使用 -- 作为长选项的前缀)。
  • action:指定当在命令行中遇到该选项时应执行的动作。例如,'store' 表示将选项的值存储在配置对象中,'store_true' 和 'store_false' 分别用于布尔选项,'count' 用于计数等。
  • dest:指定在配置对象中存储值的属性名。如果未指定,则通常基于 name 自动生成。
  • default:该选项的默认值。
  • help:描述该选项的帮助文本。

request.config.getoption() 是在 pytest 测试框架中用于获取通过命令行传递给 pytest 的选项值的方法。pytest 使用 argparse 库来解析命令行参数,并通过配置对象(通常是通过 request.config 访问的)来存储这些参数的值。当你定义了一个自定义的命令行选项,并通过 pytest_addoption 钩子函数将其添加到 pytest 的命令行解析器中时,你可以在测试函数、fixture 或其他钩子函数中使用 request.config.getoption() 方法来检索该选项的值。

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

def test_hello(pytester):  
    """确保我们的插件能正常工作。"""  
  
    # 创建一个临时的conftest.py文件  
    pytester.makeconftest(  
        """  
        import pytest  
  
        # 定义一个参数化fixture,包含三个名字  
        @pytest.fixture(params=[  
            "Brianna",  
            "Andreas",  
            "Floris",  
        ])  
        def name(request):  
            return request.param  
        """  
    )  
  
    # 创建一个临时的pytest测试文件  
    pytester.makepyfile(  
        """  
        # 定义一个接受可选名字的hello函数(此处未展示实现,假设已存在)  
  
        # 测试hello函数在不提供名字时的默认行为  
        def test_hello_default(hello):  
            assert hello() == "Hello World!"  
  
        # 测试hello函数在提供名字时的行为  
        def test_hello_name(hello, name):  
            assert hello(name) == "Hello {0}!".format(name)  
        """  
    )  
  
    # 使用pytest运行所有测试  
    result = pytester.runpytest()  
  
    # 检查是否所有4个测试都通过了  
    # 注意:这里假设除了上面定义的两个测试外,还存在另外两个由name fixture参数化生成的测试  
    result.assert_outcomes(passed=4)

这段代码演示了如何使用pytester夹具来测试pytest插件或特定测试行为。首先,它创建了一个包含参数化fixture的临时conftest.py文件,该fixture为测试提供了三个不同的名字。然后,它创建了一个包含两个测试函数的临时测试文件:一个测试hello函数在不提供名字时的行为,另一个测试在提供名字时的行为。通过pytester.runpytest()运行所有测试后,使用result.assert_outcomes(passed=4)来断言所有四个测试。需要注意的是,虽然这里只明确展示了两个测试函数,但name fixture的参数化会导致另外两个测试被自动生成和执行。

注:

在上述代码中,return request.param 出现在一个由 @pytest.fixture(params=[...]) 装饰的 fixture 函数内部。这里,request.param 是 pytest 提供的一个特殊属性,用于在参数化 fixture 中获取当前测试的参数值。具体来说,当你使用 params 参数来装饰一个 fixture 时,pytest 会为 params 列表中的每个值创建一个独立的测试环境(或称为“测试上下文”),并在每个环境中调用该 fixture 函数。在每次调用中,request.param 会被设置为当前环境的参数值。因此,return request.param 的意思是:返回当前测试环境(或上下文)中,由 params 列表指定的参数值。

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

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

# content of pytest.ini

[pytest]  
pytester_example_dir = .

这里,. 表示示例文件将与 pytest.ini 文件位于同一目录下。

# content of test_example.py

def test_plugin(pytester):  
    # 将名为 "test_example.py" 的示例文件复制到 pytester 的隔离环境中  
    pytester.copy_example("test_example.py")  
    # 运行 pytest,并使用 -k 选项来仅运行名称匹配 "test_example" 的测试  
    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 =============================

这表明 pytest 成功收集了 2 个测试项(包括 test_plugin() 和 test_example()),但由于 -k 选项的限制,只有与 "test_example" 匹配的测试被实际执行了。

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

  • 28
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值