Pytest 并发分组执行引擎(支持UI / 接口自动化测试):从设计到工程落地

目录

文档说明

一、前言:为什么需要这个并发执行器?

二、核心定位与设计目标

[2.1 核心定位](#2.1 核心定位)

[2.2 设计目标](#2.2 设计目标)

三、整体架构设计

[3.1 核心组件(三大核心)](#3.1 核心组件(三大核心))

[3.2 执行流程(标准化闭环)](#3.2 执行流程(标准化闭环))

四、核心源码深度解析

[4.1 类型定义与依赖](#4.1 类型定义与依赖)

[4.2 执行结果数据模型:GroupExecutionResult](#4.2 执行结果数据模型:GroupExecutionResult)

[4.3 核心执行器:PytestConcurrentRunner](#4.3 核心执行器:PytestConcurrentRunner)

[4.3.1 初始化配置](#4.3.1 初始化配置)

[4.3.2 日志统一输出:_log](#4.3.2 日志统一输出:_log)

[4.3.3 分组标准化:_normalize_groups](#4.3.3 分组标准化:_normalize_groups)

[4.3.4 命令构建:_build_command](#4.3.4 命令构建:_build_command)

[4.3.5 单任务执行:_run_group](#4.3.5 单任务执行:_run_group)

[4.3.6 并发调度与快速失败:dispatch](#4.3.6 并发调度与快速失败:dispatch)

[4.4 兼容入口函数:dispatch_groups](#4.4 兼容入口函数:dispatch_groups)

五、完整使用手册

[5.1 基础使用:函数式调用(兼容老代码)](#5.1 基础使用:函数式调用(兼容老代码))

[5.2 高级使用:面向对象调用(推荐)](#5.2 高级使用:面向对象调用(推荐))

[5.3 返回结果结构(结构化数据,易集成)](#5.3 返回结果结构(结构化数据,易集成))

六、工程化最佳实践

[6.1 并发数配置建议](#6.1 并发数配置建议)

[6.2 流水线集成配置](#6.2 流水线集成配置)

[6.3 报告生成优化](#6.3 报告生成优化)

[6.4 源码分享](#6.4 源码分享)

七、常见问题与解决方案

[7.1 快速失败无法终止已运行任务?](#7.1 快速失败无法终止已运行任务?)

[7.2 并发执行导致用例失败?](#7.2 并发执行导致用例失败?)

[7.3 日志输出混乱?](#7.3 日志输出混乱?)

[7.4 Python 环境报错?](#7.4 Python 环境报错?)

八、核心优势总结

九、结语


文档说明

本文档基于生产级 Pytest 并发分组执行工具 完整编写,覆盖设计理念、架构详解、核心源码解析、使用手册、工程化最佳实践、常见问题全维度内容,适用于自动化测试平台、UI/接口自动化项目、CI/CD 流水线集成,可直接用于团队技术分享、代码评审、项目文档。

一、前言:为什么需要这个并发执行器?

在中大型自动化测试项目中,串行执行用例已经无法满足效率需求:

  1. 执行耗时过长:上千条用例串行执行动辄数小时,测试反馈滞后

  2. 资源利用率低:多核服务器/PC 仅单线程运行,硬件资源浪费

  3. 配置冗余繁琐:多次执行测试时,重复编写 pytest 参数、工作目录、Python 环境配置

  4. 结果不可观测:无统一的执行结果汇总、耗时统计、日志捕获

  5. 兼容性要求高:老项目已有调用逻辑,不能破坏性重构

  6. 缺乏异常保护:无任务超时、快速失败机制,单个任务卡死导致整体阻塞

本工具以极低的侵入性、完善的工程化设计 ,完美解决以上所有问题,实现测试任务并发提速、配置复用、结果可观测、向下兼容的核心目标。

二、核心定位与设计目标

2.1 核心定位

一款面向 Python 自动化测试 的 Pytest 并发分组执行引擎,支持多线程调度 Pytest 子进程,实现测试任务分组并发执行,兼顾易用性、稳定性、可扩展性、兼容性

2.2 设计目标

  1. 并发提速:多任务并行执行,最大化利用硬件资源

  2. 灵活分组:支持任意测试目录/文件组合分组,自动格式化输入

  3. 配置复用:面向对象设计,一次初始化,多次分发任务

  4. 高可靠性:支持任务超时、异常捕获、快速失败(FailFast)

  5. 可观测性:完整日志输出、执行结果汇总、输出捕获

  6. 向下兼容:保留函数式入口,老代码零改造使用

  7. 环境安全:强制使用当前 Python 解释器,避免环境错乱

三、整体架构设计

3.1 核心组件(三大核心)

组件名称 类型 核心职责
GroupExecutionResult 数据模型类 封装单个测试分组的执行结果(命令、返回码、耗时、输出、异常、超时)
PytestConcurrentRunner 核心执行类 配置管理、命令构建、并发调度、任务执行、结果汇总
dispatch_groups 兼容入口函数 面向旧代码的函数式调用封装,无感知升级

3.2 执行流程(标准化闭环)

暂时无法在豆包文档外展示此内容

四、核心源码深度解析

4.1 类型定义与依赖

复制代码
 # 兼容 Python 3.10+ 类型注解
 from __future__ import annotations
 # 子进程执行 pytest
 import subprocess
 # 获取当前 Python 解释器
 import sys
 # 线程池并发调度
 from concurrent.futures import ThreadPoolExecutor, as_completed
 # 轻量级数据模型
 from dataclasses import asdict, dataclass
 # 日志支持
 from logging import Logger
 # 路径处理
 from pathlib import Path
 # 高精度计时
 from time import perf_counter
 # 类型约束
 from typing import Iterable
 ​
 # 兼容项目日志(无权限时自动降级)
 try:
     from ui_test_core.logger import log as project_log
 except Exception:
     project_log = None
 ​
 # 类型别名:支持字符串/Path 对象路径
 PathLike = str | Path
 GroupInput = PathLike | Iterable[PathLike]

设计亮点

  • 自动降级兼容项目日志,无环境依赖

  • 类型别名提升代码可读性,支持灵活的输入格式

  • 高精度计时perf_counter,保证耗时统计准确

4.2 执行结果数据模型:GroupExecutionResult

复制代码
 @dataclass(slots=True)
 class GroupExecutionResult:
     """单个 pytest 分组的执行结果。"""
     # 分组编号
     group_index: int
     # 测试目标路径
     targets: list[str]
     # 执行的完整命令
     command: list[str]
     # 进程返回码(0=成功,非0=失败)
     return_code: int
     # 执行耗时(秒)
     duration: float
     # 标准输出
     stdout: str = ""
     # 错误输出
     stderr: str = ""
     # 异常信息
     error: str = ""
     # 是否超时
     timed_out: bool = False
 ​
     def to_dict(self) -> dict:
         """转换为字典,兼容旧调用方的返回结构。"""
         return asdict(self)

设计亮点

  • slots=True:提升数据读写性能,减少内存占用

  • 统一结果结构:正常执行/超时/异常 三种场景共用一个模型

  • to_dict():完美兼容上层旧接口,无改造成本

4.3 核心执行器:PytestConcurrentRunner

4.3.1 初始化配置
复制代码
 def __init__(
     self,
     workers: int | None = None,        # 并发数,默认=分组数
     pytest_args: list[str] | None = None,  # pytest 公共参数
     python_executable: str | None = None,  # 指定 Python 解释器
     cwd: str | None = None,            # 工作目录
     timeout: float | None = None,      # 单任务超时时间
     fail_fast: bool = False,          # 快速失败开关
     capture_output: bool = False,      # 输出捕获开关
     logger: Logger | None = None,      # 自定义日志
     verbose: bool = True,             # 日志打印开关
 ):
     self.workers = workers
     self.pytest_args = list(pytest_args or [])
     self.python_executable = python_executable or sys.executable
     self.cwd = cwd
     self.timeout = timeout
     self.fail_fast = fail_fast
     self.capture_output = capture_output
     self.logger = logger
     self.verbose = verbose

核心保障

  • 默认使用sys.executable彻底避免 Python 环境错乱

  • 所有参数均有默认值,开箱即用

4.3.2 日志统一输出:_log
复制代码
 def _log(self, message: str, level: str = "info") -> None:
     """日志降级策略:项目日志 → print(),无依赖风险"""
     if not self.verbose:
         return
     active_logger = self.logger or project_log
     if active_logger is None:
         print(message)
         return
     log_method = getattr(active_logger, level, None)
     log_method(message) if callable(log_method) else active_logger.info(message)

设计亮点 :三层日志降级策略,任何环境都能正常输出日志

4.3.3 分组标准化:_normalize_groups
复制代码
 @staticmethod
 def _normalize_groups(groups: Iterable[GroupInput]) -> list[list[str]]:
     """宽松输入 → 标准化二维列表"""
     normalized_groups = []
     for group in groups:
         # 单个路径自动包装为列表
         if isinstance(group, (str, Path)):
             normalized_group = [str(group)]
         else:
             normalized_group = [str(item) for item in group]
         if not normalized_group:
             raise ValueError("group 不能为空")
         normalized_groups.append(normalized_group)
     return normalized_groups

设计亮点

  • 支持字符串/Path 对象/可迭代对象三种输入

  • 自动校验空分组,提前拦截非法参数

4.3.4 命令构建:_build_command
复制代码
 def _build_command(self, group: list[str]) -> list[str]:
     """构建标准 pytest 执行命令"""
     return [self.python_executable, "-m", "pytest", *group, *self.pytest_args]

执行命令示例

复制代码
 python.exe -m pytest test_case/device_manage -s -v --alluredir=allure-results
4.3.5 单任务执行:_run_group
复制代码
 def _run_group(self, group_index: int, group: list[str]) -> GroupExecutionResult:
     command = self._build_command(group)
     start_time = perf_counter()
     self._log(f"[group-{group_index}] start: {' '.join(command)}")
 ​
     try:
         # 子进程执行 pytest
         completed = subprocess.run(
             command, cwd=self.cwd, check=False, timeout=self.timeout,
             text=True, capture_output=self.capture_output
         )
         duration = round(perf_counter() - start_time, 2)
         self._log(f"[group-{group_index}] end: exit_code={completed.returncode}")
         return GroupExecutionResult(...)  # 正常结果
     # 超时异常捕获
     except subprocess.TimeoutExpired as exc:
         return GroupExecutionResult(..., timed_out=True)  # 超时结果
     # 兜底异常捕获
     except Exception as exc:
         return GroupExecutionResult(..., error=repr(exc))  # 异常结果

核心能力

  • 子进程隔离执行,任务之间互不干扰

  • 精准捕获超时/系统异常/业务异常

  • 受控输出捕获,避免日志泛滥

4.3.6 并发调度与快速失败:dispatch
复制代码
 def dispatch(self, groups: Iterable[GroupInput]) -> dict:
     # 1. 标准化分组
     normalized_groups = self._normalize_groups(groups)
     # 2. 计算并发数
     workers = self._resolve_workers(len(normalized_groups))
     # 3. 线程池执行
     with ThreadPoolExecutor(max_workers=workers) as executor:
         future_to_index = {executor.submit(...): index}
         stop_collecting = False
         # 4. 异步收集结果
         for future in as_completed(future_to_index):
             results.append(future.result())
             # 5. 快速失败:触发后取消未启动任务
             if self.fail_fast and self._is_failed(results[-1]):
                 stop_collecting = True
                 break
         # 6. 取消未执行任务
         if stop_collecting:
             for future in future_to_index:
                 if future.cancel(): cancelled_count +=1
     # 7. 结果排序与汇总
     results.sort(key=lambda item: item.group_index)
     self._print_summary(results, total_duration)
     # 8. 返回结构化结果
     return {...}

设计亮点

  • 线程池轻量级调度,无多进程资源开销

  • 快速失败:仅取消未启动任务,已启动任务正常执行完成

  • 结果按分组编号排序,保证输出有序

4.4 兼容入口函数:dispatch_groups

复制代码
 def dispatch_groups(...):
     """函数式封装,老代码零改造使用"""
     runner = PytestConcurrentRunner(...)
     return runner.dispatch(groups)

设计亮点

  • 完全复用类的能力,无冗余代码

  • 新老项目通用,平滑升级

五、完整使用手册

5.1 基础使用:函数式调用(兼容老代码)

复制代码
 from ui_test_core.runner.concurrent import dispatch_groups
 ​
 # 执行两组测试,2 并发
 result = dispatch_groups(
     groups=[
         "test_case/andon_call_task",
         "test_case/assisted_scheduling"
     ],
     workers=2,
     pytest_args=["-s", "-v", "--alluredir=allure-results"],
     cwd=".",
     timeout=1800,
     fail_fast=True,
     capture_output=True
 )
 ​
 # 获取执行结果
 print("执行成功:", result["success"])
 print("失败分组数:", result["failed_count"])

5.2 高级使用:面向对象调用(推荐)

复制代码
 from ui_test_core.runner.concurrent import PytestConcurrentRunner
 ​
 # 1. 初始化运行器(配置一次,多次使用)
 runner = PytestConcurrentRunner(
     workers=2,
     pytest_args=["-q", "-s"],
     cwd="D:/WorkSpace/python/EM-PL-UI-CORE",
     timeout=1200,
     fail_fast=True,
     capture_output=True
 )
 ​
 # 2. 分发任务
 summary = runner.dispatch(
     [
         ["test_case/quality_inspection_task", "test_case/inspection_task"],
         ["test_case/device_manage"],
     ]
 )
 ​
 # 3. 使用结果
 assert summary["success"] is True

5.3 返回结果结构(结构化数据,易集成)

复制代码
 {
     "success": True/False,          # 整体是否成功
     "results": [dict],              # 所有分组的详细结果
     "total_duration": 12.34,        # 总耗时(秒)
     "failed_count": 0,              # 失败分组数
     "cancelled_count": 0            # 取消分组数
 }

六、工程化最佳实践

6.1 并发数配置建议

  1. UI 自动化workers ≤ 4(避免浏览器抢占资源)

  2. 接口自动化workers = CPU 核心数(IO 密集型,可适当调高)

  3. 通用规则workers ≤ 分组数,避免资源浪费

6.2 流水线集成配置

复制代码
 # GitLab/Jenkins 流水线推荐配置
 dispatch_groups(
     groups=test_groups,
     workers=3,
     pytest_args=["-v", "--alluredir=allure-results"],
     timeout=3600,
     fail_fast=True,
     capture_output=True,
     verbose=True
 )

6.3 报告生成优化

多组并发执行时,避免所有分组写入同一个 allure 目录

  1. 每组指定独立目录:--alluredir=results/group_1

  2. 执行完成后,使用allure merge合并报告

6.4 源码分享

复制代码
 """并发执行 pytest 分组任务。
 ​
 该模块保留 `dispatch_groups` 作为兼容入口,同时提供
 `PytestConcurrentRunner` 类以便复用配置和扩展行为。
 ​
 使用示例:
     >>> dispatch_groups(
     ...     groups=["test_case/andon_call_task", "test_case/assisted_scheduling"],
     ...     workers=2,
     ...     pytest_args=["-s", "-v", "--alluredir=allure-results"],
     ... )
 ​
     >>> from ui_test_core.runner.concurrent import PytestConcurrentRunner
     >>> runner = PytestConcurrentRunner(
     ...     workers=2,
     ...     pytest_args=["-q", "-s"],
     ...     cwd="D:/WorkSpace/python/EM-PL-UI-CORE",
     ... )
     >>> summary = runner.dispatch(
     ...     [
     ...         ["test_case/quality_inspection_task", "test_case/inspection_task"],
     ...         ["test_case/device_manage"],
     ...     ]
     ... )
     >>> summary["success"]
     True
 """
 ​
 from __future__ import annotations
 ​
 import subprocess
 import sys
 from concurrent.futures import ThreadPoolExecutor, as_completed
 from dataclasses import asdict, dataclass
 from logging import Logger
 from pathlib import Path
 from time import perf_counter
 from typing import Iterable
 ​
 try:
     from ui_test_core.logger import log as project_log
 except Exception:  # pragma: no cover - 取决于运行环境的日志目录权限
     project_log = None
 ​
 PathLike = str | Path
 GroupInput = PathLike | Iterable[PathLike]
 ​
 ​
 @dataclass(slots=True)
 class GroupExecutionResult:
     """单个 pytest 分组的执行结果。"""
 ​
     group_index: int
     targets: list[str]
     command: list[str]
     return_code: int
     duration: float
     stdout: str = ""
     stderr: str = ""
     error: str = ""
     timed_out: bool = False
 ​
     def to_dict(self) -> dict:
         """转换为字典,兼容旧调用方的返回结构。"""
         return asdict(self)
 ​
 ​
 class PytestConcurrentRunner:
     """并发执行 pytest 分组任务的运行器。
 ​
     适用场景:
     - 将多个测试目录拆成若干组并发执行。
     - 固定 `pytest_args`、`cwd`、`python_executable` 后重复调用。
 ​
     Args:
         workers: 最大并发数。未传入时,默认等于分组数。
         pytest_args: 追加到 pytest 命令后的公共参数。
         python_executable: Python 解释器路径,默认使用当前解释器。
         cwd: 执行 pytest 时的工作目录。
         timeout: 单个分组的超时时间,单位为秒。
         fail_fast: 某个分组失败后,尽量取消尚未启动的任务。已启动的任务不会被强制终止。
         capture_output: 是否捕获每个分组的标准输出和错误输出。
         logger: 可选日志对象。未提供时优先使用 `ui_test_core.logger.log`,
             若项目日志初始化失败则回退到 `print`。
         verbose: 是否打印执行日志和汇总信息。
 ​
     Example:
         >>> runner = PytestConcurrentRunner(
         ...     workers=2,
         ...     pytest_args=["-q"],
         ...     capture_output=True,
         ...     fail_fast=True,
         ... )
         >>> runner.dispatch([["tests/smoke"], ["tests/regression"]])
         {'success': True, 'results': [...]}
     """
 ​
     def __init__(
         self,
         workers: int | None = None,
         pytest_args: list[str] | None = None,
         python_executable: str | None = None,
         cwd: str | None = None,
         timeout: float | None = None,
         fail_fast: bool = False,
         capture_output: bool = False,
         logger: Logger | None = None,
         verbose: bool = True,
     ) -> None:
         self.workers = workers
         self.pytest_args = list(pytest_args or [])
         self.python_executable = python_executable or sys.executable
         self.cwd = cwd
         self.timeout = timeout
         self.fail_fast = fail_fast
         self.capture_output = capture_output
         self.logger = logger
         self.verbose = verbose
 ​
     def _log(self, message: str, level: str = "info") -> None:
         """输出日志,默认使用项目统一日志对象,失败时回退到 `print`。"""
         if not self.verbose:
             return
 ​
         active_logger = self.logger or project_log
         if active_logger is None:
             print(message)
             return
 ​
         log_method = getattr(active_logger, level, None)
         if callable(log_method):
             log_method(message)
             return
 ​
         active_logger.info(message)
 ​
     @staticmethod
     def _normalize_groups(groups: Iterable[GroupInput]) -> list[list[str]]:
         """标准化测试分组输入。
 ​
         允许输入单个路径,也允许输入一个包含多个路径的可迭代对象。
 ​
         Args:
             groups: 测试分组列表。每个元素可以是单个路径,或多个路径组成的组。
 ​
         Returns:
             统一转换后的二维字符串列表。
 ​
         Raises:
             ValueError: 当存在空分组时抛出异常。
         """
         normalized_groups: list[list[str]] = []
         for group in groups:
             if isinstance(group, (str, Path)):
                 normalized_group = [str(group)]
             else:
                 normalized_group = [str(item) for item in group]
 ​
             if not normalized_group:
                 raise ValueError("group 不能为空")
             normalized_groups.append(normalized_group)
         return normalized_groups
 ​
     def _resolve_workers(self, group_count: int) -> int:
         """计算实际并发数并校验参数。"""
         workers = self.workers or group_count
         if workers <= 0:
             raise ValueError("workers 必须大于 0")
         return min(workers, group_count)
 ​
     def _build_command(self, group: list[str]) -> list[str]:
         """构造单个分组对应的 pytest 命令。"""
         return [self.python_executable, "-m", "pytest", *group, *self.pytest_args]
 ​
     def _run_group(self, group_index: int, group: list[str]) -> GroupExecutionResult:
         """执行单个测试分组。
 ​
         Args:
             group_index: 分组编号,从 1 开始,仅用于日志和结果排序。
             group: 当前分组包含的测试目标列表。
 ​
         Returns:
             `GroupExecutionResult`,包含命令、耗时和退出码等信息。
         """
         command = self._build_command(group)
         start_time = perf_counter()
         self._log(f"[group-{group_index}] start: {' '.join(command)}")
 ​
         try:
             completed = subprocess.run(
                 command,
                 cwd=self.cwd,
                 check=False,
                 timeout=self.timeout,
                 text=True,
                 capture_output=self.capture_output,
             )
             duration = round(perf_counter() - start_time, 2)
             self._log(
                 f"[group-{group_index}] end: exit_code={completed.returncode}, duration={duration}s"
             )
             return GroupExecutionResult(
                 group_index=group_index,
                 targets=group,
                 command=command,
                 return_code=completed.returncode,
                 duration=duration,
                 stdout=completed.stdout or "",
                 stderr=completed.stderr or "",
             )
         except subprocess.TimeoutExpired as exc:
             duration = round(perf_counter() - start_time, 2)
             error = f"执行超时,timeout={self.timeout}s"
             self._log(
                 f"[group-{group_index}] timeout: duration={duration}s, timeout={self.timeout}s",
                 level="error",
             )
             return GroupExecutionResult(
                 group_index=group_index,
                 targets=group,
                 command=command,
                 return_code=-1,
                 duration=duration,
                 stdout=exc.stdout or "",
                 stderr=exc.stderr or "",
                 error=error,
                 timed_out=True,
             )
         except Exception as exc:  # pragma: no cover - 极端环境异常
             duration = round(perf_counter() - start_time, 2)
             self._log(
                 f"[group-{group_index}] error: {exc!r}, duration={duration}s",
                 level="error",
             )
             return GroupExecutionResult(
                 group_index=group_index,
                 targets=group,
                 command=command,
                 return_code=-1,
                 duration=duration,
                 error=repr(exc),
             )
 ​
     def _print_config(self, group_count: int, workers: int) -> None:
         """打印当前执行配置,便于排查并发任务问题。"""
         self._log(f"python: {self.python_executable}")
         self._log(f"workers: {workers}")
         self._log(f"group_count: {group_count}")
         if self.cwd:
             self._log(f"cwd: {self.cwd}")
         if self.pytest_args:
             self._log(f"pytest_args: {self.pytest_args}")
         if self.timeout is not None:
             self._log(f"timeout: {self.timeout}s")
         self._log(f"fail_fast: {self.fail_fast}")
         self._log(f"capture_output: {self.capture_output}")
 ​
     def _print_summary(self, results: list[GroupExecutionResult], total_duration: float) -> None:
         """输出所有分组的汇总结果。"""
         self._log("\nsummary:")
         for item in results:
             message = (
                 f"  group-{item.group_index}: "
                 f"exit_code={item.return_code}, duration={item.duration}s"
             )
             if item.timed_out:
                 message += ", timed_out=True"
             if item.error:
                 message += f", error={item.error}"
             self._log(message)
             if self.capture_output and (item.stdout or item.stderr):
                 if item.stdout:
                     self._log(f"    stdout:\n{item.stdout.rstrip()}")
                 if item.stderr:
                     self._log(f"    stderr:\n{item.stderr.rstrip()}", level="error")
 ​
         success_count = sum(1 for item in results if item.return_code == 0)
         failed_count = len(results) - success_count
         self._log(
             f"total_duration: {round(total_duration, 2)}s, "
             f"success_count: {success_count}, failed_count: {failed_count}"
         )
 ​
     @staticmethod
     def _is_failed(result: GroupExecutionResult) -> bool:
         """判断单个分组是否执行失败。"""
         return result.return_code != 0
 ​
     def dispatch(self, groups: Iterable[GroupInput]) -> dict:
         """并发分发 pytest 分组任务。
 ​
         Args:
             groups: 二维分组配置。例如:
                 [
                     ["test_case/quality_inspection_task", "test_case/inspection_task"],
                     ["test_case/device_manage"],
                 ]
 ​
         Returns:
             与历史接口兼容的执行结果字典:
             - `success`: 是否所有分组都执行成功
             - `results`: 每个分组的执行明细列表
             - `total_duration`: 总耗时
             - `failed_count`: 失败分组数量
             - `cancelled_count`: fail-fast 模式下被取消的任务数量
 ​
         Raises:
             ValueError: 当 `groups` 为空或 `workers` 非法时抛出异常。
         """
         normalized_groups = self._normalize_groups(groups)
         if not normalized_groups:
             raise ValueError("groups 不能为空")
 ​
         workers = self._resolve_workers(len(normalized_groups))
         self._print_config(group_count=len(normalized_groups), workers=workers)
 ​
         results: list[GroupExecutionResult] = []
         cancelled_count = 0
         dispatch_start = perf_counter()
         with ThreadPoolExecutor(max_workers=workers) as executor:
             future_to_index = {
                 executor.submit(self._run_group, index, group): index
                 for index, group in enumerate(normalized_groups, start=1)
             }
             stop_collecting = False
             for future in as_completed(future_to_index):
                 results.append(future.result())
                 if self.fail_fast and self._is_failed(results[-1]):
                     stop_collecting = True
                     self._log(
                         f"fail_fast triggered by group-{results[-1].group_index}, "
                         "attempting to cancel pending groups.",
                         level="error",
                     )
                     break
 ​
             if stop_collecting:
                 for future in future_to_index:
                     if not future.done() and future.cancel():
                         cancelled_count += 1
 ​
         if stop_collecting:
             for future in future_to_index:
                 if future.done() and not future.cancelled():
                     result = future.result()
                     if all(item.group_index != result.group_index for item in results):
                         results.append(result)
 ​
         total_duration = perf_counter() - dispatch_start
 ​
         results.sort(key=lambda item: item.group_index)
         self._print_summary(results, total_duration=total_duration)
 ​
         return {
             "success": all(item.return_code == 0 for item in results) and cancelled_count == 0,
             "results": [item.to_dict() for item in results],
             "total_duration": round(total_duration, 2),
             "failed_count": sum(1 for item in results if self._is_failed(item)),
             "cancelled_count": cancelled_count,
         }
 ​
 ​
 def dispatch_groups(
     groups: Iterable[GroupInput],
     workers: int | None = None,
     pytest_args: list[str] | None = None,
     python_executable: str | None = None,
     cwd: str | None = None,
     timeout: float | None = None,
     fail_fast: bool = False,
     capture_output: bool = False,
     logger: Logger | None = None,
     verbose: bool = True,
 ) -> dict:
     """兼容旧接口的函数式封装。
 ​
     当只需要快速调用时,继续使用该函数即可;若需要复用配置,建议直接使用
     `PytestConcurrentRunner`。
 ​
     Example:
         >>> dispatch_groups(
         ...     groups=[["tests/api"], ["tests/ui"]],
         ...     workers=2,
         ...     pytest_args=["-q"],
         ...     capture_output=True,
         ... )
     """
     runner = PytestConcurrentRunner(
         workers=workers,
         pytest_args=pytest_args,
         python_executable=python_executable,
         cwd=cwd,
         timeout=timeout,
         fail_fast=fail_fast,
         capture_output=capture_output,
         logger=logger,
         verbose=verbose,
     )
     return runner.dispatch(groups)
 ​

七、常见问题与解决方案

7.1 快速失败无法终止已运行任务?

解答 :这是设计预期 。线程池无法强制终止运行中的线程,强制终止会导致进程残留、资源泄漏。快速失败仅取消未启动的任务,已启动任务会正常执行完毕。

7.2 并发执行导致用例失败?

解答 :用例必须无状态、无共享资源。若用例依赖数据库、缓存、全局变量,并发执行会导致数据竞争,建议此类用例单独串行执行。

7.3 日志输出混乱?

解答 :开启capture_output=True,工具会自动按分组隔离 stdout/stderr,日志清晰可追溯。

7.4 Python 环境报错?

解答 :工具默认使用当前解释器sys.executable,无需手动配置,彻底避免环境错乱。

八、核心优势总结

  1. 生产级稳定性:全异常捕获、超时保护、快速失败,7×24 小时稳定运行

  2. 零侵入使用:不修改 Pytest 配置、不侵入用例代码,直接集成

  3. 极高易用性:支持函数式/面向对象两种调用方式,新老项目通用

  4. 完整可观测:执行日志、耗时统计、输出捕获、结果汇总全覆盖

  5. 高性能设计:线程池调度+子进程隔离,内存占用低,执行效率高

  6. 极强扩展性:类结构设计,可轻松新增重试、告警、分布式执行等能力

九、结语

这款 Pytest 并发分组执行引擎,不是简单的多线程包装,而是工程化的测试执行解决方案 。它解决了自动化测试中「慢、乱、杂、难排查」的核心痛点,真正实现了测试任务的高效、稳定、可控执行

从代码设计上,它遵循单一职责、开闭原则、兼容复用 的软件工程最佳实践;从工程落地中,它适配所有主流自动化测试场景,是中大型测试项目的必备基础设施

相关推荐
小猪咪piggy4 小时前
【接口自动化】(2) pytest 测试框架
运维·自动化·pytest
忘忧记5 小时前
Pytest + Requests + YAML 数据驱动+日志模块
网络·python·pytest
清水白石0082 天前
pytest Fixture 设计实战指南:作用域、依赖链、自动清理与测试资源高效复用
python·pytest
亚马逊云开发者2 天前
Amazon Nova Act 浏览器自动化测试实战:AI 驱动的端到端测试 + pytest 集成 + OpenClaw 场景落地
人工智能·pytest
cendy-LL4 天前
自动化测试之Pytest框架
pytest
我的xiaodoujiao4 天前
API 接口自动化测试详细图文教程学习系列7--相关Python基础知识6
python·学习·测试工具·pytest
忘忧记4 天前
pytest + YAML + requests`简单实例化
网络·pytest
我的xiaodoujiao4 天前
API 接口自动化测试详细图文教程学习系列8--测试接口
python·学习·测试工具·pytest
鹿鸣悠悠6 天前
pytest + requests + allure 接口自动化测试框架指南
pytest