pytest 钩子 pytest_report_teststatus 与启动参数 -n 踩坑记录

在自动化测试项目中,我们通过 pytest_report_teststatus 钩子收集用例结果并上报服务端。本地调试一切正常,可一旦给启动命令加上 -n 参数(无论是 -n 1 还是 -n 5),同一个用例就会被上报两次------用例明明只执行了一次,服务端却收到两条一模一样的记录。本文从 pytest-xdist 的进程模型入手,剖析 "master + worker 双重触发钩子" 这一根因,给出基于 report.worker_id 的推荐修复方案与流水线配置建议,并附上服务端幂等兜底思路,帮你绕开这个看似诡异、实则必然的坑。


一、问题背景

本文的问题源于此前两篇系列文章的工作:

在这套上报机制里,我们通常会在 conftest.py 中通过 pytest_report_teststatus 钩子收集每个用例的执行结果,然后上报到服务端:

python 复制代码
# conftest.py

def pytest_report_teststatus(report):
    if (report.when == 'setup' and report.outcome.lower() == 'skipped') or report.when == 'call':
        if not UPLOAD_FLAG:
            return
        ...

在继续深入开发新功能的过程中,本地无论是用 PyCharm 直接运行用例,还是执行命令:

bash 复制代码
pytest -vs cases/demo

数据都完全正常:每条用例对应一条记录,干净且无重复。

然而一旦进入流水线,并在启动命令中追加并发参数:

bash 复制代码
pytest -vs -l cases/demo -n 1

问题立刻出现:同一个用例被上报了两次。

更诡异的是数据规律-------n 5 是两次,-n 1 也是两次,不多不少,恰好翻倍。这种"稳定地翻倍"通常意味着不是随机竞态,而是有确定性的结构原因。


二、现象

启动方式 用例执行次数 上报次数
pytest -vs cases/demo 正常 正常
pytest -vs cases/demo -n 1 正常 × 2
pytest -vs cases/demo -n 5 正常 × 2

这里有两个关键观察点:

  1. 用例本身只执行了一次 ,pytest 输出里每个用例也只出现一次 PASSED;
  2. 上报次数与并发量无关 ,-n 1 和 -n 5 都是两次。

也就是说,重复发生在"上报"这一环,而不是"执行"这一环。


三、原因分析

1. -n 会启动 master + worker 两组进程

pytest-xdist 的 -n 参数表示启动并行 worker 的数量。不同启动方式对应的实际进程数如下:

命令 实际进程
不带 -n 只有 1 个主进程
-n 1 master + 1 个 worker(gw0),共 2 个进程
-n 5 master + 5 个 worker(gw0 ~ gw4),共 6 个进程

关键点在于:pytest_report_teststatus 钩子在 master 和 worker 里各会被调用一次。

  • worker 侧 :xdist 的 worker 是独立运行的 pytest 进程,它自己执行用例时,也会触发本进程内的 pytest_report_teststatus;
  • master 侧:master 进程在汇总各 worker 回传的结果时,同样会再次触发这个钩子。

因此,只要开启 -n,哪怕只有 -n 1,钩子就会在 master 和 gw0 里各执行一次,同一个用例自然被上报两次。

2. 为什么 -n 5 也是两次,而不是六次?

因为 xdist 默认调度策略下,每个用例只会被分配给一个 worker 执行 。worker 只对自己实际执行的用例触发 pytest_report_teststatus,而 master 会对所有用例再触发一次。所以:

text 复制代码
上报次数 = worker 上报 1 次 + master 上报 1 次 = 2 次

这个结果与 worker 数量无关------无论开几个 worker,单个用例的"执行侧上报"始终只有一次,"汇总侧上报"也只有一次。

3. 为什么不带 -n 就正常?

不带 -n 时没有 xdist,只有 1 个进程,pytest_report_teststatus 只被调用一次,自然不会有重复。


四、解决办法

推荐方案:通过 report.worker_id 判断当前是否处于 xdist 模式

python 复制代码
# conftest.py

# 用于修改测试报告的状态(单个用例执行完成后)
def pytest_report_teststatus(report):
    # 输出用例状态(可以进行数据上报)
    if not hasattr(report, 'worker_id'):
        # 只有在 pytest 测试命令中使用 -n 参数设置了并发量才会触发上报
        return

    if (report.when == 'setup' and report.outcome.lower() == 'skipped') or report.when == 'call':
        if not UPLOAD_FLAG:
            return

        ...

这个写法有两个好处:

  1. 本地调试不会上报 :本地不带 -n 运行时,report 上没有 worker_id 属性,hasattr(report, 'worker_id') 为 False,直接 return,不会触发任何上报逻辑,也就不会产生脏数据。
  2. 流水线才上报 :流水线里使用 -n 1 或 -n N 时,report 上带有 worker_id 属性,上报逻辑才会生效。

原理提醒 :report.worker_id 是 pytest-xdist 注入的属性,仅存在于 xdist 模式下。它的存在与否,恰好可以作为"当前是否处于并发模式"的判别依据。

流水线配置建议

流水线中可以设置默认并发量为 -n 1,同时允许用户自定义:

bash 复制代码
# 默认值
PYTEST_CONCURRENCY=${PYTEST_CONCURRENCY:--n 1}

pytest -vs -l cases/demo $PYTEST_CONCURRENCY --alluredir ./allure-results

这样本地开发时直接执行 pytest -vs cases/demo 不会上报,只有流水线里才会触发上报逻辑。

兜底方案:服务端加幂等去重

无论客户端怎么控制,网络重试、重复启动、rerun 插件都可能导致重复上报。因此建议服务端用唯一键(如 pipeline_id + task_id + node_id + 执行序号)做幂等,让重复上报在落库时被吞掉。客户端过滤负责"少发",服务端幂等负责"万无一失"。


五、常见误区

误区 1:--dist=worksteal 能解决重复

不能。 --dist 只影响用例如何分配给 worker,不影响 "master / worker 都会触发 hook" 这一事实。改变调度策略,重复问题依旧。

误区 2:-n 1 等于不带 -n

不等。 -n 1 仍然处于 xdist 模式,会启动 master + gw0 两个进程;只有不带 -n 才是纯单进程模式。这正是"本地正常、流水线翻倍"的根源所在。

误区 3:在 pytest_collection_finish 里判断了并发模式就够了

不够。 pytest_collection_finish 只控制用例清单的上传,而 pytest_report_teststatus 是另一个钩子,需要单独加判断。两个钩子各管一段,不能混为一谈。


六、总结

场景 行为 是否重复
不带 -n 单进程,无 worker_id 不上报
-n 1 master + 1 worker,钩子带 worker_id 否,× 1
-n N master + N workers,钩子带 worker_id 否,× 1

核心结论 :通过 hasattr(report, 'worker_id') 判断当前是否处于 xdist 并发模式------本地单进程无此属性、直接跳过,流水线并发模式下才执行上报。这样既能避免本地调试时产生脏数据,又能保证流水线里正常上报;同时配合服务端幂等兜底,方能万无一失。

一句话记住这个坑 :钩子会在 master 和 worker 两侧各跑一次,-n 一开就翻倍------用 worker_id 判断自己是否处于并发模式,只在流水线上报,才是正解。