Functions, Custom Properties, Reusable Fragments, and Modular Plans: Guided Hands-On Workflow
The lab starts from a duplicated setup sequence and progressively moves configuration and setup logic into explicit reusable boundaries. The final Include variant contains no machine-specific path in the JMX: a project launcher resolves the fragment prefix and records it in a command manifest before JMeter starts.
Learning objectives
- Create a disposable localhost session/work fixture.
-
Parameterize host/port/threads/loops/pacing through an external
property file and
__P. - Extract a session bootstrap flow into a Test Fragment and call it with Module Controller.
- Move the fragment to an external JMX and call it with Include Controller.
- Resolve Include paths with an absolute project-root prefix supplied by a launcher.
- Run the same plan from two unrelated working directories and compare command/path/target evidence.
1. Hard safety envelope
http://127.0.0.1:8020. Maximum 2
threads, exactly 3 work loops per thread in the mandatory profile,
25 ms Constant Timer, ≤10 seconds/run, synthetic user/session
identifiers only, no credentials, no plugins/containers/remote
engines. Abort on non-loopback resolved host, >2 threads, >3
loops, active sessions left after run, duplicate/cross-thread
SESSION_ID use, include errors, or sustained generator saturation.
2. Project tree
p20-modular-lab/
├── config/
│ └── local.properties
├── fixtures/
│ └── modular_fixture.py
├── fragments/
│ └── session-bootstrap.jmx
├── plans/
│ ├── main-module.jmx
│ └── main-include.jmx
├── tools/
│ ├── run-local.ps1
│ ├── run-local.sh
│ ├── analyze_events.py
│ └── check_manifests.py
└── results/
The five Chapter HTML files remain the only ZIP contents; these lab files are shown as code/configuration learners create locally.
3. Create the local fixture
Save fixtures/modular_fixture.py:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import argparse
import hashlib
import itertools
import json
import re
import threading
import time
FIXTURE_VERSION = "prompt20-modular-fixture-v1"
SAFE_USER = re.compile(r"^user-[1-9][0-9]*$")
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
lock = threading.Lock()
event_log = None
session_seq = itertools.count(1)
sessions = {}
metrics = {
"requests": 0,
"errors": 0,
"sessions_created": 0,
"sessions_deleted": 0,
"work_requests": 0,
"by_run": {},
"by_variant": {},
}
def now_ms():
return int(time.time() * 1000)
def make_session_id(run_id, user, seq):
raw = f"{run_id}|{user}|{seq}".encode("utf-8")
return "S-" + hashlib.sha256(raw).hexdigest()[:16]
def log_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):
raw = 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(raw)))
self.send_header("X-Fixture-Version", FIXTURE_VERSION)
self.end_headers()
self.wfile.write(raw)
def send_empty(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
self.send_header("X-Fixture-Version", FIXTURE_VERSION)
self.end_headers()
def record(self, started, operation, status, **extra):
with lock:
metrics["requests"] += 1
if status >= 400:
metrics["errors"] += 1
run_id = extra.get("run_id", "")
variant = extra.get("variant", "")
if run_id:
metrics["by_run"][run_id] = metrics["by_run"].get(run_id, 0) + 1
if variant:
metrics["by_variant"][variant] = metrics["by_variant"].get(variant, 0) + 1
event = {
"ts_ms": now_ms(),
"operation": operation,
"status": status,
"service_wall_ms": now_ms() - started,
}
event.update(extra)
log_event(event)
def do_GET(self):
started = now_ms()
parsed = urlparse(self.path)
q = parse_qs(parsed.query)
if parsed.path == "/health":
self.send_json(200, {"status": "ok", "fixture_version": FIXTURE_VERSION})
self.record(started, "health", 200)
return
if parsed.path == "/stats":
with lock:
snapshot = json.loads(json.dumps(metrics))
active = [
{
"session_id": sid,
"user": item["user"],
"run_id": item["run_id"],
"variant": item["variant"],
}
for sid, item in sorted(sessions.items())
]
self.send_json(200, {
"fixture_version": FIXTURE_VERSION,
"active_sessions": len(active),
"sessions": active,
"metrics": snapshot,
})
self.record(started, "stats", 200)
return
if parsed.path != "/work":
self.send_json(404, {"status": "not_found"})
self.record(started, "unknown_get", 404)
return
sid = q.get("session_id", [""])[0]
thread_id = q.get("thread", [""])[0]
seq_raw = q.get("seq", [""])[0]
run_id = q.get("run_id", [""])[0]
variant = q.get("variant", [""])[0]
if not SAFE_TOKEN.fullmatch(sid) or not SAFE_TOKEN.fullmatch(thread_id):
self.send_json(400, {"status": "invalid_metadata"})
self.record(started, "work", 400, run_id=run_id, variant=variant)
return
if not SAFE_TOKEN.fullmatch(run_id) or not SAFE_TOKEN.fullmatch(variant):
self.send_json(400, {"status": "invalid_run"})
self.record(started, "work", 400, run_id=run_id, variant=variant)
return
try:
seq = int(seq_raw)
except ValueError:
seq = -1
if not 1 <= seq <= 1000:
self.send_json(400, {"status": "invalid_seq"})
self.record(started, "work", 400, run_id=run_id, variant=variant)
return
with lock:
session = dict(sessions.get(sid, {}))
if not session:
self.send_json(401, {"status": "unknown_session"})
self.record(started, "work", 401, run_id=run_id, variant=variant, session_id=sid)
return
if session["run_id"] != run_id or session["variant"] != variant:
self.send_json(409, {"status": "session_scope_mismatch"})
self.record(started, "work", 409, run_id=run_id, variant=variant, session_id=sid)
return
with lock:
metrics["work_requests"] += 1
payload = {
"status": "ok",
"session_id": sid,
"user": session["user"],
"thread": thread_id,
"seq": seq,
"run_id": run_id,
"variant": variant,
}
self.send_json(200, payload)
self.record(
started, "work", 200,
run_id=run_id, variant=variant, session_id=sid,
user=session["user"], thread=thread_id, seq=seq,
)
def do_POST(self):
started = now_ms()
parsed = urlparse(self.path)
q = parse_qs(parsed.query)
if parsed.path != "/session":
self.send_json(404, {"status": "not_found"})
self.record(started, "unknown_post", 404)
return
user = q.get("user", [""])[0]
run_id = q.get("run_id", [""])[0]
variant = q.get("variant", [""])[0]
if not SAFE_USER.fullmatch(user) or not SAFE_TOKEN.fullmatch(run_id) or not SAFE_TOKEN.fullmatch(variant):
self.send_json(400, {"status": "invalid_session_input"})
self.record(started, "session_create", 400, run_id=run_id, variant=variant, user=user)
return
seq = next(session_seq)
sid = make_session_id(run_id, user, seq)
with lock:
sessions[sid] = {"user": user, "run_id": run_id, "variant": variant}
metrics["sessions_created"] += 1
self.send_json(201, {
"status": "created",
"session_id": sid,
"user": user,
"run_id": run_id,
"variant": variant,
})
self.record(
started, "session_create", 201,
run_id=run_id, variant=variant, user=user, session_id=sid,
)
def do_DELETE(self):
started = now_ms()
parsed = urlparse(self.path)
if not parsed.path.startswith("/session/"):
self.send_json(404, {"status": "not_found"})
self.record(started, "unknown_delete", 404)
return
sid = parsed.path.split("/", 2)[2]
with lock:
item = sessions.pop(sid, None)
if item:
metrics["sessions_deleted"] += 1
if not item:
self.send_json(404, {"status": "unknown_session", "session_id": sid})
self.record(started, "session_delete", 404, session_id=sid)
return
self.send_empty(204)
self.record(
started, "session_delete", 204,
run_id=item["run_id"], variant=item["variant"],
user=item["user"], session_id=sid,
)
def log_message(self, format, *args):
return
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8020)
parser.add_argument("--log", default="results/server-events.jsonl")
args = parser.parse_args()
global event_log
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_version={FIXTURE_VERSION}")
print(f"listen=http://{args.host}:{args.port}")
print(f"event_log={event_log}")
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()
Start it:
python .\fixtures\modular_fixture.py `
--host 127.0.0.1 `
--port 8020 `
--log .\results\server-events.jsonl
Bash uses the same flags. The service creates run/user-scoped synthetic sessions, rejects mismatched session/run/variant combinations, records work requests, and supports exact per-session deletion.
4. Read-only target preflight
curl --fail --silent http://127.0.0.1:8020/health
curl --fail --silent http://127.0.0.1:8020/stats
Require fixture version prompt20-modular-fixture-v1 and
active_sessions=0 before each measured run.
5. Externalize local environment/load properties
Save config/local.properties:
# Prompt 20 mandatory localhost environment
target.host=127.0.0.1
target.port=8020
threads=2
loops=3
pacing.ms=25
connect.timeout.ms=500
response.timeout.ms=2000
These properties are not user/session state. They describe the
environment and configured load. JMeter reads them with
-q, and plan fields use explicit fallbacks such as:
Host: ${__P(target.host,127.0.0.1)}
Port: ${__P(target.port,8020)}
Threads: ${__P(threads,1)}
Loop Controller loops: ${__P(loops,1)}
Constant Timer: ${__P(pacing.ms,25)}
Connect timeout: ${__P(connect.timeout.ms,500)}
Response timeout: ${__P(response.timeout.ms,2000)}
6. Selected functions with clear ownership
| Function | Where used | State/effect |
|---|---|---|
${{__P(run.id,p20-default)}} |
HTTP query fields | Reads immutable JVM run property. |
${{__P(variant,module)}} |
HTTP query fields | Labels target evidence by plan variant. |
${{__threadNum}} |
Session-bootstrap sampler field |
Current Thread Group thread number; produces
user-1/user-2.
|
${{__property(user.dir,JMETER_USER_DIR)}}
|
One-thread debug/preflight only | Reads generator working-directory system property and saves to a variable. |
${{__UUID()}} |
Optional trace/debug only | Unique pseudo-random ID; not used for replay-sensitive business data. |
Do not put __threadNum inside User Defined Variables.
Use it where a sampler is executing in the JMeter thread.
7. Start from the duplicated setup problem
Suppose two scenario branches each contain:
POST /session?user=user-${__threadNum}&run_id=${__P(run.id)}&variant=${__P(variant)}
├── JSON JMESPath Assertion: status == created
├── JSON JMESPath Extractor: SESSION_ID <- session_id
└── Response Assertion: SESSION_ID != __NOT_FOUND__
The definitions are duplicated even though runtime needs the same behavior. Refactor definitions without changing when the setup executes.
8. Same-JMX refactor with Test Fragment + Module Controller
In plans/main-module.jmx:
Test Plan
├── HTTP Request Defaults (property-backed)
├── Test Fragment — FRAG.session.bootstrap.v1
│ └── POST Session Create
│ ├── JSON JMESPath Assertion status == created
│ ├── JMESPath Extractor SESSION_ID <- session_id
│ └── Response Assertion SESSION_ID exists
└── Thread Group
├── Once Only Controller
│ └── Module Controller -> FRAG.session.bootstrap.v1
├── Counter -> SEQ (per user)
├── Loop Controller loops=${__P(loops,1)}
│ └── GET Work
│ ├── Constant Timer ${__P(pacing.ms,25)} ms
│ ├── JMESPath Assertion status == ok
│ └── Response Assertion returned session_id == ${SESSION_ID}
└── DELETE Session ${SESSION_ID}
└── assert 204
The fragment is defined once and the Module Controller reference executes it once per thread because it sits under Once Only Controller.
9. Extract the fragment to an external JMX
Create fragments/session-bootstrap.jmx containing one
Test Plan with a Test Fragment named
FRAG.session.bootstrap.v1 and the Session Create
sampler/assertions/extractor. An optional debug Thread Group may
exist in that file, but it is ignored when included.
Do not put HTTP Cookie Manager/User Defined Variables in the external fragment; keep shared configuration in the top-level main plan.
10. External reuse with Include Controller
In plans/main-include.jmx, replace the Module
Controller with:
Once Only Controller
└── Include Controller — INC.session.bootstrap.v1
Filename: session-bootstrap.jmx
The Filename is intentionally plain and stable because Include
Controller does not support functions/variables there. The path
comes from includecontroller.prefix supplied by the
launcher.
11. Windows PowerShell project launcher
Save tools/run-local.ps1:
param(
[ValidateSet("module","include")]
[string]$Variant = "include",
[string]$RunId = "p20-local"
)
$ErrorActionPreference = "Stop"
if (-not $env:JMETER_HOME) {
throw "JMETER_HOME must point to Apache JMeter 5.6.3."
}
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$ProjectRootUnix = ($ProjectRoot -replace "\\","/")
$Props = Join-Path $ProjectRoot "config\local.properties"
$Jmx = Join-Path $ProjectRoot ("plans\main-{0}.jmx" -f $Variant)
$ResultDir = Join-Path $ProjectRoot ("results\{0}" -f $RunId)
New-Item -ItemType Directory -Force $ResultDir | Out-Null
# Include Controller filename is fixed as session-bootstrap.jmx.
# Resolve its prefix outside the JMX so the same plan works from any cwd.
$IncludePrefix = "$ProjectRootUnix/fragments/"
$Jtl = Join-Path $ResultDir "results.jtl"
$Log = Join-Path $ResultDir "jmeter.log"
$Manifest = Join-Path $ResultDir "command-manifest.json"
[ordered]@{
cwd = (Get-Location).Path
project_root = $ProjectRoot
jmx = $Jmx
property_file = $Props
include_prefix = $IncludePrefix
run_id = $RunId
variant = $Variant
result_jtl = $Jtl
jmeter_log = $Log
} | ConvertTo-Json | Set-Content -Encoding UTF8 $Manifest
& "$env:JMETER_HOME\bin\jmeter.bat" `
-n `
-t "$Jmx" `
-q "$Props" `
-Jrun.id="$RunId" `
-Jvariant="$Variant" `
-Jincludecontroller.prefix="$IncludePrefix" `
-l "$Jtl" `
-j "$Log"
exit $LASTEXITCODE
The launcher reads its own location ($PSScriptRoot)
rather than trusting the caller's working directory. It records cwd,
project root, JMX, property file, include prefix and output paths
before starting JMeter.
12. Bash project launcher
Save tools/run-local.sh:
#!/usr/bin/env bash
set -euo pipefail
VARIANT="${1:-include}"
RUN_ID="${2:-p20-local}"
case "$VARIANT" in
module|include) ;;
*) echo "Variant must be module or include" >&2; exit 2 ;;
esac
: "${JMETER_HOME:?JMETER_HOME must point to Apache JMeter 5.6.3}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PROPS="$PROJECT_ROOT/config/local.properties"
JMX="$PROJECT_ROOT/plans/main-$VARIANT.jmx"
RESULT_DIR="$PROJECT_ROOT/results/$RUN_ID"
mkdir -p "$RESULT_DIR"
INCLUDE_PREFIX="$PROJECT_ROOT/fragments/"
JTL="$RESULT_DIR/results.jtl"
LOG="$RESULT_DIR/jmeter.log"
MANIFEST="$RESULT_DIR/command-manifest.json"
python - "$MANIFEST" "$PWD" "$PROJECT_ROOT" "$JMX" "$PROPS" "$INCLUDE_PREFIX" "$RUN_ID" "$VARIANT" "$JTL" "$LOG" <<'PY'
import json, sys
keys = ["manifest","cwd","project_root","jmx","property_file","include_prefix","run_id","variant","result_jtl","jmeter_log"]
d = dict(zip(keys, sys.argv[1:]))
manifest = d.pop("manifest")
with open(manifest, "w", encoding="utf-8") as f:
json.dump(d, f, indent=2)
PY
"$JMETER_HOME/bin/jmeter" \
-n \
-t "$JMX" \
-q "$PROPS" \
-Jrun.id="$RUN_ID" \
-Jvariant="$VARIANT" \
-Jincludecontroller.prefix="$INCLUDE_PREFIX" \
-l "$JTL" \
-j "$LOG"
On Linux/macOS mark it executable with
chmod +x tools/run-local.sh. The same design resolves
the project root from the launcher file rather than
$PWD.
13. Prove portability from two working directories
PowerShell — run once from project root:
Set-Location "F:\Labs\p20-modular-lab"
.\tools\run-local.ps1 -Variant include -RunId p20-root
Then from an unrelated directory:
Set-Location "$env:TEMP"
& "F:\Labs\p20-modular-lab\tools\run-local.ps1" `
-Variant include `
-RunId p20-temp
Expected: the two command manifests have different
cwd values but the same resolved
project_root/include_prefix; both target
runs produce two sessions, six work requests, two deletes, and zero
active sessions afterward.
14. Compare same-plan Module variant
Run:
.\tools\run-local.ps1 -Variant module -RunId p20-module
Module variant does not require the external fragment path to execute its same-JMX fragment, but the launcher still records the include prefix for a common manifest schema. Target behavior should match the Include variant.
15. Analyze target evidence
Save tools/analyze_events.py:
import json
import sys
from collections import Counter
from pathlib import Path
path = Path(sys.argv[1])
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
interesting = [e for e in events if e.get("operation") in {"session_create", "work", "session_delete"}]
print(f"events={len(events)}")
print(f"interesting_events={len(interesting)}")
print(f"operations={dict(Counter(e.get('operation') for e in interesting))}")
print(f"statuses={dict(Counter(e.get('status') for e in interesting))}")
print(f"runs={dict(Counter(e.get('run_id') for e in interesting if e.get('run_id')))}")
print(f"variants={dict(Counter(e.get('variant') for e in interesting if e.get('variant')))}")
print(f"users={dict(Counter(e.get('user') for e in interesting if e.get('user')))}")
print(f"work_by_thread={dict(Counter(e.get('thread') for e in interesting if e.get('operation') == 'work'))}")
created = {e["session_id"] for e in interesting if e.get("operation") == "session_create" and e.get("status") == 201}
deleted = {e["session_id"] for e in interesting if e.get("operation") == "session_delete" and e.get("status") == 204}
print(f"created_sessions={len(created)}")
print(f"deleted_sessions={len(deleted)}")
print(f"undeleted_sessions={len(created - deleted)}")
print(f"max_service_wall_ms={max([int(e.get('service_wall_ms',0)) for e in interesting] or [0])}")
python tools/analyze_events.py results/server-events.jsonl
For each clean mandatory run, expect 2 session creates, 6 work requests, 2 deletes, zero undeleted sessions, and only 2xx statuses.
16. Verify the two-run command manifests
Save tools/check_manifests.py:
import json
import sys
from pathlib import Path
paths = [Path(p) for p in sys.argv[1:]]
for path in paths:
doc = json.loads(path.read_text(encoding="utf-8"))
required = {
"cwd", "project_root", "jmx", "property_file",
"include_prefix", "run_id", "variant", "result_jtl", "jmeter_log"
}
missing = required - set(doc)
if missing:
raise SystemExit(f"{path}: missing keys {sorted(missing)}")
print(
f"{path}: cwd={doc['cwd']} project_root={doc['project_root']} "
f"jmx={doc['jmx']} include_prefix={doc['include_prefix']} "
f"run_id={doc['run_id']} variant={doc['variant']}"
)
python tools/check_manifests.py results/p20-root/command-manifest.json results/p20-temp/command-manifest.json
The different cwd values are intentional; resolved project root, JMX, properties and include prefix must point to the same project assets.
17. Duplication-reduction note
Before refactoring, two scenarios each owned their own four-element bootstrap definition: eight setup definitions to review. After externalization, the four-element bootstrap exists once plus lightweight controller references. Runtime still executes one bootstrap per thread; only definition duplication is reduced. Record that distinction in code review.
18. Challenge
Three top-level plans use the same session bootstrap, but one plan needs a different response assertion. Should the fragment receive dozens of flags?
Keep the stable common bootstrap in the fragment and place scenario-specific assertions near the calling plan when practical. If conditional flags make the fragment's behavior opaque, split it into clearly named modules instead of building a hidden framework inside properties/functions.
Knowledge check
Why does the launcher set includecontroller.prefix instead of putting ${__P(fragment.path)} in Include Filename?
Current Include Controller does not support variables/functions in the Filename field; a prefix property is the supported path mechanism.
Why is setup under Once Only Controller?
Each virtual user should create one session before the work loop, not create a new session for every work request.
What should differ between the root-cwd and temp-cwd command manifests?
Only caller-specific fields such as cwd/run ID/results; resolved project assets and include prefix should still point to the same project.
What does the duplication note measure?
Definitions maintained in source, not runtime invocations; runtime workload must remain unchanged.
Why is __UUID not used as replay-sensitive test data here?
It generates a new pseudo-random UUID, so exact replay is weaker than explicit deterministic fixtures/counters/properties.
Official references and version notes
- JMeter Component Reference — Module Controller — runtime substitution of an already loaded controller/fragment and unique fragment naming.
-
JMeter Component Reference — Include Controller
— external JMX/Test Fragment use, filename limitations,
includecontroller.prefix, and path fallback behavior. - JMeter Component Reference — Test Fragment — reusable fragment semantics with Module/Include Controllers.
- JMeter Functions — __P — simplified command-line property lookup and default behavior.
- JMeter Functions — __property — general JMeter property lookup and optional variable assignment.
- JMeter Functions — __threadNum — thread-group-local thread numbering and Configuration Element restrictions.
- JMeter Functions — __Random — pseudo-random values and optional variable assignment.
- JMeter Component Reference — Random Variable — seeded/per-thread reproducible random-number option.
-
JMeter Getting Started
—
-q/-J, property files, and JMeter configuration loading. - JMeter Best Practices — GUI authoring/debugging and non-GUI execution for load.
- Apache JMeter downloads — current stable release and Java requirement.
Version-sensitive statements were rechecked against current Apache
JMeter primary documentation on 2026-09-05. The course baseline
remains Apache JMeter 5.6.3 with a Java 17 JDK;
JMeter 5.6.3 requires Java 8+. A Module Controller substitutes an
already loaded controller/fragment into the active runtime path;
fragments referenced by Module Controller need unique names
because JMeter uses the controller/parent name path to find them
after reload. Include Controller is for external JMX/Test Fragment
content. Its Filename field does not support
variables/functions. The
includecontroller.prefix property can prefix the
filename; if prefix+filename cannot be found, JMeter attempts the
filename relative to the JMX launch directory. Top-level Cookie
Manager/User Defined Variables belong in the main test plan rather
than an included JMX because included copies are not guaranteed to
work as expected. __P(name,default) is intended for
command-line properties; without an explicit default it returns
1. __threadNum is local to its Thread
Group and should not be placed in Configuration Elements such as
User Defined Variables because those are processed by a separate
thread. For reproducible random numeric data, the Random Variable
Config Element exposes a seed/per-thread option; the simple
__Random function does not expose an equivalent seed
parameter.
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.