在大多数项目中,引入本地的 conftest 插件或者使用pip安装插件都是可以的,包括第三方的项目。如果要使用一个插件而非自己写一个插件,可以参考上一章。
一个插件包括一个或者多个钩子方法。下一章(编写钩子)解释了写一个钩子所需要的基本信息和详细信息。pytest通过以下插件中指定的钩子来实现 配置,收集,运行,报告等多个方面:
- 内置的插件:从pytest内部的_pytest目录加载
- 外部的插件:通过setuptools入口点发现的模块( setuptools entry points)19.4
- conftest.py插件:测试目录中自动发现的模块
原则上, 每一个钩子都是 1:N的,这个N是一系列符合某个给定规格的方法的实现。所有的规格和实现都遵循以 pytest_ 为前缀的命名规范,让他们更方便的分辨和查找。
19.1 工具启动时的插件发现顺序
pytest在启动的时候加载插件时按照下面的顺序的:
- 通过扫描命令行中的 -p no:name 选项,阻止相应的插件加载(即使是内置的插件也可以这样阻止)。这个过程会在正常的命令行解析之前进行。
- 加载所有内置的插件
- 通过扫面命令行中 -p name 的选项,加载指定的插件,这个过程会在正常的命令行解析之前进行。
- 加载所有通过 setuptools 入口点注册的插件
- 加载通过 PYTEST_PLUGINS 环境变量指定的插件
- 通过命令行的参数推断所有 conftest.py 文件
- 如果没有指定任何的路径,使用当前路径
- 如果存在,加载conftest.py和相对于指定路径的test*/conftest.py。在 conftest.py 加载完成之后,加载 pytest_plugins 变量指定的插件,如果这个变量存在的话
注意pytest启动的时候不会再更深层次的 conftest.py 中查找插件了。建议你把conftest.py文件放到最顶层目录或项目根目录。
- 递归的加载在 conftest.py 文件中 pytest_plugins 变量指定的所有插件。
19.2 conftest.py: 本地单目录的插件
本地的 conftest.py 插件包括了指定目录的钩子实现。钩子会话和测试运行活动会调用定义在 根目录的conftest.py中的所有钩子,例如,实现 pytest_runtest_setup 钩子会被在 a 目录下的所有测试调用,别的目录不会调用:
a/conftest.py:
def pytest_runtest_setup(item):
#所有在a目录下的测试会调用该方法
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"
注意: 如果你的conftest.py不在包里(也就是说这个目录不包含__init__.py),那么“import conftest”这个代码就会引起歧义,因为可能PYTHONPATH 或者 sys.path中存在conftest.py文件。所以,好的做法是要么把conftest文件放到包里,要么永远不要从conftest中引用任何内容。
在第二十五章还会讲到这个内容。
由于pytest在启动过程中发现插件的方式机制原因,有些钩子只能在插件中引用,或者在位于根目录的conftest.py文件中调用,详情请参阅每个钩子的文档。
19.3 编写你自己的插件
如果你想编写一个插件,这里有很多实际的例子供你效仿:
- 一个自定义收集测试的例子:使用yaml file指定测试(27.7.1)
- 为pytest提供功能的内置插件
- 为pytest提供功能的很多外部插件
所有的这些插件都是调用了钩子或者夹具来扩展或者添加功能。
注意:请务必查看十分优秀的 cookiecutter-pytest-plugin,这是一个编写插件的cookiecutter模板。
该模板提供了一个很好的起点,包括一个可工作的插件、一个使用tox运行的测试、一个全面的README文件以及一个预先配置的入口点。
译者注:大家可以理解cookiecutter模板就是一个快速开始编写插件的脚手架,大家要写插件可以在这个模板的基础上写会方便很多
如果你的插件有很多人喜欢,你还可以考虑把插件贡献给 pytest-dev,具体做法参见33.6.
19.4 让别人安装你的插件
如果你希望你的插件能被外部的人使用,你应该给你的插件定义一个所谓的“入口点”,以便pytest能够找到你的插件模块。入口点是 setuptools这个模块提供的功能。pytest通过寻找pytest11入口点去发现插件,所以你可以在setuptools的配置中定义你自己的插件:
# sample ./setup.py file
from setuptools import setup
setup(
name="myproject",
packages=["myproject"],
# the following makes a plugin available to pytest
entry_points={"pytest11": ["name_of_plugin = myproject.pluginmodule"]},
# custom PyPI classifier for pytest plugins
classifiers=["Framework :: Pytest"],
)
如果包是这个安装的,pytest把myproject.pluginmodule作为一个可以定义钩子的插件加载。
注意:确保你在 PyPI classifiers 引入了 Framework :: Pytest,以便其他用户发现的你的插件。
19.5 复写断言
pytest的一个特色的功能是使用assert关键字和在断言失败的时候显示表达式的详情。这个功能是由“断言重写”提供的,它可以在字节码被编译之前修改AST(抽象语法树)。这个操作又是通过PEP 302引入的钩子,这个钩子在模块被引入的情况下,可以在pytest运行的早期进行安装,然后重写assert。然而因为我们不想测试与生产环境不同的字节码,所以这个钩子只会重写测试模块本身(在python_files配置中定义的),和插件中的其他模块。其他引入的模块的不会被重写,使用asset会表现出普通的行为。
译者注:AST:python代码的处理过程是 源代码解析 --> 语法树 --> 抽象语法树(AST) --> 控制流程图 --> 字节码
译者注:说人话就是,pytest复写的断言仅对当前的工程有效,工程中引入的模块中如果有assert,这个是不会被重写的
如果你需要在引入的模块中让assert也表现出复写之后的功能,你需要在引入这个模块之前显式的调用它。
译者注:这种情况常见于你写的测试程序引用了你自己写的模块,你希望把这个模块抽出去做一个第三方库,这时候就要考虑第三方库中有没有assert的问题了
register_assert_rewrite(*names: str) → None
注册一个或者多个在引入的时候需要被重写的模块。
这个方法会保证一个模块或者包里的所有模块中的assert声明都会被重写。因此,你需要确保在import之前调用上面这一行代码,如果是在一个包中的插件,可以写到__init__.py中。
Raises TypeError – 如果给定的模块名不是字符串。
当你在一个包的基础上编写一个pytest插件的时候,这个情况尤为重要。import钩子只认为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的模块中包含assert,而且这个断言需要被复写,它就也需要在被调用之前像上面那样标记。最简单的方法是在__init__.py中进行标记,这个文件总是会在一个包中所有的模块引入之前被引入。这样做的话,plugin.py 还是可以正常的引入 helper.py,pytest_foo/init.py看起来像下面这样:
import pytest
pytest.register_assert_rewrite("pytest_foo.helper")
译者注:这一节说的是在编写插件的时候,处理assert复写的问题
19.6 在测试模块或者conftest文件中加载插件
你可以通过改变pytest_plugins这个变量在模块中或者conftest文件中加载插件:
pytest_plugins = ["name1", "name2"]
当测试模块或者conftest文件加载的时候,插件也会同时加载。任何模块都可以被当作插件加载,包括应用内部模块:
pytest_plugins = "myapp.testsupport.myplugin"
pytest_plugins 会递归的执行,所以如果myapp.testsupport.myplugin这个模块也声明了pytest_plugins,它里面的插件也会被加载,以此类推。
注意:强烈不建议在非根目录下的conftest.py中使用pytest_plugins变量。这一点是很重要的,因为conftest.py本质上是一个目录钩子的实现,一旦在这个文件中引入一个插件,整个目录都会受影响。为了避免混乱,在任何非根目录的conftest中定义pytest_plugins都是不推荐的,这样做会引发一个警告。
这种机制让我们在应用程序中共享夹具甚至给外部应用共享夹具变得简单,无需新建一个插件,并且使用setuptools入口点技术了。
pytest_plugins导入的插件会自动的标记为需要被重写assert。然而要达到这种效果,模块不能被事先导入,如果在pytest_plugins执行之前模块就已经加载了,那么它里面的assert不会被复写,并且会产生一个警告。要解决这个问题,你可以在模块加载之前调用 pytest.register_assert_rewrite(),或者是通过修改代码,让模块在插件注册之后加载。
19.7 通过名字访问别的插件
如果一个插件希望从代码上与另一个插件合作,可以通过插件管理模块获得一个引用:
plugin = config.pluginmanager.get_plugin("name_of_plugin")
如果你想查找所有已经存在的插件的名字,使用 --trace-config 选项。
19.8 注册自定的标签
如果你的插件要新增标签,你需要注册它们,注册之后在测试中使用就不会引起异常(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."
)
19.9 测试插件
pytest提供了一个名叫 pytester 的插件帮助你编写插件的测试代码。这个插件默认是关闭的,你可以在你要使用它之前启动它。
具体的做法是在 conftest.py 中添加下面的代码:
# content of conftest.py
pytest_plugins = ["pytester"]
另一种选择是在命令行中使用 -p pytester 参数。
这样做会允许你使用 testdir 夹具测试你的插件代码。
下面用一个例子说明一下这个这个插件能做什么。假设我们开发了一个名叫hello的插件,这个插件返回一个有一个参数的函数,返回值是string类型的 Hello {value}!,如果没有提供参数,就返回 Hello World!.
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 "Hello {name}!".format(name=name)
return _hello
现在,testdir夹具提供了一个方便的API创建一个临时的conftest.py文件和测试文件。它还允许我们运行测试并返回一个结果,我们可以断言该测试的结果。
def test_hello(testdir):
"""Make sure that our plugin works."""
# create a temporary conftest.py file
testdir.makeconftest(
"""
import pytest
@pytest.fixture(params=[
"Brianna",
"Andreas",
"Floris",
])
def name(request):
return request.param
"""
)
# create a temporary pytest test file
testdir.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 = testdir.runpytest()
# check that all 4 tests passed
result.assert_outcomes(passed=4)
另外,也可以在pytest运行之前从一个例子的目录复制例子。
# content of pytest.ini
[pytest]
pytester_example_dir = .
# content of test_example.py
def test_plugin(testdir):
testdir.copy_example("test_example.py")
testdir.runpytest("-k", "test_example")
def test_example():
pass
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR, configfile: pytest.ini
collected 2 items
test_example.py .. [100%]
============================= warnings summary =============================
test_example.py::test_plugin
$REGENDOC_TMPDIR/test_example.py:4: PytestExperimentalApiWarning: testdir.copy_
˓→example is an experimental api that may change over time
testdir.copy_example("test_example.py")
-- Docs: https://docs.pytest.org/en/stable/warnings.html
======================= 2 passed, 1 warning in 0.12s =======================
要知道关于 runpytest() 返回值的更多信息和它提供的更多方法,请看API文档中的RunResult说明。