别等测试跑完才知道命中了哪些用例:`pytest_collection_finish` 钩子实战
在上一篇《别再干等测试报告了:用 pytest 钩子实时追上用例执行进度》中,我们用
pytest_report_teststatus实现了"每个用例执行完后即时上报状态"。但有个问题遗留下来:执行进度再实时,也只能告诉你'哪些跑过了',没法回答'原本应该跑哪些、还有哪些没跑'。本文用pytest_collection_finish钩子补齐这块短板——在用例收集完成的瞬间,把命中的用例清单整体上报,让"未执行用例"也可被追踪。
一、背景:执行进度之外,还差什么
上一篇《别再干等测试报告了:用 pytest 钩子实时追上用例执行进度》我们用 pytest_report_teststatus 解决了"实时进度可见"的问题:每跑完一条用例就推一条数据,服务端聚合后能看到当前进度、通过率、失败列表。
但实际工程中,光知道"已执行"还不够:
| 场景 | 仅靠执行上报的问题 |
|---|---|
| CI 中途崩了/OOM 了 | 你不知道"本应该跑多少条",无法判断丢失了多少 |
| 用例被 marker 筛选 | 实际命中的子集不明确,跑完后才发现漏选了关键用例 |
--ignore 跳过目录 |
跳过了哪些路径,没有结构化记录 |
用例被 deselect |
失败重试中某些用例被跳过,事后查不清是"没跑到"还是"跑了没上报" |
| 测试套件动态变化 | 增删用例后,每次"应跑总量"无法稳定比对 |
理想方案:在采集完成、执行开始前这个时间点,把 pytest 实际命中的用例清单整批上报。这样服务端就有了"应跑集合",对比执行上报就能算出"未跑集合"。
pytest 提供的 pytest_collection_finish 钩子,正好踩在这个时间点上。
二、pytest_collection_finish 钩子是什么
pytest_collection_finish 是 pytest 的收集阶段钩子,在 pytest 完成所有用例收集、即将开始执行之前被调用。
签名:
def pytest_collection_finish(session):
"""用例收集完成后触发,session.items 已填充完毕"""
pytest 三个收集相关钩子的时机对比
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
pytest_collection_start |
收集开始前 | 重置收集计数、打日志 |
pytest_collection_modifyitems |
收集完成、items 可被修改 | 重排序、过滤、加 marker |
pytest_collection_finish |
收集彻底结束、items 已定型 | 上报命中清单、统计快照 |
关键差异:
modifyitems时还能改session.items(增删、排序、加标记)finish时items 已定型,是"最终命中用例"的快照——最适合上报
pytest 启动
├─ pytest_collection_start ← 收集开始
├─ 收集所有符合 python_files 等模式的用例
├─ pytest_collection_modifyitems ← 可改 items
├─ pytest_collection_finish ← 上报命中用例(本文重点)
├─ 开始执行
└─ pytest_runtest_loop ← 用例逐条执行
三、最小可运行示例
下面是最简版本——把所有命中的用例 nodeid 打印出来:
# conftest.py
def pytest_collection_finish(session):
"""用例收集完成后,打印命中的用例清单"""
print(f"\n[收集完成] 本次共命中 {len(session.items)} 个用例:")
for item in session.items:
print(f" - {item.nodeid}")
输出示例:
[收集完成] 本次共命中 4 个用例:
- tests/test_user.py::test_login
- tests/test_user.py::test_register
- tests/test_order.py::test_create_order
- tests/test_skip.py::test_disabled
把 print 换成 HTTP/文件上报,就是命中用例清单的采集系统。
四、session 对象的核心字段
session 是 Session 实例,常用字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
session.items |
list[Item] |
采集到的所有用例对象 |
session.config |
Config |
全局配置对象 |
session.testsfailed |
int | 已失败用例数(执行中动态变化) |
session.testscollected |
int | 已收集用例数 |
session.exitstatus |
int | 退出状态码 |
item 的关键字段
for item in session.items:
item.nodeid # 'tests/test_a.py::test_x[param]' 唯一标识
item.location # (filename, lineno, domain)
item.module # 测试模块对象
item.function # 测试函数对象
item.keywords # 关键字集合(marker、fixture 等)
item.fspath # 文件路径
item.user_properties # record_property 写入的属性
实用片段:构造结构化上报数据
def pytest_collection_finish(session):
cases = []
for item in session.items:
cases.append({
"nodeid": item.nodeid,
"filename": item.location[0],
"lineno": item.location[1],
"markers": [m.name for m in item.iter_markers()],
})
payload = {
"total": len(cases),
"cases": cases,
"collected_at": time.time(),
}
upload_to_server(payload)
五、完整实战:上报到文件服务器
下面是基于原文扩展的完整实战——把命中用例清单写入 JSON,上传到文件服务器,支持 CI 多任务区分:
# conftest.py
import os
import sys
import json
import time
import logging
import requests
log = logging.getLogger(__name__)
# ---------- 文件服务器地址 ----------
FILE_SERVER = "http://10.132.1.127:8123/files/Test"
def _build_task_meta():
"""构建任务元数据:CI 任务 ID + 流水线 ID"""
pipeline_id = os.getenv("CI_PIPELINE_CONFIG_ID", "10000")
task_id = os.getenv("CI_PIPLINE_HISTORY_NUMBER", "10001")
return pipeline_id, task_id
def _build_cases_payload(session):
"""从 session.items 构造结构化用例清单"""
cases = []
for item in session.items:
cases.append({
"nodeid": item.nodeid,
"filename": item.location[0],
"lineno": item.location[1],
"markers": [m.name for m in item.iter_markers()],
})
return {
"total": len(cases),
"cases": cases,
"collected_at": time.time(),
"pipeline_id": _build_task_meta()[0],
"task_id": _build_task_meta()[1],
}
def _upload_via_curl(file_name):
"""Windows 下没有 curl 时的兜底用 requests"""
if sys.platform == "win32":
# Windows 跳过上传(或改成 requests 实现)
return False
os.system(f'curl -X POST {FILE_SERVER} -F file=@{file_name}')
return True
def upload_target_cases(session):
"""上报命中用例清单到文件服务器"""
payload = _build_cases_payload(session)
pipeline_id, task_id = payload["pipeline_id"], payload["task_id"]
file_name = f"{pipeline_id}_{task_id}_cases.json"
log.info("本次测试命中 %d 个用例", payload["total"])
# 写入 JSON 文件
with open(file_name, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4, ensure_ascii=False)
# 上传到文件服务器
_upload_via_curl(file_name)
# 清理临时文件
if os.path.exists(file_name):
os.remove(file_name)
log.info("本次测试命中用例集下载地址:%s/%s", FILE_SERVER, file_name)
def pytest_collection_finish(session):
"""用例收集完成后:上报命中清单"""
# 仅在主 worker 上报一次(pytest-xdist 兼容,详见第七章)
if hasattr(session.config, "workerinput") and \
session.config.workerinput.get("workerid", "") != "gw0":
return
upload_target_cases(session)
服务端数据结构示例
{
"total": 4,
"cases": [
{
"nodeid": "tests/test_user.py::test_login",
"filename": "tests/test_user.py",
"lineno": 5,
"markers": ["smoke", "p0"]
},
{
"nodeid": "tests/test_order.py::test_create_order",
"filename": "tests/test_order.py",
"lineno": 12,
"markers": ["api"]
}
],
"collected_at": 1691047212.45,
"pipeline_id": "10000",
"task_id": "10001"
}
服务端按 pipeline_id + task_id 聚合,即可在执行前拿到"本次应跑集合",执行后对比"实际执行集合",差集即"未执行用例"。
六、关键设计要点
1. 上报时机:必须在 finish 而非 modifyitems
pytest_collection_modifyitems 时 items 还能被修改,上报的不是"最终命中"。必须用 finish——此时 items 已定型。
2. nodeid 是唯一标识
item.nodeid 包含模块路径、函数名、参数化值,如:
tests/test_user.py::test_login[admin-user]
服务端用它和执行上报中的 nodeid 做匹配,得出"未执行"集合。
3. 多任务区分
CI 场景下,每次跑出来的命中集合可能不同(不同分支、不同参数)。所以上报数据必须带任务标识:
pipeline_id = os.getenv("CI_PIPELINE_CONFIG_ID")
task_id = os.getenv("CI_PIPLINE_HISTORY_NUMBER")
服务端按 pipeline_id + task_id 隔离不同任务。
4. 上报失败不应中断测试
采集阶段失败,不应该让 pytest 整体跑不起来:
def pytest_collection_finish(session):
try:
upload_target_cases(session)
except Exception as e:
log.warning("命中用例上报失败,不影响测试执行: %s", e)
5. 文件清理
上传成功后立即 os.remove(file_name),避免 CI 工作区残留文件越积越多。
七、与 pytest-xdist 并发的兼容
pytest-xdist 并发执行时,每个 worker 是独立进程,每个 worker 都会触发 pytest_collection_finish。如果直接上报,会出现:
- 同一份命中清单被上报 N 次(N = worker 数)
- 不同 worker 的
session.items可能切片不同
解决方案:只在主 worker 上报
原文采用的方式:
if hasattr(session.config, "workerinput") and \
session.config.workerinput.get("workerid", "") == "gw0":
upload_target_cases(session)
- pytest-xdist 启动时会给每个 worker 注入
workerinput workerid形如"gw0"、"gw1"、...- 只在
gw0上报一次,保证全局唯一
非并发场景:session.config 没有 workerinput 属性,需要 hasattr 兜底——非并发时直接上报。
更稳的写法:用 --dist=loadscope + 主进程上报
如果用 pytest-xdist 但希望"在主进程而非 worker 中上报",可以用 pytest_sessionfinish 配合 session 级缓存:
_collected_cases = []
def pytest_collection_finish(session):
global _collected_cases
_collected_cases = [item.nodeid for item in session.items]
def pytest_sessionfinish(session, exitstatus):
# 主进程会触发,且此时所有 worker 已结束
if not hasattr(session.config, "workerinput"):
upload_target_cases({"cases": _collected_cases})
但通常只在 gw0 上报已经够用,简单直接。
八、常见坑点速查
| 坑 | 现象 | 解决 |
|---|---|---|
| xdist 下重复上报 | 同一份清单上报 N 次 | 用 workerinput.workerid == 'gw0' 过滤 |
| 非 xdist 下不上报 | hasattr 判断错位 |
用 hasattr(session.config, "workerinput") 兜底 |
| 中文 nodeid 乱码 | Windows 上传后乱码 | json.dump(..., ensure_ascii=False) + 文件 encoding="utf-8" |
| 上传失败影响测试 | 上传接口挂掉导致 pytest 退出 | try/except 包住上报逻辑 |
| 文件残留 | CI 工作区越来越多 JSON | 上传后立即 os.remove |
| 上报时机错 | 在 modifyitems 上报,结果被后续过滤改了 |
改用 pytest_collection_finish |
| Windows 无 curl | os.system('curl ...') 失败 |
改用 requests.post 或加平台判断 |
| 环境变量缺失 | os.getenv 返回 None 导致文件名异常 |
用 os.getenv(key, default) 兜底 |
九、扩展思路
1. 与执行上报组合,形成"未执行用例"看板
应跑集合 = pytest_collection_finish 上报的 cases
已跑集合 = pytest_report_teststatus 上报的 cases
未跑集合 = 应跑集合 - 已跑集合
服务端按 task_id 关联两者,实时计算未跑用例清单,方便定位"丢失"。
2. 加 marker 维度统计
from collections import Counter
marker_count = Counter()
for item in session.items:
for m in item.iter_markers():
marker_count[m.name] += 1
payload["marker_stats"] = dict(marker_count)
输出:
"marker_stats": {"smoke": 12, "api": 35, "slow": 8, "p0": 20}
CI 上一眼看出"本次是不是漏了 smoke 用例"。
3. 按文件聚合
from collections import defaultdict
file_cases = defaultdict(list)
for item in session.items:
file_cases[item.location[0]].append(item.nodeid)
payload["by_file"] = dict(file_cases)
便于定位"哪个文件被全量选中、哪个被部分选中"。
4. 命中率对比
历史趋势:本周采集到的用例数 vs 上周。突然减少可能意味着:
- 用例被误删
- marker 配置错误导致大量 deselect
- 测试目录结构变了
5. 接入告警
if payload["total"] < 100:
send_alert(f"命中用例过少:{payload['total']},疑似采集异常")
避免"测试 silently 跑空"。
6. 配合 --collect-only 做干跑
pytest --collect-only -q
--collect-only 也会触发 pytest_collection_finish,因此可以用来"只采集不上报"地验证命中清单,方便调试。
十、总结
pytest_collection_finish 是采集阶段的"终点站",items 已定型、即将开始执行——是上报命中用例清单的最佳时机。掌握它需要抓住三条主线:
- 时机:
modifyitems之后、runtest_loop之前,items 不可再改 - 数据:
session.items含 nodeid / location / markers 等关键字段 - 协作:与
pytest_report_teststatus互补,前者给"应跑集合",后者给"已跑集合"
与上一篇的衔接:
| 钩子 | 上报内容 | 回答的问题 |
|---|---|---|
pytest_collection_finish |
命中用例清单 | "本次应该跑哪些?" |
pytest_report_teststatus |
用例执行状态 | "已跑的每条结果如何?" |
| 两者结合 | 应跑 - 已跑 = 未跑 | "还有哪些没跑?" |
一句话记法:
pytest_collection_finish给"应跑集合"打快照,pytest_report_teststatus给"已跑集合"做实时流,两者结合让测试过程从黑盒变白盒。
实践路径:
- 起步:先用最简示例打印
session.items,熟悉 items 内容 - 进阶:加 xdist 兼容、JSON 文件上传、任务标识
- 生产化:服务端做"应跑 vs 已跑"对比、告警、历史趋势看板
把这套机制建立起来后,你不仅知道"跑到哪了",还知道"本应该跑什么、还有哪些没跑"——CI 测试过程完全可视化。
建议动手实验:在项目
conftest.py加上本文第三章的最小示例,运行pytest --collect-only -q,观察session.items的内容——这是理解采集钩子最快的方式。

浙公网安备 33010602011771号