← 返回测试与调试目录
← 返回学习笔记首页
专题: Python 测试与调试系统学习
关键词: Python, 测试, 调试, pytest插件, pytest-xdist, pytest-cov, 钩子函数, 自定义插件, Python测试
一、pytest插件概述
pytest之所以成为Python生态中最受欢迎的测试框架之一,其高度可扩展的插件体系功不可没。pytest插件机制的核心理念是"约定优于配置"和"钩子函数驱动"——框架提供了一系列预定义的钩子(hook)点,插件通过实现这些钩子函数来介入测试生命周期的各个阶段,从而扩展或修改pytest的行为。
pytest的插件来源分为三个层次。第一层是内置插件 ,它们随pytest一起安装,存放在pytest安装目录的`_pytest/hookspec.py`和`_pytest`子模块中,例如测试收集(collection)、测试运行(runner)、断言重写(assertion rewriting)等核心功能都是通过内置插件实现的。第二层是外部第三方插件 ,通过pip安装后在`conftest.py`或`pytest.ini`中注册使用。第三层是本地conftest插件 ,在项目根目录或测试目录的`conftest.py`文件中定义,作用域限定于该目录及其子目录。
插件的发现顺序 遵循严格的规则:首先加载内置插件,其次加载通过`setuptools`的entry points注册的外部插件(即通过pip安装且在`pyproject.toml`中声明了`[project.entry-points.pytest11]`的包),最后按目录层级从根到叶依次加载各级`conftest.py`。如果多个插件实现了同一个钩子函数,pytest会根据`tryfirst`和`trylast`装饰器以及插件加载顺序来协调执行顺序。若需要禁用某个外部插件,可以使用`-p no:插件名`命令行参数或者在`pytest.ini`中设置`addopts = -p no:someplugin`。
安装第三方插件非常简单,大部分插件通过pip即可安装。社区维护的插件索引可以在pytest的官方文档和GitHub的`pytest-dev`组织下找到。截至当前,pytest的第三方插件生态已超过1000个,覆盖了从Web框架测试到数据库、从并行执行到覆盖率分析等各个领域。
# 常用插件安装命令
pip install pytest-xdist # 并行执行
pip install pytest-cov # 覆盖率
pip install pytest-rerunfailures # 失败重试
pip install pytest-ordering # 执行顺序
pip install pytest-timeout # 超时控制
pip install pytest-sugar # 美化输出
pip install pytest-mock # Mock支持
pip install pytest-django # Django测试
# 查看已安装插件列表
pytest --trace-config
# 输出示例:
# plugins: xdist-3.6.1, cov-5.0.0, timeout-2.3.1, mock-3.14.0, ...
# 禁用特定插件
pytest -p no:xdist -p no:cov
# 或在 pytest.ini 中全局禁用
# [pytest]
# addopts = -p no:someplugin
核心要点: pytest插件体系基于钩子函数机制,提供了三层插件来源(内置/外部/conftest)。开发者通过实现特定的钩子函数介入测试生命周期,实现功能扩展。理解插件发现顺序和禁用方法对于大型项目的测试管理至关重要。
二、并行与分布式
pytest-xdist 并行执行
pytest-xdist是pytest生态中最常用的并行执行插件,它允许将测试用例分发到多个CPU核心甚至多台机器上并行运行,从而大幅缩短测试套件的执行时间。在微服务和大型单体应用中,测试用例数量往往数以千计,串行执行可能需要数十分钟甚至数小时,xdist可以将这些测试均匀分配到多个worker进程中并行执行。
xdist的核心机制是工作进程(worker)模型 。主进程负责收集测试用例并构建测试队列,然后启动多个worker进程,每个worker领取一部分测试用例独立执行。xdist提供了多种分发模式:`--dist=load`(默认模式)将测试动态分发给空闲的worker,负载均衡效果最佳;`--dist=loadscope`按测试模块分组分发,同一个模块的测试会分配给同一个worker,适合测试间存在模块级共享状态的场景;`--dist=loadfile`按文件分组;`--dist=worksteal`(较新版本支持)允许worker之间"偷取"未完成的任务,进一步改善负载均衡。
在使用xdist时需要注意测试隔离性。由于不同worker运行在不同进程中,共享状态(如全局变量、数据库记录、文件系统)可能引发竞态条件。推荐的做法是让每个测试用例都独立创建和清理自己的数据,或者使用`--dist=loadscope`/`--dist=loadfile`减少跨worker的干扰。此外,xdist默认会使用CPU核心数作为worker数量,也可以使用`-n 4`指定4个worker,或者`-n auto`自动检测。
# 基本用法:使用4个worker并行执行
pytest -n 4
# 自动检测CPU核心数
pytest -n auto
# 按模块分组分发
pytest -n 4 --dist=loadscope
# 覆盖测试并指定worker数量
pytest tests/ -n 2 --cov=myapp
# 结合重复执行和并行
# 安装:pip install pytest-repeat
pytest -n 4 --count 3 tests/test_api.py
# 超时控制+并行
pytest -n 4 --timeout=30 tests/
# 失败即停止(所有worker)
pytest -n 4 -x tests/
# 仅重新运行失败的测试
pytest -n 4 --lf tests/
# conftest.py 中标记需要串行的测试
# 安装:pip install pytest-ordering
import pytest
def pytest_collection_modifyitems(items):
"""标记耗时的集成测试为串行执行"""
for item in items:
if "integration" in item.nodeid:
item.add_marker(pytest.mark.serial)
# 运行:pytest -n 4 --dist=loadscope
核心要点: pytest-xdist通过多进程并行执行大幅缩短测试时间。`-n`控制并发度,`--dist`选择分发策略(load/loadscope/loadfile/worksteal)。使用xdist时需注意测试隔离性,避免共享状态竞态。对于数据库等共享资源场景,建议采用loadscope模式。
三、覆盖率与报告
pytest-cov 集成
测试覆盖率是衡量测试质量的重要指标之一。pytest-cov插件将广受欢迎的coverage.py工具无缝集成到pytest中,让开发者可以在运行测试的同时收集覆盖率数据,并生成多种格式的报告。覆盖率指标通常包括行覆盖率(line coverage)、分支覆盖率(branch coverage)和路径覆盖率(path coverage),pytest-cov主要关注前两者。
pytest-cov的核心使用方式是通过命令行参数控制。`--cov=myapp`指定要测量覆盖率的包或模块路径,`--cov-report`指定输出格式。支持的输出格式包括:`term`(终端输出,默认)、`term-missing`(终端输出并显示未覆盖的行号)、`html`(生成HTML格式的交互式报告)、`xml`(Cobertura格式,用于CI集成)、`json`(JSON格式)和`annotate`(带注释的源文件)。可以同时指定多个输出格式,例如`--cov-report=term --cov-report=html`。
在实际项目中,覆盖率配置通常放在`.coveragerc`或`pyproject.toml`的`[tool.coverage]`部分。常见的配置项包括`source`(指定要测量的源码路径)、`omit`(排除不需要测量的文件,如迁移文件、测试文件本身)、`include`(只包含特定模式的文件)、`branch`(是否启用分支覆盖率测量)和`fail_under`(设置覆盖率阈值,低于此值时测试视为失败)。增量覆盖率是大型项目的常用策略——只测量新增或修改代码的覆盖率,而不是整个项目,这样更有利于CI流水线中的质量门禁。
# 基本覆盖率测量
pytest --cov=myapp tests/
# 终端显示未覆盖的行号
pytest --cov=myapp --cov-report=term-missing tests/
# 生成HTML报告
pytest --cov=myapp --cov-report=html tests/
# HTML报告输出到 htmlcov/ 目录
# pyproject.toml 覆盖率配置
[tool.coverage.run]
source = ["myapp"]
omit = [
"*/migrations/*",
"*/test_*.py",
"*/tests/*",
"*/setup.py",
]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover" ,
"def __repr__" ,
"if self.debug:" ,
"if __name__ == .__main__.:" ,
"raise AssertionError" ,
"raise NotImplementedError" ,
]
fail_under = 80
show_missing = true
skip_covered = true
# 分支覆盖率测量
pytest --cov=myapp --cov-branch --cov-report=html tests/
# 增量覆盖率(仅测量指定分支的新代码)
# 使用 git diff 获取变更文件列表,再传入 --cov
git diff --name-only main...HEAD | grep '.py$' | xargs pytest --cov=myapp --cov-report=term-missing
# 多模块覆盖率
pytest --cov=module_a --cov=module_b --cov-report=term --cov-report=xml tests/
核心要点: pytest-cov整合了coverage.py的强大功能,支持行覆盖率和分支覆盖率。通过`.coveragerc`或`pyproject.toml`精细控制测量范围。HTML报告提供了交互式浏览体验,是团队Code Review的有力辅助工具。建议在CI中设置`fail_under`阈值作为质量门禁。
四、排序与重试
pytest-ordering 顺序控制
在理想情况下,测试用例应该是独立且无序的。然而在实际项目中,某些测试确实存在执行顺序的依赖(例如先创建用户再测试用户功能),或者为了调试方便需要优先执行特定测试。pytest-ordering插件提供了一种声明式的方式来控制测试用例的执行顺序。
pytest-ordering通过`@pytest.mark.run`标记来控制执行顺序,支持多种排序方式:`first`(第一个执行)、`last`(最后一个执行)、`before=test_func`(在指定测试之前执行)、`after=test_func`(在指定测试之后执行)、`order=N`(指定数字序号,数字小的先执行)。此外,新版本还支持`@pytest.mark.order`标记,语法更加简洁。需要注意的是,过度依赖测试顺序是一种代码坏味,应尽量通过重构来消除测试间的依赖关系,仅在不得已的情况下才使用排序控制。
pytest-rerunfailures 失败重试
在UI测试、集成测试或网络相关的测试中,测试失败有时是由环境不稳定而非代码缺陷导致的(所谓的"flaky tests")。pytest-rerunfailures插件允许自动重试失败的测试用例,提高CI流水线的稳定性。使用方法是在命令行中通过`--reruns=N`指定重试次数,也可以通过`--reruns-delay=秒数`在重试之间添加延迟。该插件还会在测试报告中清晰标记哪次运行成功(例如"第一次失败,第二次通过"),帮助区分"间歇性失败"和"确定性失败"。
插件支持条件重试和选择性重试。可以通过`@pytest.mark.flaky(reruns=3, reruns_delay=2)`标记对特定测试进行单独配置,也可以结合条件判断只在特定异常类型时触发重试。值得注意的是,过多的重试会掩盖真正的代码缺陷,合理的做法是将重试次数控制在3次以内,并为重试添加明确的日志记录以便后续分析根因。
# pytest-ordering 基本用法
import pytest
class TestOrder :
def test_login(self ):
pass
def test_logout(self ):
pass
@pytest.mark.run(order=1)
def test_create_user(self ):
"""先创建用户"""
pass
@pytest.mark.run(after='test_create_user' )
def test_delete_user(self ):
"""在创建用户之后执行"""
pass
# pytest-rerunfailures 基本用法
# 命令行方式:所有失败测试重试2次
pytest --reruns 2 tests/
# 带延迟的重试(每次重试间隔3秒)
pytest --reruns 3 --reruns-delay 3 tests/
# 标记方式:仅特定测试重试
@pytest.mark.flaky(reruns=2, reruns_delay=1)
def test_unstable_api ():
import requests
response = requests.get("https://api.example.com/status" )
assert response.status_code == 200
# 仅对特定异常重试
@pytest.mark.flaky(reruns=2, condition="ConnectionError" in "" )
def test_db_connection ():
pass
# pytest.ini 配置排序和重试
[pytest]
addopts =
--reruns 2
--reruns-delay 1
# 结合 xfail 使用重试
@pytest.mark.flaky(reruns=3)
@pytest.mark.xfail(strict=True )
def test_flaky_feature ():
# 重试3次后如果仍然失败,标记为 xfail(不中断流水线)
pass
核心要点: pytest-ordering通过标记控制测试执行顺序,适用于有合理依赖的场景。pytest-rerunfailures为flaky测试提供自动重试机制,建议配合延迟和条件判断使用。过度的排序和重试会掩盖设计问题,应在项目初期就建立测试独立性规范。
五、超时与进度
pytest-timeout 超时控制
测试超时是防止测试"挂死"、保证CI流水线可控性的重要机制。pytest-timeout插件为测试用例提供了灵活的超时控制功能,支持函数级别、类级别、模块级别和全局级别的超时设置。超时机制可以基于`signal.SIGALRM`(Unix平台,精确且开销低)或线程计时器(跨平台兼容)实现,通过`--timeout_method=signal`或`--timeout_method=thread`切换。
pytest-timeout的配置非常灵活。全局超时通过`--timeout=300`(单位秒)设置,所有测试超过此时间将被强制终止。也可以通过`@pytest.mark.timeout(60)`标记对特定测试设置不同的超时值。在CI环境中,建议为整个测试套件设置一个合理的全局超时(如300秒),同时为已知的耗时测试单独设置更宽松的超时。对于涉及外部API调用或大数据量处理的测试,超时设置尤为重要——它们既要在足够的时间内完成工作,又不能无限期等待网络故障。
pytest-sugar 与 pytest-rich
pytest-sugar和pytest-rich是两个改善测试执行体验的插件。pytest-sugar美化了pytest的终端输出,在测试运行时显示彩色进度条,并通过图标(如"x"表示失败、"✓"表示通过)让测试结果一目了然。它还改进了失败信息的展示格式,将断言错误的上下文更清晰地呈现出来。pytest-rich则基于Rich库提供了更强大的终端输出增强功能,包括语法高亮、更好的traceback格式、以及丰富的进度显示。
# pytest-timeout 基本用法
# 全局超时(所有测试最多300秒)
pytest --timeout=300 tests/
# 设置超时方法为线程(跨平台)
pytest --timeout=60 --timeout_method=thread tests/
# 标记特定测试超时
import pytest
import time
@pytest.mark.timeout(5)
def test_quick_api ():
"""此测试必须在5秒内完成"""
response = call_api()
assert response.status_code == 200
@pytest.mark.timeout(30)
def test_heavy_computation ():
"""大数据量处理,30秒超时"""
result = process_large_dataset()
assert len (result) > 1000
# conftest.py 中按测试类型设置超时
def pytest_collection_modifyitems (config, items):
for item in items:
if "integration" in item.nodeid:
# 集成测试默认60秒超时
item.add_marker(pytest.mark.timeout(60))
elif "unit" in item.nodeid:
# 单元测试默认5秒超时
item.add_marker(pytest.mark.timeout(5))
# pytest-sugar 和 pytest-rich 使用
# 安装后直接运行即可看到增强输出
pip install pytest-sugar pytest-rich
# sugar 自动生效,无需额外参数
pytest tests/
# rich 也自动生效,但与 sugar 二选一使用更好
pytest --rich tests/
# pyproject.toml 中配置 rich
# [tool.pytest.ini_options]
# rich = true
# rich_traceback_show_locals = true
核心要点: pytest-timeout为测试提供超时保护,防止测试挂死影响CI流水线。支持信号和线程两种实现方式。pytest-sugar和pytest-rich显著改善终端输出体验,让测试结果更加清晰直观。建议至少使用pytest-sugar作为开发环境的标准配置。
六、Web框架插件
pytest-django
pytest-django是Django项目中使用pytest替代unittest的标准方案。它将Django的测试基础设施(数据库访问、客户端请求、设置管理)无缝集成到pytest中。核心功能包括:`django_db`标记(控制数据库访问权限)、`client`和`admin_client`夹具(提供HTTP测试客户端)、`settings`夹具(允许在测试中临时修改Django设置)、以及`rf`夹具(RequestFactory的快捷方式)。
pytest-django的一个关键设计是数据库访问的显式控制。默认情况下,测试函数不能访问数据库,这鼓励开发者编写轻量级的纯逻辑测试。只有被`@pytest.mark.django_db`显式标记的测试才能操作数据库,这种设计有助于维持测试的快速执行。此外,pytest-django提供了灵活的数据库事务管理,支持使用`transaction=True`参数启用事务测试。
pytest-flask 与 pytest-sqlalchemy
pytest-flask为Flask应用提供测试支持,其核心是`app`夹具(用于创建Flask应用实例)和`client`夹具(用于发送测试请求)。它自动管理应用上下文和请求上下文,让测试代码更加简洁。pytest-sqlalchemy则提供了SQLAlchemy的测试支持,包括数据库连接管理、会话管理和数据清理。这两个插件常用于构建Web API的端到端测试。
pytest-httpx是专门用于测试HTTP客户端的插件。它拦截所有通过`httpx`库发出的HTTP请求,允许开发者在不实际发起网络请求的情况下验证请求行为。这在测试调用外部API的代码时特别有用——既能避免对外部服务的依赖,又能精确验证请求参数和响应处理逻辑。
# pytest-django 基本用法
import pytest
from django.urls import reverse
def test_home_page (client):
"""访问首页(无需数据库访问)"""
response = client.get("/" )
assert response.status_code == 200
assert "Welcome" in response.content.decode()
@pytest.mark.django_db
def test_user_creation (client):
"""创建用户(需要数据库访问)"""
response = client.post("/users/" , {
"username" : "testuser" ,
"email" : "test@example.com" ,
"password" : "securepass123" ,
})
assert response.status_code == 201
from django.contrib.auth.models import User
assert User.objects.filter(username="testuser" ).exists()
@pytest.mark.django_db(transaction=True )
def test_transaction_rollback (client):
"""测试数据库事务回滚"""
response = client.post("/users/" , {"username" : "rollback_user" })
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM auth_user WHERE username='rollback_user'" )
assert cursor.fetchone()[0] == 1
# pytest-flask 基本用法
import pytest
from myapp import create_app
@pytest.fixture
def app ():
app = create_app(testing=True )
yield app
def test_api_endpoint (client):
"""测试Flask API端点"""
response = client.get("/api/health" )
assert response.status_code == 200
assert response.json == {"status" : "healthy" }
def test_json_post (client):
response = client.post(
"/api/data" ,
json={"key" : "value" },
content_type="application/json"
)
assert response.status_code == 201
# pytest-httpx 拦截HTTP请求
import pytest
import httpx
def test_external_api (httpx_mock):
"""模拟外部API响应,不实际发出网络请求"""
httpx_mock.add_response(
url="https://api.example.com/users/1" ,
json={"id" : 1, "name" : "Alice" },
status_code=200
)
with httpx.Client() as client:
response = client.get("https://api.example.com/users/1" )
assert response.status_code == 200
assert response.json()["name" ] == "Alice"
# 验证所有注册的请求都被触发
assert httpx_mock.call_count == 1
核心要点: Web框架插件让pytest能够无缝集成到Django、Flask等项目中。pytest-django的显式数据库控制设计值得学习——默认禁止数据库访问以保持测试快速。pytest-httpx模拟外部HTTP请求,消除网络依赖,是微服务测试的利器。
七、数据与Mock插件
pytest-factoryboy
pytest-factoryboy将流行的factory_boy库集成到pytest的夹具系统中,为测试数据准备提供了简洁优雅的解决方案。factory_boy使用声明式语法定义数据模型工厂,而pytest-factoryboy自动将这些工厂注册为pytest的夹具(fixture),并处理依赖关系和数据库会话管理。开发者只需在工厂类中定义模型的字段默认值,然后在测试函数中通过参数注入即可创建测试数据对象。
pytest-factoryboy的一个亮点是支持关联工厂的嵌套和继承 。例如,创建"订单"对象时,关联的"用户"工厂会自动创建对应的用户记录。工厂类还支持`SubFactory`、`RelatedFactory`、`PostGeneration`等高级特性,可以精确控制关联数据的生成逻辑。
pytest-mock
pytest-mock是pytest官方推荐的mock插件,它将`unittest.mock`的功能包装为pytest的夹具形式。核心是一个名为`mocker`的夹具,它提供了`mocker.patch()`、`mocker.patch.object()`、`mocker.patch.multiple()`等方法,与`unittest.mock.patch`的API完全兼容。最大的优势是自动清理 ——mock对象在测试结束后自动撤销,不会"泄漏"到其他测试中。
pytest-mock还提供了`mocker.spy()`用于监视(spy)已有对象的方法调用,在不修改对象行为的前提下记录调用信息。`stub`方法用于创建简单的桩对象。在接口测试、外部服务调用测试中,pytest-mock是不可或缺的工具。
pytest-subtests
pytest-subtests实现了Python 3.11+中`unittest.TestCase.subTest()`的等价功能,允许在一个测试函数中产生多个独立的子测试结果。当使用参数化测试或在一个循环中检测多个条件时,子测试确保所有条件都被检查,而不是在第一个失败处就停止。这对于数据验证、批量接口响应检查等场景非常有用。
# pytest-factoryboy 基本用法
import pytest
from factory import DjangoModelFactory, SubFactory, Sequence
from pytest_factoryboy import register
# 定义工厂
class UserFactory (DjangoModelFactory):
class Meta :
model = 'auth.User'
username = Sequence(lambda n: f"user_{n}" )
email = Sequence(lambda n: f"user_{n}@example.com" )
# 注册为夹具(自动生成 user, user__db 等夹具)
register(UserFactory)
# 在测试中直接使用
def test_user_created (user):
assert user.username.startswith("user_" )
def test_user_email (user__db):
"""user__db 是从数据库持久化的实例"""
assert user__db.email.endswith("@example.com" )
# pytest-mock 基本用法
def test_external_api_call (mocker):
"""使用 mocker 模拟外部API"""
mock_get = mocker.patch("requests.get" )
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"status" : "ok" }
# 调用被测代码
result = check_api_health()
assert result == "ok"
mock_get.assert_called_once_with("https://api.example.com/health" )
def test_spy (mocker):
"""使用 spy 监视方法调用"""
class Calculator :
def add (self , a, b):
return a + b
calc = Calculator()
spy = mocker.spy(calc, "add" )
calc.add(1, 2)
calc.add(3, 4)
assert spy.call_count == 2
spy.assert_any_call(1, 2)
spy.assert_any_call(3, 4)
# pytest-subtests 基本用法
def test_multiple_conditions (subtests):
"""在一个测试中检查多个独立条件"""
data = {"a" : 1, "b" : 2, "c" : 3}
for key, expected in data.items():
with subtests.test(msg=f"checking key {key}" , key=key):
result = process_data(key)
assert result == expected
# 在参数化中结合 subtests
@pytest.mark.parametrize("endpoint" , ["/api/v1/users" , "/api/v1/posts" ])
def test_api_endpoints (client, endpoint, subtests):
for method in ["GET" , "HEAD" , "OPTIONS" ]:
with subtests.test(method=method, endpoint=endpoint):
response = client.request(method, endpoint)
assert response.status_code < 500
核心要点: pytest-factoryboy自动化数据工厂创建,适合复杂模型关系的测试数据准备。pytest-mock提供自动清理的mock夹具,是隔离外部依赖的标准方案。pytest-subtests支持单测试中的多条件验证,让参数化测试的错误报告更加精细。
八、自定义插件开发
钩子函数详解
pytest的插件系统建立在约40个明确定义的钩子(hook)函数之上。这些钩子覆盖了测试执行的完整生命周期:从初始化阶段 的`pytest_load_initial_conftests`、`pytest_addoption`(添加命令行参数),到收集阶段 的`pytest_collection`、`pytest_collect_file`、`pytest_make_item`,再到运行阶段 的`pytest_runtestloop`、`pytest_runtest_protocol`、`pytest_runtest_setup/call/teardown`,最后到报告阶段 的`pytest_report_teststatus`、`pytest_terminal_summary`。每个钩子函数都有明确的签名和返回值规范,定义在`_pytest/hookspec.py`中。
钩子的执行严格遵循LIFO(Last In, First Out) 原则——最后注册的插件最先执行。但使用`@pytest.hookimpl(tryfirst=True)`可以提升优先级,`@pytest.hookimpl(trylast=True)`则降低优先级。钩子函数还可以设置`hookwrapper=True`来包装其他插件的钩子实现,在执行前后分别插入自定义逻辑,类似于中间件模式。
插件项目结构
一个标准的pytest插件项目遵循Python包的规范结构。最简单的插件可以仅是一个Python文件加上`pyproject.toml`声明。在`pyproject.toml`中,关键在于`[project.entry-points.pytest11]`部分,它将包名映射到插件模块路径,pytest通过这个entrypoint发现并加载插件。
对于较大的插件,推荐按功能拆分为多个模块,使用`pytest_plugins`变量在主模块中声明子模块。插件中定义的钩子函数会自动被pytest调用,不需要手动注册。良好的插件应该包含类型注解、文档字符串、以及完整的单元测试。
添加命令行选项与配置项
通过实现`pytest_addoption`钩子可以为pytest添加新的命令行参数。该钩子接收一个`parser`对象,支持`parser.addoption()`(添加命令行参数)和`parser.addini()`(添加`pytest.ini`/`pyproject.toml`配置项)。添加的选项可以通过`request.config.getoption()`在运行时获取。
# 插件项目结构
my-pytest-plugin/
README.md
pyproject.toml
src/
my_plugin/
__init__.py # 主插件代码
utils.py # 工具函数
reporters.py # 报告生成
tests/
test_plugin.py # 插件自身的测试
conftest.py
# pyproject.toml 配置
[project]
name = "my-pytest-plugin"
version = "0.1.0"
[project.entry-points.pytest11]
my_plugin = "my_plugin"
[tool.pytest.ini_options]
minversion = "7.0"
# 添加命令行选项的插件示例
# src/my_plugin/__init__.py
def pytest_addoption (parser):
"""添加自定义命令行选项"""
parser.addoption(
"--my-option" ,
action="store" ,
default ="default_value" ,
help="自定义选项说明"
)
parser.addini(
"my_config_key" ,
type ="string" ,
default ="" ,
help="pytest.ini / pyproject.toml 中的配置项"
)
def pytest_configure (config):
"""在pytest配置完成后执行"""
my_option = config.getoption("--my-option" )
my_config = config.getini("my_config_key" )
config.my_plugin_data = {
"option" : my_option,
"config" : my_config,
}
def pytest_report_header (config):
"""在测试报告头部添加信息"""
if hasattr(config, "my_plugin_data" ):
return [f"my-plugin: option={config.my_plugin_data['option']}" ]
# Hookwrapper 模式 -- 在测试执行前后插入逻辑
def pytest_runtest_protocol (item, nextitem):
"""hookwrapper 示例:测量每个测试的执行时间"""
import time
start = time.time()
yield # 让实际的测试逻辑执行
duration = time.time() - start
print (f" [perf] {item.nodeid} took {duration:.3f}s" , flush=True )
# 或者使用装饰器语法
@pytest.hookimpl(hookwrapper=True )
def pytest_pyfunc_call (pyfuncitem):
"""包装测试函数调用"""
print (f"\n [hook] 开始执行: {pyfuncitem.name}" )
yield
print (f" [hook] 执行完毕: {pyfuncitem.name}" )
# 在 conftest.py 中使用 pytest_plugins 加载子模块插件
# conftest.py
pytest_plugins = [
"my_project.plugins.database_cleaner" ,
"my_project.plugins.screenshot" ,
]
# 插件自身的测试 -- 使用 pytester 夹具
# tests/test_plugin.py
def test_my_option (pytester):
"""测试自定义命令行选项"""
pytester.makeconftest("""
import pytest
def pytest_addoption(parser):
parser.addoption("--my-name", action="store", default="world")
@pytest.fixture
def greeting(request):
name = request.config.getoption("--my-name")
return f"Hello, {name}!"
""" )
pytester.makepyfile("""
def test_greeting(greeting):
assert greeting == "Hello, world!"
def test_custom_greeting(greeting):
assert greeting == "Hello, pytest!"
""" )
result = pytester.runpytest("--my-name" , "pytest" )
result.assert_outcomes(passed=2)
核心要点: 自定义插件开发的核心是实现pytest钩子函数。`pytest_addoption`添加命令行和配置文件选项,`pytest_configure`初始化插件状态,hookwrapper模式实现中间件式的前后处理。插件必须通过`pyproject.toml`的`pytest11` entrypoint注册才能被自动发现。使用`pytester`夹具为插件编写测试是保证插件质量的最佳实践。
九、实战案例
案例一:自定义数据库清理插件
在大型项目中,数据库测试经常遇到"脏数据"问题——前面的测试创建的数据记录遗留到后续的测试中,导致不可预期的失败。本例实现一个数据库自动清理插件,在每次测试执行完成后自动清理指定表的数据,保证每个测试都在干净的数据库状态下运行。
该插件的工作原理:通过`pytest_runtest_teardown`钩子在每个测试结束后获取当前数据库连接,执行`TRUNCATE`或`DELETE`操作清理指定表。支持白名单(只清理哪些表)和黑名单(不清理哪些表)两种模式。插件还提供了`@pytest.mark.keep_data`标记,允许特定测试保留数据库状态供后续测试使用。通过`pytest_addoption`添加`--db-clean-mode`选项,支持`full`(全部清理)、`white_list`(仅清理白名单表)和`off`(不清理)三种模式。
案例二:自定义失败截图插件
在UI自动化测试中,当测试失败时能够自动截取浏览器或应用界面的截图,对于调试和溯源至关重要。本例实现一个基于Selenium或Playwright的失败截图插件,在测试失败时自动捕获并保存截图,同时将截图路径输出到测试报告中。
该插件的核心逻辑在`pytest_runtest_makereport`钩子中实现——该钩子在每个测试的setup/call/teardown阶段结束后都会触发,并携带`report.passed`属性指示测试是否通过。当检测到测试失败并且存在WebDriver夹具时,插件自动截图并保存到指定目录。截图文件名包含测试用例名称和时间戳,方便关联定位。插件还支持将截图嵌入到HTML测试报告中,通过`conftest.py`中定义的`pytest_terminal_summary`钩子输出截图统计信息。
# db_cleaner_plugin.py -- 数据库自动清理插件
"""
pytest插件:测试结束后自动清理数据库表
安装:将本文件放入项目的 conftest.py 中直接使用
或注册为独立插件通过 pyproject.toml 的 pytest11 entrypoint 加载
"""
import pytest
def pytest_addoption (parser):
"""添加数据库清理相关的命令行选项"""
group = parser.getgroup("db-cleaner" )
group.addoption(
"--db-clean-mode" ,
action="store" ,
default ="white_list" ,
choices=["full" , "white_list" , "off" ],
help="数据库清理模式: full(全部清理), white_list(白名单清理), off(不清理)"
)
parser.addini(
"db_clean_tables" ,
type ="linelist" ,
default =[],
help="白名单表名列表,仅清理这些表"
)
@pytest.hookimpl(trylast=True )
def pytest_runtest_teardown (item, nextitem):
"""每个测试结束后清理数据库"""
config = item.config
mode = config.getoption("--db-clean-mode" )
if mode == "off" :
return
# 检查是否有 keep_data 标记,跳过清理
marker = item.get_closest_marker("keep_data" )
if marker is not None :
return
# 执行清理
clean_tables(config, mode)
def clean_tables (config, mode):
"""根据模式清理数据库表"""
try :
from django.db import connection
white_list = config.getini("db_clean_tables" )
with connection.cursor() as cursor:
if mode == "full" :
# 获取所有用户表
cursor.execute("""
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT LIKE 'django_%'
""" )
tables = [row[0] for row in cursor.fetchall()]
else : # white_list
tables = white_list
for table in tables:
cursor.execute(f"TRUNCATE TABLE {table} CASCADE" )
config.hook.pytest_db_cleanup_completed(tables=tables)
except ImportError:
pass # 非Django环境跳过
# screenshot_plugin.py -- UI测试失败自动截图插件
import pytest
import os
from datetime import datetime
class ScreenshotPlugin :
"""pytest插件:UI测试失败时自动截图"""
def __init__(self , config):
self .config = config
self .screenshot_dir = config.getoption("--screenshot-dir" )
self .failed_tests = []
os.makedirs(self .screenshot_dir, exist_ok=True )
@pytest.hookimpl(hookwrapper=True )
def pytest_runtest_makereport (self , item, call):
"""测试报告生成时检查是否失败,若失败则截图"""
outcome = yield
report = outcome.get_result()
if report.when == "call" and not report.passed:
# 尝试获取 WebDriver 夹具
driver = item.funcargs.get("driver" ) or item.funcargs.get("page" )
if driver is not None :
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S" )
filename = f"{item.nodeid.replace('::', '_')}_{timestamp}.png"
filepath = os.path.join(self .screenshot_dir, filename)
try :
driver.screenshot(filepath)
self .failed_tests.append({
"test" : item.nodeid,
"screenshot" : filepath,
})
# 在报告信息中添加截图路径
report.user_properties.append(
("screenshot" , filepath)
)
except Exception as e:
print (f"[screenshot] 截图失败: {e}" )
def pytest_addoption (parser):
parser.addoption(
"--screenshot-dir" ,
action="store" ,
default ="screenshots" ,
help="失败截图保存目录"
)
def pytest_configure (config):
config.pluginmanager.register(
ScreenshotPlugin(config),
"screenshot_plugin"
)
def pytest_terminal_summary (terminalreporter, exitstatus, config):
"""终端额外输出截图统计信息"""
plugin = config.pluginmanager.get_plugin("screenshot_plugin" )
if plugin and plugin.failed_tests:
terminalreporter.section("失败截图信息" )
for entry in plugin.failed_tests:
terminalreporter.write_line(
f" {entry['test']}: {entry['screenshot']}" ,
yellow=True
)
# 插件的使用示例
# conftest.py 中加载自定义插件
pytest_plugins = [
"my_plugins.db_cleaner_plugin" ,
"my_plugins.screenshot_plugin" ,
]
# pytest.ini 中配置插件参数
[pytest]
addopts =
--db-clean-mode=white_list
--screenshot-dir=reports/screenshots
--html=reports/test_report.html
db_clean_tables =
orders
order_items
payments
# 测试中使用 keep_data 标记保留数据
@pytest.mark.keep_data
def test_create_and_keep (client):
# 此测试结束后,创建的数据不会被清理
response = client.post("/orders/" , {"product" : "book" })
assert response.status_code == 201
# 运行命令
pytest tests/ --db-clean-mode=full --screenshot-dir=./screenshots -v
核心要点: 两个实战案例展示了自定义插件的完整开发流程。数据库清理插件通过`pytest_runtest_teardown`钩子实现测试隔离,失败截图插件通过`pytest_runtest_makereport`钩子捕获失败状态。关键设计模式包括:hookwrapper实现无侵入包装、`pytest_addoption`实现可配置性、`pytest_configure`进行插件初始化。这些模式可以复用于各种自定义测试扩展需求。
总结: pytest插件体系是pytest最强大的特性之一。从开箱即用的第三方插件(xdist并行、cov覆盖率、timeout超时)到自定义开发的专用插件,这个生态让pytest能够适应几乎任何测试场景。掌握插件开发的核心钩子函数(pytest_addoption、pytest_configure、pytest_runtest_protocol、pytest_runtest_makereport等)和项目结构(pyproject.toml entrypoint注册),就可以根据项目需求打造专属测试工具链。建议团队成员共同维护项目级插件库,积累测试基础设施,持续提升测试效率和可靠性。