Chapter 01Lesson 02~120 minutes

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.

Loopback labCLI modeJTLPreflightEvidence

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.log artifacts.
  • 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.

Hard target guard: the JMX below names 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?

Why does the lab write both a JTL and a separate jmeter.log?

Ten JTL rows exist. Does that prove the server handled ten business transactions correctly?

Why is the ten-sample p95 not a production-grade SLO measurement?

If delay doubles while thread count stays fixed, why may throughput fall?

Next lesson

Choose the experiment before choosing the component

Lesson 3 compares load, stress, spike, soak, and capacity designs; closed versus open workload thinking; exploratory characterization versus gates; percentile reporting; and repeated controlled experiments.

Official references and version notes

Version and compatibility note

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.

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