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.
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.
Knowledge check
Answer before revealing the explanation.
1. Why use returnStatus around the test runner?
It lets the Pipeline preserve/publish reports before converting the execution failure into final policy, rather than losing evidence at the first non-zero shell exit.
2. Why keep allowEmptyResults false in the lab?
A missing report is a configuration/evidence failure that should be visible, not silently interpreted as no tests.
3. What does recordCoverage need before it can work?
A real coverage tool must already have generated a supported report such as Cobertura or JaCoCo XML.
4. Why is the SonarQube gate stage agent none?
The webhook-backed external wait should not occupy a scarce build agent.
5. Why guard Selenium/JMeter targets as localhost in the mandatory examples?
Browser/load tests can create side effects or traffic. The course requires controlled disposable targets and explicit environment identity.
Official references and version notes
Test-report schemas, Jenkins plugins, SonarQube integration and browser/load-test tooling evolve. Prefer current primary documentation.
- Jenkins LTS changelog
- Jenkins Java Support Policy
- Jenkins Security Advisories
- JUnit plugin
- Coverage plugin
- SonarQube Scanner plugin
- SonarQube Server 2026.1 — Jenkins analysis integration
- SonarQube Server 2026.1 — waitForQualityGate / pipeline pause
- Selenium documentation
- Apache JMeter User Manual
- Jenkins Performance plugin
- pytest
- coverage.py
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.