Controllers: Simple, Loop, Transaction, If, While, Switch, and Throughput: Guided Hands-On Workflow
This workflow uses a disposable loopback service whose endpoints make controller choices visible. The JMeter plan predicts exact target request counts, then an independent server log verifies them so Transaction Controller reporting cannot be confused with actual traffic.
Learning objectives
- Create a multi-step local journey with every Chapter 12 controller.
- Drive If/Switch logic from explicit thread variables.
- Use a response-derived boolean as the While condition.
- Predict target request counts before running.
- Compare additional-mode versus parent-mode Transaction Controller CSV JTL.
- Compare Throughput Controller total-execution and percentage modes without calling either an RPS target.
1. Safety envelope
http://127.0.0.1:8000. Main workflow uses 1 thread × 2
outer iterations. Percentage demonstration uses at most 1 thread ×
10 iterations. Failure reproductions add an explicit Thread Group
duration ≤4 seconds and a 200 ms poll timer. No public or production
target is permitted.
2. Start the controller fixture
Save as fixtures/controller_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 = {}
poll_count = {}
event_log = None
def record(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")
def encode(obj):
return json.dumps(obj, sort_keys=True).encode("utf-8")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send(self, status, payload):
raw = encode(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
global total
parsed = urlparse(self.path)
path = parsed.path
q = {k: v[-1] for k, v in parse_qs(parsed.query).items()}
if path == "/health":
self._send(200, {"status": "ok"})
return
if path == "/reset":
with lock:
by_path.clear()
poll_count.clear()
self._send(200, {"status": "reset"})
return
if path == "/stats":
with lock:
snap = {
"total": total,
"by_path": dict(by_path),
"poll_count": dict(poll_count),
}
self._send(200, snap)
return
started_ms = int(time.time() * 1000)
with lock:
total += 1
request_no = total
by_path[path] = by_path.get(path, 0) + 1
status = 200
extra = {}
time.sleep(0.02)
if path == "/browse":
payload = {"status": "ok", "page": "catalog", "request_no": request_no}
elif path == "/search":
idx = q.get("idx", "")
payload = {"status": "ok", "search_index": idx, "results": 3}
elif path == "/poll-reset":
thread_id = q.get("thread", "unknown")
with lock:
poll_count[thread_id] = 0
extra["thread"] = thread_id
payload = {"status": "ok", "poll_more": "true", "thread": thread_id}
elif path == "/poll":
thread_id = q.get("thread", "unknown")
with lock:
n = poll_count.get(thread_id, 0) + 1
poll_count[thread_id] = n
more = n < 2
extra.update({"thread": thread_id, "poll_number": n})
payload = {
"status": "ok",
"poll_number": n,
"poll_more": "true" if more else "false",
"thread": thread_id,
}
elif path == "/checkout":
mode = q.get("mode", "")
extra["mode"] = mode
if mode not in {"standard", "express"}:
status = 400
payload = {"status": "bad_mode", "mode": mode}
else:
payload = {"status": "accepted", "mode": mode}
elif path == "/upsell":
payload = {"status": "shown", "offer": "synthetic-addon"}
elif path == "/error":
status = 500
payload = {"status": "synthetic_error"}
else:
status = 404
payload = {"status": "not_found", "path": path}
finished_ms = int(time.time() * 1000)
event = {
"path": path,
"status": status,
"request_no": request_no,
"started_ms": started_ms,
"finished_ms": finished_ms,
"service_wall_ms": finished_ms - started_ms,
}
event.update(extra)
record(event)
self._send(status, payload)
def log_message(self, format, *args):
return
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--log", default="results/server-events.jsonl")
args = parser.parse_args()
event_log = Path(args.log).resolve()
event_log.parent.mkdir(parents=True, exist_ok=True)
event_log.write_text("", encoding="utf-8")
print("fixture=http://127.0.0.1:8000")
print(f"event_log={event_log}")
ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()
Start and preflight:
python fixtures/controller_fixture.py --log results/server-events.jsonl
curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8000/reset
3. Define explicit branch data
At Test Plan/Thread Group scope define:
DO_CHECKOUT = true
CHECKOUT_MODE = standard
POLL_MORE = true
DO_CHECKOUT is control data,
CHECKOUT_MODE selects one business branch, and
POLL_MORE is refreshed from the synthetic poll
response. They are thread-local JMeter variables during execution.
4. Build the progressive controller tree
Thread Group — 1 user × 2 outer iterations
└── Transaction Controller — "Journey Transaction"
Generate Parent Sample: OFF (first run)
Include timers/pre-post processors: OFF
├── Simple Controller — "Browse Phase"
│ └── Browse — GET /browse
│
├── Loop Controller — "SearchLoop" × 2
│ └── Search — GET /search?idx=${__jm__SearchLoop__idx}
│
├── Poll Reset — GET /poll-reset?thread=${__threadNum}
│ └── JSON JMESPath Extractor: poll_more -> POLL_MORE (default false)
│
├── While Controller — "WaitForReady"
│ Condition: ${POLL_MORE}
│ └── Poll — GET /poll?thread=${__threadNum}
│ └── JSON JMESPath Extractor: poll_more -> POLL_MORE (default false)
│
├── If Controller — "Do Checkout"
│ Interpret as Variable Expression: ON
│ Condition: ${DO_CHECKOUT}
│ └── Switch Controller — "Checkout Mode"
│ Switch value: ${CHECKOUT_MODE}
│ ├── Simple Controller named "standard"
│ │ └── Checkout Standard — GET /checkout?mode=standard
│ ├── Simple Controller named "express"
│ │ └── Checkout Express — GET /checkout?mode=express
│ └── Simple Controller named "default"
│ └── Checkout Default — GET /checkout?mode=standard
│
└── Throughput Controller — "Upsell Population"
Mode: Total Executions
Throughput: 1
Per User: OFF
└── Upsell — GET /upsell
5. Why the While Controller terminates safely
/poll-reset returns poll_more=true and
resets the server's per-thread poll count. The first
/poll returns true; the second returns false. The
extractor overwrites POLL_MORE after each Poll sample.
Because Post-Processors run before the next controller evaluation,
the While loop exits after two Poll requests.
The extractor default is false, so a malformed poll
response fails closed instead of turning into an infinite loop.
6. Predict exact target requests
Per outer iteration before the Throughput branch:
| Step | Requests per outer iteration |
|---|---|
| Browse | 1 |
| Search Loop ×2 | 2 |
| Poll Reset | 1 |
| While Polls | 2 |
| Checkout branch | 1 |
| Subtotal | 7 |
Two outer iterations produce 14 deterministic target requests. Throughput Controller Total Executions=1, Per User off adds exactly one Upsell execution across the plan. Therefore:
expected target requests = 14 + 1 = 15
Transaction Controller is not a target request.
7. Predict CSV JTL rows in additional-sample mode
The additional Transaction Controller generates one transaction result per outer iteration. With two outer iterations:
15 child HTTP samples + 2 "Journey Transaction" samples = 17 CSV JTL rows
The server event log should still contain only 15 target requests.
8. Run additional-sample mode in CLI
mkdir -p results/additional
jmeter -n -t plans/controllers-additional.jmx -l results/additional/results.jtl -j results/additional/jmeter.log -Jjmeter.save.saveservice.print_field_names=true -Jjmeter.save.saveservice.thread_counts=true
python tools/analyze_jtl_labels.py results/additional/results.jtl
python tools/analyze_server_events.py results/server-events.jsonl
curl --fail --silent http://127.0.0.1:8000/stats
PowerShell uses jmeter.bat, backtick continuation, and
Invoke-WebRequest for stats.
9. Save the evidence analyzers
tools/analyze_jtl_labels.py:
import csv
import sys
from collections import Counter, defaultdict
from pathlib import Path
path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/run/results.jtl")
rows = list(csv.DictReader(path.open(encoding="utf-8")))
if not rows:
raise SystemExit("No JTL rows found")
required = {"label", "success", "responseCode", "elapsed"}
missing = required.difference(rows[0])
if missing:
raise SystemExit(f"Missing JTL columns: {sorted(missing)}")
labels = Counter(r["label"] for r in rows)
codes = Counter(r["responseCode"] for r in rows)
failures = [r for r in rows if r["success"].strip().lower() != "true"]
print(f"jtl_rows={len(rows)}")
print(f"failures={len(failures)}")
print(f"response_codes={dict(codes)}")
print("labels:")
for label, count in sorted(labels.items()):
print(f" {label}: {count}")
child_like = [
r for r in rows
if r["label"] not in {"Journey Transaction"}
]
print(f"non_transaction_rows={len(child_like)}")
print(
"Interpretation warning: a Transaction Controller sample is a reporting sample, "
"not an extra target request. In Generate Parent Sample mode child samples do not "
"appear as separate CSV JTL rows, so compare against independent target/server counts."
)
tools/analyze_server_events.py:
import json
import sys
from collections import Counter
from pathlib import Path
path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/server-events.jsonl")
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
paths = Counter(e["path"] for e in events)
codes = Counter(e["status"] for e in events)
print(f"target_requests={len(events)}")
print(f"target_statuses={dict(codes)}")
print("target_paths:")
for path, count in sorted(paths.items()):
print(f" {path}: {count}")
print(
"Target request count excludes JMeter Transaction Controller samples because "
"those samples are generated by JMeter rather than sent to the server."
)
10. Compare Generate Parent Sample
Reset fixture state, copy the plan, and enable Generate Parent Sample on Journey Transaction. Keep all other controller settings identical.
Expected target traffic remains 15 HTTP requests. But CSV JTL changes: child samples are sub-samples of the two transaction parents and are not separate CSV rows. Expect approximately:
2 CSV JTL rows: one "Journey Transaction" parent per outer iteration
View Results Tree may show child sub-samples during tiny GUI authoring, but do not use it in the load run.
11. Compare transaction timing option
With a small 100 ms Constant Timer under Browse, compare Transaction Controller:
- Include timer/pre-post processing OFF: transaction timing excludes those waits by default.
- ON: transaction timing includes processing/timer delays in its scope.
Endpoint sampler elapsed remains protocol timing. Label the transaction metric clearly so a business-journey duration is not misreported as endpoint latency.
12. If Controller branch proof
Change only DO_CHECKOUT=false for a one-iteration debug
run. Expected target requests drop by one checkout request for that
iteration. The Switch Controller is never entered.
Do not delete the checkout branch from the tree; the difference is control data, not test structure.
13. Switch Controller branch proof
Restore DO_CHECKOUT=true, set
CHECKOUT_MODE=express, and run one iteration. Only
Checkout Express should execute. Then set an unmatched
name such as unknown; the child named
default should run.
14. Throughput Controller total-execution proof
With 1 thread × 5 outer iterations, Throughput Controller Total Executions=2 and Per User off, Upsell executes exactly twice across the plan. This controls branch occurrence count. It does not specify how many requests/second those two executions occur at.
15. Throughput Controller percentage proof
Change to Percent Executions=30, 1 thread × 10 outer iterations. Predict roughly three Upsell branch executions across the ten controller opportunities, then measure the actual label/target count. The percentage semantics are about execution opportunities, not a timing schedule.
For a tiny iteration count, do not infer production traffic distribution from one run; branch-mix statistics need enough opportunities to become representative.
16. Challenge
Requirement: 20% of completed journeys should attempt optional warranty lookup, while the total overall scenario start rate remains 2 journeys/s. Which components own those requirements?
Use a Throughput Controller (or explicit branch data) for the ~20% branch population and the Chapter 7 workload/timer model for 2 journey starts/s. Do not try to satisfy both requirements with Throughput Controller alone.
Knowledge check
Why are there 17 CSV JTL rows but only 15 target requests in additional transaction mode?
Two extra rows are Transaction Controller reporting samples, not HTTP requests.
What changes when Generate Parent Sample is enabled?
Child samples become sub-samples and are not separate CSV JTL rows; the target request count stays unchanged.
Why does WaitForReady terminate after two polls?
Each poll response updates the thread-local POLL_MORE variable; the second response sets false and the While condition stops.
What does Throughput Controller Total Executions=2 mean?
Its child branch executes twice in the relevant controller scope; it says nothing about requests per second.
What happens when CHECKOUT_MODE names no child but a child named default exists?
Switch Controller executes the default child.
Official references and version notes
- Component Reference — Logic Controllers — Simple, Loop, Throughput, If, While, Switch, and Transaction Controller semantics.
- Elements of a Test Plan — scope and execution-order rules for samplers, controllers, timers, processors, and assertions.
- Functions and Variables — current JEXL3/Groovy/function/variable behavior used in conditions.
- Best Practices — GUI authoring versus CLI load execution and generator-validity guidance.
- Apache JMeter downloads — current production release and Java requirement.
Version-sensitive behavior was checked against current Apache
JMeter primary documentation on 2026-09-05. 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 requires Java
8+. Simple Controller is organizational only. Loop Controller
multiplies its loop count by the enclosing Thread Group iterations
and exposes an index variable named
__jm__<controller-name>__idx. If Controller
should normally use
Interpret Condition as Variable Expression with a
boolean variable or __jexl3/__groovy;
JavaScript condition mode has a potentially large performance
penalty. While Controller evaluates its condition before and after
its children, so non-idempotent functions such as counters in the
condition can produce surprising behavior. Switch Controller
selects one child by numeric index or name and has explicit
fallback semantics. Throughput Controller is intentionally
documented as badly named: it controls branch execution
count/percentage, not request throughput; use a throughput Timer
for rate control. Transaction Controller creates an additional
transaction SampleResult unless Generate Parent Sample is enabled.
In parent mode, child samples do not appear as separate CSV JTL
rows. By default transaction elapsed excludes timers and
pre/post-processor processing; the optional include-duration
setting changes that measurement.
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.