Skip to content
End To End Tester

Testing in Jenkins

Declarative pipelines, parallel stages, agent control and JUnit reporting — how to run a modern test suite on the CI server you probably inherited.

1 min read · updated 19 September 2026

Jenkins is the CI server you inherit. It is also, once configured properly, extremely capable — total control over agents, an enormous plugin ecosystem, and the ability to run in environments no hosted platform can reach.

#A declarative pipeline

groovy
// Jenkinsfile
pipeline {
    agent none                       // per-stage agents; no global executor held

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

    environment {
        CI = 'true'
        NODE_OPTIONS = '--max-old-space-size=4096'
    }

    stages {
        stage('Checks') {
            agent { docker { image 'node:22'; args '-v $HOME/.npm:/root/.npm' } }
            steps {
                sh 'npm ci'
                sh 'npm run lint'
                sh 'npm run typecheck'
            }
        }

        stage('Tests') {
            parallel {
                stage('Unit') {
                    agent { docker { image 'node:22' } }
                    steps {
                        sh 'npm ci'
                        sh 'npm test -- --coverage --reporters=default --reporters=jest-junit'
                    }
                    post {
                        // always: the report matters most when the stage failed.
                        always {
                            junit testResults: 'reports/junit.xml', skipPublishingChecks: false
                            publishHTML(target: [
                                reportDir: 'coverage/lcov-report',
                                reportFiles: 'index.html',
                                reportName: 'Coverage'
                            ])
                        }
                    }
                }

                stage('Integration') {
                    agent {
                        // Testcontainers needs a docker socket.
                        docker {
                            image 'node:22'
                            args '-v /var/run/docker.sock:/var/run/docker.sock --network host'
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npm run test:integration'
                    }
                    post { always { junit 'reports/integration-junit.xml' } }
                }

                stage('E2E') {
                    agent {
                        docker {
                            // The official image has the browsers and their
                            // OS dependencies preinstalled.
                            image 'mcr.microsoft.com/playwright:v1.49.0-noble'
                            args '--ipc=host'      // without this, Chromium crashes under load
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npm run build'
                        sh 'npx playwright test --reporter=junit,html'
                    }
                    post {
                        always {
                            junit 'test-results/junit.xml'
                            archiveArtifacts artifacts: 'playwright-report/**', allowEmptyArchive: true
                        }
                    }
                }
            }
        }

        stage('Deploy') {
            when { branch 'main' }
            agent any
            steps { sh './deploy.sh' }
        }
    }

    post {
        unstable {
            // "unstable" means tests failed but the build itself worked —
            // a distinction most platforms do not make.
            slackSend channel: '#builds', color: 'warning',
                      message: "Tests failed: ${env.JOB_NAME} #${env.BUILD_NUMBER} (${env.BUILD_URL})"
        }
        failure {
            slackSend channel: '#builds', color: 'danger',
                      message: "Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
        }
    }
}

Two container arguments matter more than they look:

--ipc=host for anything running Chromium. The default IPC namespace gives 64MB of shared memory, and Chrome crashes intermittently under parallel load without it. This is one of the most common causes of "flaky browser tests in Jenkins" and it is not a test problem at all.

Mounting the Docker socket for Testcontainers. Note the security implication: a container with the host's Docker socket can control the host. Use a dedicated agent for this, not a shared one.

#Dynamic parallel stages

Sharding, generated rather than copy-pasted:

groovy
stage('E2E sharded') {
    steps {
        script {
            def shards = 4
            def branches = [:]
            (1..shards).each { i ->
                branches["shard-${i}"] = {
                    node('linux && docker') {
                        checkout scm
                        docker.image('mcr.microsoft.com/playwright:v1.49.0-noble')
                              .inside('--ipc=host') {
                            sh 'npm ci'
                            sh "npx playwright test --shard=${i}/${shards} --reporter=blob"
                            stash name: "blob-${i}", includes: 'blob-report/**'
                        }
                    }
                }
            }
            branches.failFast = false
            parallel branches
        }
    }
}

stage('Merge report') {
    steps {
        script { (1..4).each { unstash "blob-${it}" } }
        sh 'npx playwright merge-reports --reporter=html ./blob-report'
        archiveArtifacts 'playwright-report/**'
    }
}

See Playwright sharding.

#Shared libraries

The single most important thing for keeping fifty repositories sane.

groovy
// vars/standardNodePipeline.groovy — in a separate, versioned repo
def call(Map config = [:]) {
    pipeline {
        agent none
        stages {
            stage('Checks') { /* ... */ }
            stage('Tests')  { /* ... */ }
        }
    }
}
groovy
// Jenkinsfile in each repository
@Library('ci-standards@v3') _
standardNodePipeline(nodeVersion: '22', e2eShards: 4)

Without this, every repository accumulates its own divergent copy of the same 200-line file and a platform change becomes fifty pull requests.

#The JUnit plugin

Jenkins' test reporting rests on JUnit XML, which every runner can emit:

groovy
junit(
    testResults: '**/junit*.xml',
    allowEmptyResults: false,
    skipPublishingChecks: false,
    // Flag a test that alternates between pass and fail on the same commit.
    // Jenkins' detection is cruder than TeamCity's but it exists.
    healthScaleFactor: 1.0
)

Failing tests mark the build unstable (yellow) rather than failed (red) — a distinction Jenkins makes and most other platforms do not. It is useful: red means the pipeline itself broke, yellow means the code did.

#Where it fits

Choose Jenkins when you need self-hosted or air-gapped CI, complete control over agent hardware (GPUs, specific OS versions, physical devices), or integration with something only a Jenkins plugin supports.

Do not choose it for a small team on GitHub who would be served by GitHub Actions in a tenth of the setup time. The running cost of Jenkins is not the server; it is the plugin upgrades, the security advisories and the accumulated configuration nobody documented.

Common questions

Should new projects use Jenkins?
Usually not, unless you need what it uniquely offers — total control over agents, on-premise or air-gapped operation, or integration with something no hosted platform supports. Hosted CI is less work for most teams. Jenkins is the one you inherit, and it remains very capable once configured.
Declarative or scripted pipelines?
Declarative. It is more readable, it validates earlier, and it covers everything most pipelines need. Drop into a script block for the occasional piece of imperative logic rather than writing the whole file that way.
How do I keep Jenkinsfiles from becoming unmaintainable?
A shared library. Put the common pipeline shape in a versioned Groovy library and have each repository's Jenkinsfile be a handful of lines that calls it. Without this, fifty repositories means fifty divergent copies of the same 200-line file.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?