pytest 的钩子函数体系绝大多数测试开发都用过 pytest_runtest_logreport、pytest_terminal_summary 这类执行期钩子做用例统计、JUnit 报告、失败重跑。但如果你要的数据是「这次到底收集到了哪些测试用例、命中哪些用例、参数化展开后一共多少条」也就是在用例真正开始跑之前就要拿到一份完整的命中清单执行期钩子就有点绕了跑完一条统计一条还得自己维护状态被跳过和 setUp 阶段失败的用例还得小心别漏。这个需求的最佳切入点其实是 pytest_collection_finish。简单说pytest_collection_finish 是 pytest 在「收集阶段全部结束、执行阶段正式开始之前」触发的一个钩子。你在这个钩子里拿到的 session.items就是这一轮 pytest 命中所有筛选条件之后、即将执行的完整用例集合。做测试平台上报、精准回归范围确认、用例资产盘点、命中率分析用这个钩子比在终端解析那行 “collected 123 items” 或统计执行报告干净得多。这篇我直接分享自己用这个钩子的完整思路和踩坑记录适合三类人正在写内部测试平台、需要用例级清单上报的想统计参数化展开后实际用例量、按标记/文件/类维度做大盘的以及对 pytest 收集机制本身好奇、想搞明白 collection 阶段到底发生了什么的人。1. 先搞清楚 pytest 的 Collection 阶段到底给了我们什么很多同学写 pytest 插件上来就找执行期钩子其实 pytest 的生命周期里收集阶段才是最该插手的时机。我先把这个阶段讲透后面你写钩子会顺手很多。1.1 Collection一个大筛子把测试文件变成用例对象pytest 启动之后并不是直接跑用例而是先进入 collection 阶段。这个阶段会从 rootdir 开始沿着目录一层层往下扫描匹配 test_*.py 或 *test.py 文件再在每个测试文件里匹配以 test开头的函数、TestXxx 类里的 test_ 方法以及 pytest.mark.parametrize 展开出来的参数组合。每一个最终匹配到的单元会被包装成一个 Item 对象。你可以把 collection 理解成一个大筛子第一步筛出所有“看起来像测试”的节点第二步再做命令行筛选比如 -k 表达式、-m 标记表达式、--ignore 忽略路径第三步给参数化用例做笛卡尔积展开。等这些步骤全部走完留下来的就是“命中”的用例集合。你想想看我们说的“收集测试命中用例数据”本质上就是把筛子输出端拿到的那一摞清单完整记录下来。这里有个容易忽略的点参数化是在收集阶段完成的。一个测试函数只要写了 pytest.mark.parametrize 传了 5 组参数收集之后就变成 5 条独立的 itemnodeid 长得不一样执行时也按 5 条独立用例算。所以你在终端看到 “collected 120 items”往往是你写代码时数的 20 个测试函数被参数化一展开就成了 120。这种数据只有收集阶段才能准确拿到跑到执行期再数就得累死。1.2 收集钩子全家桶三个钩子怎么分工pytest 在收集阶段暴露了不止一个钩子想清楚分工才不会用错。我整理成表钩子触发粒度典型用途pytest_collectstart每个 collector目录/文件/类开始收集时做收集进度条、日志pytest_itemcollected每个 item 成功生成时对单个用例打点、计数pytest_collection_modifyitems(session, config, items)全部 item 收集完毕、筛选完成后对 items 列表做增删改排序选择/排序插件的地盘pytest_collection_finish(session)collection 完全收尾、执行前读取最终 items做大盘统计、上报、落地pytest_collectstart 和 pytest_itemcollected 是“过程性”钩子粒度太碎。pytest_collection_modifyitems 和 pytest_collection_finish 是“结果性”钩子都在收集结束后触发差别在于顺序modifyitems 在前finish 在后。换句话说modifyitems 阶段其他插件还可能对你手里的 items 动手比如 pytest-randomly 在这里乱序比如你想在这里按优先级筛掉一批用例到了 finish 阶段集合已经定了不适合再改只适合读。1.3 为什么放弃执行期钩子选择 collection 阶段拿数据我最早做用例统计用的也是 pytest_runtest_logreport后来发现三个问题第一执行期钩子只能拿到“已经执行出结果”的用例一个用例如果被 skipIf 跳过在 setup 阶段就被拦截了你要统计“本轮全部命中用例”就怎么都数不全。第二收集阶段能看到还没开始执行的完整清单可以做预检查——比如命中集为空、命中集和预期范围不一致这在执行前就该发现。第三执行期数据往往带着状态passed、failed、error、skipped如果你只是想回答“这轮跑了哪些用例、一共多少、参数化规模多大”这样的问题这些状态信息反而是噪音。所以我的经验是凡是要“用例清单”的一律用 finish 钩子凡是要“执行结果”的才用执行期钩子。两个切面的定位完全不同。2. 钩子签名与前置机制动手前先读懂这些细节光知道 finish 钩子能拿到 session.items 还不够签名里藏着很多细节不看清楚写出来的代码脆弱得很。2.1 签名与触发时机顺便理清 -k / -m 筛选的影响钩子签名非常简单def pytest_collection_finish(session: pytest.Session) - None: ...只有 session 一个参数。触发时机是整个收集机制彻底结束后、pytest_runtestloop 开始前。我用一句话记忆modifyitems 之后、执行之前。这里有个很关键的语义触发当时session.items 已经是经过命令行筛选的最终命中集合。也就是说你执行 pytest -k test_login 时finish 钩子里看到的 items 已经只剩包含 test_login 的用例执行 pytest -m p0 时items 已经只剩带 p0 标记的用例。这不是坏事恰恰相反——「命中」二字本来就应该是筛选后的结果。如果你想对比筛选前全量就另外用无筛选的 --collect-only 建立基线后面我会讲这个做法。另一个容易被忽略的点即使你用了 -x失败即停、-k 筛完一个不剩、或者收集阶段出了一些非致命问题这个钩子照样会触发。所以这也是一个理想的“兜底检查点”比如在这里判断 len(session.items) 0直接打警告避免后面执行阶段空跑一趟才发现没收集到用例。2.2 session.items 里每个元素都是什么session.items 是一个列表每个元素是 pytest.Item 的实例。你平时写的测试函数收集之后最常见的类型是 pytest.Function但 pytest 也支持 doctest、自定义 collector所以代码里别假设每个 item 都有 module 或 cls。拿一个普通用例举例item 身上这些属性非常有用属性说明注意点nodeid唯一标识如 test_demo.py::TestLogin::test_login_ok[case1]多级路径 类 函数 参数name测试函数显示名参数化时末尾可能带 [参数id]originalname参数化前的原始函数名pytest 6.2 才有path / fspath文件路径新版用 path旧版用 fspath最好做个兼容module所属模块对象doctest item 可能是 Nonecls所属测试类模块级函数没有 clskeywords关键字字典包含类名、函数名、mark 名、参数名markers该 item 上的所有 mark可能包含自动生成的 parametrize 标记callspec参数化调用信息含 params 和 id没有参数化的 item 没有这个属性location三元组 (文件路径, 行号, 名)定位用例很方便刚开始用的时候我老想着把 item 整个丢进 JSON后来发现 item 对象里包含大量不可序列化、还可能带 fixture 实例的东西序列化几乎必然翻车。正确姿势是写一个转换函数只抽取你要的标量字段。2.3 参数化用例的 nodeid 与 callspec 解析参数化用例的 nodeid 长这样tests/test_demo.py::TestCalc::test_add[a-b-1]nodeid 的本质是用 :: 把模块路径、类名、测试函数名串联起来末尾的 [a-b-1] 是参数组合标识。坑点在于参数内容本身可能包含逗号、中文、甚至方括号你要是用 nodeid.rsplit([, 1) 暴力拆遇到参数值里带左括号就炸了。我推荐用 callspec 来解析if hasattr(item, callspec): callspec item.callspec param_id getattr(callspec, id, ) params { k: repr(v) for k, v in getattr(callspec, params, {}).items() }callspec.id 是 pytest 根据参数值或你传入的 ids 生成的参数组合标识比你自己从 nodeid 里抠字符串靠谱得多。callspec.params 则是一个 dictkey 是参数名value 是参数值。这里还要提个醒参数值可能是 fixture 实例、字典、甚至类对象直接扔进 JSON 必然报错所以上面代码里我用了 repr()输出到报告里至少还能看个大概。如果担心参数里有密码等敏感信息这里要么只保留 param_id要么在存储前做脱敏。3. 实操把命中用例数据落地成可用数据理论铺垫够了下面直接上代码。我会从一个最小 demo 开始逐步扩展到一个能直接用到生产环境的版本。3.1 最小实现先打印一份用例清单验证切面第一步在项目根目录建一个 conftest.py写最简版本import pytest pytest.hookimpl(tryfirstTrue) def pytest_collection_finish(session): print(f\n[pytest_collection_finish] 共收集到 {len(session.items)} 条用例, flushTrue) for item in session.items: print( , item.nodeid, flushTrue)注意我加了 tryfirstTrue意思是如果还有其他插件也实现了这个钩子我这个先跑。这里的意图是尽快把命中清单打出来避免其他插件的耗时代码拖慢输出。跑一下pytest --collect-only -q如果 conftest.py 放对了位置你会在终端看到我们打印的用例清单。这一步验证完说明钩子本身已经通了接下来所有逻辑都往这个函数里放。3.2 完整输出把命中数据结构化落地成 JSON打印到终端只适合临时看真正做平台上报、用例盘点得把数据落成结构化文件。下面这个版本是我实际项目里改出来的可以直接抄import json from datetime import datetime from pathlib import Path import pytest def _safe_str(value): try: return str(value) except Exception: return unserializable def _item_to_dict(item): path str(getattr(item, path, None) or getattr(item, fspath, )) module getattr(item, module, None) d { nodeid: item.nodeid, name: getattr(item, originalname, item.name), path: path, module: getattr(module, __name__, None) if module else None, line: item.location[1] if getattr(item, location, None) else None, keywords: sorted(getattr(item, keywords, {}).keys()), markers: [m.name for m in item.iter_markers()], parametrized: hasattr(item, callspec), } if d[parametrized]: callspec item.callspec d[param_id] getattr(callspec, id, ) or d[params] { k: _safe_str(v) for k, v in getattr(callspec, params, {}).items() } return d pytest.hookimpl(tryfirstTrue) def pytest_collection_finish(session): payload { generated_at: datetime.now().isoformat(), command_args: list(getattr(session.config, args, [])), total: len(session.items), cases: [_item_to_dict(item) for item in session.items], } output_path Path(session.config.rootdir) / collection_report.json output_path.write_text( json.dumps(payload, ensure_asciiFalse, indent2), encodingutf-8, ) print( f[pytest_collection_finish] 已写入 {output_path}共 {len(session.items)} 条命中用例, flushTrue, )这里有几个细节值得说ensure_asciiFalse 必须带上否则 nodeid、参数里的中文全部变成 \uXXXX报告根本没法看。path 属性做了兼容新版本 pytest 用 item.path老版本用 item.fspath二选一写死容易在升级后炸。item.location 三元组的第二项就是用例定义行号这个信息在排查用例命中错误时极其好用。params 里用 _safe_str 包一层再奇怪的参数对象也不会让 JSON 序列化崩溃。跑完之后 collection_report.json 会落在项目根目录用 jq 或者随便什么 JSON 工具都能查。这已经能回答“这轮命中多少用例、每个用例在哪个文件哪一行、有没有参数化”这类问题了。3.3 做命中率与基线差异分析从“收集数据”到“分析数据”只有一份 JSON 还不够很多时候我们想知道的是“命中率”。这个词在精准测试场景下非常常用假设整个产品全量用例库有 5000 条这次回归因为需求范围限制只该跑其中的 800 条钩子收集完一看实际命中了 750 条那命中率 93.75%剩下 50 条去哪了这就是精华所在。做法分两步。第一步先不带任何筛选执行一次全量收集生成基线pytest --collect-only -q -p no:cacheprovider复用上面那个钩子把 collection_report.json 的内容当作 baseline。接着我写一个简化版的对比逻辑import json from pathlib import Path import pytest def _load_baseline(path: str collection_report.json) - dict: p Path(path) if not p.exists(): return {} return json.loads(p.read_text(encodingutf-8)) pytest.hookimpl(tryfirstTrue) def pytest_collection_finish(session): baseline _load_baseline() if not baseline: return baseline_ids {case[nodeid] for case in baseline.get(cases, [])} current_ids {item.nodeid for item in session.items} hit_ids current_ids baseline_ids new_ids current_ids - baseline_ids # 新增/不在基线里的用例 lost_ids baseline_ids - current_ids # 基线里有这轮没命中的用例 hit_rate len(hit_ids) / len(baseline_ids) if baseline_ids else 0 print( f[命中分析] 基线 {len(baseline_ids)} 条本轮命中 {len(hit_ids)} 条 f命中率 {hit_rate:.2%}新增 {len(new_ids)} 条未命中 {len(lost_ids)} 条, flushTrue, )这个思路扩展性很强。比如你想按优先级分析就提前在全量基线里给每个用例打上 p0/p1/p2 标记然后按标记分别算命中率想按模块分析就把 lost_ids 按文件路径前缀分组直接定位漏测风险集中在哪里。我把这套逻辑接到内部测试平台之后最常用的其实不是总量命中率而是 p0 用例命中率——跑了一次回归核心用例漏了一半平台直接就飘红比事后翻报告快多了。第 4 章我会展开讲几个实战场景先提醒一点基线文件本身会过期用例库在持续变化建议每次发版前重新生成基线并且把基线生成动作固化到 CI 流水线里别靠手敲命令。4. 进阶场景命中数据如何变成平台能力数据弄到手之后怎么让它真正产生价值我分享三个我实际做过的场景每个都对应一种不同的业务诉求。4.1 场景一直接上报测试平台实现用例清单回放内部测试平台往往有“测试记录”功能想展示每条测试用例的执行结果就得先知道这轮跑了哪些用例。很多人是在全部跑完后从 JUnit XML 里解析其实在 finish 钩子里直接把命中清单上报给平台平台立刻就能生成一条“测试记录”执行结果跑完后再一轮 patch 上去整体体验会好很多。上报代码也不复杂关键是要兜住异常import json import os import pytest def _report_to_platform(payload: dict): try: import requests api os.environ.get(TEST_PLATFORM_API, ) if not api: return resp requests.post(api, jsonpayload, timeout5) resp.raise_for_status() except Exception as exc: # noqa: BLE001 # 上报失败绝不能影响 pytest 主流程 print(f[report] 上报失败: {exc}, flushTrue) pytest.hookimpl(tryfirstTrue) def pytest_collection_finish(session): payload { run_id: os.environ.get(RUN_ID, local), total: len(session.items), case_ids: [item.nodeid for item in session.items], } _report_to_platform(payload)注意一个铁律钩子里做网络请求必须 try/except 包住因为测试环境经常断网、内网通不了、超时很久如果这些异常冒泡到 pytest 主流程整个测试都会被中断这是绝对不能接受的。4.2 场景二精准回归的命中集校验精准回归大家应该都懂开发只改了某个模块理论上只需要跑这个模块相关的用例。但在改动分析不靠谱的时候真正执行范围可能跟预期差很多。我在 CI 里加了一个校验环节依赖的就是 finish 钩子。流程是MR/PR 里解析出受影响的源码文件列表 - 通过路径映射生成“预期命中用例集合” - 执行 pytest - finish 钩子收集实际命中集合 - 对比得到漏测集和新测集。漏测集里如果包含核心用例CI 直接报错阻塞合入。这个“预期命中集合”怎么生成不是本文重点但我可以给你一个简单可用的替代思路很多团队会给用例按模块打标记比如 pytest.mark.module_user。精准回归时执行 pytest -m module_user or module_orderfinish 钩子拿到的就是实际命中集合再跟改动分析脚本预测的集合一对比谁多谁少一目了然。多做的用例虽然浪费点资源但漏掉用例才是大问题。4.3 场景三配合 pytest-xdist 汇总 worker 数据分布式执行时数据汇总是个容易踩坑的点。pytest-xdist 的机制是 master 先做收集然后把收集到的 item 分发给各个 worker 执行worker 内部一般不重复走完整的 collection 流程。所以稳妥的做法是在 master 进程的 finish 钩子里拿全局命中数据落一个文件如果需要按 worker 维度统计再在 worker 侧把各自执行的 nodeid 写日志最后合并。我在项目里的做法是两阶段先跑一次pytest --collect-only -q生成全局命中清单让老板和平台先看到范围然后再正式跑分布式执行。这样即使执行中途某个 worker 挂了本轮“命中范围”早就留下了快照复盘的时候不至于啥都没了。这个思路比强行在 xdist worker 里汇总数据简单得多也更稳。5. 常见问题与排查实录写钩子不难但真实环境里会碰到各种妖魔鬼怪。我把常遇到的坑和排查方法整理出来你对照着看能少走不少弯路。5.1 钩子没触发、重复触发、print 被吞最基础但最高频的问题是钩子压根没触发。先看 conftest.py 放对位置没有——pytest 的 conftest 加载机制是分层级的只有放在 rootdir 或测试目录的父级目录里才会对目标测试用例生效。你在某个子目录的 conftest.py 里写了钩子但跑的是另一个目录下的用例自然不触发。其次是函数名拼写。pytest 的钩子是通过 hookspec 名字匹配的少写一个字母就静默失效pytest 还不会报错。排查方法很简单临时在钩子第一行加一个 print然后执行pytest --collect-only -q能打印出来说明钩子生效了没打印就是位置或者拼写问题。重复触发则可能来自多份 conftest.py。如果测试目录层级里有多层 conftest且每层都实现了同一钩子pytest 会依次调用多个实现。大多数情况下这也是合法需求但如果你的本意是全局一份逻辑就要注意清理多余的 conftest或者用pytest.hookimpl(trylastTrue)之类的方式控制执行顺序。print 被吞的问题也很常见。pytest 默认对测试执行过程的输出捕获collection 阶段虽然在通常场景下认为还在执行捕获逻辑之前但保险起见所有调试 print 都加 flushTrue或者干脆直接写到文件别依赖终端输出。这是我踩过最多次的坑。5.2 item 属性访问炸了怎么破最常见的是 AttributeError。比如你遍历 session.items假设每个 item 都有 module 属性结果遇到 doctest 类型的 itemitem.module 是 None遇到 pytest 7 之前的版本item.path 不存在只有 item.fspath遇到自定义 collector 生成的 item可能连 callspec 都没有。我总结下来的三条防御式写法所有属性访问都用 getattr(item, xxx, None) 给默认值关键路径用getattr(item, path, None) or getattr(item, fspath, )。判断是否有参数化用 hasattr(item, callspec)不要靠 nodeid 里有没有 [ 来猜。序列化前统一经过 _safe_str()参数值里可能塞了对象、函数、甚至 mock 实例repr 一下兜底。另外提一句item.location 在某些极端情况下也可能没有访问前用 getattr(item, location, None) 判空。这些防御代码看起来啰嗦但能帮你在 pytest 小版本升级时不炸。5.3 参数化解析与 JSON 序列化的坑如果不用 callspec 非要自己拆 nodeid遇到参数值里有中文逗号、空格、方括号你会得到一个又长又乱的字符串。尤其参数值是(1, 2)这种元组的 reprpytest 会转义很多字符你肉眼很难还原原始参数。所以再次强调解析参数化用 callspec。JSON 序列化那边最常见的问题是 params 里有一个 requests.Session 对象或者 fixture 返回的连接对象json.dumps 直接抛 TypeError。我的 _safe_str 方案会把所有 value 变成字符串信息量虽然损失一点但报告文件永远能生成出来。如果担心报告文件本身越来越大——全量收集 5000 条用例一个 JSON 可能就 1~2MBCI 上多存几份倒是没啥但要记得把 collection_report.json 加进 .gitignore别污染代码仓库。5.4 排查速查表我最后把经验整理成一个表直接贴墙上用现象可能原因解法钩子完全没触发conftest.py 位置不对 / 函数名拼错先加 print 验证再查文件层级钩子触发多次多层 conftest 都有实现用 hookimpl 的 tryfirst/trylast 控制或减少 conftestitems 数量为 0-k/-m 筛得太狠 / 路径不存在去掉筛选再收集对比或在这里打兜底警告item.module 为 Nonedoctest/自定义 itemgetattr 判空按 None 处理参数化解析乱自己拆 nodeid改用 item.callspecJSON 序列化报错参数值是对象遍历前用 _safe_str 包裹上报失败中断测试网络异常冒泡钩子里所有网络请求 try/exceptfinish 拿到的 items 与终端统计不一致其他插件在 modifyitems 阶段改过 items先执行 modifyitems 再执行 finish读取时别修改6. 最后分享几点真实心得钩子本身写起来很简单但要把这个切面真正用好我总结了几条个人经验希望对你有帮助。第一finish 钩子里绝对不要修改 items。这个阶段数据已经定型你修改它也只是在内存里改执行阶段到底用不用取决于 pytest 内部的调度顺序搞不好还能跟其他插件打架。想改用例集合请老老实实去 pytest_collection_modifyitems 里做那是官方设计好的“修改窗口”。第二别在 finish 里统计执行结果。有人问我能不能在这个钩子里判断用例通过多少、失败多少做不到因为它发生在执行之前。你要执行结果去用 pytest_terminal_summary 或者 report 相关钩子各管一段才最清晰。第三我建议把这段逻辑收成一个小插件。比如做一个 pip 可安装的包内部就一个 conftest.py 和几个工具函数通过 entry_points 注册到 pytest 里。这样不用每个项目粘一份代码升级逻辑只改一个地方CI 流水线里也能很干净地复用。第四平时调试这套东西我常年配合pytest --collect-only -q使用。它不执行用例但收集阶段完整跑完finish 钩子照样触发数据照样落盘。这等于给了你一个“只盘点、不执行”的开关做用例库大盘再合适不过。你甚至可以写个定时任务每天夜里全量收集一次把命中数据归档慢慢就是一个很有价值的用例资产库。