Lesson 3 of 8 · DevOps for QA
Jenkins Pipelines
Write declarative Jenkins pipelines for test suites: stages, agents, parallel test runs, credentials with withCredentials, JUnit results, archived reports and timeouts.
intermediate 8 min read Updated
You will learn to
- Read and write a declarative Jenkinsfile with agents, stages, steps and post conditions
- Run test suites in parallel stages and publish JUnit results
- Inject secrets safely with withCredentials and avoid Groovy interpolation leaks
- Archive reports and traces so failed builds are debuggable
Prerequisites
- Git for Testers
- Basic command line
- A test runner that can produce JUnit XML
Jenkins is a self-hosted automation server that has been running builds and tests for a very long time, and it is still the CI system in a large share of enterprise teams. If you join a company with on-premise infrastructure, regulated environments or a long-lived monolith, there is a good chance its tests run in Jenkins.
Modern Jenkins jobs are defined as pipelines as code: a file called Jenkinsfile in the repository describes the build. This lesson covers the declarative pipeline syntax, which is the structured, opinionated form you should use for new work. (The older scripted syntax is plain Groovy and more flexible, but harder to read and review.)
The shape of a declarative pipeline
pipeline {
agent any
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds()
}
environment {
CI = 'true'
BASE_URL = 'https://staging.example.com'
}
stages {
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Unit tests') {
steps {
sh 'npm test -- --reporter=junit --outputFile=results/unit.xml'
}
}
}
post {
always {
junit allowEmptyResults: true, testResults: 'results/*.xml'
}
}
}
The building blocks:
pipeline: the required top-level block for declarative syntax.agent: where the work runs.agent anyuses any available executor;agent { label 'linux' }targets machines with a label;agent { docker { image '...' } }runs inside a container (requires the Docker Pipeline plugin and Docker on the agent).agent noneat the top level means each stage declares its own.options: pipeline-wide settings. Always set atimeout: a hung browser should fail the build, not hold an executor for hours.environment: variables available to every step.stages/stage/steps: the ordered work. Each stage appears as a column in the Jenkins UI, so name stages by what they prove, not how they work.post: actions after the pipeline (or a stage) finishes. Conditions includealways,success,failure,unstable,changedandcleanup.
The command in the unit stage depends on your runner; the point is that it writes JUnit XML somewhere predictable. Jenkins understands JUnit XML natively through the junit step (from the JUnit plugin), which gives you test trends, per-test history and failure details in the UI.
Failed vs unstable
Jenkins distinguishes between a build that failed (a step exited non-zero) and one that is unstable (it completed, but tests failed). The junit step marks the build unstable when the XML contains failures. If your test command itself exits non-zero, the build fails at that step instead, and post { always { ... } } is what guarantees the results are still published. Agree with your team which signal blocks a merge: most teams treat both as red.
Running Playwright in a container
Using the official Playwright image avoids installing browsers and system libraries on every agent:
pipeline {
agent {
docker {
image 'mcr.microsoft.com/playwright:v1.65.0-noble'
args '--ipc=host'
}
}
options {
timeout(time: 45, unit: 'MINUTES')
}
stages {
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('E2E') {
steps {
sh 'npx playwright test --reporter=junit,html'
}
}
}
post {
always {
junit allowEmptyResults: true, testResults: 'results/e2e.xml'
archiveArtifacts artifacts: 'playwright-report/**, test-results/**', allowEmptyArchive: true
}
}
}
The image tag must match the @playwright/test version in your package-lock.json, otherwise the browsers in the image will not match what the library expects. The Docker for Testers lesson explains this in detail. Set the JUnit output file with the PLAYWRIGHT_JUNIT_OUTPUT_NAME environment variable or the reporter options in playwright.config.ts.
archiveArtifacts stores files with the build so anyone can download the HTML report and traces from the build page. allowEmptyArchive: true stops the post step failing when a run crashed before producing files.
Parallel stages
Independent suites should run at the same time. Declarative pipelines support a parallel block inside a stage:
pipeline {
agent none
options {
timeout(time: 40, unit: 'MINUTES')
}
stages {
stage('Tests') {
failFast false
parallel {
stage('API') {
agent { label 'linux' }
steps {
sh 'npm ci'
sh 'npm run test:api'
}
post {
always { junit allowEmptyResults: true, testResults: 'results/api.xml' }
}
}
stage('E2E shard 1') {
agent { docker { image 'mcr.microsoft.com/playwright:v1.65.0-noble'; args '--ipc=host' } }
steps {
sh 'npm ci'
sh 'npx playwright test --shard=1/2'
}
post {
always { junit allowEmptyResults: true, testResults: 'results/e2e.xml' }
}
}
stage('E2E shard 2') {
agent { docker { image 'mcr.microsoft.com/playwright:v1.65.0-noble'; args '--ipc=host' } }
steps {
sh 'npm ci'
sh 'npx playwright test --shard=2/2'
}
post {
always { junit allowEmptyResults: true, testResults: 'results/e2e.xml' }
}
}
}
}
}
}
Points to note:
- With
agent noneat the top, each parallel branch gets its own agent and workspace, so they do not overwrite each other’s files. If branches share one agent and workspace, give each a distinct output path. failFast false(the default) lets all branches finish so you see every failure. SetfailFast truewhen a failure in one branch makes the others pointless and you want to save executor time.- Each branch publishes its own results in its own
postblock, because each runs in its own workspace.
For larger matrices, declarative pipelines also have a matrix directive that generates parallel branches from axes such as browser and OS. Start with explicit parallel stages; reach for matrix when the list becomes repetitive.
Credentials
Never put passwords or tokens in a Jenkinsfile. Store them in the Jenkins Credentials store (ideally scoped to the folder that needs them) and bind them only for the steps that use them with withCredentials (from the Credentials Binding plugin):
stage('E2E') {
steps {
withCredentials([
usernamePassword(credentialsId: 'staging-test-user',
usernameVariable: 'TEST_USER',
passwordVariable: 'TEST_PASSWORD'),
string(credentialsId: 'staging-api-token', variable: 'API_TOKEN')
]) {
sh 'npx playwright test'
}
}
}
Inside the block, the values are environment variables that your test code reads with process.env.TEST_USER. Jenkins masks bound values in the console log.
For a whole stage you can also use environment with the credentials() helper, for example TOKEN = credentials('staging-api-token'). For username-and-password credentials this helper also defines _USR and _PSW suffixed variables. withCredentials is more explicit about scope, which is why it is the better default.
Triggers and parameters
Common ways a pipeline starts:
- Multibranch pipelines scan the repository and create a job per branch and pull request, each using that branch’s
Jenkinsfile. This is the usual setup for PR checks. triggers { cron('H 2 * * *') }runs a nightly build. TheHspreads load by hashing the job name into the time slot, so not every nightly job starts at exactly 02:00.parameterslet people run a build with choices, such as which environment or tag to run:
parameters {
choice(name: 'TARGET_ENV', choices: ['staging', 'qa'], description: 'Environment to test')
string(name: 'GREP', defaultValue: '@smoke', description: 'Playwright --grep filter')
}
Read them as params.TARGET_ENV. Be careful passing parameter values into shell commands, since they are user input; prefer choice parameters over free text where you can.
Keeping Jenkins pipelines healthy
- Pin agents and images.
agent anyon a mixed fleet leads to “works on agent 3, fails on agent 7”. Use labels or containers so every run has the same tools. - Clean workspaces. Leftover reports from a previous run can be published as if they were new. Delete result folders at the start of the test stage, or use
cleanWs()(Workspace Cleanup plugin) inpost { cleanup { ... } }. - Keep logic out of Groovy. Put real work in shell scripts or npm scripts that also run locally. The Jenkinsfile should orchestrate, not implement.
- Share carefully. Shared libraries let teams reuse pipeline steps across repositories, which is powerful but means a library change can break every pipeline at once. Version them.
- Replay is for experiments. The Replay feature lets you edit a Jenkinsfile for one run without committing it, which is useful for debugging; commit the fix afterwards.
Practice exercise
Your team’s Jenkinsfile runs API and E2E tests one after another on agent any, takes 40 minutes, has no timeout, and calls sh "npx playwright test --password=${env.ADMIN_PASSWORD}" with a password stored as a plain environment variable in the job configuration. Failed builds show no test results. List what you would change and sketch the key parts of a new Jenkinsfile.
Show a model answer
Changes:
- Move the password into the Jenkins Credentials store, rotate it (it has been visible in job configuration and possibly logs), and use a dedicated low-privilege test account instead of an admin.
- Bind it with
withCredentialsand read it from the environment in the test code instead of a command-line flag; use single-quotedshstrings. - Add
options { timeout(...) }. - Run API and E2E in
parallel, each with its own agent, and shard E2E. - Publish JUnit results and archive reports in
post { always { ... } }so failures are visible. - Replace
agent anywith a label or the Playwright container image pinned to the project’s Playwright version.
pipeline {
agent none
options { timeout(time: 30, unit: 'MINUTES') }
stages {
stage('Tests') {
parallel {
stage('API') {
agent { label 'linux' }
steps {
sh 'npm ci && npm run test:api'
}
post { always { junit allowEmptyResults: true, testResults: 'results/api.xml' } }
}
stage('E2E') {
agent { docker { image 'mcr.microsoft.com/playwright:v1.65.0-noble'; args '--ipc=host' } }
steps {
sh 'npm ci'
withCredentials([usernamePassword(credentialsId: 'staging-test-user',
usernameVariable: 'TEST_USER', passwordVariable: 'TEST_PASSWORD')]) {
sh 'npx playwright test'
}
}
post {
always {
junit allowEmptyResults: true, testResults: 'results/e2e.xml'
archiveArtifacts artifacts: 'playwright-report/**', allowEmptyArchive: true
}
}
}
}
}
}
}Key takeaways
- Use declarative pipelines:
pipeline,agent,stages,stepsandpost, committed as aJenkinsfile. - Always set a
timeout, and publish results withjunitandarchiveArtifactsinsidepost { always { ... } }. - Run independent suites in
parallelstages with separate agents or separate output paths. - Bind secrets with
withCredentials, scoped to the steps that need them, and keepshstrings single-quoted. - Pin agents with labels or container images so every build runs on the same toolchain.
- Keep real logic in scripts that run locally; the Jenkinsfile orchestrates.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- Declarative vs scripted pipeline in Jenkins: which would you use to run tests?
- How do you publish test results in Jenkins, and what is the difference between UNSTABLE and FAILURE?
- Write a GitHub Actions workflow that runs tests in parallel shards and keeps the reports
- How would you run a large test suite in parallel across Jenkins agents?