【pytest高阶】-2- 内置hook插件扩展机制和定制开发

发布于:2025-08-01 ⋅ 阅读:(17) ⋅ 点赞:(0)

一、可爱版 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 类中的 runtestreportinfo 这类方法属于 “框架内部接口钩子”—— 它们是框架提前定义好的 “接口方法”,要求子类必须(或可以)重写,以实现特定功能。


这类钩子 不需要插件声明,因为它们是框架内部的 “约定”:只要你继承了 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:

  1. 扫描项目目录:pytest 从指定目录(默认当前目录)开始,递归扫描所有文件和文件夹。
  2. 根据文件 / 内容创建 collector
    • 当扫描到一个 .py 文件(比如 test_demo.py),pytest 会创建一个 Module 类型的 collector,负责处理这个文件里的测试内容。
    • 如果这个 .py 文件里有测试类(比如 class TestXXX:),pytest 会为这个类创建一个 Class 类型的 collector。
    • 如果文件里有测试函数(比如 def test_xxx():),会创建 Function 类型的 collector。
  3. 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
  • 配置信息(如命令行参数)
  • 插件设置
  • 其他环境信息

 

钩子函数为什么需要父对象?

钩子函数的核心作用是 “干预测试收集过程”,而父对象是这个过程中的关键节点。通过传递父对象,你可以:

  1. 获取上下文信息:比如从父收集器的 fspath 获取测试文件所在目录,从而定位测试数据文件。
  2. 保持层级关系:让你的自定义测试项正确地挂载到测试树中,确保报告和执行顺序正确。
  3. 复用配置:继承父对象的配置(如 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 框架推荐的方式,它有两个好处:

 

  1. 自动处理父子关系from_parent 会帮你把 item 和 collector 关联起来,确保 item 能继承 collector 的配置(比如路径、测试上下文)。
  2. 符合框架规范:pytest 的各种内置测试项(比如函数测试、类测试)都是用 from_parent 创建的,统一方式能避免兼容性问题。


网站公告

今日签到

点亮在社区的每一天
去签到