
用例数据每次都靠肉眼数执行完才发现漏了几条参数化分支用例规模上千之后想统计全量用例分布翻遍conftest.py也不知道该在哪里下手如果你正在被这些问题折腾那今天这篇东西应该能帮你省下不少时间。这篇博客围绕pytest里一个容易被忽视、但极其关键的钩子函数——pytest_collection_finish讲讲怎么用它精准、可靠地收集测试命中用例数据把用例资产盘清楚。适合谁来读凡是负责pytest测试框架维护、用例量超过几百条、或者正在搭测试平台需要统计用例资产的人都建议认真过一遍。我会尽量把原理讲透同时给出可以直接抄作业的代码实现。1. 收集用例数据为什么卡在pytest_collection_finish上先抛一个实际场景。项目里的接口测试用例跑一次全量回归执行完报告显示“共执行356条”但用例文件里肉眼统计是380条。中间差的24条去哪了有些人会打开IDE逐个文件数有些人会在conftest里用pytest_collection_modifyitems打印item数量但对不少团队来说用例收集阶段的数据统计一直是笔糊涂账。1.1 一条用例从发现到执行的完整链路要弄明白pytest_collection_finish为什么适合做这件事得先知道pytest在执行一条用例前经历了什么。pytest的用例收集是分阶段进行的整个过程大致是定位rootdir读取pytest.ini、pyproject.toml等配置文件确定测试路径和参数启动collection逐个扫描测试文件用默认的Python收集器pytest_pycollect_makeitem等发现测试函数、测试类、fixture参数化组合在收集过程中pytest会触发一系列钩子从pytest_collectstart开始到pytest_collect_file、pytest_pycollect_makeitem、pytest_collection_modifyitems最后是pytest_collection_finish收集结束后pytest才进入pytest_runtestloop对每条用例逐一执行。如果你尝试过在fixture内部统计用例总数会发现根本不靠谱因为fixture是在用例执行阶段才实例化的收集阶段压根轮不到它。而pytest_collection_finish正好卡在整个收集流程的最后一道关口这时候所有用例已经被整理进Session对象钩子函数拿到的是完整、稳定、可遍历的用例集合。1.2 选它不选别的三条理由收集阶段也有别的钩子可用比如pytest_collection_modifyitems。为什么我强调用pytest_collection_finish而不是它第一条理由pytest_collection_modifyitems是“修改”用例的钩子适合做删除、排序、过滤这些动作它触发的时候用例数据是“可变”状态。如果你只是想要一份统计数据在这个阶段去处理可能被后续其他插件二次改动数据不一定准。而pytest_collection_finish是收集结束后的最终回调此时用例集合已经定型。第二条理由pytest_collection_finish入参是session里面既有顶层目录信息又能通过session.items拿到全部用例还能借助nodeid反查出用例所属文件路径、测试类、函数名、参数化ID等结构性信息。这种数据组织方式在处理大型用例资产时非常友好。第三条理由pytest_collection_finish在pytest的钩子规范里属于非破坏性钩子它不要求返回值。你在钩子里写统计逻辑、写数据库、写文件都不会干扰pytest自身的收集流程出问题也不至于让整个测试运行崩溃除了代码本身抛异常。这对做平台集成的人来说安全边际高很多。2. 钩子机制拆解pytest_collection_finish到底在哪个环节触发很多人写钩子函数只会照着网上抄一个def完全不理解pytest为什么能识别它、在什么时机调用它、参数从哪来。这节把机制拆开讲清楚。2.1 钩子函数的前世今生pytest的钩子体系基于pluggy库pluggy定义了一套插件事件通信机制。简单理解pytest在运行到某个阶段时会广播一个事件所有注册了对应钩子函数的插件都会收到通知并被执行。pytest_collection_finish就是pluggy规范里的一个钩子规格hookspec它的定义在pytest源码的_hooks.py中。你可以这样理解pytest就像一个舞台导演用例收集流程是剧本钩子函数是舞台侧幕条的那些工作人员。导演喊一句“收集结束”侧幕条的人就开始干活——有人清点道具统计用例数、有人记录位置记录用例归属pytest_collection_finish这个钩子就是那一句“收集结束”的口令。这个钩子的触发必走流程是pytest_collectstart开始收集目录和文件pytest_itemcollected在每条用例被收集到时触发pytest_collection_modifyitems在收集完成后允许插件调整用例顺序和集合最后执行pytest_collection_finish。从源码层面看它在_pytest/main.py的perform_collect方法中被调用。2.2 collection_finish与其他收集期钩子的分工我给出一张内部对照表方便你看清楚哪个阶段该干什么。这张表也是我实际排查“用例数对不上”问题时反复对照的依据钩子函数触发时机核心用途是否建议做数据统计pytest_collectstart开始收集某个目录或文件标记收集起点一般不用pytest_itemcollected每收集到一条用例单条用例的即时处理可做增量累计但要注意参数化分支pytest_collection_modifyitems全部收集完成、执行前排序、过滤、去重、修改用例可以做但属修改接口建议专注修改pytest_collection_finishcollection全部结束收尾、统计、清理最适合做全量统计从表格能看出来pytest_itemcollected其实也能做统计——它每遇到一条用例就会写一条数据。但问题在于它触发太早后续pytest_collection_modifyitems可能把已经触发过的用例删掉、合并或者重新排序前置统计容易“多算”。比如你在itemcollected里统计了380条但modifyitems里因为某些条件跳过了一部分用例最终真正执行的只有356条账就算错了。2.3 拿到的是什么东西session对象结构剖析pytest_collection_finish(session, exitstatus)的两个参数多数人只用到了session因为exitstatus只有在pytest命令行执行时才有明确含义在编程式调用pytester或pytest.main([])里可能被当成普通退出码传入。咱们重点说session。session是pytest.Session实例也是pytest.collect.Session的子类。它继承自FSCollector内部维护了收集到的所有顶层收集器。关键结构如下session.items所有用例对象的列表每个元素是一个Function或其他类型的收集节点。session.collector当前收集器收尾时通常指向顶层目录收集器。session.configpytest的配置对象可以读取命令行参数、ini文件配置。session.stashpytest 7.0 提供的跨插件数据存储机制适合在钩子之间传递数据。拿到session.items之后每条item都有一些属性必须用熟属性名含义使用场景item.nodeid用例唯一标识包含了文件路径和参数化ID写入数据库作为主键item.name函数名或者参数化显示名展示用例名称item.module用例所在的模块对象通过__file__拿到绝对路径item.cls用例所在的测试类None表示没有类统计类级分组item.originalname参数化之前的原始函数名区分参数化分支和独立用例item.funcargs用例fixture参数的名称列表注意不是值了解用例依赖的fixture我用过stash跨钩子传数据但实际写下来发现很多场景没必要引入stash直接在pytest_collection_finish里一次性把所有统计工作做完代码更短更直观。stash适合的是那种“modifyitems阶段需要决定是否删除用例finish阶段想记录被删原因”的复杂链路一般人碰不到。3. 写一个能直接用的用例采集钩子这一节给出可落地的代码配合注释拆解关键操作。先从最简版本开始逐步加工程化能力。3.1 最小可复现只用标准库也能做你没有安装任何额外插件没关系一个纯标准库的简单版本就够了。放在conftest.py里或者做成独立插件放到site-packages目录都行。# conftest.py import json def pytest_collection_finish(session): items session.items stats { total: len(items), files: {}, classes: {}, } for item in items: # nodeid格式类似 test_api/test_user.py::TestUser::test_login file_path item.nodeid.split(::)[0] class_name None if item.cls is not None: class_name item.cls.__name__ stats[files][file_path] stats[files].get(file_path, 0) 1 stats[classes][class_name] stats[classes].get(class_name, 0) 1 with open(collect_stats.json, w, encodingutf-8) as f: json.dump(stats, f, ensure_asciiFalse, indent2) print(f\n 用例收集统计: 共 {len(items)} 条 )这段代码把用例按文件路径、测试类两个维度统计输出collect_stats.json。注意nodeid.split(::)[0]拿到的文件路径已经经过pytest规范化windows下盘符前会多一个/比如/D:/project/tests/test_demo.py如果需要原生路径要用Path(item.fspath)去拿。运行效果类似这样$ pytest --collect-only -q ... 用例收集统计: 共 382 条 命令行加了--collect-only时pytest在收集结束后直接退出不执行任何用例。钩子同样会被触发这就意味着你可以纯粹拿它来生成用例清单不影响测试执行。这一点在做CI预检时很有用。3.2 加一点工程化处理参数化用例参数化用例是统计逻辑里最容易翻车的地方。比如这样一个参数化测试import pytest pytest.mark.parametrize(username,password, [ (alice, 123456), (bob, 654321), (carol, abcdef), ]) def test_login(username, password): assert username收集时pytest会把这条函数展开成3条用例nodeid类似test_login.py::test_login[alice-123456] test_login.py::test_login[bob-654321] test_login.py::test_login[carol-abcdef]如果你想把“函数级”用例和“参数化分支”区分开统计里就得用item.originalname。originalname在非参数化用例上等于name在参数化用例上等于原始函数名。做一个更健壮的采集器# conftest.py import json from pathlib import Path def pytest_collection_finish(session): summary { total_cases: len(session.items), total_functions: set(), by_file: {}, by_marker: {}, } for item in session.items: func_name item.originalname or item.name summary[total_functions].add(f{item.nodeid.split(::)[0]}::{func_name}) file_path str(Path(item.fspath)) summary[by_file][file_path] summary[by_file].get(file_path, 0) 1 # 统计marker分布 for marker in item.iter_markers(): summary[by_marker][marker.name] summary[by_marker].get(marker.name, 0) 1 summary[total_functions] len(summary[total_functions]) with open(collect_stats.json, w, encodingutf-8) as f: json.dump(summary, f, ensure_asciiFalse, indent2)核心逻辑是在统计函数级用例前用file_path originalname拼一个set自动去重这样参数化出来的多条用例就不会把“函数数量”撑大。这个设计在输出“全量用例共382条去重后函数级用例127条”这类报告时特别直观管理层和研发同事都会需要这两份数字。还有个必须处理的情况用例类里的参数化。item.cls是类对象item.originalname是方法名拼接键的时候要把两者都带上否则两个类里同名方法会被误判成同一个函数。我早期踩过这个坑两个测试类都叫TestUser方法是test_create拼成TestUser::test_create就把数量算少了。3.3 更进一步按目录结构生成用例地图用例多了之后“每个模块到底覆盖了多少条用例”是CI统计的硬需求。我写过一个用例地图生成器基于session.items按目录层级生成树形JSON结构前端可以用antd Tree组件直接渲染。# conftest.py import json from collections import defaultdict from pathlib import Path def build_tree(paths: list) - dict: root {} for p in paths: parts Path(p).parts node root for part in parts: if children not in node: node[children] [] # 简化逻辑实际需逐个匹配 # 实现略见下文 return root def pytest_collection_finish(session): tree { name: root, children: [] } node_map { root: tree } for item in session.items: file_path Path(item.fspath) parts file_path.parts current_key root current_node tree for part in parts: child_key f{current_key}/{part} child None for existing in current_node[children]: if existing[name] part: child existing break if child is None: child {name: part, children: []} current_node[children].append(child) current_node child current_key child_key if data not in current_node: current_node[data] [] current_node[data].append({ nodeid: item.nodeid, name: item.name, class: item.cls.__name__ if item.cls else None, }) with open(case_tree.json, w, encodingutf-8) as f: json.dump(tree, f, ensure_asciiFalse, indent2)这个地图的好处是配合--collect-only可以在一两秒内生成全量用例资产目录不用启动被测系统、不用执行接口请求。平台做“用例资产盘点”的时候非常有用。用法名称也可以换成case_tree我实际项目中叫这个方便同事理解。4. 数据落库与报告集成统计文件生成后只能当下看看真正要形成持续积累得落库。绝大多数团队最终是做测试平台把用例数据同步到MySQL或MongoDB然后在前端展示用例覆盖率、增量变化、归属关系。4.1 入库方案对比与选型先给结论用例资产管理场景MySQL够用MongoDB也可以但更推荐MySQL加一个JSON字段把参数化列表、marker列表、fixture列表都塞进去。为什么不用ES因为用例数据的查询模式基本是“按模块、按负责人、按标签统计”不是全文检索。ES为用例这种结构化数据建索引纯属过度设计。数据量在十万条用例以内MySQL单表完全扛得住查询也能控制在毫秒级。我遇到过把用例统计放ES的团队光同步管线和mapping维护就消耗了大量精力不值得。设计一张最简用例表字段名类型说明idint(11) PK AUTO_INCREMENT主键nodeidvarchar(512) UNIQUE用例唯一标识file_pathvarchar(255)所属文件绝对路径class_namevarchar(255)所属测试类func_namevarchar(255)原始函数名param_idsjson参数化ID列表markersjsonmarker名称列表last_seen_datedate最近一次收集日期collected_countint被收集次数nodeid设唯一键天然支持幂等写入。同一套代码、同一个nodeid入库两次也只会有一条记录。4.2 增量更新与幂等设计落库的逻辑要遵循两条原则已存在的用例只更新时间戳和统计次数不重复插入跑过一次收集后要能找出“删除了哪些用例”。第二条是很多平台的痛点——开发重构了用例文件删了不少旧用例但线上统计数据还留着造成“僵尸用例”。我的做法是给每次收集生成一个批次号batch_id批次内记录所有nodeid。收集完成后对比当前批次和上次批次差集就是新增和删除的用例。SQL可以用NOT IN或者用LEFT JOIN处理数据量大时用临时表性能更好。# conftest.py import json from datetime import datetime def pytest_collection_finish(session): batch_id datetime.now().strftime(%Y%m%d%H%M%S) new_items [] for item in session.items: new_items.append({ nodeid: item.nodeid, file_path: str(item.fspath), class_name: item.cls.__name__ if item.cls else , func_name: item.originalname or item.name, markers: [m.name for m in item.iter_markers()], }) # 这里调用你自己封装的入库函数 # sync_cases_to_db(batch_id, new_items)幂等设计的核心不是“插入前查是否存在”而是让数据库唯一键当“裁判”。你可以用INSERT ... ON DUPLICATE KEY UPDATE也可以用INSERT INTO ... ON CONFLICT总之利用数据库自身的约束来避免并发场景下的重复插入。并发场景是有可能出现的CI有多个job每个job各自跑了一轮收集同时写库。4.3 与allure、junit报告的关系有人会问我用了allure-pytest它会不会影响pytest_collection_finish的触发答案是它会触发但你要注意顺序。pytest的钩子函数可以定义多个同一钩子会被多个插件依次调用顺序取决于插件加载顺序。allure本身也监听了pytest_collection_finish来准备它的报告数据结构。如果你在conftest.py里定义的钩子函数和allure的钩子函数存在执行顺序问题你写的统计代码大概率不受影响不会有致命冲突。真正要注意的是pytest_collection_modifyitemsallure会在修改阶段给用例追加allure相关的属性和标签你的统计代码放在finish阶段拿到的item已经带有allure标签正好可以把allure.story、allure.feature这些信息一起统计进去。在pytest_collection_finish里加一行就能拿到allure标签for marker in item.iter_markers(): if marker.name allure_label: label_name marker.kwargs.get(label) # 常见取值: feature, story, tag, epic这个数据入库后前端就能按allure需求维度做用例覆盖率展示。比如“登录模块feature下覆盖了多少条用例”这个指标很多测试平台都想要但因为收集阶段没接好导致实现起来很别扭。我在这里加了一次统计后后续组内同事做质量看板的数据底表直接就能用。5. 常见问题与排查技巧实录写钩子不难难的是线上跑挂了不知道去哪查。这节把我在不同项目中遇到过的高频问题列出来每个都附带排查思路。5.1 用例数对不上钩子被插件覆盖了有段时间我统计出的用例数一直比实际少反复看了自己的代码没发现问题。后来在CI日志里发现其他插件也定义了pytest_collection_finish而且是用了pytest.hookimpl(tryfirstTrue)装饰的它提前return了一个值把后续的实现直接短路了。pytest的钩子调用遵循LIFO后进先出顺序但可以用tryfirst、trylast调整优先级。如果你的统计结果不对第一件事就是检查有没有其他插件hook了同一个函数。排查方法很简单在命令行加--co--collect-only的简写看输出然后在你的钩子函数里加一行print(session.items)如果print的内容没出现说明你的钩子根本没被调用到多半是插件短路了。注意pytest.hookimpl(hookwrapperTrue)这种写法在某些特殊场景下会改变执行流程如果发现问题先用最小复现判断是不是hookwrapper导致的。我最后用了一个比较稳妥的方案在自己的钩子函数上也加了pytest.hookimpl(trylastTrue)装饰器确保它在绝大多数成功收集的插件之后拿到最终数据。这样做的原因是收集阶段如果有插件抛异常finish钩子有时不会被调用加到trylast至少能处理更多正常路径下的情况。5.2 参数化用例重复执行数据膨胀有些团队把数据驱动写在装饰器里但每跑一次CI就重新收集一遍用例数据库不断堆积“同一函数不同参数化分支”的记录。这个不算bug但确实会造成库存里全是一个函数的一堆变体。我的建议是以函数级别file class originalname做主键存储函数信息再用一个独立的param_id表存参数化分支。这样函数表里一行就是一个用例函数参数化分支表可以关联查询到具体参数组合。统计“总共有多少用例函数”和“参数化后执行多少条”就一目了然。5.3 收集报错中断一条用例都拿不到pytest_collection_finish触发的前提是收集过程没有中断。如果某个测试文件import时报错、某个fixture定义有问题collection会在中途失败finish钩子就不会执行。这在采集可靠性上是个大隐患——CI里某个模块报了个语法错误用例采集统计就变成0前端展示“全量用例清空”。应对办法采集动作不要只依赖finish钩子可以在pytest_collection_modifyitems里先缓存一份原始数据再在finish里做最终写入同时如果多了一道“收集失败就报警”的逻辑比数据静默丢失强得多。我在项目中还封装过一种兜底手段用pytest的--continue-on-collection-errors参数让收集阶段即使遇到个别文件的错误也能尽量收集其他文件。配合这个参数跑完统计函数能拿到部分用例数据。虽然不全但比全为0好日志里也能定位到具体失败文件。5.4 fixture定义错误导致收集失败fixture里的函数名如果写错了比如用了一个没有定义的函数pytest在收集阶段可能不会立刻暴露直到fixture被解析才报错。这会导致finish阶段没有触发。排查时我要重点看pytest的输出里有没有ERROR字样只有收集阶段全部通过钩子才会稳定执行。还有一类情况你定义了pytest.fixture装饰的函数但没有把fixture放到合理的conftest或插件模块里pytest认为它是普通工具函数不会纳入fixture管理。这种问题生成用例统计数据时影响不大但在后续执行时容易爆出fixture找不到的错误。所以如果采集逻辑没问题但用例执行时报fixture错误建议检查conftest的层级关系和命名规范。5.5 不要忘了pytest_collection_modifyitems要说pytest collection阶段最实用、配合finish做数据处理最顺手的钩子我觉得是pytest_collection_modifyitems。很多团队在finish里统计完用例数却忽略了要先用modifyitems做用例筛选和排序。比如你只打算回归P0级别的用例那应该在modifyitems阶段根据marker把非P0用例过滤掉或者标记为跳过。这时finish统计的就是过滤后的用例。如果你不做任何过滤finish拿到的就是全量数据。两者的差异直接决定了你写出来的统计报告准不准。modifyitems还能做的一件事是给没有加标记的用例补默认标记。比如所有接口测试用例都自动打上api标记实现如下def pytest_collection_modifyitems(items): for item in items: if api not in item.keywords: item.add_marker(pytest.mark.api)这样后续在finish里统计marker分布时所有用例都会归属到“api”维度下。这个组合拳在测试分组和精确统计的实操中很常用。6. 最后的实操心得如果让我总结一个能立刻上手的组合我会推荐pytest_collection_modifyitems负责做筛选和默认标记pytest_collection_finish负责输出统计结果两者配合在conftest.py里各写一个函数就行。数据落库时坚持“批次号 nodeid唯一键 函数级主键”的设计用例资产就能从一次性统计变成持续积累。我个人实际用下来最大的感受是pytest_collection_finish真正舒服的地方不在于它能打印一行总数——这在任何语言里都很容易做到——而在于它把“什么时候用例算真正定下来”这个时机标准化了。你不必在业务代码里埋点不必去解析日志不必在pytest-runtestloop里靠计数器估所有插件、所有项目成员在同一触点上拿到的是同一份数据结构。还有一个经验分享一定要用--collect-only模式压测你的统计代码。我通常会在本地跑pytest --collect-only -q看看统计脚本在几百条用例上的耗时。如果超过2秒大概率是代码里有大量重复的字符串拼接或者低效的文件IO需要优化。收集阶段的性能瓶颈会直接影响CI流程不要等到全量用例上千条时才后悔。如果你后续想扩展建议往两个方向想一是把收集结果输出成HTML报告按目录层级、责任人维度可视化展示二是把用例统计和覆盖率数据如coverage.py生成的结果联动算出“用例资产密度”。这两块做起来都不算难但能极大提升团队对测试资产的可感知程度。我的经验是用例数据一旦“看得见”治理起来就有抓手测试平台的很多其他功能也会跟着顺手起来。