Chapter 06Lesson 02~190 minutes

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.

Property resolution-q project files-J overrideDebug SamplerTwo local configs

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 -J for a deliberate non-secret local override and -D for 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

Allow-list: only 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}&amp;data_path=${DATA_PATH}&amp;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.

No remote lab here: do not expose JMeter RMI/server ports or start remote engines for Chapter 06. The mandatory exercise is single-process local. Distributed security/provisioning is taught later.

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.log per 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?

Why copy HOST and PORT through UDV if properties already exist?

What does -Jrun.label=cli-override-a change?

Does -G synchronize a CSV file or environment variable to remote engines?

Why is a per-user response token a variable rather than a property?

Next lesson

Choose configuration layers by ownership

Lesson 3 turns the workflow into design rules for JMX defaults, UDV, project files, user.properties, environment mapping, local -J, distributed -G, Java -D, and mandatory versus fallback values.

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.properties rather than modifying distribution defaults.
  • Apache JMeter downloads — current production release and Java requirement.
Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.