A load test that only works against one environment is a liability — it requires manual edits before every non-default run, encourages keeping a separate copy per environment, and frequently gets accidentally pointed at production. The goal is one test plan, zero edits between environments, all differences supplied externally at run time.
The parameterisation pattern
The approach combines three mechanisms:
- Properties files define environment-specific values outside the
.jmxfile - JMeter Properties (
-Jflags or-pfile) inject those values at run time - User-Defined Variables read Properties and provide defaults, so the test is always self-runnable
Repository structure
Keep all environment-specific files alongside the test plan:
jmeter-tests/
├── test-plans/
│ ├── load-test.jmx
│ ├── stress-test.jmx
│ └── soak-test.jmx
├── environments/
│ ├── dev.properties
│ ├── staging.properties
│ └── production.properties.template ← real passwords not committed
├── data/
│ ├── users.csv
│ └── products.csv
├── .gitignore
└── results/ ← gitignored
The production.properties.template contains the key structure but not the values — it documents what is needed without committing secrets.
Properties files
environments/staging.properties:
baseUrl=https://api.staging.example.com
dbHost=staging-db.internal
adminEmail=perf-test@staging.example.com
vusers=50
rampUpSecs=120
durationSecs=300
thinkTimeMs=2000environments/production.properties:
baseUrl=https://api.example.com
dbHost=db.internal
adminEmail=perf-test@example.com
vusers=200
rampUpSecs=300
durationSecs=600
thinkTimeMs=3000Properties files use standard Java properties format: key=value, one per line. No quotes around values. Comments start with #.
Wiring up the test plan
In the test plan, add User-Defined Variables at the Test Plan root level:
| Name | Value |
|---|---|
baseUrl | ${__P(baseUrl,https://localhost:3000)} |
vusers | ${__P(vusers,1)} |
rampUpSecs | ${__P(rampUpSecs,5)} |
durationSecs | ${__P(durationSecs,30)} |
thinkTimeMs | ${__P(thinkTimeMs,1000)} |
Each UDV reads from a Property, falling back to a sensible default. The defaults make the test runnable with jmeter -n -t test-plans/load-test.jmx without any flags — 1 user, 30-second duration against localhost.
Thread Group configuration references these variables:
- Number of Threads:
${vusers} - Ramp-Up Period:
${rampUpSecs} - Duration:
${durationSecs}(with "Specify Thread Lifetime" enabled)
HTTP Request Defaults references:
- Server Name: extracted from
${baseUrl}using a JSR223 Pre-Processor or set directly
Uniform Random Timer:
- Constant Delay Offset:
${thinkTimeMs}
Running against each environment
# Default (localhost, 1 user — smoke test any time)
jmeter -n -t test-plans/load-test.jmx -l results/smoke.jtl
# Staging (50 users, 5-minute run)
jmeter -n -t test-plans/load-test.jmx \
-p environments/staging.properties \
-l results/staging-$(date +%Y%m%d-%H%M).jtl \
-e -o results/staging-report/
# Production (200 users, 10-minute run — with CI/CD secrets)
jmeter -n -t test-plans/load-test.jmx \
-p environments/production.properties \
-JadminPassword=$PROD_ADMIN_PASSWORD \
-l results/prod-$(date +%Y%m%d-%H%M).jtl \
-e -o results/prod-report/The -p flag loads the properties file. -J flags override individual properties inline. -J wins over -p for the same key.
Handling secrets
Never put passwords, API keys, or tokens in properties files committed to source control.
Pattern 1 — CI/CD environment variables:
jmeter -n -t test.jmx \
-JadminPassword=$ADMIN_PASSWORD \
-JapiKey=$API_KEYThe CI/CD system (GitHub Actions, Jenkins) provides the secrets as environment variables. The pipeline reads them and passes them as -J flags.
Pattern 2 — Groovy script reading OS environment:
// JSR223 Pre-Processor
def password = System.getenv("DB_PASSWORD")
if (!password) {
log.error("DB_PASSWORD environment variable not set")
ctx.getEngine().stopTest()
}
vars.put("dbPassword", password)System.getenv() reads the OS environment inside the test plan. This keeps secrets entirely out of the properties file system.
Pattern 3 — Secrets manager integration:
// JSR223 Pre-Processor — fetch from HashiCorp Vault
def vaultUrl = props.get("vaultUrl")
def vaultToken = System.getenv("VAULT_TOKEN")
def conn = new URL("${vaultUrl}/v1/secret/perf-test/credentials").openConnection()
conn.setRequestProperty("X-Vault-Token", vaultToken)
def secret = new groovy.json.JsonSlurper().parseText(conn.inputStream.text)
vars.put("apiKey", secret.data.api_key)This runs once per thread (in a Once Only Controller) and fetches secrets at test start rather than embedding them anywhere.
CI/CD pipeline integration
GitHub Actions:
- name: Run staging load test
env:
ADMIN_PASSWORD: ${{ secrets.STAGING_ADMIN_PASSWORD }}
run: |
jmeter -n \
-t test-plans/load-test.jmx \
-p environments/staging.properties \
-JadminPassword=$ADMIN_PASSWORD \
-l results/results.jtl \
-e -o results/report/
- name: Archive results
uses: actions/upload-artifact@v4
with:
name: jmeter-report
path: results/Jenkins:
pipeline {
environment {
ADMIN_PASSWORD = credentials('staging-admin-password')
}
stages {
stage('Load Test') {
steps {
sh """
jmeter -n \
-t test-plans/load-test.jmx \
-p environments/staging.properties \
-JadminPassword=${ADMIN_PASSWORD} \
-l results/results.jtl
"""
}
}
}
}⚠️ Common mistakes
- Committing secrets in properties files. Even "staging" passwords committed to source control will eventually leak. Any file in a repository should be treated as potentially public. Use
.gitignoreon actual properties files and commit only.templateversions with placeholder values. - Using
-Jflags for the base URL without also providing a default.${__P(baseUrl,)}with an empty default evaluates to an empty string when no property is set — meaning the HTTP Request sends to an empty server name and fails with a network error. Always provide a working default (usually localhost):${__P(baseUrl,https://localhost:3000)}. - Having different test plans per environment. This is the pattern parameterisation replaces. Three test plans diverge immediately — a bug fix in one is forgotten in the others. A new assertion added to staging does not make it to production. One parameterised plan eliminates the divergence problem entirely.
🎯 Practice task
Parameterise your existing test plan for two environments.
-
Create an
environments/folder next to your.jmxfile. Createdev.propertieswithbaseUrl=https://test.k6.io,vusers=1,durationSecs=30. Createstaging.propertieswithbaseUrl=https://test.k6.io,vusers=5,durationSecs=60. -
In your test plan, add User-Defined Variables at the Test Plan root:
baseUrl = ${__P(baseUrl,https://test.k6.io)},vusers = ${__P(vusers,1)},durationSecs = ${__P(durationSecs,30)}. -
Update HTTP Request Defaults to use
${baseUrl}. Update the Thread Group to use${vusers}and enable Specify Thread Lifetime using${durationSecs}. -
Run without any flags — confirm it uses the defaults (1 user, test.k6.io).
-
Run with
-p environments/staging.properties— confirm the Thread Group spawns 5 users and runs for 60 seconds. -
Run with
-JvUsers=10 -JdurationSecs=15(note: your UDV name isvusers, lowercase — so-Jvusers=10) — confirm the inline flag overrides the file.