Performance Testing Foundations: Load, Stress, Spike, Soak, and Capacity: Guided Hands-On Workflow
Turn the Chapter 01 experiment charter into one conservative loopback workflow: a disposable service, explicit preflight, a tiny JMX plan, CLI execution, raw JTL evidence, generator/SUT observations, and a validity note.
Learning objectives
- Create a loopback-only target with synthetic behavior and simple server-side counters.
- Verify target authorization, JMeter/Java versions, load ceilings, and stop criteria before execution.
- Read a minimal JMX plan without confusing tree configuration with achieved throughput.
-
Run JMeter in CLI mode and preserve JTL and
jmeter.logartifacts. - Compute a small-sample p50/p95 and approximate throughput while stating the statistical limitations.
- Compare configured threads/loops with observed samples, resource state, and target counters.
1. Lab assumptions and safety envelope
The executable path assumes Apache JMeter 5.6.3 is already available and uses Java 17 as the pinned Chapter 01 lab runtime. Java 17 is a course choice, not a claim that JMeter 5.6.3 requires exactly Java 17; the current release supports Java 8+. No plugins are required.
127.0.0.1 literally. Keep it that way. Maximum
configured work is 10 HTTP samples. Do not “make the test
interesting” by increasing users, loops, payloads, or changing the
host.
2. Create the disposable loopback service
Create a new directory such as chapter01-lab, save this
as fixture_server.py, and start it in its own terminal.
It binds only to the IPv4 loopback interface. The
/work endpoint intentionally waits for a bounded delay
and exposes synthetic counters; /health and
/stats let you inspect target state without external
telemetry.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import json
import threading
import time
HOST = "127.0.0.1"
PORT = 8000
lock = threading.Lock()
active = 0
total = 0
class Handler(BaseHTTPRequestHandler):
def _send_json(self, status, payload):
body = json.dumps(payload).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 active, total
parsed = urlparse(self.path)
if parsed.path == "/health":
self._send_json(200, {"status": "ok"})
return
if parsed.path == "/stats":
with lock:
snapshot = {"active": active, "total": total}
self._send_json(200, snapshot)
return
if parsed.path != "/work":
self._send_json(404, {"error": "not_found"})
return
values = parse_qs(parsed.query)
try:
requested = int(values.get("delay_ms", ["40"])[0])
except ValueError:
requested = 40
delay_ms = min(max(requested, 0), 250)
with lock:
active += 1
total += 1
now_active = active
now_total = total
try:
time.sleep(delay_ms / 1000.0)
self._send_json(
200,
{
"status": "ok",
"delay_ms": delay_ms,
"active_at_start": now_active,
"request_number": now_total,
},
)
finally:
with lock:
active -= 1
def log_message(self, format, *args):
return
if __name__ == "__main__":
print(f"fixture=http://{HOST}:{PORT}")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
Start it:
python fixture_server.py
Expected first line:
fixture=http://127.0.0.1:8000
3. Preflight: prove the target, tools, and limits before load
In a second terminal, verify the service and versions. A healthy response is evidence that the exact loopback target is reachable before JMeter starts.
curl --fail --silent http://127.0.0.1:8000/health
java -version
jmeter -v
PowerShell equivalents:
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8000/health).Content
java -version
& "$env:JMETER_HOME\bin\jmeter.bat" -v
Record the output. Also inspect host CPU/memory in Task Manager,
Activity Monitor, top, or another local monitor. The
run is so small that resource movement may be barely visible; that
is acceptable. The point is to establish the habit of checking the
injector.
4. Read the starter JMX as workload intent, not magic XML
Save the following as chapter01-baseline.jmx. Chapter
02 will teach the JMeter GUI and test-plan architecture in depth.
For now, read only the evidence-bearing facts: two threads, a
two-second ramp, five loops per thread, one HTTP GET sampler,
loopback target, and short timeouts.
<?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 01 Tiny Baseline" 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="User Defined Variables">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="TestPlan.user_define_classpath"></stringProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup"
testname="Tiny Baseline — 2 users × 5 loops" 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">5</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">2</stringProp>
<stringProp name="ThreadGroup.ramp_time">2</stringProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui"
testclass="HTTPSamplerProxy"
testname="GET /work" 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">127.0.0.1</stringProp>
<stringProp name="HTTPSampler.port">8000</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/work?delay_ms=40</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>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.connect_timeout">1000</stringProp>
<stringProp name="HTTPSampler.response_timeout">2000</stringProp>
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
The configured maximum is ten samples because two threads each execute the one sampler five times. That tells you sample count intent; it does not tell you a fixed samples-per-second rate. The target delay, thread scheduling, connection reuse, JVM state, and OS scheduling still affect observed timing.
5. Run only in CLI mode and preserve both result and engine log
Create an empty results directory. Do not reuse a
previous result file because mixing runs destroys provenance.
mkdir -p results
jmeter -n -t chapter01-baseline.jmx -l results/run-001.jtl -j results/run-001-jmeter.log
PowerShell:
New-Item -ItemType Directory -Force results | Out-Null
& "$env:JMETER_HOME\bin\jmeter.bat" -n `
-t chapter01-baseline.jmx `
-l results/run-001.jtl `
-j results/run-001-jmeter.log
Apache's current manual is explicit: use GUI mode to construct/debug plans and CLI mode for load tests. This tiny run follows the same production habit even though it is not large enough to stress the host.
6. Inspect evidence from both sides of the request
First inspect the server counter:
curl --fail --silent http://127.0.0.1:8000/stats
After a clean run that started from a fresh fixture,
total should be 10. The exact peak
active value is not stored by this simple endpoint, so
do not invent it after the fact. Next, preserve the JTL and JMeter
engine log. JTL column details can be changed by save-service
configuration, so this lesson relies only on standard fields that
the default CSV format normally includes and tells you to inspect
the header before analysis.
The following analyzer is deliberately external to JMeter. It demonstrates that raw result files are evidence that can be checked independently.
import csv
import math
from pathlib import Path
path = Path("results/run-001.jtl")
rows = list(csv.DictReader(path.open(encoding="utf-8")))
elapsed = [float(r["elapsed"]) for r in rows]
success = [r["success"].strip().lower() == "true" for r in rows]
def percentile(values, p):
values = sorted(values)
rank = max(1, math.ceil((p / 100.0) * len(values)))
return values[rank - 1]
start_ms = min(float(r["timeStamp"]) for r in rows)
end_ms = max(float(r["timeStamp"]) + float(r["elapsed"]) for r in rows)
wall_s = max((end_ms - start_ms) / 1000.0, 0.001)
print(f"samples={len(rows)}")
print(f"errors={sum(not ok for ok in success)}")
print(f"p50_elapsed_ms={percentile(elapsed, 50):.1f}")
print(f"p95_elapsed_ms={percentile(elapsed, 95):.1f}")
print(f"approx_samples_per_second={len(rows) / wall_s:.2f}")
Run it only after confirming the JTL header contains
timeStamp, elapsed, and
success. With only ten observations, p95 is
pedagogical, not statistically stable. The correct interpretation is
“this small run produced this distribution,” not “the service p95 is
proven.”
7. Configured load versus achieved load
| Observation | Configured or observed? | Interpretation |
|---|---|---|
| 2 threads | Configured | Maximum concurrent scenario executors in this simple closed workload. |
| 5 loops per thread | Configured | Each thread intends five iterations. |
| 10 JTL rows | Observed | The expected sample count was actually recorded. |
| Approximate samples/second | Observed | Emerges from start/end timestamps; not guaranteed by the thread count. |
| p50/p95 elapsed | Observed | Describes this sample set only. |
| Java/host CPU and memory | Observed | Evidence that the injector had headroom or did not. |
/stats total |
Observed target-side counter | Independent confirmation that requests reached the fixture. |
If the JTL contains fewer than ten rows, do not smooth the
discrepancy away. Inspect run-001-jmeter.log, failed
rows, target availability, and the exact JMX. The expected count is
part of the experiment contract.
8. Abort, stop, and cleanup are part of the workflow
For this bounded run, abort if the host in the JMX is no longer
127.0.0.1, if the fixture becomes unresponsive, or if
the workstation exhibits unsafe resource pressure. For larger future
tests, define a graceful-stop procedure and hard emergency stop
before starting. Do not wait until a runaway test exists to decide
how to stop it.
When finished, stop the Python fixture with Ctrl+C. Keep the result directory long enough to compare evidence. Delete it only after the lesson review is complete.
9. Small challenge: predict before rerunning
Without changing the target or increasing total work, change only
the fixture query from delay_ms=40 to
delay_ms=80 in a copy named
chapter01-slower.jmx. Predict which values should
change and which should not:
- Expected sample count should remain 10.
- Elapsed response-time distribution should shift upward.
- Approximate throughput will usually fall because the same two threads spend longer waiting.
- The run still does not establish target capacity.
Run it as run-002, preserve both runs, and compare. One
factor changed: synthetic service delay.
Knowledge check
Why do we query /health before launching JMeter?
It proves the intended loopback target is reachable and helps distinguish target setup failure from a JMeter/load-test problem.
Why does the lab write both a JTL and a separate jmeter.log?
They are different evidence sources: JTL records sample results, while jmeter.log records JMeter engine/runtime diagnostics.
Ten JTL rows exist. Does that prove the server handled ten business transactions correctly?
It proves ten samples were recorded. Business correctness still needs appropriate protocol/content assertions and, when relevant, server-side state verification.
Why is the ten-sample p95 not a production-grade SLO measurement?
The sample is tiny, short, and lacks the duration/repetition/environment controls needed for a stable tail-latency claim.
If delay doubles while thread count stays fixed, why may throughput fall?
In a closed workload the threads spend more time waiting for responses, so they complete iterations less frequently.
Official references and version notes
- Apache JMeter downloads — current production-release and Java requirement baseline.
- Getting Started — GUI authoring, CLI load execution, Java requirements, CLI flags, and operational guidance.
- Best Practices — load-generation and result-collection practices.
- Component Reference — Thread Group and current Open Model Thread Group status.
- HTML Dashboard Report — result-report terminology and percentile-oriented reporting.
Version-sensitive statements were rechecked against current Apache
JMeter primary documentation on 2026-09-04. The production
download is Apache JMeter 5.6.3 and requires Java 8+. The
mandatory Chapter 01 executable lab uses Java 17 as a pinned local
lab choice, no third-party plugins, no distributed engines, and
only the loopback target 127.0.0.1:8000. Apache
guidance requires CLI mode for actual load execution; GUI mode is
limited to construction and bounded debugging. The Open Model
Thread Group is discussed conceptually only and remains marked
experimental in the current Component Reference.
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.