跳转至

Git + Jenkins 自动化测试流水线:逐阶段避坑指南

这篇不是流程概述,而是一条跑过真实项目的流水线:每个 stage 为什么这么写、失败时的原始报错是什么、根因在哪、怎么改。

0. 先看整体结构

flowchart LR
    A[git push] --> B[Checkout]
    B --> C[依赖安装]
    C --> D[静态检查]
    D --> E[接口测试]
    E --> F[UI 冒烟]
    F --> G[Allure 报告]
    G --> H[通知/归档]

约束先定死,后面每一步都围绕它:

约束 取值 原因
单次流水线时长 < 25 分钟 超过后没人等结果,PR 会先被合并
每个 stage 超时 10 分钟 防止 UI 用例挂死拖满 executor
用例并行度 4 8C16G runner 的经验上限,再高浏览器抢 CPU
报告保留 30 次构建 足够定位“上周还好好的”问题
失败策略 smoke 失败即停,regression 允许部分失败 保证反馈速度

1. 执行机镜像:所有“诡异失败”的根源

UI 用例在 CI 上挂,八成不是脚本问题,而是镜像缺东西。用固定版本的 Dockerfile,别用 latest。

FROM python:3.11-slim-bookworm

ENV DEBIAN_FRONTEND=noninteractive \
    TZ=Asia/Shanghai \
    LANG=C.UTF-8 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=0

RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium chromium-driver \
      fonts-noto-cjk fonts-noto-color-emoji \
      tzdata ca-certificates curl jq \
    && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /workspace
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

三个细节值得单独说:

  • fonts-noto-cjk:缺中文字体时页面文字宽度变化,元素位置偏移,点击会落到别的元素上;截图里中文全是方块。
  • fonts-noto-color-emoji:带 emoji 的按钮文案定位不到(get_by_text("提交 ✅") 匹配失败)。
  • 固定 chromium 与 chromium-driver 同源安装:避免 driver 与浏览器版本不匹配的 session not created: This version of ChromeDriver only supports Chrome version 114。

2. Checkout:浅克隆的两个坑

stage('Checkout') {
  steps {
    checkout([
      $class: 'GitSCM',
      branches: [[name: '*/main']],
      userRemoteConfigs: [[
        url: 'git@gitlab.example.com:qa/api-tests.git',
        credentialsId: 'gitlab-deploy-key'
      ]],
      extensions: [
        [$class: 'CloneOption', depth: 1, shallow: true, timeout: 5],
        [$class: 'SubmoduleOption', disableSubmodules: false, recursiveSubmodules: true]
      ]
    ])
  }
}

坑 1:depth: 1 + 需要 git describe 取版本号时拿不到 tag,报 fatal: No names found, cannot describe anything。解法是加 [$class: 'GitTag'...] 或改用 git rev-parse --short HEAD。

坑 2:测试数据放在 submodule 里,浅克隆默认不拉,用例报 FileNotFoundError: data/users.csv。必须显式开 recursiveSubmodules。

3. 依赖安装:缓存与“锁文件漂移”

stage('Install') {
  steps {
    sh '''
      set -euo pipefail
      python -m pip install --upgrade pip
      # 依赖不变时命中缓存,避免每次重装 3 分钟
      pip install -r requirements.txt
    '''
  }
}

真实问题与解法:

报错 根因 解法
error: externally-managed-environment Debian 12 的 PEP 668 保护 用虚拟环境 python -m venv .venv 或在镜像里装
本地过、CI 挂,报 ImportError requirements.txt 未锁小版本 pip freeze > requirements.lock,CI 装 lock
每次构建耗时 4 分钟 没复用 pip cache Jenkins 里 --cache-dir 挂持久目录,或做一层基础镜像

4. 静态检查:让它“阻断”而不是“提示”

stage('Lint') {
  steps {
    sh '''
      ruff check . --output-format=github
      mypy apis core --ignore-missing-imports || true
    '''
  }
}

建议只让 ruff(或 flake8)阻断,mypy 先观察。原因:类型检查在历史代码上一次性全绿的成本很高,强行阻断会让人直接关掉这个 stage。

5. 接口测试:环境、密钥与“假绿”

stage('API Test') {
  environment {
    BASE_URL = 'https://test.example.com'
  }
  steps {
    withCredentials([
      string(credentialsId: 'qa-api-user', variable: 'API_USER'),
      string(credentialsId: 'qa-api-password', variable: 'API_PASSWORD')
    ]) {
      sh '''
        set -euo pipefail
        pytest -m "smoke or regression" \
          --alluredir=allure-results \
          --junitxml=reports/junit.xml \
          -p no:cacheprovider
      '''
    }
  }
  post {
    always {
      junit 'reports/junit.xml'
      archiveArtifacts artifacts: 'allure-results/**', allowEmptyArchive: true
    }
  }
}

这里的四个“假绿”必须堵住:

  1. 只断言 HTTP 200:业务失败也返回 200。必须断言业务 code。
  2. --alluredir 写相对路径:stage 切换目录后报告找不到,表现为 Allure 页面空白或 There are no results to show。统一写绝对路径 ${WORKSPACE}/allure-results。
  3. pytest 返回码被管道吞掉:pytest ... | tee log 的退出码是 tee 的 0。要么 set -o pipefail,要么用 pytest ... 2>&1 | tee log; exit ${PIPESTATUS[0]}。
  4. 用例依赖上次数据:CI 是干净环境,必须自带造数与清理,不能依赖“库里有这条订单”。

6. UI 冒烟:容器内 Chrome 的五个必配参数

# core/driver.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

def build_driver() -> webdriver.Chrome:
    o = Options()
    o.add_argument("--headless=new")
    o.add_argument("--no-sandbox")                 # 容器内无 user namespace
    o.add_argument("--disable-dev-shm-usage")      # /dev/shm 只有 64MB
    o.add_argument("--disable-gpu")
    o.add_argument("--window-size=1440,900")       # 无头模式默认 800x600,响应式布局会变
    o.add_argument("--lang=zh-CN")
    o.add_experimental_option("prefs", {
        "intl.accept_languages": "zh-CN,zh",
        "credentials_enable_service": False,
    })
    return webdriver.Chrome(options=o)

对应报错与根因:

现象 原始报错 根因 解法
浏览器起不来 Chrome failed to start: crashed / DevToolsActivePort file doesn't exist 容器无 sandbox 权限 --no-sandbox
打开大页面即崩 unknown error: session deleted because of page crash /dev/shm 默认 64MB --disable-dev-shm-usage 或 --shm-size=2g
点击被拦 ElementClickInterceptedException: element click intercepted: Element <button> is not clickable at point (x, y). Other element would receive the click 固定遮罩/动画/吸底栏 等遮罩消失,或 scrollIntoView + JS 点击
元素引用失效 StaleElementReferenceException: element is not attached to the page document 列表重渲染 重新定位,用返回元素的方法而非缓存 WebElement
找不到元素 NoSuchElementException 在 iframe / Shadow DOM / 新标签页 切 context 后再定位

如果 UI 用例跑在 compose 里,别忘了:

services:
  chrome:
    image: selenium/standalone-chromium:4.25.0
    shm_size: 2gb
    ipc: host
    environment:
      - SE_SCREEN_WIDTH=1440
      - SE_SCREEN_HEIGHT=900
      - TZ=Asia/Shanghai

7. 并行:快,但会踩数据

stage('Regression') {
  failFast true
  parallel {
    stage('API') {
      steps { sh 'pytest -m api -n 4 --dist loadscope --alluredir=allure-results-api' }
    }
    stage('UI') {
      steps { sh 'pytest -m ui -n 2 --alluredir=allure-results-ui' }
    }
  }
}
  • -n 4 并行后,两个 worker 同时抢同一个测试账号,必现“库存不足”“订单已被支付”。
  • 解法:worker 维度隔离数据,pytest-xdist 提供 worker_id:
@pytest.fixture(scope="session")
def api_user(worker_id):
    suffix = "master" if worker_id == "master" else worker_id
    return create_user(f"qa_ci_{suffix}")   # 每个 worker 独立账号
  • UI 并行还有浏览器实例数上限:2 个 Chrome 在 2C4G runner 上就会互相拖慢,P95 翻倍。并行度要按 runner 规格定,不是越多越快。

8. Allure 报告:路径、历史与“空报告”

post {
  always {
    allure includeProperties: false,
    jdk: '',
    results: [[path: 'allure-results*']],
    reportBuildPolicy: 'ALWAYS'
  }
}

三个高频坑:

  1. results 只写了一个目录,而并行 stage 写了 allure-results-api、allure-results-ui,报告显示不全。用 glob allure-results* 或统一写入同一目录。
  2. Allure 插件未安装:pipeline 直接报 No such DSL method 'allure' found。装 Allure Jenkins Plugin 并在全局工具里配置 allure 命令行(或让插件自动安装)。
  3. 历史趋势断档:allure-results 被 cleanWs() 删掉后,趋势图每次都从零开始。报告生成前不要清理 workspace,或使用 allure 的 history 目录挂载。

9. 通知:只发结论,不发日志

post {
  failure {
    sh '''
      curl -s -X POST "$WEBHOOK" -H 'Content-Type: application/json' \
        -d "{\\"msgtype\\":\\"markdown\\",\\"markdown\\":{\\"content\\":\\"**构建失败** [$JOB_NAME #$BUILD_NUMBER]($BUILD_URL)\\n阶段: 见控制台\\"}}"
    '''
  }
}

通知里放三样就够:谁失败了、哪个阶段、报告链接。把 500 行日志贴进群,只会让人屏蔽机器人。

10. 完整 Jenkinsfile

pipeline {
  agent { label 'qa-runner' }

  options {
    timeout(time: 30, unit: 'MINUTES')
    buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10'))
    disableConcurrentBuilds(abortPrevious: true)
    ansiColor('xterm')
  }

  environment {
    TZ         = 'Asia/Shanghai'
    ALLURE_DIR = "${WORKSPACE}/allure-results"
    BASE_URL   = 'https://test.example.com'
  }

  stages {
    stage('Checkout') {
      steps { checkout scm }
    }

    stage('Install') {
      steps {
        sh 'python -m pip install -r requirements.txt'
      }
    }

    stage('Lint') {
      steps { sh 'ruff check . --output-format=github' }
    }

    stage('API Smoke') {
      steps {
        withCredentials([string(credentialsId: 'qa-api-token', variable: 'API_TOKEN')]) {
          sh '''
            set -euo pipefail
            pytest -m smoke \
              --alluredir="$ALLURE_DIR" \
              --junitxml=reports/junit.xml
          '''
        }
      }
      post {
        always {
          junit allowEmptyResults: true, testResults: 'reports/junit.xml'
        }
      }
    }

    stage('Regression') {
      when { branch 'main' }
      failFast true
      parallel {
        stage('API') {
          steps {
            sh 'pytest -m regression -n 4 --alluredir="$ALLURE_DIR"'
          }
        }
        stage('UI') {
          steps {
            sh 'pytest -m ui_smoke -n 2 --alluredir="$ALLURE_DIR"'
          }
        }
      }
    }
  }

  post {
    always {
      allure includeProperties: false,
      jdk: '',
      results: [[path: 'allure-results']],
      reportBuildPolicy: 'ALWAYS'
      archiveArtifacts artifacts: 'reports/**,logs/**,screenshots/**', allowEmptyArchive: true
    }
    cleanup {
      // 只清构建产物,保留 allure-results 供报告归档
      sh 'find . -name "__pycache__" -type d -prune -exec rm -rf {} + || true'
    }
  }
}

11. 失败排查顺序

出现红构建,按这个顺序看,比从头翻日志快:

1. Console 最后的 "ERROR:" 行 → 判断是环境、依赖还是用例
2. Allure 报告 → 失败用例的 step 与 attach 的请求/响应
3. reports/junit.xml → 失败数量与耗时分布
4. 截图与 page source → UI 失败时先看页面停在哪个状态
5. 用例日志中的 trace id → 去服务端日志查同一次请求

12. 验收标准

流水线改完后,用下面几项确认它真的可用:

  • 空 commit 触发一次,耗时应 < 预算的 80%
  • 故意让一条 smoke 断言失败,构建必须红,且 Allure 能定位到具体 step
  • 删除测试环境的一条基础数据,回归应报错而不是“假绿”
  • 并行跑 3 次,结果集合应一致(无偶发失败)
  • Allure 趋势图连续 5 次构建有数据

相关阅读