Gatling in CI/CD: Jenkins and GitHub Actions Integration Guide

Gatling in CI/CD: Jenkins and GitHub Actions Integration Guide

Running Gatling in CI/CD transforms load testing from a one-off activity into a continuous performance gate. This guide shows the exact Maven plugin configuration, GitHub Actions YAML, and Jenkinsfile needed to run Gatling simulations automatically, archive the HTML report, and fail the build when your p95 or error rate threshold is breached.

Key Takeaways

  • Assertions in the simulation file are your CI gate — declare p95 and error rate thresholds in code, and Gatling exits non-zero automatically when they are breached.
  • HTML reports must be explicitly archived in both GitHub Actions and Jenkins — they are not preserved between runs unless you add an artifact upload step.
  • Parameterize baseUrl with system properties so the same simulation runs against staging and production without code changes.
  • Separate your load test job from your build job — load tests should run after deployment, not during compilation, to avoid slowing down developer feedback loops.
  • Set a test duration cap in CI to prevent flaky, runaway tests from blocking your pipeline for hours.

Load testing belongs in CI. Not as a nightly batch job nobody monitors, but as a proper pipeline gate that fails a deploy when response times regress. This guide covers the exact configuration to make that happen with Gatling, both in Jenkins and GitHub Actions.

Prerequisites

The examples assume:

  • A Maven project with Gatling simulations in src/test/java/
  • The gatling-maven-plugin in pom.xml
  • Simulations use system property baseUrl for the target environment

If you are starting from scratch, follow the quickstart guide first to get a working simulation before integrating with CI.

Maven Plugin Configuration

Your pom.xml needs the Gatling plugin configured to run non-interactively:

<build>
  <plugins>
    <plugin>
      <groupId>io.gatling</groupId>
      <artifactId>gatling-maven-plugin</artifactId>
      <version>4.9.6</version>
      <configuration>
        <!-- Run specific simulation; omit to run all simulations -->
        <simulationClass>com.example.ApiLoadTest</simulationClass>
        <!-- Suppress interactive prompts — required for CI -->
        <runMultipleSimulations>false</runMultipleSimulations>
        <!-- Results directory (default is fine; shown here for clarity) -->
        <resultsFolder>${project.build.directory}/gatling</resultsFolder>
        <!-- Pass JVM args: increase heap for large tests -->
        <jvmArgs>
          <jvmArg>-Xmx2g</jvmArg>
          <jvmArg>-XX:+UseG1GC</jvmArg>
        </jvmArgs>
      </configuration>
    </plugin>
  </plugins>
</build>

The key option is suppressing interactive prompts. Without it, Gatling will pause and ask which simulation to run — which hangs a CI job indefinitely.

Assertions: Your CI Pass/Fail Gate

Assertions in your simulation file control the process exit code. Gatling exits with code 2 when any assertion fails, which both GitHub Actions and Jenkins treat as a build failure.

{
    setUp(
        scenario.injectOpen(
            rampUsers(50).during(30),
            constantUsersPerSec(10).during(120)
        )
    ).protocols(httpProtocol)
     .assertions(
         // p95 response time under 500ms across all requests
         global().responseTime().percentile(95).lt(500),
         // p99 response time under 1000ms
         global().responseTime().percentile(99).lt(1000),
         // Less than 1% of requests fail
         global().failedRequests().percent().lt(1.0),
         // Throughput at least 5 req/sec (sanity check)
         global().requestsPerSec().gt(5.0),
         // Per-request assertions for critical paths
         forAll().responseTime().percentile(95).lt(800)
     );
}

The forAll() scope applies the assertion to every named request individually. This catches cases where one slow endpoint drags up the aggregate but passes the global() threshold.

You can also scope assertions to specific requests:

details("Get Product Detail").responseTime().percentile(95).lt(200),
details("Create Order").responseTime().percentile(95).lt(1000)

GitHub Actions Integration

Create .github/workflows/load-test.yml:

name: Load Test

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      baseUrl:
        description: 'Target URL'
        required: false
        default: 'https://staging.api.example.com'

jobs:
  load-test:
    runs-on: ubuntu-latest
    # Only run after deployment succeeds
    needs: deploy
    timeout-minutes: 30  # Hard cap to prevent runaway tests

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run Gatling load test
        run: |
          mvn gatling:test \
            -DbaseUrl=${{ github.event.inputs.baseUrl || 'https://staging.api.example.com' }} \
            -Dgatling.simulationClass=com.example.ApiLoadTest
        # Continue so we can upload the report even if assertions fail
        continue-on-error: true
        id: gatling

      - name: Upload Gatling HTML report
        uses: actions/upload-artifact@v4
        if: always()  # Upload even on failure — you need the report to diagnose regressions
        with:
          name: gatling-report-${{ github.run_number }}
          path: target/gatling/
          retention-days: 30

      - name: Fail build if load test failed
        if: steps.gatling.outcome == 'failure'
        run: |
          echo "Load test failed. Download the Gatling HTML report from artifacts to diagnose."
          exit 1

The continue-on-error: true + explicit failure step pattern is important. Without it, a failed load test would skip the artifact upload, leaving you without the report you need to debug the regression.

Running Load Tests Only on Specific Branches

For large teams, running load tests on every push to every branch adds noise. Restrict to main and release branches, or use a path filter:

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'src/**'
      - 'pom.xml'
      - '.github/workflows/load-test.yml'
  schedule:
    - cron: '0 2 * * *'  # Also run nightly at 2am UTC

Storing Baseline Numbers

To track regressions over time, extract the summary metrics after each run and commit them:

      - name: Extract summary metrics
        if: always()
        run: |
          REPORT_DIR=$(ls -td target/gatling/*/ | head -1)
          python3 scripts/extract-gatling-stats.py "$REPORT_DIR/stats.json" \
            > metrics/load-test-baseline.json

      - name: Commit baseline update
        if: github.ref == 'refs/heads/main' && steps.gatling.outcome == 'success'
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add metrics/load-test-baseline.json
          git diff --staged --quiet || git commit -m "chore: update load test baseline [skip ci]"
          git push

Jenkins Integration

Declarative Jenkinsfile

pipeline {
    agent {
        docker {
            image 'maven:3.9-eclipse-temurin-21'
            args '-v $HOME/.m2:/root/.m2'  // Cache Maven dependencies
        }
    }

    parameters {
        string(
            name: 'BASE_URL',
            defaultValue: 'https://staging.api.example.com',
            description: 'Target environment URL'
        )
        string(
            name: 'SIMULATION_CLASS',
            defaultValue: 'com.example.ApiLoadTest',
            description: 'Fully qualified simulation class name'
        )
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
        disableConcurrentBuilds()
    }

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

        stage('Run Load Test') {
            steps {
                script {
                    // catchError lets the pipeline continue to the archive step
                    catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
                        sh """
                            mvn gatling:test \\
                                -DbaseUrl=${params.BASE_URL} \\
                                -Dgatling.simulationClass=${params.SIMULATION_CLASS} \\
                                -Dmaven.test.failure.ignore=false
                        """
                    }
                }
            }
        }

        stage('Publish Report') {
            steps {
                // Publish HTML report in Jenkins UI
                publishHTML(target: [
                    allowMissing: false,
                    alwaysLinkToLastBuild: true,
                    keepAll: true,
                    reportDir: 'target/gatling',
                    reportFiles: '**/index.html',
                    reportName: 'Gatling Load Test Report',
                    reportTitles: 'Gatling Report'
                ])
            }
        }
    }

    post {
        always {
            // Archive raw report for download
            archiveArtifacts artifacts: 'target/gatling/**/*', allowEmptyArchive: true

            // Send Slack notification with report link
            script {
                def status = currentBuild.result == 'SUCCESS' ? ':white_check_mark:' : ':x:'
                def reportUrl = "${env.BUILD_URL}Gatling_20Load_20Test_20Report/"
                slackSend(
                    color: currentBuild.result == 'SUCCESS' ? 'good' : 'danger',
                    message: "${status} Load test ${currentBuild.result}: ${env.JOB_NAME} #${env.BUILD_NUMBER} | <${reportUrl}|View Report>"
                )
            }
        }
    }
}

HTML Publisher Plugin

The publishHTML step requires the Jenkins HTML Publisher Plugin. Install it via Manage Jenkins → Plugins → Available. After installation, the Gatling report is accessible from the build page as a navigation link.

If the report renders as plain HTML without styles, Jenkins' Content Security Policy (CSP) may be blocking inline scripts. Adjust it in the Script Console:

System.setProperty(
    "hudson.model.DirectoryBrowserSupport.CSP",
    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
)

For a permanent fix, add it to Jenkins startup arguments.

Configuring Multiple Environments

Define environment profiles in your simulation or pass all parameters via system properties:

public class ApiLoadTest extends Simulation {

    // Read all config from system properties with sensible defaults
    private final String baseUrl = System.getProperty("baseUrl", "https://staging.api.example.com");
    private final int rampUsers = Integer.parseInt(System.getProperty("rampUsers", "50"));
    private final int rampDurationSec = Integer.parseInt(System.getProperty("rampDuration", "30"));
    private final int steadyDurationSec = Integer.parseInt(System.getProperty("steadyDuration", "120"));
    private final int p95ThresholdMs = Integer.parseInt(System.getProperty("p95Threshold", "500"));

    // ... rest of simulation using these variables
}

Call it with environment-specific values:

# Staging: lighter load, more lenient threshold
mvn gatling:test \
  -DbaseUrl=https://staging.api.example.com \
  -DrampUsers=20 \
  -Dp95Threshold=800

# Production smoke: minimal load, strict threshold
mvn gatling:test \
  -DbaseUrl=https://api.example.com \
  -DrampUsers=5 \
  -DrampDuration=10 \
  -DsteadyDuration=60 \
  -Dp95Threshold=200

Handling Long-Running Tests in CI

Load tests that run for 10–30 minutes create pipeline pressure. Strategies:

1. Separate pipeline for load tests. Keep the main CI pipeline (build, unit tests, deploy) fast. Trigger load tests as a downstream job after deployment, or run them on a schedule.

2. Short CI variant + full nightly run. Write two injection profiles — a 3-minute CI variant and a 30-minute full run:

// CI: quick check
private final boolean isCi = Boolean.parseBoolean(System.getProperty("ci", "false"));

InjectOpen[] injection = isCi
    ? new InjectOpen[]{rampUsers(20).during(30), constantUsersPerSec(5).during(120)}
    : new InjectOpen[]{rampUsers(100).during(120), constantUsersPerSec(20).during(600)};

3. Skip load tests on draft PRs. In GitHub Actions, check github.event.pull_request.draft and skip the load test job.

Troubleshooting Common CI Failures

"Could not find simulation class" — The simulationClass property requires the fully qualified class name including the package. Verify with find target/test-classes -name "*.class".

"GatlingException: Cannot run simulation" on Java 17+ — Add --add-opens JVM args to jvmArgs in the Maven plugin. Gatling 3.10+ handles this automatically; older versions need:

<jvmArg>--add-opens=java.base/java.lang=ALL-UNNAMED</jvmArg>

Report not found after failed run — Check that you are using if: always() on the upload step (GitHub Actions) or catchError before the archive step (Jenkins).

Assertions passing locally but failing in CI — CI environments typically have more network latency to the target environment. Either adjust thresholds for CI or run load tests within the same network as the target service (e.g., run the GitHub Actions runner in the same VPC as your staging environment).

Summary

A complete Gatling CI/CD integration has four components: a Maven plugin configuration that runs non-interactively, assertions in the simulation file that control exit codes, an artifact upload step that preserves the HTML report, and an explicit failure step that surfaces the failure clearly in the pipeline UI. The templates in this guide are production-tested starting points — adjust the load shape and thresholds to match your actual SLOs.

Read more

Start now free