一、可爱版 pytest 插件 & hook 知识大礼包 🎁
准备好和 pytest 插件来一场可爱约会了吗~ 咱们用超甜的 emoji 把知识串成棉花糖🍡 一口一个知识点!
一、 pytest 插件:框架的 “魔法百宝箱” 🧙♀️
1. 核心秘密:75% 都是插件!
pytest 源码里 3/4 都是插件代码!这说明啥?
→ pytest 本身就是个 “插件大礼包”🎁 所有灵活扩展的功能,全靠插件机制撑起来!
2. 插件能做啥?
给 pytest 装插件,就像给手机装 App~
- 想生成花里胡哨的测试报告?装报告插件!📊
- 想自定义测试用例收集规则?装收集插件!🧹
- 想在测试前后自动初始化数据?装初始化插件!🧪
- 总之:插件 = pytest 的 “超能力扩展包”,想改功能、加功能,全靠它!
二、写 pytest 插件的 “甜甜四步法” 🍰
给 pytest 写插件,就像给朋友做定制礼物🎁 分四步:
1. 第一步:摸清 pytest 的 “钩子清单”
pytest 有 52 个左右的 hook(钩子),全藏在 _pytest/hookspec.py
文件里!
每个 hook 长这样:
@hookspec
def pytest_addhooks(pluginmanager): ...
→ 你需要知道:
- 名字:比如
pytest_addhooks
是 hook 的 “身份证”~ - 参数:它需要啥参数(比如
pluginmanager
),插件里的函数必须 “对得上”! - 返回值:它要返回啥(比如
None
),插件实现时得 “按规矩来”~ - 执行时机:它在 pytest 运行的哪个阶段触发(比如 “初始化时”“收集测试用例时”)
2. 第二步:对准需求选 hook 🎯
先想清楚:你想让 pytest 做啥?
- 需求:“想在测试报告里加表情包!” → 选
Reporting hooks
相关的 hook~ - 需求:“想跳过特定环境的测试!” → 选
Test running hooks
相关的 hook~ - → 整理出能解决你痛点的 hook,就像 “选对工具做手工”✂️
3. 第三步:实现 hook(写插件!) ✨
找到想扩展的 hook 后,写插件函数~ 记得贴 @hookimpl
标签!
示例:想在测试前打印 “加油”💪
from pluggy import hookimpl
def pytest_runtest_setup(item):
print("💪 测试加油!这是插件扩展的逻辑~")
→ 关键点:
- 函数名和 hook 名字一致(比如
pytest_runtest_setup
)~ - 参数和 hook 声明的参数一致(比如
item
代表测试用例)~ - 用
@hookimpl
装饰器标记,告诉 pytest:“我是插件!”
4. 第四步:打包插件(分享甜蜜!) 📦
写好的插件,要打包成 “安装包”~
- 用
setuptools
写setup.py
,把插件打包成whl
或tar.gz
文件~ - 别人装你的插件,就像
pip install 你的插件包
,超方便!
三、 hook 的 “执行时机”: pytest 的生命周期派对 🎉
pytest 运行时,像一场 “分阶段派对”,每个阶段触发不同的 hook~
1. 官方分类(超甜版) 🍬
pytest 把 hook 分成 6 类,对应派对的 6 个环节:
分类名 | 派对环节比喻 | 作用 |
---|---|---|
Bootstrapping hooks | 派对筹备 | 初始化 pytest 最基础的配置 |
Initialization hooks | 宾客签到 | 初始化插件、配置,准备测试 |
Collection hooks | 菜品收集 | 收集要执行的测试用例(找 “测试菜”) |
Test running hooks | 吃饭环节 | 执行测试用例(干饭!) |
Reporting hooks | 写用餐评价 | 生成测试报告(写好评 / 差评) |
Debugging/Interaction hooks | 突发状况处理 | 调试、交互(比如测试卡住时干预) |
2. 执行顺序:派对流程 🎊
pytest 运行时,hook 会按以下顺序 “登场”:
筹备(Bootstrapping)
→ 签到(Initialization)
→ 收菜(Collection)
→ 干饭(Test running)
→ 写评价(Reporting)
→ 处理突发状况(Debugging)
→ 每个环节的 hook 依次调用,像接力赛一样~ 前一个环节的 hook 跑完,下一个接上!
四、生成 & 读 pytest 的 debug log:看 hook 怎么 “蹦迪” 🕺
想知道 hook 具体啥时候执行?用 debug log 看它们的 “蹦迪轨迹”!
1. 生成 log:开启 debug 模式
在命令行执行:
# Windows
set PYTEST_DEBUG=1
pytest 你的测试文件.py > pytest.log
# Linux/Mac
PYTEST_DEBUG=1 pytest 你的测试文件.py > pytest.log
→ 这会让 pytest 把 所有 hook 调用、执行细节 都记在 pytest.log
里,像 “蹦迪全程录像”!
2. 读 log:找 hook 的 “蹦迪镜头” 🎥
打开 pytest.log
,搜索 pytest_
开头的函数名(比如 pytest_runtest_setup
),就能看到:
DEBUG: pytest: Calling hook pytest_runtest_setup
DEBUG: pytest: Hook pytest_runtest_setup executed with args (...)
→ 这说明:
Calling hook...
:pytest 要开始调用这个 hook 啦(喊它 “上台蹦迪”)~executed with args...
:hook 执行了,还带了参数(比如测试用例信息)~
3. 看执行时机:派对时间线 🕒
把所有 hook 的 Calling hook
日志按顺序排,就能画出 pytest 的 “生命周期时间线”~
比如:
Calling hook pytest_collectstart # 开始收测试用例
Calling hook pytest_runtest_setup # 准备执行测试
Calling hook pytest_runtest_call # 正在执行测试
Calling hook pytest_runtest_teardown # 测试结束清理
→ 这样就清楚每个 hook 在 “派对” 的哪个时间点蹦迪啦!
总结: pytest 插件 & hook 超甜关系 💕
- pytest 是 “派对主办方”,提供基础流程~
- hook 是 “派对环节接口”,每个环节开放给插件扩展~
- 插件 是 “派对嘉宾”,用
@hookimpl
实现 hook,在对应环节 “表演节目”~
想玩转 pytest 源码或写插件?记住这颗 “糖”:hook 是桥梁,插件是魔法,pytest 靠它们无限扩展! 🍭
二、用 pytest hook 自动生成测试用例 🧪✨
一、项目结构(更简单!)
my_project/
├── conftest.py # 核心:写插件逻辑
├── test_data/ # 测试数据文件夹
│ ├── test_add.json
│ └── test_sub.json
└── test_demo.py # 测试文件(触发插件)
二、编写 conftest.py(核心!)
# conftest.py
from _pytest.nodes import Item
from pathlib import Path
import json
# 1. 自定义测试项类:实现测试逻辑
class CatDataItem(Item):
def __init__(self, *, name, parent, data):
super().__init__(name=name, parent=parent)
self.data = data # 保存测试数据
def runtest(self):
a = self.data["a"]
b = self.data["b"]
expected = self.data["c"]
result = a + b
assert result == expected, f"{a} + {b} != {expected}"
def reportinfo(self):
return self.fspath, 0, f"CatData: {self.name}"
# 2. 实现 pytest_pycollect_makeitem hook
def pytest_pycollect_makeitem(collector, name, obj):
# 只处理特定测试文件(这里假设文件名包含 "test_data")
if "test_data" not in collector.name:
return None # 不是目标文件,走默认流程
# 获取测试数据文件夹路径(相对于当前执行目录)
data_dir = Path(__file__).parent / "test_data"
# 收集所有 .json 文件
test_files = list(data_dir.glob("test_*.json"))
if not test_files:
return None # 没有测试数据文件,走默认流程
# 为每个 JSON 文件创建一个测试项
items = []
for test_file in test_files:
with open(test_file, "r", encoding="utf-8") as f:
test_data = json.load(f)
# 创建测试项,名字取自 JSON 中的 "name" 字段
item = CatDataItem.from_parent(
parent=collector,
name=test_data["name"],
data=test_data
)
items.append(item)
return items # 返回所有自定义测试项
三、编写测试数据(test_data/ 目录)
1. test_add.json
{
"name": "test_addition",
"a": 1,
"b": 2,
"c": 3
}
2. test_sub.json
{
"name": "test_subtraction",
"a": 5,
"b": 2,
"c": 3
}
四、编写测试文件(test_demo.py)
# test_demo.py
# 文件名包含 "test_data",会触发我们的插件逻辑
def test_data_demo():
pass # 这个函数本身不会被执行,只是作为触发插件的载体
五、运行测试(无需安装插件!)
pytest -v
预期输出:
test_demo.py::test_data_demo::CatData: test_addition PASSED [ 50%]
test_demo.py::test_data_demo::CatData: test_subtraction PASSED [100%]
实际输出:
六、关键区别(与插件方式对比)
对比项 | conftest.py 方式 | 插件方式 |
---|---|---|
位置 | 项目根目录或测试目录下的 conftest.py | 独立的 Python 包(setup.py) |
安装 | 无需安装,pytest 自动发现 | 需要 pip install 或 poetry |
适用场景 | 单个项目自用 | 多个项目共享或发布到 PyPI |
复杂度 | 简单,适合快速实现 | 复杂,适合大型插件 |
七、进阶玩法(在 conftest.py 中)
1. 动态数据目录(通过命令行参数)
# conftest.py
def pytest_addoption(parser):
parser.addoption(
"--data-dir", action="store", default="test_data", help="测试数据目录"
)
def pytest_pycollect_makeitem(collector, name, obj):
# ... 其他代码不变 ...
# 获取命令行指定的数据目录
data_dir = Path(collector.config.getoption("--data-dir"))
# ... 后续代码不变 ...
使用方法:
pytest --data-dir=my_custom_data -v
2. 支持多种数据格式(JSON + YAML)
# 需要先安装:pip install pyyaml
import yaml
def pytest_pycollect_makeitem(collector, name, obj):
# ... 其他代码不变 ...
for test_file in test_files:
with open(test_file, "r", encoding="utf-8") as f:
if test_file.suffix == ".json":
test_data = json.load(f)
elif test_file.suffix == ".yaml":
test_data = yaml.safe_load(f)
# ... 后续代码不变 ...
顺便记一下这些文件属性的小tips:
属性 | 示例 (addition.json ) |
输出 |
---|---|---|
.suffix |
Path("addition.json").suffix |
.json |
.stem |
Path("addition.json").stem |
addition |
.name |
Path("addition.json").name |
addition.json |
.parent |
Path("addition.json").parent |
test_data |
.parts |
Path("test_data/addition.json").parts |
('test_data', 'addition.json') |
三、超详细拆分和解读代码🐱
一、导入模块
from _pytest.nodes import Item
from pathlib import Path
import json
Item
:pytest 框架中的测试项基类,我们要继承它来创建自定义测试Path
:Python 的路径操作工具,比字符串处理路径更方便json
:处理 JSON 格式数据的模块
二、自定义测试项类
class CatDataItem(Item):
def __init__(self, *, name, parent, data, path):
super().__init__(name=name, parent=parent)
self.data = data
self.path = path # 保存测试数据文件路径,用于调试
CatDataItem
:继承自Item
的自定义测试项类__init__
:初始化函数,创建对象时自动调用name
:测试名称parent
:父级测试容器data
:从 JSON 文件加载的测试数据path
:JSON 文件路径(用于调试)
super().__init__
:调用父类的初始化函数
三、测试执行逻辑
def runtest(self):
a = self.data["a"]
b = self.data["b"]
expected = self.data["c"]
result = a + b
assert result == expected, f"{a} + {b} != {expected} (from {self.path.name})"
runtest
:pytest 框架要求的方法,定义测试执行逻辑- a=self.data["a"] 在data里获取键为a的值 把这个值赋值给变量a
四、测试报告信息
def reportinfo(self):
return self.fspath, 0, f"CatData: {self.name}"
reportinfo
:定义测试报告中显示的信息- 返回元组:(文件路径,行号,测试名称)
- 第一个元素
self.fspath
:
测试项关联的文件路径(比如test_demo.py
的路径),用于在报告中显示 “这个测试来自哪个文件”。 - 第二个元素
0
:
测试项在文件中的行号(这里固定为0
,因为你的测试是动态生成的,没有实际行号)。 - 第三个元素
f"CatData: {self.name}"
:
测试项的描述信息,会显示在报告中。self.name
是你定义的测试名称(比如test_add_addition
),前面加CatData:
是为了区分这是你自定义的测试项。
pytest 有两类核心钩子,它们的工作方式不同:
1. 插件级钩子(函数形式)
就是在 conftest.py
中写的 pytest_pycollect_makeitem
这类函数 —— 它们必须以 pytest_
开头,放在 conftest.py
中,pytest 会自动扫描并加载这些函数,作为插件扩展逻辑
这类钩子需要 “显式声明”(通过命名规范和文件位置),因为它们是 “外部插件” 向框架注入逻辑的入口。
2. 类级钩子(方法形式)
Item
类中的 runtest
、reportinfo
这类方法属于 “框架内部接口钩子”—— 它们是框架提前定义好的 “接口方法”,要求子类必须(或可以)重写,以实现特定功能。
这类钩子 不需要插件声明,因为它们是框架内部的 “约定”:只要你继承了 Item
类,并重写了这些方法,框架就会自动识别并调用它们,无需额外声明。
五、防止重复处理的全局变量
# 用于跟踪已处理的文件,避免重复
processed_files = set()
set()
:无序且唯一的数据集合- 记录已经处理过的文件,避免重复生成测试
🐍 Python 里超好用的
set()
集合!它就像一个神奇的小口袋,装东西有特别的规矩哦~ 😜🌟 什么是
set()
?set
是 Python 的一种数据类型,中文叫 “集合”。它最大的特点就是:里面的元素不能重复,而且没有固定顺序~ 就像一堆散乱但绝不重复的糖果🍬,拿出来的时候顺序说不定会变,但每种糖只会有一颗!🛠️ 基本用法
1. 创建集合
# 用 {} 直接创建(注意:空集合不能用 {},要用 set()!) candies = {"草莓糖", "牛奶糖", "巧克力"} print(candies) # 可能输出:{'牛奶糖', '草莓糖', '巧克力'}(顺序不定) # 用 set() 转换其他类型(比如列表) fruit_list = ["苹果", "香蕉", "苹果"] # 列表里有重复的苹果哦 fruit_set = set(fruit_list) print(fruit_set) # 输出:{'苹果', '香蕉'}(自动去重啦!)
2. 给集合 “加东西”
用
add()
方法,一次加一个元素~ 🍡pets = {"猫", "狗"} pets.add("兔子") # 加一只兔子 pets.add("猫") # 再加一只猫?不会成功哦,因为集合里不能有重复! print(pets) # 输出:{'猫', '狗', '兔子'}
3. 从集合 “拿东西”
用
remove()
或discard()
方法~ 注意remove()
删不存在的元素会生气(报错),discard()
会默默忽略哦~ 😌toys = {"积木", "娃娃", "汽车"} toys.remove("娃娃") # 删掉娃娃 toys.discard("飞机") # 删掉不存在的飞机,不报错 print(toys) # 输出:{'积木', '汽车'}
4. 集合的 “魔法操作”
集合最擅长做 “交集、并集、差集”,就像玩拼图一样~ 🧩
a = {1, 2, 3, 4} b = {3, 4, 5, 6} # 交集(两个集合都有的元素)& print(a & b) # 输出:{3, 4} # 并集(两个集合所有元素,去重)| print(a | b) # 输出:{1, 2, 3, 4, 5, 6} # 差集(a有但b没有的元素)- print(a - b) # 输出:{1, 2}
5. 检查元素在不在集合里
用
in
关键词,超快速!比列表检查快很多哦~ ⚡snacks = {"薯片", "饼干", "果冻"} print("薯片" in snacks) # 输出:True(薯片在里面!) print("巧克力" in snacks) # 输出:False(没有巧克力~)
🎯 什么时候用
set()
?- 想 去重 的时候(比如清理重复数据)
- 想快速 判断元素是否存在 的时候(比列表快 N 倍!)
- 想做 集合运算 的时候(比如找共同元素、不同元素)
六、测试收集钩子函数
def pytest_pycollect_makeitem(collector, name, obj):
# 只处理 my_test 目录下的 test_demo.py
if not hasattr(collector, 'fspath'):
return None
这里是在检查 collector
(pytest 的收集器对象)有没有 fspath
这个属性。
fspath
是 “文件路径” 的意思,只有当collector
正在处理具体文件时,才会有这个属性;- 如果
collector
处理的是文件夹(或者其他非文件对象),就没有fspath
,这时候就返回None
跳过处理。
collector
是 pytest 中一类特殊的对象,中文叫 “收集器”,作用是 “发现并收集测试用例”。不同类型的 collector
负责处理不同的测试结构:
Module
类型的 collector:处理.py
测试文件(比如test_demo.py
)。Class
类型的 collector:处理测试类(比如class TestDemo:
)。Function
类型的 collector:处理测试函数(比如def test_add():
)。- 还有
Package
(处理测试包)、File
(处理非 Python 文件)等类型。
collector 是在哪里创建的?
当你运行 pytest
命令时,pytest 会按以下流程自动创建 collector:
- 扫描项目目录:pytest 从指定目录(默认当前目录)开始,递归扫描所有文件和文件夹。
- 根据文件 / 内容创建 collector:
- 当扫描到一个
.py
文件(比如test_demo.py
),pytest 会创建一个Module
类型的 collector,负责处理这个文件里的测试内容。 - 如果这个
.py
文件里有测试类(比如class TestXXX:
),pytest 会为这个类创建一个Class
类型的 collector。 - 如果文件里有测试函数(比如
def test_xxx():
),会创建Function
类型的 collector。
- 当扫描到一个
- collector 工作:每个 collector 会 “收集” 自己负责的测试元素(比如
Module
收集文件里的所有测试类和函数),并生成对应的测试项(Item
)。
在的pytest_pycollect_makeitem
钩子函数中,collector
是 pytest 自动传递给你的 “已经创建好的收集器”。比如:
- 当 pytest 扫描到
my_test/test_demo.py
时,会创建一个Module
类型的 collector(对应这个文件),然后调用你的钩子函数,并把这个 collector 作为参数传进来。 - 你的钩子函数通过判断这个 collector 的属性(比如
fspath
路径),决定是否要为它生成自定义测试项(CatDataItem
)。
七、路径检查与过滤
fspath = Path(collector.fspath)
# 关键过滤:只处理 test_demo.py 中的 test_demo 函数
if (
fspath.parent.name != "my_test"
or fspath.name != "test_demo.py"
or name != "test_demo"
):
return None
fspath
:转换为 Path 对象的文件路径- 只处理
my_test/test_demo.py
文件中的test_demo
函数 - 其他文件或函数会被忽略
八、防止重复处理
# 防止重复处理
if fspath in processed_files:
return None
processed_files.add(fspath)
- 如果该文件已经处理过,直接返回
- 否则将文件路径添加到已处理集合中
九、加载 JSON 数据
# 加载 JSON 数据
data_dir = fspath.parent / "test_data"
# 检查数据目录是否存在
if not data_dir.exists():
return None
test_files = list(data_dir.glob("*.json"))
if not test_files:
return None
/ "test_data"
- 这里的
/
不是除法哦!在pathlib
模块里,它是 路径拼接符,专门用来把 “父目录” 和 “子目录 / 文件名” 拼在一起。 - 所以
fspath.parent / "test_data"
就是把my_test
和test_data
拼起来,得到my_test/test_data
。
- 这里的
data_dir
:测试数据目录路径exists()
:检查目录是否存在glob("*.json")
:查找所有 JSON 文件
十、生成测试项
items = []
for test_file in test_files:
with open(test_file, "r", encoding="utf-8") as f:
test_data = json.load(f)
# 使用 JSON 中的 name 字段作为测试名称
test_name = test_data.get("name", test_file.stem)
# 使用文件名作为后缀(去掉 .json 扩展名)
suffix = test_file.stem
unique_name = f"{test_name}_{suffix}" # 例如: test_add_addition
item = CatDataItem.from_parent(
parent=collector,
name=unique_name,
data=test_data,
path=test_file
)
items.append(item)
return items
CatDataItem.from_parent(...)
的作用和 CatDataItem(...)
一样,都是创建 CatDataItem
类的实例(对象)。只不过 from_parent
是框架推荐的 “便捷工厂方法”,专门用于从父对象(这里的 collector
)创建子对象。
from_parent
是CatDataItem
类继承自父类Item
的一个类方法(用@classmethod
定义的方法,专门用来创建实例)参数含义
parent=collector
:指定当前测试项的父对象是collector
(之前说的 “收集器”,比如test_demo.py
对应的模块对象)。
为什么父对象很重要?
在这个层级结构中,每个收集器 / 测试项都有明确的父对象:
Module
收集器的父对象是Package
或Directory
;Class
收集器的父对象是Module
;Function
收集器的父对象是Class
或Module
;- 你的
CatDataItem
测试项的父对象是Module
(因为它来自测试文件)。
父对象包含了重要的上下文信息,比如:
- 文件路径(
fspath
) - 配置信息(如命令行参数)
- 插件设置
- 其他环境信息
钩子函数为什么需要父对象?
钩子函数的核心作用是 “干预测试收集过程”,而父对象是这个过程中的关键节点。通过传递父对象,你可以:
- 获取上下文信息:比如从父收集器的
fspath
获取测试文件所在目录,从而定位测试数据文件。 - 保持层级关系:让你的自定义测试项正确地挂载到测试树中,确保报告和执行顺序正确。
- 复用配置:继承父对象的配置(如 pytest.ini 中的设置)。
name=unique_name
:测试项的名称(比如test_add_addition
)。data=test_data
:从 JSON 文件加载的测试数据(比如{"a":1, "b":2, "c":3}
)。path=test_file
:测试数据文件的路径(比如test_data/addition.json
)。item = ...
这行代码会创建一个
CatDataItem
实例,并把它赋值给变量item
—— 这就是实例化的结果!items.append(item)
把创建好的
item
实例添加到items
列表中,最后统一返回给 pytest,让 pytest 知道 “这些是要执行的测试项”。
为什么不用 CatDataItem(...)
直接实例化?
虽然也可以用 CatDataItem(name=..., parent=...)
直接创建实例,但 from_parent
是 pytest 框架推荐的方式,它有两个好处:
- 自动处理父子关系:
from_parent
会帮你把item
和collector
关联起来,确保item
能继承collector
的配置(比如路径、测试上下文)。 - 符合框架规范:pytest 的各种内置测试项(比如函数测试、类测试)都是用
from_parent
创建的,统一方式能避免兼容性问题。