pytest插件体系:常用插件与自定义开发

Python 测试与调试专题 · 扩展pytest能力的插件生态

专题: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注册),就可以根据项目需求打造专属测试工具链。建议团队成员共同维护项目级插件库,积累测试基础设施,持续提升测试效率和可靠性。