Chapter 33Lesson 02~220 minutes

Testing and Quality Pipelines: JUnit, Coverage, SonarQube, Selenium, JMeter, and Quality Gates: Guided Hands-On Workflow and Core Operations

Build a small quality Pipeline that creates JUnit and coverage evidence on a disposable agent, then extend the model safely to SonarQube, Selenium and JMeter.

Hands-onpytestcoverage.pyreport ingestionSonar gatelocal targets

Learning objectives

  • Create reproducible unit-test and coverage evidence from synthetic code.
  • Publish JUnit XML and Cobertura-format coverage to Jenkins with strict missing-report behavior.
  • Capture tool exit status without losing reports.
  • Model an external quality gate separately from the analysis command.
  • Run browser/load examples only against controlled local targets.

1. Scenario and resource identities

Create one disposable Pipeline job named jenkins-labs/quality-evidence and one agent label quality-lab. The synthetic repository contains a tiny Python module, tests and CI scripts. No production URL, external secret or paid service is required.

Resource Lab identity Why explicit
Job jenkins-labs/quality-evidence Separates retained build history from other labs.
Agent label quality-lab Avoids the built-in node and identifies the toolchain.
Virtual environment .venv-quality Pins the Python dependencies for this workspace.
JUnit XML reports/junit.xml One exact ingestion path.
Coverage XML reports/coverage.xml One exact coverage evidence path.
Simulated Sonar gate reports/sonar-gate.json Separates external-policy semantics without requiring a server.
Optional browser/load target 127.0.0.1 only Prevents uncontrolled external traffic.

2. Create the synthetic project

set -euo pipefail
mkdir -p quality-lab/tests quality-lab/reports quality-lab/ci
cd quality-lab
cat > calculator.py <<'PYAPP'
def add(a, b):
    return a + b

def classify(n):
    if n < 0:
        return "negative"
    if n == 0:
        return "zero"
    return "positive"
PYAPP
cat > tests/test_calculator.py <<'PYTEST'
import os
from calculator import add, classify

def test_add():
    assert add(2, 3) == 5

def test_zero():
    assert classify(0) == "zero"

def test_injected_failure():
    if os.environ.get("QUALITY_SCENARIO") == "failing-test":
        assert add(2, 2) == 5
PYTEST
cat > requirements-ci.txt <<'REQ'
pytest==9.1.1
coverage==7.16.1
REQ
python3 -m venv .venv-quality
. .venv-quality/bin/activate
python -m pip install --upgrade pip
python -m pip install --requirement requirements-ci.txt
python -m pytest --version
python -m coverage --version

The requirements file is part of the evidence chain. In a production repository, add hashes or use a lockfile/controlled package mirror according to your dependency policy.

3. Run tests while preserving the report

Use coverage as the Python launcher so the same run produces execution data. The JUnit XML must survive even when a test fails.

set -uo pipefail
mkdir -p reports
rm -f reports/junit.xml reports/coverage.xml .coverage
set +e
QUALITY_SCENARIO="${QUALITY_SCENARIO:-baseline}" \
  python -m coverage run --branch -m pytest -q --junitxml=reports/junit.xml
test_rc=$?
set -e
python -m coverage xml -o reports/coverage.xml
python -m coverage report --show-missing
printf '%s\n' "$test_rc" > reports/test-exit-code.txt
test -s reports/junit.xml
test -s reports/coverage.xml
exit "$test_rc"

If you let the shell stop immediately on the test runner’s non-zero exit, the later publication step may never run. Jenkins Pipeline can instead capture the return status, publish evidence in post or a later guarded block, then decide how to mark the build.

4. Jenkinsfile: execute, ingest, then decide

pipeline {
  agent none
  options { timestamps(); buildDiscarder(logRotator(numToKeepStr: '20')) }
  environment { QUALITY_SCENARIO = 'baseline' }
  stages {
    stage('Unit tests + coverage generation') {
      agent { label 'quality-lab' }
      steps {
        checkout scm
        sh '''
          set -euo pipefail
          python3 -m venv .venv-quality
          . .venv-quality/bin/activate
          python -m pip install --requirement requirements-ci.txt
        '''
        script {
          env.TEST_RC = sh(returnStatus: true, script: '''
            set -uo pipefail
            . .venv-quality/bin/activate
            mkdir -p reports
            rm -f reports/junit.xml reports/coverage.xml .coverage
            QUALITY_SCENARIO="$QUALITY_SCENARIO" \
              python -m coverage run --branch -m pytest -q --junitxml=reports/junit.xml
            rc=$?
            python -m coverage xml -o reports/coverage.xml || true
            printf '%s\n' "$rc" > reports/test-exit-code.txt
            exit "$rc"
          ''').toString()
        }
      }
      post {
        always {
          junit testResults: 'reports/junit.xml',
                allowEmptyResults: false,
                skipPublishingChecks: true
          recordCoverage(
            tools: [[parser: 'COBERTURA', pattern: 'reports/coverage.xml']],
            id: 'python-coverage', name: 'Python Coverage',
            sourceCodeRetention: 'MODIFIED',
            qualityGates: [[threshold: 80.0, metric: 'LINE', baseline: 'PROJECT', unstable: true]]
          )
          archiveArtifacts artifacts: 'reports/**', allowEmptyArchive: true, fingerprint: true
        }
      }
    }
    stage('Execution policy') {
      agent none
      steps {
        script {
          if (env.TEST_RC != '0') {
            error("Test runner failed with exit ${env.TEST_RC}; reports were preserved first")
          }
        }
      }
    }
  }
}

Three facts can now be read independently: the shell return code, the JUnit publisher’s parsed test state, and the coverage gate result. A build status is the composition of those facts—not a substitute for them.

5. Expected baseline observations

Layer Expected evidence
SCM One exact Git SHA shown in checkout/build metadata.
Execution pytest/coverage versions and TEST_RC=0.
JUnit reports/junit.xml parsed; three tests reported, no failures in baseline.
Coverage Cobertura XML parsed; line/branch metrics attached to the build.
Policy Coverage gate may be SUCCESS or UNSTABLE depending on measured threshold; that outcome is visible separately.
Archive Raw XML plus exit-code evidence retained with the Jenkins build.

6. Prove missing-report behavior safely

Do not simulate a missing report by deleting arbitrary workspace files. Change only this lab path and preserve the command output.

stage('Missing-report exercise') {
  agent { label 'quality-lab' }
  steps {
    sh '''
      set -euo pipefail
      mkdir -p reports
      printf '<testsuite tests="0" failures="0"/>\n' > reports/not-the-configured-name.xml
      rm -f reports/junit.xml
    '''
    // Intentionally fails because the configured report does not exist.
    junit testResults: 'reports/junit.xml', allowEmptyResults: false, skipPublishingChecks: true
  }
}

The failure belongs to the ingestion/path layer. Changing allowEmptyResults to true would hide the lab’s intended signal rather than repair the path.

7. Faithful local SonarQube gate simulation

The mandatory lab does not require a SonarQube server. Preserve the same state split with a tiny task/gate document.

set -euo pipefail
mkdir -p reports
cat > reports/sonar-gate.json <<'JSON'
{
  "analysisTaskId": "ce-task-lab-0042",
  "projectKey": "quality-lab",
  "sourceSha": "REPLACE_AT_RUNTIME",
  "status": "OK",
  "conditions": [
    {"metric": "new_coverage", "operator": ">=", "threshold": 80, "actual": 92.5}
  ]
}
JSON
python3 - <<'PYSIM'
import json, subprocess
p='reports/sonar-gate.json'
d=json.load(open(p, encoding='utf-8'))
d['sourceSha']=subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip()
open(p,'w',encoding='utf-8').write(json.dumps(d, indent=2)+'\n')
if d['status'] != 'OK':
    raise SystemExit(42)
print('gate=', d['status'], 'task=', d['analysisTaskId'])
PYSIM

This does not claim to reproduce SonarQube analysis. It faithfully teaches the Jenkins boundary: analysis/task identity, external gate result and policy decision are separate. An optional real server replaces the JSON producer, not the evidence model.

8. Optional disposable SonarQube integration

Install only the maintained SonarQube Scanner plugin and configure a disposable SonarQube Server/Community Build with a fake scoped token. Avoid the old Jenkins Quality Gates plugin: its current page reports an unresolved credentials-transmitted-in-plain-text security issue. Use the maintained SonarQube integration’s waitForQualityGate instead.

pipeline {
  agent none
  stages {
    stage('Sonar analysis') {
      agent { label 'quality-lab' }
      steps {
        withSonarQubeEnv('sonarqube-lab') {
          sh './sonar-scanner/bin/sonar-scanner'
        }
      }
    }
    stage('Sonar quality gate') {
      agent none
      steps {
        timeout(time: 15, unit: 'MINUTES') {
          waitForQualityGate abortPipeline: true
        }
      }
    }
  }
}

Configure the documented webhook endpoint and, where appropriate, a webhook secret. Record project key, compute-engine task ID and final gate status.

9. Optional Selenium smoke test against localhost only

Selenium 4.49 can drive a locally available browser. Do not make the course depend on an automatic browser download. Preflight the browser first; if unavailable, treat this as an architecture exercise.

# tests/test_browser_smoke.py
import os
import pytest
from selenium import webdriver

@pytest.mark.browser
def test_local_title():
    target = os.environ.get("LAB_URL", "http://127.0.0.1:8765")
    assert target.startswith("http://127.0.0.1:")
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    driver = webdriver.Chrome(options=options)
    try:
        driver.get(target)
        assert "Quality Lab" in driver.title
    finally:
        driver.quit()
# Terminal/agent setup for a controlled target
mkdir -p web
printf '<!doctype html><title>Quality Lab</title><h1>OK</h1>\n' > web/index.html
python3 -m http.server 8765 --directory web --bind 127.0.0.1 &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
LAB_URL=http://127.0.0.1:8765 \
  python -m pytest -q -m browser --junitxml=reports/selenium.xml

Publish reports/selenium.xml with the JUnit plugin as a distinct report/check identity. If a browser crash occurs, keep browser/driver logs and screenshots before retrying.

10. Optional JMeter CLI against localhost only

JMeter’s own guidance says load tests should run in CLI mode, not the GUI. Build/validate a tiny JMX separately, then run it only against a disposable local service.

set -euo pipefail
TARGET_HOST=127.0.0.1
TARGET_PORT=8765
test "$TARGET_HOST" = '127.0.0.1'
jmeter -n \
  -t ci/local-smoke.jmx \
  -Jlab.host="$TARGET_HOST" \
  -Jlab.port="$TARGET_PORT" \
  -l reports/jmeter.jtl \
  -j reports/jmeter.log
test -s reports/jmeter.jtl

The optional Jenkins Performance plugin can ingest JMeter results, but the mandatory lesson keeps the raw JTL regardless. A parser/publisher result is distinct from JMeter’s own process exit status.

11. Challenge: name the layer before changing configuration

A build shows: pytest rc=0, reports/junit.xml exists, the JUnit publisher reports three passed tests, Coverage records 79.4%, and Jenkins becomes UNSTABLE because the threshold is 80%. Which layer failed?

Answer: the coverage policy gate—not test execution, report generation or JUnit ingestion. The correct response is to investigate coverage evidence or intentionally change the reviewed policy; do not rerun tests blindly.

Next

Choose quality architecture deliberately

Lesson 3 compares command failure versus report thresholds, webhook versus synchronous waiting, isolated evidence stages and disciplined flaky-test handling.

Knowledge check

Answer before revealing the explanation.

1. Why use returnStatus around the test runner?

2. Why keep allowEmptyResults false in the lab?

3. What does recordCoverage need before it can work?

4. Why is the SonarQube gate stage agent none?

5. Why guard Selenium/JMeter targets as localhost in the mandatory examples?

Official references and version notes

Test-report schemas, Jenkins plugins, SonarQube integration and browser/load-test tooling evolve. Prefer current primary documentation.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.