Configuration Elements, Variables, Properties, and Environment Control: Guided Hands-On Workflow
This workflow leaves the JMX unchanged while external non-secret
property files select two local fixtures. It also demonstrates a
-J override, a Java -D system property,
startup User Defined Variables, thread-local response variables, and
Debug Sampler inspection.
Learning objectives
- Create two loopback configurations without creating two JMX files.
- Resolve host, port, threads, duration, run label, global mode, and data path from JMeter properties.
- Copy selected startup properties into User Defined Variables and observe per-thread copies.
-
Use
-Jfor a deliberate non-secret local override and-Dfor a Java system-property diagnostic. - Use Debug Sampler only in a bounded authoring plan to inspect variables/JMeter/system properties.
-
Preserve JTL,
jmeter.log, property files, server logs, and a resolution table for each run.
1. Safety envelope
127.0.0.1:8000 and
127.0.0.1:8001. Maximum property-file settings are 2
threads, 4 seconds, one HTTP sampler per loop, 30 ms local service
delay. Abort before execution if any resolved host is not exactly
127.0.0.1 or the port is outside
8000–8001.
2. Start two disposable local configurations
Save as fixtures/config_fixture.py:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from pathlib import Path
import argparse
import json
import threading
import time
lock = threading.Lock()
total = 0
by_path = {}
active = 0
max_active = 0
server_name = "UNSET"
event_log = None
def write_event(event):
if event_log is None:
return
with lock:
with event_log.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send_json(self, status, payload):
body = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
global total, active, max_active
parsed = urlparse(self.path)
if parsed.path == "/health":
self._send_json(200, {"status": "ok", "server": server_name})
return
if parsed.path == "/stats":
with lock:
payload = {
"server": server_name,
"total": total,
"active": active,
"max_active": max_active,
"by_path": dict(by_path),
}
self._send_json(200, payload)
return
with lock:
total += 1
active += 1
max_active = max(max_active, active)
by_path[parsed.path] = by_path.get(parsed.path, 0) + 1
request_no = total
active_now = active
try:
query = {k: v[-1] for k, v in parse_qs(parsed.query).items()}
time.sleep(0.03)
self._send_json(
200,
{
"status": "ok",
"server": server_name,
"path": parsed.path,
"request_no": request_no,
"active_at_start": active_now,
"query": query,
"x_run_label": self.headers.get("X-Run-Label", ""),
"x_global_mode": self.headers.get("X-Global-Mode", ""),
},
)
write_event(
{
"ts_ms": int(time.time() * 1000),
"server": server_name,
"path": parsed.path,
"request_no": request_no,
"query": query,
"x_run_label": self.headers.get("X-Run-Label", ""),
"x_global_mode": self.headers.get("X-Global-Mode", ""),
}
)
finally:
with lock:
active -= 1
def log_message(self, format, *args):
return
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--name", required=True)
parser.add_argument("--log", required=True)
args = parser.parse_args()
server_name = args.name
event_log = Path(args.log).resolve()
event_log.parent.mkdir(parents=True, exist_ok=True)
event_log.write_text("", encoding="utf-8")
print(f"fixture=http://127.0.0.1:{args.port}")
print(f"name={server_name}")
print(f"event_log={event_log}")
ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()
Start two independent fixture processes:
python fixtures/config_fixture.py --port 8000 --name LAB-A --log results/server-a.jsonl
python fixtures/config_fixture.py --port 8001 --name LAB-B --log results/server-b.jsonl
PowerShell uses the same Python commands in two terminals. Preflight both:
curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8001/health
3. Create two non-secret project property files
config/lab-a.properties:
# Non-secret local configuration A
lab.host=127.0.0.1
lab.port=8000
lab.name=LAB-A
load.threads=2
load.duration=3
run.label=properties-a
global.mode=project-file
lab.data.path=fixtures/data/config-a.csv
config/lab-b.properties:
# Non-secret local configuration B
lab.host=127.0.0.1
lab.port=8001
lab.name=LAB-B
load.threads=1
load.duration=4
run.label=properties-b
global.mode=project-file
lab.data.path=fixtures/data/config-b.csv
These are repository-friendly synthetic values. Do not place passwords, bearer tokens, client secrets, private keys, or production URLs in these files.
4. Build one parameterized JMX
The core plan can be authored in the GUI using these rules:
Test Plan
├── User Defined Variables
│ ├── HOST = ${__P(lab.host,127.0.0.1)}
│ ├── PORT = ${__P(lab.port,8000)}
│ └── DATA_PATH = ${__P(lab.data.path,fixtures/data/default.csv)}
└── Thread Group
├── Number of Threads = ${__P(load.threads,1)}
├── Loop Count = 100
├── Specify Thread lifetime = ON
├── Duration = ${__P(load.duration,3)}
├── HTTP Request Defaults
│ ├── HttpClient4
│ ├── http://${HOST}:${PORT}
│ ├── Connect timeout = 1000 ms
│ └── Response timeout = 2000 ms
├── HTTP Header Manager
│ ├── X-Run-Label = ${__P(run.label,default-run)}
│ └── X-Global-Mode = ${__P(global.mode,default-mode)}
├── HTTP Request — Resolved Config Echo
│ └── /config?thread=${__threadNum}&data_path=${DATA_PATH}&run=${__P(run.label,default-run)}
└── Debug Sampler — AUTHORING COPY ONLY
Save two copies only for instrumentation—not environment:
plans/config-debug.jmx contains Debug Sampler/View
Results Tree; plans/config-load.jmx has them disabled.
Both environment A and B use the same load JMX bytes.
5. Minimal XML skeleton for the parameterized core
This XML shows the key variable/property references. JMeter's GUI should remain the authoritative serializer for your full plan:
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan"
testname="Chapter 06 Environment-Control Lab" enabled="true">
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables"
elementType="Arguments"
guiclass="ArgumentsPanel"
testclass="Arguments"
testname="Startup Variables">
<collectionProp name="Arguments.arguments">
<elementProp name="HOST" elementType="Argument">
<stringProp name="Argument.name">HOST</stringProp>
<stringProp name="Argument.value">${__P(lab.host,127.0.0.1)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="PORT" elementType="Argument">
<stringProp name="Argument.name">PORT</stringProp>
<stringProp name="Argument.value">${__P(lab.port,8000)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="DATA_PATH" elementType="Argument">
<stringProp name="Argument.name">DATA_PATH</stringProp>
<stringProp name="Argument.value">${__P(lab.data.path,fixtures/data/default.csv)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="TestPlan.user_define_classpath"></stringProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup"
testname="Parameterized users" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller"
elementType="LoopController"
guiclass="LoopControlPanel"
testclass="LoopController"
testname="Loop Controller">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">100</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">${__P(load.threads,1)}</stringProp>
<stringProp name="ThreadGroup.ramp_time">1</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<stringProp name="ThreadGroup.duration">${__P(load.duration,3)}</stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<ConfigTestElement guiclass="HttpDefaultsGui"
testclass="ConfigTestElement"
testname="HTTP Request Defaults — resolved target"
enabled="true">
<elementProp name="HTTPsampler.Arguments"
elementType="Arguments"
guiclass="HTTPArgumentsPanel"
testclass="Arguments"
testname="User Defined Variables">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${HOST}</stringProp>
<stringProp name="HTTPSampler.port">${PORT}</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.implementation">HttpClient4</stringProp>
<stringProp name="HTTPSampler.connect_timeout">1000</stringProp>
<stringProp name="HTTPSampler.response_timeout">2000</stringProp>
</ConfigTestElement>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui"
testclass="HTTPSamplerProxy"
testname="Resolved Config Echo" enabled="true">
<elementProp name="HTTPsampler.Arguments"
elementType="Arguments"
guiclass="HTTPArgumentsPanel"
testclass="Arguments"
testname="User Defined Variables">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">/config?thread=${__threadNum}&data_path=${DATA_PATH}&run=${__P(run.label,default-run)}</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
6. Predict resolution before running
| Field | JMX expression | Run A | Run B | State owner |
|---|---|---|---|---|
| Host | ${{__P(lab.host,127.0.0.1)}} → UDV HOST |
127.0.0.1 | 127.0.0.1 | JMeter property → startup variable copy. |
| Port | ${{__P(lab.port,8000)}} → UDV PORT |
8000 | 8001 | JMeter property → startup variable copy. |
| Threads | ${{__P(load.threads,1)}} |
2 | 1 | Instance-wide run configuration. |
| Duration | ${{__P(load.duration,3)}} |
3 s | 4 s | Instance-wide run configuration. |
| Run label | ${{__P(run.label,default-run)}} |
properties-a | properties-b | Instance-wide JMeter property. |
| Data-path string | UDV from lab.data.path |
config-a.csv | config-b.csv | Startup value copied into each thread. |
7. Run a bounded Debug Sampler inspection
In GUI mode, load config-debug.jmx with one of the
property profiles available to the JMeter process. Configure Debug
Sampler to display:
- JMeter variables — yes;
- JMeter properties — yes for this tiny diagnostic;
- System properties — yes for this tiny diagnostic.
Run one thread × one loop only. Verify HOST/PORT/DATA_PATH appear in
variables, run/global settings appear among JMeter properties, and
the Java system property from -Dlab.jvm.tag=... appears
among system properties. Disable/remove Debug Sampler for load
execution because dumping full property stores is noisy and can
expose secrets in real environments.
8. Run configuration A from CLI
mkdir -p results/run-a
jmeter -n -t plans/config-load.jmx -q config/lab-a.properties -Dlab.jvm.tag=JVM-A -l results/run-a/results.jtl -j results/run-a/jmeter.log
PowerShell:
New-Item -ItemType Directory -Force results\run-a | Out-Null
jmeter.bat -n `
-t plans\config-load.jmx `
-q config\lab-a.properties `
-Dlab.jvm.tag=JVM-A `
-l results\run-a\results.jtl `
-j results\run-a\jmeter.log
Expected target: LAB-A on port 8000, two configured threads, roughly a three-second lifetime, and event-log query/header values matching properties A.
9. Demonstrate a local -J override
Keep the A property file but override only the run label:
jmeter -n -t plans/config-load.jmx -q config/lab-a.properties -Jrun.label=cli-override-a -Dlab.jvm.tag=JVM-A-OVERRIDE -l results/run-a-override/results.jtl -j results/run-a-override/jmeter.log
The server event log should now show
X-Run-Label: cli-override-a while the target remains
LAB-A. This demonstrates a command-line property override without
changing the JMX or the project properties file.
10. Run configuration B with the same JMX
jmeter -n -t plans/config-load.jmx -q config/lab-b.properties -Dlab.jvm.tag=JVM-B -l results/run-b/results.jtl -j results/run-b/jmeter.log
The expected target is LAB-B on port 8001, with one configured thread and a four-second lifetime. The plan itself remains byte-for-byte unchanged.
11. Prove thread-local variables using response extraction
For the authoring-only variant, add a Regular Expression Extractor under Resolved Config Echo:
Variable name: THREAD_ECHO
Regular expression: "thread":\s*"?(\d+)"?
Template: $1$
Match No.: 1
Default: NOT_FOUND
Then add a second HTTP Request
/verify?thread_var=${THREAD_ECHO}&thread_func=${__threadNum}. With two threads, each thread should carry its own extracted
THREAD_ECHO value. One thread's extractor does not
overwrite the other thread's variable store.
12. Map an OS environment value deliberately
Instead of reading environment state implicitly from the JMX, use a launcher to validate and map it:
# Bash example — non-secret only
export LAB_PORT=8001
case "$LAB_PORT" in
8000|8001) ;;
*) echo "Refusing unsafe LAB_PORT=$LAB_PORT" >&2; exit 2 ;;
esac
jmeter -n -t plans/config-load.jmx -q config/lab-b.properties -Jlab.port="$LAB_PORT" -l results/env-map/results.jtl -j results/env-map/jmeter.log
# PowerShell example — non-secret only
$env:LAB_PORT = "8001"
if ($env:LAB_PORT -notin @("8000", "8001")) {
throw "Refusing unsafe LAB_PORT=$env:LAB_PORT"
}
jmeter.bat -n `
-t plans\config-load.jmx `
-q config\lab-b.properties `
"-Jlab.port=$env:LAB_PORT" `
-l results\env-map\results.jtl `
-j results\env-map\jmeter.log
13. Demonstrate property scope without using it as thread communication
run.label and global.mode are JMeter
properties, so every thread in one JMeter process reads the same
values. This is appropriate because they describe the run. Do not
mutate them per user.
14. Understand -G without starting remote engines
In native distributed JMeter testing, remote servers run the test
and need their own JMeter property values.
-Glab.port=8000 sends that property to remote servers;
-Gconfig/remote.properties can send a property file. It
does not create or synchronize thread-local variables, data files,
plugin jars, OS environment variables, or Java system properties on
remote machines.
15. Evidence to preserve
- unchanged JMX hash for A and B runs;
- both property files;
- exact CLI commands without secrets;
- bounded Debug Sampler screenshot/text for variables/JMeter/system properties;
- JTL and
jmeter.logper run; - server A/B JSONL logs and
/stats; - property-resolution table and generator observation;
- statement of which values were thread-local versus process-global.
16. Challenge: which store owns a per-user correlation token?
A login response returns a unique token for every virtual user. Should it be written to a JMeter property because properties are accessible everywhere?
No. It is per-user runtime state, so it belongs in a thread-local variable extracted from that user's response. A JMeter property would make all threads race over one shared token.
Knowledge check
What changes between Run A and Run B?
External property inputs and the local target process; the JMX logic remains unchanged.
Why copy HOST and PORT through UDV if properties already exist?
It demonstrates the startup boundary: selected instance-wide properties are resolved once into the initial variable set that each thread receives.
What does -Jrun.label=cli-override-a change?
The local JMeter property run.label for that process; samplers reading __P(run.label,...) then use the new value.
Does -G synchronize a CSV file or environment variable to remote engines?
No. It sends JMeter properties to remote servers; external files/environment/plugin state must be provisioned separately.
Why is a per-user response token a variable rather than a property?
Because it belongs to one virtual user's runtime state and should not be shared/raced across threads.
Official references and version notes
-
Functions and Variables
— current variable syntax, undefined-variable behavior,
__P,__property,__setProperty, and thread-local versus global-property rules. - Elements of a Test Plan — User Defined Variables startup processing and variable-copy behavior per thread.
-
Getting Started
—
-J,-G,-D,-q, property-file loading, command-line processing order, GUI versus CLI guidance, and jmeter.log behavior. -
Properties Reference
— current guidance for setting JMeter properties through
user.propertiesrather than modifying distribution defaults. - Apache JMeter downloads — current production release and Java requirement.
Version-sensitive statements were rechecked against current Apache
JMeter documentation on 2026-09-04. The course baseline remains
Apache JMeter 5.6.3 with a Java 17 JDK for labs
and no third-party plugins; JMeter 5.6.3 itself requires Java 8+.
JMeter variables are thread-local after each thread receives its
startup copy; JMeter properties are shared within one JMeter
instance. An undefined ${VAR} reference is returned
unchanged rather than failing automatically.
__P(name,default) reads a JMeter property and
defaults to 1 when no explicit default is supplied.
-J defines a local JMeter property,
-G defines/sends properties to remote JMeter servers,
and -D defines a Java system property. The standard
user.properties layer is loaded after the base
property file and before later command-line additions such as
-q/-J; JMeter's Properties Reference
recommends setting ordinary JMeter properties in
user.properties rather than editing distribution
defaults. Mandatory labs remain single-process/local;
-G is demonstrated as a distributed-boundary concept
without starting remote engines.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.