目录
为你的项目实现本地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 在工具启动时按以下方式加载插件模块:
-
扫描命令行中的
-p no:name
选项:首先,pytest 会扫描命令行以查找-p no:name
选项,并阻止指定的插件被加载(即使是内置插件也可以通过这种方式被阻止)。这一步骤发生在正常的命令行参数解析之前。 -
加载所有内置插件:之后,pytest 会加载所有内置的插件。
-
扫描命令行中的
-p name
选项:接下来,pytest 会扫描命令行以查找-p name
选项,并加载指定的插件。这一步骤也发生在正常的命令行参数解析之前。 -
加载通过 setuptools 入口点注册的所有插件:随后,pytest 会加载所有通过 setuptools 入口点( setuptools entry points.)注册的插件。
-
加载通过 PYTEST_PLUGINS 环境变量指定的所有插件:然后,pytest 会加载通过 PYTEST_PLUGINS 环境变量指定的所有插件。
-
加载所有“初始”的 conftest.py 文件:
- 确定测试路径:首先,pytest 会确定测试路径。这些路径可以是命令行上指定的,如果定义了testpaths并且从
rootdir
运行,则使用testpaths
,否则使用当前目录。 - 加载 conftest.py 文件:对于每个测试路径,pytest 会加载与该测试路径目录部分相对的
conftest.py
文件以及test*/conftest.py
文件(如果存在)。在加载一个conftest.py
文件之前,pytest 会先加载其所有父目录中的conftest.py
文件。加载完一个conftest.py
文件后,如果该文件定义了 pytest_plugins 变量,pytest 会递归地加载该变量中指定的所有插件。
- 确定测试路径:首先,pytest 会确定测试路径。这些路径可以是命令行上指定的,如果定义了testpaths并且从
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
文件中实现。有关详细信息,请参阅每个钩子的文档。
编写自己的插件
如果你想编写一个插件,有很多实际例子可以供你参考:
- 一个自定义收集示例插件:一个在 YAML 文件中指定测试的基本示例(A basic example for specifying tests in Yaml files)
- 内置插件,提供 pytest 自身的功能
- 许多外部插件(external plugins),提供额外的特性
所有这些插件都通过实现钩子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_marker
和mark_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
则被描述为“这个标记接受参数”,并展示了如何接受多个参数(虽然示例中只明确提到了arg
和arg2
,但实际上它可以接受任意数量的参数,具体取决于标记的实现)。
测试插件
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文档。