Jenkins 自动化流水线整合报告(Windows 环境)

Jenkins 自动化流水线整合报告(Windows 环境)

一、目标

实现一个完整的 Jenkins 自动化测试流水线,包含以下功能:

功能 描述
✅ 参数化构建 支持 Git 地址、分支、平台、设备 ID、筛选用例、发送通知 等参数
✅ 写入配置文件 将参数写入配置文件,便于后续py脚本调用,无需再流水线脚本多次传入变量
✅ 执行自动化测试脚本 使用 Python + Airtest/Pytest 执行测试
✅ 归档日志和结果 保存 [log/]目录 jenkins或者http服务器
✅ 发送通知 通过邮件或钉钉发送测试报告

二、整体流程图

[Jenkins Pipeline][clone or pull 拉取代码][writeJSON → jenkins_config.json 写入配置][python jenkins/env_setup.py 更新虚拟环境][python jenkins/test_executor.py 执行测试][解析allure报告][写入测试结果到result.json][备份log/allure报告到文件服务][python jenkins/notification_sender.py][解析result.json.py][ 拼接通知内容][发送邮件/钉钉通知]

三、关键组件说明

1. Jenkinsfile(流水线脚本)
pipeline {
    agent any

    environment {
        // 设置工作空间路径为 ${WORKSPACE}/SuuntoTest
         WORKSPACE_DIR = "${env.WORKSPACE}\\SuuntoTest"
        // git项目一致的话,多个任务公用一个job,避免重复拉取代码
        // 设置工作空间路径为 ${WORKSPACE} 的上级目录 + /AndroidSmoke,多个任务公用一个代码目录
    	// WORKSPACE_DIR = "${new File(env.WORKSPACE).getParent()}/AndroidSmoke"
    }

    parameters {
        // string(name: 'JOB_NAME', defaultValue: 'SuuntoTest', description: 'Jenkins Job 名称')
        string(name: 'GIT_REPO_URL', defaultValue: '', description: 'Git SSH 地址')
        string(name: 'BRANCH', defaultValue: 'dengping_dev', description: '分支名称')
        string(name: 'PLATFORM', defaultValue: 'android', description: '测试平台 (android/ios/ng/dilu)')
        string(name: 'DEVICE_ID', defaultValue: '', description: '设备 ID')
        string(name: 'TESTCASE', defaultValue: 'testcase_android/', description: '测试用例目录')
        string(name: 'TEST_MARKER', defaultValue: 'SMOKE and not NG', description: '''
        根据mark标签选择测试 适配-m 参数:
        示例:
          SMOKE
          SMOKE and not NG
        ''')
        string(name: 'TEST_NAME_EXPR', defaultValue: '', description: '''
        根据测试函数/类名匹配选择测试 适配-k 参数:
        示例:
          test_week_tab
          TestCalendar
        ''')
        // string(name: 'NOTIFY_EMAIL', defaultValue: '[email protected]', description: '接收通知的邮箱')
        credentials(name: 'GIT_SSH_CRED', credentialType: 'ssh', defaultValue: 'github-ssh', description: 'SSH凭证用于拉取代码-私钥Jenkins配置的凭据ID')
        string(name: 'SEND_METHOD', defaultValue: 'dingtalk', description: '通知方式email_163、email_aliyun、dingtalk')    }
    stages {
        stage('Checkout Code') {
            steps {
                script {
                    echo "准备拉取代码到目录: ${env.WORKSPACE_DIR}"
                    

                    if (!fileExists("${env.WORKSPACE_DIR}\\.git")) {
                        echo "首次拉取,执行 git clone..."
                        bat """
                            @echo off
                                setlocal
                                set WORKSPACE_DIR=${env.WORKSPACE_DIR}
                                if not exist \"${env.WORKSPACE_DIR}\" mkdir \"${env.WORKSPACE_DIR}\"
                                cd /d \"${env.WORKSPACE_DIR}\"
                                echo 当前路径: %CD%
                                echo 克隆代码到SuuntoTest
                                git clone ${GIT_REPO_URL} SuuntoTest
                        """
                    } else {
                        echo "已存在代码仓库,执行 git pull..."
                        dir("${env.WORKSPACE_DIR}") {
                            withCredentials([sshUserPrivateKey(credentialsId: 'GIT_SSH_CRED', keyFileVariable: 'GIT_KEY')]) {
                              bat """
                                @echo on
                                set GIT_SSH_COMMAND=ssh -i \"${env.GIT_KEY}\"
                                cd /d \"${env.WORKSPACE_DIR}\"
                                git pull origin ${BRANCH}
                            """
                            }
                        }
                    }
                }
            }
        }
        stage('Initialize Config') {
            steps {
                script {
                    echo "初始化配置并写入 jenkins_config.json"
                    def JENKINS_CONFIG = [
                        GIT_REPO_URL: params.GIT_REPO_URL,
                        BRANCH: params.BRANCH,
                        WORKSPACE_DIR: env.WORKSPACE_DIR,
                        PLATFORM: params.PLATFORM,
                        DEVICE_ID: params.DEVICE_ID,
                        TESTCASE: params.TESTCASE,
                        JENKINS_BUILD_URL: env.BUILD_URL,
                        NOTIFY_EMAIL: params.NOTIFY_EMAIL,
                        JOB_URL: env.JOB_URL,
                        SEND_METHOD: env.SEND_METHOD,
                        BUILD_UR: env.BUILD_UR,
                        BUILD_NUMBER: env.BUILD_NUMBER,
                        // NODE_NAME = "node/3"
                    ]

                    def CONFIG_PATH = "${env.WORKSPACE_DIR}\\jenkins\\jenkins_config.json"
                    writeJSON file: CONFIG_PATH, json: JENKINS_CONFIG
                }
            }
        }
        stage('Setup Virtual Environment') {
            steps {
                script {
                    echo "设置虚拟环境并安装依赖..."
                    dir(env.WORKSPACE_DIR) {
                        bat """
                            @echo on
                            python jenkins\\env_setup.py
                        """
                    }
                }
            }
        }
        stage('Run Tests') {
            steps {
                script {
                    echo "开始执行测试用例..."
                    try {
                        dir(env.WORKSPACE_DIR) {
                            bat """
                                @echo on
                                .venv\\Scripts\\python.exe jenkins\\executor.py
                            """
                        }
                    } catch (Exception e) {
                        currentBuild.result = 'FAILURE'
                        throw e
                    }
                }
            }
        }
        // stage('Archive Artifacts') {
        //     steps {
        //         script {
        //             def latestLogDir = powershell(returnStdout: true, script: '''
        //                 $logPath = "$env:WORKSPACE_DIR\\log"

        //                 if (-Not (Test-Path -Path $logPath)) {
        //                     Write-Output ""
        //                     exit 1
        //                 }

        //                 $latestDir = Get-ChildItem -Path $logPath -Directory | Sort-Object -Property Name -Descending | Select-Object -First 1
        //                 if ($latestDir) {
        //                     Write-Output $latestDir.Name
        //                 } else {
        //                     Write-Output ""
        //                 }
        //             ''').trim()

        //             if (latestLogDir == "") {
        //                 echo "没有找到日志目录,跳过归档"
        //                 return
        //             }
        //             echo "${env.WORKSPACE_DIR}\\\\log\\\\${latestLogDir}\\\\allure\\\\rawdata"
        //             echo "${env.WORKSPACE}\\\\allure-report"
        //             // 生成报告(重新解析项目里allure报告的json文件)
        //             // bat """
        //             //     @echo on
        //             //     allure generate "${env.WORKSPACE_DIR}\\\\log\\\\${latestLogDir}\\\\allure\\\\rawdata" -o "${env.WORKSPACE_DIR}\\\\allure-report" --clean
        //             // """

        //             // 发布报告(必须指向 .json 数据目录)
        //             // allure includeProperties: false, jdk: '', results: [[path: "${env.WORKSPACE_DIR}\\\\allure-report\\\\data\\\\test-cases"]]
        //             // allure includeProperties: false, jdk: 'JDK1.8', results: [[path: "log\\\\${latestLogDir}\\\\allure\\\\rawdata"]]
        //             // allure includeProperties: false, jdk: '', results: [[path: "SuuntoTest\\\\log\\\\${latestLogDir}\\\\allure\\\\rawdata"]]
        //             // def artifactPath = "SuuntoTest\\\\log\\\\${latestLogDir}\\\\**"
        //             // archiveArtifacts artifacts: artifactPath, allowEmptyArchive: false
        //         }
        //     }
        // }
        stage('Send Notification') {
            steps {
                script {
                    echo "发送测试结果通知..."
                    dir("${env.WORKSPACE_DIR}") {
                        bat '''
                             @echo on
                             set PYTHONIOENCODING=utf-8
                             cd /d \"${env.WORKSPACE_DIR}\"
                            .venv\\Scripts\\python.exe jenkins\\notification_sender.py
                        '''
                    }
                }
            }
        }
    }
    post {
            success {
                echo "✅ 流水线执行成功"
            }
            failure {
                echo "❌ 流水线执行失败"
            }
        }
        
}

注意:确保 Jenkins 工作空间中存在 log/ 文件夹用于存放日志。


四、Python 脚本说明

jenkins/
├── __init__.py
├── config_manager.py
├── env_setup.py
├── jenkins_config.json
├── notification_sender.py
├── result.json
└── test_executor.py
config_manager.py: 配置管理脚本,用于处理Jenkins的配置相关操作。
env_setup.py: 环境设置脚本,可能用于准备测试或部署环境。
jenkins_config.json: Jenkins配置文件,存储Jenkins的相关配置信息。
notification_sender.py: 通知发送脚本,可能用于在任务完成后发送通知。
result.json: 结果文件,通常用于存储测试结果或其他执行输出。
test_executor.py: 测试执行器脚本,负责运行测试用例并生成报告。
1. config_manager.py
  • 用于从命令行更新 jenkins_config.json
  • 支持 JSON 字符串或 JSON 文件传参。
  • 可模拟 Jenkins 的 writeJSON 行为。
# config_manager.py

import json
import os
import sys

# 获取当前脚本所在目录(即 jenkins/ 目录)
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(CURRENT_DIR, 'jenkins_config.json')


def update_config(new_values, config_file=CONFIG_FILE):
    """
    更新指定的配置字段,保留原有字段

    :param new_values: 新传入的配置字典
    :param config_file: 配置文件路径
    """
    # 如果配置文件存在,加载现有配置
    if os.path.exists(config_file):
        with open(config_file, 'r', encoding='utf-8') as f:
            current_config = json.load(f)
    else:
        current_config = {}

    # 只更新传入的字段,None 值将被忽略
    for key, value in new_values.items():
        if value is not None:
            current_config[key] = value

    # 写回配置文件
    with open(config_file, 'w', encoding='utf-8') as f:
        json.dump(current_config, f, indent=4)

    print(f"配置已更新至 {os.path.abspath(config_file)}")


def load_config():
    """加载 jenkins_config.json 配置文件"""
    if not os.path.exists(CONFIG_FILE):
        raise FileNotFoundError(f"配置文件 {CONFIG_FILE} 不存在")

    with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
        return json.load(f)


if __name__ == "__main__":
    import sys
    import json

    if len(sys.argv) < 2:
        print("Usage: python config_manager.py ''")
        print("或: python config_manager.py '@'")
        print("示例: python config_manager.py '{\"PLATFORM\": \"android\"}'")
        print("示例: python config_manager.py '@test_params.json'")
        sys.exit(1)

    raw_input = sys.argv[1]

    # 如果以 @ 开头,则从文件加载 JSON
    if raw_input.startswith('@'):
        file_path = raw_input[1:]
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                raw_json = f.read()
        except Exception as e:
            print(f"❌ 无法读取文件: {e}")
            sys.exit(1)
    else:
        raw_json = raw_input

    # 去除首尾可能存在的单引号或双引号
    raw_json = raw_json.strip().lstrip("'").lstrip('"').rstrip("'").rstrip('"')

    try:
        new_config = json.loads(raw_json)
    except json.JSONDecodeError as e:
        print(f"❌ JSON 解析失败: {e}")
        print("请确保输入的是合法的 JSON 格式字符串,例如:")
        print("{\"PLATFORM\": \"android\", \"DEVICE_ID\": \"emulator-5554\"}")
        sys.exit(1)

    update_config(new_config)
    print("✅ 更新后的配置:")
    print(load_config())

2.test_executor.py
  • jenkins_config.json 中读取配置;
  • 运行测试用例;
  • 解析allure报告 生成 result.json 测试结果文件;
  • 支持 Windows 下 .venv 虚拟环境调用。
import shutil
import subprocess
import os
import sys
import time
import json
import config_manager

# from jenkins import config_manager

print(f"Python executable: {sys.executable}")
print(f"Current working directory: {os.getcwd()}")
print("sys.path:")
for p in sys.path:
    print(p)


def run_tests(workspace_dir, testcase, device_id, platform):
    """
    从 jenkins_config.json 中读取参数并执行测试用例
    """

    # 切换到项目根目录
    os.chdir(workspace_dir)

    # 设置 Python 虚拟环境路径

    python_executable = os.path.join(workspace_dir, '.venv', 'Scripts', 'python.exe')
    # print(python_executable)

    # 检查 python.exe 是否存在
    if not os.path.exists(python_executable):
        raise FileNotFoundError(f"找不到虚拟环境中的 python.exe: {python_executable}")

    script_path = os.path.join(workspace_dir, 'run_jenkins_main.py')

    start_time = time.time()
    print("正在执行测试脚本...")
    result = subprocess.run([
        python_executable,
        script_path,
        platform,
        device_id,
        testcase
    ], capture_output=True, text=True)
    time.sleep(5)
    # 显式打印输出
    print("STDOUT:")
    print(result.stdout)
    if result.stderr:
        print("STDERR:")
        print(result.stderr)
    end_time = time.time()

    # stdout = result.stdout
    # stderr = result.stderr
    # exit_code = result.returncode
    log_dir = os.path.join(workspace_dir, "log")
    allure_results = count_allure_results(log_dir)

    duration_seconds = round(end_time - start_time, 2)
    hours, remainder = divmod(duration_seconds, 3600)
    minutes, seconds = divmod(remainder, 60)
    duration = f"{int(hours)}小时{int(minutes)}分钟{seconds:.2f}秒"

    result_data = {
        "start_time": start_time,
        "end_time": end_time,
        "duration": duration,
        "platform": platform,
        "device_id": device_id,
        "testcase": testcase,
        "passed": allure_results.get("passed", 0),
        "failed": allure_results.get("failed", 0),
        "broken": allure_results.get("broken", 0),
        "skipped": allure_results.get("skipped", 0),
        "total": allure_results.get("total", 0),
        "pass_rate": allure_results.get("pass_rate", 0),
        # "exit_code": exit_code,
        # "stdout": stdout,
        # "stderr": stderr
    }

    # 将结果写入本地文件
    return result_data


def write_result_to_file(result_data, workspace_dir):
    result_file = os.path.join(workspace_dir, 'jenkins/result.json')
    with open(result_file, 'w', encoding='utf-8') as f:
        json.dump(result_data, f, indent=4)
        print(f"测试结果已保存到 {os.path.abspath(result_file)}")


def count_allure_results(log_dir):
    """
    统计 allure 报告中的成功和失败用例数
    """
    # 获取 log_dir 下的所有子文件夹
    sub_dirs = [d for d in os.listdir(log_dir) if os.path.isdir(os.path.join(log_dir, d))]

    if not sub_dirs:
        print("没有找到子文件夹。")
        return

    # 找出最新的子文件夹
    latest_sub_dir = max(sub_dirs, key=lambda x: os.path.getctime(os.path.join(log_dir, x)))
    result_dir = os.path.join(log_dir, latest_sub_dir, "allure", "rawdata")
    print(result_dir)

    passed = 0
    failed = 0
    broken = 0
    skipped = 0

    for file_name in os.listdir(result_dir):
        if file_name.endswith(".json"):
            file_path = os.path.join(result_dir, file_name)
            with open(file_path, "r", encoding="utf-8") as f:
                data = json.load(f)
                status = data.get("status")
                if status == "passed":
                    passed += 1
                elif status == "failed":
                    failed += 1
                elif status == "broken":
                    broken += 1
                elif status == "skipped":
                    skipped += 1

    total = passed + failed + broken + skipped
    pass_rate =  round(passed / (total - skipped) * 100, 2) if (total - skipped) > 0 else 100

    return {
        "passed": passed,
        "failed": failed,
        "broken": broken,
        "skipped": skipped,
        "total": total,
        "pass_rate": pass_rate
    }


def copy_log_file_to_http_server(log_dir):
    """
    将 log_dir 下的最新子文件夹复制到 D:\auto_test_log\allure-report
    """
    # 获取 log_dir 下的所有子文件夹
    sub_dirs = [d for d in os.listdir(log_dir) if os.path.isdir(os.path.join(log_dir, d))]

    if not sub_dirs:
        print("没有找到子文件夹。")
        return

    # 找出最新的子文件夹
    latest_sub_dir = max(sub_dirs, key=lambda x: os.path.getctime(os.path.join(log_dir, x)))
    source_path = os.path.join(log_dir, latest_sub_dir)

    # 定义目标路径
    target_base_path = r"D:\auto_test_log\allure-report"
    target_path = os.path.join(target_base_path, latest_sub_dir)

    # 如果目标路径已存在,则删除旧目录
    if os.path.exists(target_path):
        shutil.rmtree(target_path)

    # 复制文件夹
    try:
        shutil.copytree(source_path, target_path)
        print(f"成功将 {latest_sub_dir} 复制到 {target_path}")
    except Exception as e:
        print(f"复制失败: {e}")


if __name__ == "__main__":
    config = config_manager.load_config()
    workspace_dir = config.get('WORKSPACE_DIR')
    device_id = config.get('DEVICE_ID', '')
    testcase = config.get('TESTCASE', 'testcase_android/calendar/test_calendar.py::TestCalendar::test_week_tab')
    # testcase = "testcase_android/calendar/test_calendar.py::TestCalendar::test_week_tab"
    platform = config.get('PLATFORM', 'android')
    result_data = run_tests(workspace_dir, testcase, device_id, platform)
    print(result_data)
    write_result_to_file(result_data, workspace_dir)
    log_dir = os.path.join(workspace_dir, 'log')  # 确定日志目录
    copy_log_file_to_http_server(log_dir)  # 复制最新日志文件夹
    # print(count_allure_results(log_dir))

3. notification_sender.py
  • jenkins_config.jsonresult.json 加载数据;
  • 支持:
    • ✅ 163 邮箱通知;
    • ✅ 阿里云邮箱通知;
    • ✅ 钉钉机器人通知;
  • 使用统一内容模板 _generate_report_content() 提供结构化报告。
# notification_sender.py
import os
import json
import time
import smtplib
from email.mime.text import MIMEText
from email.header import Header
import requests

# 读取配置文件
JENKINS_DIR = os.path.dirname(os.path.abspath(__file__))
RESULT_JSON_PATH = os.path.join(JENKINS_DIR, 'result.json')
CONFIG_JSON_PATH = os.path.join(JENKINS_DIR, 'jenkins_config.json')


def _load_result_data():
    """从 result.json 加载测试结果数据"""
    with open(RESULT_JSON_PATH, 'r', encoding='utf-8') as f:
        return json.load(f)


def _load_config_data():
    """从 jenkins_config.json 加载配置数据"""
    with open(CONFIG_JSON_PATH, 'r', encoding='utf-8') as f:
        return json.load(f)


def get_latest_log_dir(workspace_dir):
    # 获取 log 目录下最新的子目录名称(按修改时间排序)
    # workspace_dir = _load_config_data().get('WORKSPACE_DIR')
    log_dir = os.path.join(workspace_dir, 'log')
    log_dirs = [d for d in os.listdir(log_dir) if os.path.isdir(os.path.join(log_dir, d))]
    return max(log_dirs, key=lambda x: os.path.getmtime(os.path.join(log_dir, x)))


# def build_log_url():
#     # 获取 log 目录的最新子目录
#     log_dir = get_latest_log_dir()
#     # 在build——url中截取IP地址
#     job_url = _load_config_data().get('JOB_URL')
#     job_url = job_url.split('/job')[0].replace('8080', '8081')
#     # 拼接 log url
#     log_url = f"{job_url}/log/{log_dir}"
#     return log_url
#
#
# def build_allure_url():
#     allure_url = build_log_url() + "/allure/report"
#     return allure_url


def build_log_url_and_allure_url(workspace_dir, job_url):
    """
    构建日志URL和Allure报告URL。

    本函数旨在生成两个重要的URL:
    1. 日志URL(log_url),用于访问最新日志目录下的日志信息。
    2. Allure报告URL(allure_url),用于展示最新测试结果的Allure报告。

    无参数。

    返回:
    - log_url: 最新日志的访问URL。
    - allure_url: Allure报告的访问URL。
    """
    # 获取 log 目录的最新子目录
    log_dir = get_latest_log_dir(workspace_dir)

    # 从配置数据中提取作业URL,并修改端口从8080到8085,以访问日志服务
    job_ip = job_url.split('/')[2].replace(':8080', ':8085')

    # 构建日志URL
    log_url = f"http://{job_ip}/{log_dir}"

    # 在日志URL基础上,构建Allure报告URL
    allure_url = log_url + "/allure/report"

    # 返回日志URL和Allure报告URL
    return log_url, allure_url


# def get_jenkins_log_url():
#     # 需要在流水线脚本中set 变量
#     # set LATEST_LOG_DIR =${latestLogDir}
#     # set JENKINS_BUILD_URL =${env.BUILD_URL}
#     build_url = os.getenv("JENKINS_BUILD_URL", "未知")
#     latest_log_dir = os.getenv("LATEST_LOG_DIR", "")
#
#     if build_url == "未知" or not latest_log_dir:
#         return "未知"
#
#     return f"{build_url.rstrip('/')}/artifact/SuuntoTest/log/{latest_log_dir}/"


def get_jenkins_log_dir():
    job_url = _load_config_data().get('JOB_URL')  # e.g., http://localhost:8080/job/AndroidSmokePipline/
    build_number = _load_config_data().get("BUILD_NUMBER")  # Jenkins 构建号,如 "35"
    node_name = _load_config_data().get("NODE_NAME", "node/3")  # 可以在 Jenkins Pipeline 中设置或默认使用 node/3

    if not build_number:
        print("BUILD_NUMBER 环境变量未设置,日志路径可能不准确")
        build_number = "unknown"

    log_dir = get_latest_log_dir()

    # 构造完整日志路径
    return f"{job_url.rstrip('/')}/{build_number}/execution/{node_name}/ws/SuuntoTest/log/{log_dir}/"


def get_jenkins_log_dir_new():
    build_url = _load_config_data().get('JENKINS_BUILD_URL')  # Jenkins 提供的 BUILD_URL
    log_dir = get_latest_log_dir()

    # 使用 artifact 路径访问日志目录
    return f"{build_url.rstrip('/')}/artifact/SuuntoTest/log/{log_dir}/"


def _generate_report_content(result_info, get_repo_url, branch, jenkins_build_url, workspace_dir, job_url):
    """
    生成统一的报告内容
    :param result_info: 测试结果字典
    :param config_info: 配置信息字典
    :return: (title, content)
    """
    title = f"【自动化测试报告】 - {result_info['platform']}"

    # # 构造 Jenkins 下的 log 文件夹访问路径
    # jenkins_log_url = jenkins_url.rstrip('/') + 'artifact/log/'

    content = f"""
【Jenkins 构建地址】
{jenkins_build_url}

【Git 项目信息】
仓库地址:{get_repo_url}
分支:{branch}

【执行时间】
开始时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(result_info['start_time']))}
结束时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(result_info['end_time']))}
总耗时:{result_info['duration']}

【执行信息】
平台:{result_info['platform']}
设备ID:{result_info['device_id']}
测试用例:{result_info['testcase']}

【日志目录及报告】
Jenkins 日志访问地址:{build_log_url_and_allure_url(workspace_dir, job_url)[0]}
allure 报告访问地址:{build_log_url_and_allure_url(workspace_dir, job_url)[1]}

【执行结果】
passed:{result_info['passed']}
failed:{result_info['failed']}
broken:{result_info['broken']}
skipped:{result_info['skipped']}
总数:{result_info['total']}
通过率:{result_info['pass_rate']}%
    """
    return title, content


# def send_email_163():
#     """
#     使用 163 邮箱发送测试结果通知。
#     """
#     result_info = _load_result_data()
#     config_info = _load_config_data()
#
#     title, content = _generate_report_content(result_info, config_info)
#
#     # 从配置中获取邮件信息
#     sender = config_info.get("MAIL_USERNAME_163", "[email protected]")
#     receiver = config_info.get("NOTIFY_EMAIL_163", "[email protected]")
#     smtp_server = "smtp.163.com"
#     smtp_port = 465
#     username = config_info.get("MAIL_USERNAME_163", "")
#     password = config_info.get("MAIL_PASSWORD_163", "")
#
#     message = MIMEText(content, 'plain', 'utf-8')
#     message['From'] = Header(sender)
#     message['To'] = Header(receiver)
#     message['Subject'] = Header(title)
#     server = None
#     try:
#         server = smtplib.SMTP_SSL(smtp_server, smtp_port)
#         server.login(username, password)
#         server.sendmail(sender, [receiver.split(',')], message.as_string())
#         print("163 邮件通知已发送")
#     except Exception as e:
#         print(f"发送 163 邮件失败: {e}")
#     finally:
#         server.quit()
#
#
def send_email_aliyun(get_repo_url, branch, jenkins_build_url, workspace_dir, job_url):
    """
    使用 阿里云邮箱 发送测试结果通知。
    """
    result_info = _load_result_data()
    config_info = _load_config_data()

    title, content = _generate_report_content(result_info, get_repo_url, branch, jenkins_build_url, workspace_dir, job_url)

    # 从配置中获取邮件信息
    sender = ""
    receiver = ""
    smtp_server = ""
    smtp_port = 465
    username = ""
    password = ""

    message = MIMEText(content, 'plain', 'utf-8')
    message['From'] = Header(sender)
    message['To'] = Header(receiver)
    message['Subject'] = Header(title)
    server = None
    try:
        server = smtplib.SMTP_SSL(smtp_server, smtp_port)
        server.login(username, password)
        if "," in receiver:
            server.sendmail(sender, receiver.split(','), message.as_string())
        else:
            server.sendmail(sender, receiver, message.as_string())
        print("阿里云邮箱通知已发送")
    except Exception as e:
        print(f"发送阿里云邮件失败: {e}")
    finally:
        server.quit()


def send_dingtalk(webhook_url, jenkins_build_url, get_repo_url, branch, workspace_dir, job_url):
    """
    使用钉钉机器人发送测试结果通知。
    """
    result_info = _load_result_data()

    if not webhook_url:
        print("缺少钉钉 Webhook URL,请在 jenkins_config.json 中配置 DINGTALK_WEBHOOK")
        return

    title = f"【自动化测试报告】 - {result_info['platform']}"

    text = f"""
#### {title}

> **Jenkins 构建地址**  
{jenkins_build_url}
account/password:user_1/user123

> **Git 项目信息**  
仓库地址:{get_repo_url}  
分支:{branch}  

> **执行时间**  
开始时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(result_info['start_time']))}  
结束时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(result_info['end_time']))}  
总耗时:{result_info['duration']}

> **执行信息**  
平台:{result_info['platform']}  
设备ID:{result_info['device_id']}  
测试用例:{result_info['testcase']}  

> **测试报告、日志**  
测试报告地址:{build_log_url_and_allure_url(workspace_dir, job_url)[1]}
日志地址:{build_log_url_and_allure_url(workspace_dir, job_url)[0]}



> **执行结果**  
passed:{result_info['passed']}
failed:{result_info['failed']}
broken:{result_info['broken']}
skipped:{result_info['skipped']}
总数:{result_info['total']}
通过率:{result_info['pass_rate']}%  
    """

    data = {
        "msgtype": "text",
        "text": {
            "content": text,
            "mentioned_list": []  # 可指定提醒用户 @all 表示全体成员
        }
    }
    print(data)

    try:
        response = requests.post(webhook_url, json=data)
        if response.status_code == 200:
            print("钉钉通知已发送")
        else:
            print(f"钉钉通知发送失败: {response.text}")
    except Exception as e:
        print(f"发送钉钉通知失败: {e}")


if __name__ == "__main__":
    # 自动化测试new群
    webhook_url = ''
    config_info = _load_config_data()
    send_method = config_info.get("SEND_METHOD", 'dingtalk')
    # send_method = "email_aliyun"
    jenkins_build_url = config_info.get("JENKINS_BUILD_URL")
    get_repo_url = config_info.get("GIT_REPO_URL")
    branch = config_info.get("BRANCH")
    workspace_dir = config_info.get("WORKSPACE_DIR")
    job_url = config_info.get("JOB_URL")
    if send_method == "dingtalk":
        send_dingtalk(webhook_url, jenkins_build_url, get_repo_url, branch, workspace_dir, job_url)
    # elif send_method == "email_163":
    #     send_email_163()
    elif send_method == "email_aliyun":
        send_email_aliyun(get_repo_url, branch, jenkins_build_url, workspace_dir, job_url)
    else:
        print("无法识别的推送方式")

    # print(get_latest_log_dir())
    # print(get_jenkins_log_dir())
    # print(get_jenkins_log_dir_new())
    # send_dingtalk()
    # print(build_allure_url())
    # print(build_log_url_and_allure_url()[1])
    # print(build_log_url_and_allure_url()[0])

4. env_setup.py.py
  • 更新虚拟环境的库;
# env_setup.py
import sys
import os
import subprocess
import config_manager


# 获取当前脚本所在目录(即 jenkins/ 目录)
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# 获取项目根目录(SuuntoTest/)
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, '..'))


def setup_virtual_env(workspace_dir):
    """
    设置虚拟环境并安装依赖。

    :param workspace_dir: 工作空间目录
    """
    os.chdir(workspace_dir)
    venv_path = os.path.join(workspace_dir, '.venv')


    # Step 1: 创建虚拟环境(如果不存在)
    if not os.path.exists(venv_path):
        print("Creating virtual environment...")
        result = subprocess.run([sys.executable, "-m", "venv", venv_path])
        if result.returncode != 0:
            print("Failed to create virtual environment.")
            exit(1)
    # Step 2: 确保虚拟环境中 python.exe 存在
    venv_python = os.path.join(venv_path, "Scripts", "python.exe")
    if not os.path.exists(venv_python):
        print(f"Error: {venv_python} does not exist! Virtualenv creation failed.")
        exit(1)

    # Step 3: 升级 pip(使用虚拟环境中的 python)
    print("Upgrading pip in virtual environment...")
    subprocess.run([venv_python, "-m", "pip", "install", "--upgrade", "pip"])

    # Step 4: 安装 pipenv(使用虚拟环境中的 pip)
    venv_pip = os.path.join(venv_path, "Scripts", "pip.exe")
    print("Installing pipenv...")
    # 检查虚拟环境中pipenv是否存在
    if not os.path.exists(os.path.join(venv_pip, "Scripts", "pipenv")):
        subprocess.run([venv_pip, "install", "pipenv"])

    # 判断是否存在 Pipfile.lock
    has_lock_file = os.path.exists(os.path.join(workspace_dir, 'Pipfile.lock'))

    venv_pipenv = os.path.join(venv_path, "Scripts", "pipenv.exe")
    try:
        if has_lock_file:
            print('Using existing Pipfile.lock to install dependencies...')
            result = subprocess.run([venv_pipenv, 'install'], check=True)
            # 检测安装结果,虚拟环境是否有airtest库
            if not os.path.exists(os.path.join(venv_path, 'Lib', 'site-packages', 'airtest')):
                print('airtest not found in Pipfile.lock, installing...')
                # 删除 Pipfile.lock
                if os.path.exists(os.path.join(workspace_dir, 'Pipfile.lock')):
                    os.remove(os.path.join(workspace_dir, 'Pipfile.lock'))

                # 重新 lock 并安装
                try:
                    subprocess.run([venv_pipenv, 'lock', '--pypi-mirror', 'https://pypi.tuna.tsinghua.edu.cn/simple'],
                                   check=True)
                    subprocess.run([venv_pipenv, 'install'], check=True)
                except subprocess.CalledProcessError as retry_e:
                    print(f'Retry failed with error code {retry_e.returncode}')
                    print('Tip: Manually check your Pipfile or network connection.')
                    exit(1)
        else:
            raise FileNotFoundError("No Pipfile.lock found, forcing lock and install...")
    except subprocess.CalledProcessError as e:
        print(f'Failed to install dependencies with error code {e.returncode}')
        print('Attempting to remove Pipfile.lock and retrying...')

        # 删除 Pipfile.lock
        if os.path.exists(os.path.join(workspace_dir, 'Pipfile.lock')):
            os.remove(os.path.join(workspace_dir, 'Pipfile.lock'))

        # 重新 lock 并安装
        try:
            subprocess.run([venv_pipenv, 'lock', '--pypi-mirror', 'https://pypi.tuna.tsinghua.edu.cn/simple'], check=True)
            subprocess.run([venv_pipenv, 'install'], check=True)
        except subprocess.CalledProcessError as retry_e:
            print(f'Retry failed with error code {retry_e.returncode}')
            print('Tip: Manually check your Pipfile or network connection.')
            exit(1)


if __name__ == '__main__':
    config = config_manager.load_config()
    setup_virtual_env(PROJECT_ROOT)

5. run_jenkins_main.py.py
  • 接收参数执行用例也可以用于Jenkins直接调用执行用例
# 接收Jenkins传入的platform,device_id,testcase
import sys
import time
from testcase_android.runner_android import AndroidSmokeTestRunner
from testcase_ios.runner_ios import IosSmokeTestRunner
from jenkins import config_manager

platform_android = "android"
platform_ios = "ios"
platform_ng = "ng"
platform_dilu = "dilu"
concat_char = "+"

if __name__ == '__main__':
    print("run_jenkins_main.py is running!")
    config = config_manager.load_config()
    if len(sys.argv) >= 3:
        platform = sys.argv[1]
        device_id = sys.argv[2]
        testcase = sys.argv[3]
    else:
        print("No enough command line arguments, using default values.")
        config = config_manager.load_config()
        platform = config.get('PLATFORM', 'android')
        device_id = config.get('DEVICE_ID', '')
        testcase = config.get('TESTCASE', 'testcase_android/')

    test_mark = config.get('TEST_MARKER', '')
    test_name_expr = config.get('TEST_NAME_EXPR', '')
    start_time = time.time()

    runner = None
    if platform == platform_android:
        runner = AndroidSmokeTestRunner(phone_udid=device_id, testcase=testcase, open_allure_report=False)
    elif platform == platform_ios:
        runner = IosSmokeTestRunner(phone_udid=device_id, testcase=testcase)
    elif platform == platform_ng:
        # create ng platform smoke test runner
        pass
    elif platform == platform_dilu:
        # create ng platform smoke test runner
        pass
    elif platform == platform_android + concat_char + platform_ng:
        # create android+ng platform smoke test runner
        pass
    elif platform == platform_android + concat_char + platform_dilu:
        # create android+dilu platform smoke test runner
        pass
    elif platform == platform_ios + concat_char + platform_ng:
        # create ios+ng platform smoke test runner
        pass
    elif platform == platform_ios + concat_char + platform_dilu:
        # create ios+dilu platform smoke test runner
        pass
    else:
        print("args are error, exit")
        exit(1)
    # 接收pytest -m -k 参数
    if test_mark or test_name_expr:
        runner.start_test(test_mark=test_mark, test_name_expr=test_name_expr)
    else:
        runner.start_test()


推荐 Jenkins 参数设置(支持归档+通知)

在 Jenkins 创建任务时建议添加如下参数:

参数名 类型 示例值 用途
GIT_REPO_URL String https://github.com/your-repo.git Git 仓库地址
GIT_SSH_CRED String https://github.com/your-repo.git SSH凭据ID
BRANCH String main 分支名称
PLATFORM String android / ios 平台
DEVICE_ID String emulator-5554 / 3bbca138 设备 ID
TESTCASE String testcase_android/test_example.py::TestClass::test_method 测试用例 支持-m -k参数根据节点 ID 选择测试:testcase_android/calendar/test_calendar.py::TestCalendar::test_week_tab根据mark标签选择测试:-m SMOKE根据测试名称选择测试:-k test_month_tab
SEND_METHOD Choice email_163 / dingtalk 通知方式
NOTIFY_EMAIL_163 String [email protected] 163 收件人
MAIL_USERNAME_163 String [email protected] 163 登录用户名
MAIL_PASSWORD_163 Password ********* 163 授权码
DINGTALK_WEBHOOK String https://oapi.dingtalk.com/robot/send?access_token=xxx 钉钉 Webhook
WORKSPACE_DIR String E:/project/SuuntoTest/ Jenkins 工作空间路径(可选)

后续扩展

功能 说明
多设备支持 支持多设备并行执行测试,一个job同时运行多次会污染配置文件(缓存变量,测试用例多少配置?未知),多job可解决
多节点支持 用例数量过多后支持多节点并行执行测试
多节点绑定多设备ID 一个节点绑定多台设备,支持多节点多设备并行(设备ID跟节点做映射)
自动分配空闲节点 定时自动触发,自动分配空闲节点,并发测试(设备ID跟节点做映射)
读取已有allure报告 生成可视化报告
每日执行通过率记录看板 保留每次定时任务执行的测试记录,通过看板展示在报告通知
失败重试机制 失败用例,二次运行,只跑失败用例
模块化通知 通过配置文件,将通知发送到对应模块的测试群,@对应人员(后期,自动化需要分模跑的时候)

你可能感兴趣的:(Jenkins,jenkins,自动化,windows)