别等测试跑完才知道命中了哪些用例:`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(增删、排序、加标记)
  • finishitems 已定型,是"最终命中用例"的快照——最适合上报
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 对象的核心字段

sessionSession 实例,常用字段如下:

字段 类型 说明
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 已定型、即将开始执行——是上报命中用例清单的最佳时机。掌握它需要抓住三条主线:

  1. 时机modifyitems 之后、runtest_loop 之前,items 不可再改
  2. 数据session.items 含 nodeid / location / markers 等关键字段
  3. 协作:与 pytest_report_teststatus 互补,前者给"应跑集合",后者给"已跑集合"

与上一篇的衔接

钩子 上报内容 回答的问题
pytest_collection_finish 命中用例清单 "本次应该跑哪些?"
pytest_report_teststatus 用例执行状态 "已跑的每条结果如何?"
两者结合 应跑 - 已跑 = 未跑 "还有哪些没跑?"

一句话记法

pytest_collection_finish 给"应跑集合"打快照,pytest_report_teststatus 给"已跑集合"做实时流,两者结合让测试过程从黑盒变白盒。

实践路径

  1. 起步:先用最简示例打印 session.items,熟悉 items 内容
  2. 进阶:加 xdist 兼容、JSON 文件上传、任务标识
  3. 生产化:服务端做"应跑 vs 已跑"对比、告警、历史趋势看板

把这套机制建立起来后,你不仅知道"跑到哪了",还知道"本应该跑什么、还有哪些没跑"——CI 测试过程完全可视化。


建议动手实验:在项目 conftest.py 加上本文第三章的最小示例,运行 pytest --collect-only -q,观察 session.items 的内容——这是理解采集钩子最快的方式。


posted @ 2026-08-24 20:29  EXIORAN  阅读(5)  评论(0)    收藏  举报