You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现Pytest多主机并行、单主机内测试串行执行

pytest多主机SSH自动化测试定制执行实现

实现逻辑对齐需求

  • 会话初始化阶段统一创建所有目标主机SSH连接,存入全局连接池
  • 用例收集完成后按归属主机分组,每组分配独立工作线程
  • 单线程内严格按用例收集顺序串行执行归属当前主机的用例,避免同主机多连接并发操作的冲突
  • 会话销毁阶段统一关闭连接池内所有SSH连接,无资源泄漏
  • 修复自定义插件日志线程名显示异常、执行流程不符合pytest原生逻辑的问题

完整实现代码

公共模块 shared.py

负责SSH连接池管理、线程上下文绑定:

import threading
import paramiko
from typing import Dict

SSH_POOL: Dict[str, paramiko.SSHClient] = {}
# 线程本地存储,记录当前线程绑定的主机信息
THREAD_CTX = threading.local()

def init_ssh_pool(host_configs: list) -> None:
    for cfg in host_configs:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            hostname=cfg["ip"],
            port=cfg.get("port", 22),
            username=cfg["user"],
            password=cfg["pwd"],
            timeout=10
        )
        SSH_POOL[cfg["host_tag"]] = client

def destroy_ssh_pool() -> None:
    for client in SSH_POOL.values():
        try:
            client.close()
        except Exception:
            pass
    SSH_POOL.clear()

def get_current_ssh() -> paramiko.SSHClient:
    host_tag = getattr(THREAD_CTX, "bind_host", None)
    if not host_tag:
        raise RuntimeError("当前线程未绑定目标主机")
    return SSH_POOL[host_tag]

pytest配置文件 conftest.py

负责钩子注册、用例分组、日志修复:

import pytest
import threading
from _pytest.nodes import Item
from shared import init_ssh_pool, destroy_ssh_pool, THREAD_CTX

# 目标主机配置,可根据实际场景改成从yaml/命令行参数读取
HOST_CONFIGS = [
    {"host_tag": "node1", "ip": "192.168.1.11", "user": "root", "pwd": "test123"},
    {"host_tag": "node2", "ip": "192.168.1.12", "user": "root", "pwd": "test123"},
]

def pytest_configure(config):
    config.addinivalue_line(
        "markers", "target_host(tag): 标记用例归属的主机标签"
    )
    # 会话初始化建连
    init_ssh_pool(HOST_CONFIGS)

def pytest_unconfigure(config):
    # 会话结束销毁连接
    destroy_ssh_pool()

def pytest_collection_modifyitems(config, items: list[Item]):
    # 按主机标签对用例分组
    case_group = {h["host_tag"]: [] for h in HOST_CONFIGS}
    for item in items:
        mark = item.get_closest_marker("target_host")
        if not mark:
            case_group.setdefault("common", []).append(item)
            continue
        tag = mark.args[0]
        if tag not in case_group:
            raise ValueError(f"用例{item.nodeid}配置的主机标签{tag}不存在")
        case_group[tag].append(item)
    config.case_group = case_group

# 修复日志线程名、主机信息显示问题
class LogContextFilter:
    def filter(self, record):
        record.threadName = threading.current_thread().name
        record.bind_host = getattr(THREAD_CTX, "bind_host", "main")
        return True

@pytest.hookimpl
def pytest_configure_logging(config, log_plugin):
    for handler in log_plugin.handlers:
        handler.addFilter(LogContextFilter())

自定义调度器 host_parallel_runner.py

负责接管pytest执行流程,按主机分线程执行:

import threading
from typing import Dict, List
from _pytest.nodes import Item
from _pytest.runner import runtestprotocol
from shared import THREAD_CTX

class HostParallelRunner:
    def __init__(self, config, case_group: Dict[str, List[Item]]):
        self.config = config
        self.case_group = case_group
        self.threads = []

    def _run_single_host_cases(self, host_tag: str, cases: List[Item]):
        # 绑定当前线程的主机上下文,修改线程名
        THREAD_CTX.bind_host = host_tag
        threading.current_thread().name = f"worker-{host_tag}"
        # 按收集顺序串行执行当前主机下所有用例
        for item in cases:
            # 复用pytest原生执行逻辑,兼容fixture、断言、报告生成全流程
            runtestprotocol(item, nextitem=None, log=False)

    def run(self):
        # 启动所有主机工作线程
        for host_tag, cases in self.case_group.items():
            if host_tag == "common":
                continue
            t = threading.Thread(
                target=self._run_single_host_cases,
                args=(host_tag, cases),
                daemon=False
            )
            self.threads.append(t)
            t.start()
        # 等待所有工作线程执行完成
        for t in self.threads:
            t.join()
        # 主线程执行无主机标记的公共用例
        if "common" in self.case_group:
            self._run_single_host_cases("common", self.case_group["common"])

# 替换pytest默认执行循环
def pytest_runtestloop(session):
    if not hasattr(session.config, "case_group"):
        return False
    runner = HostParallelRunner(session.config, session.config.case_group)
    runner.run()
    return True

测试用例示例 test_cases.py

import pytest
import logging
from shared import get_current_ssh

log = logging.getLogger(__name__)

@pytest.mark.target_host("node1")
def test_node1_cpu_load():
    ssh = get_current_ssh()
    _, stdout, _ = ssh.exec_command("uptime")
    res = stdout.read().decode()
    log.info(f"node1负载信息: {res.strip()}")
    assert "load average" in res

@pytest.mark.target_host("node1")
def test_node1_disk_usage():
    ssh = get_current_ssh()
    _, stdout, _ = ssh.exec_command("df -h /")
    res = stdout.read().decode()
    log.info(f"node1根分区使用情况: {res.strip()}")
    assert "%" in res

@pytest.mark.target_host("node2")
def test_node2_memory():
    ssh = get_current_ssh()
    _, stdout, _ = ssh.exec_command("free -h")
    res = stdout.read().decode()
    log.info(f"node2内存使用情况: {res.strip()}")
    assert "Mem" in res

关键问题修复说明

  • 日志线程名始终显示MainThread:通过自定义日志过滤器,在日志输出时动态读取当前线程的真实名称,同时追加绑定的主机标签,日志格式配置为%(asctime)s [%(threadName)s] [%(bind_host)s] %(levelname)s %(message)s即可正常显示
  • 执行逻辑不符合预期:不修改pytest内部用例执行逻辑,仅接管最外层的调度循环,单条用例执行完全复用原生runtestprotocol方法,所有fixture、参数化、跳过、失败逻辑和原生pytest行为完全一致
  • 连接管理问题:SSH连接生命周期完全绑定pytest会话的初始化/销毁钩子,不会出现执行中途连接意外释放、结束后连接泄漏的问题

内容的提问来源于stack exchange,提问作者Rami Hassan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.31 01:01:00