Skip to content
intermediate Phase 13 · Performance Testing

Designing JMeter Test Plans

Build thread groups, samplers, timers, and CSV-driven data in non-GUI load runs.

1h 15m
0 problems
Topic Progress 0%

Click-Path to .jmx: Thread Groups, Samplers, and the Controllers That Shape a Journey

qa-jmeter-test-plan-design-thread-groups -->

A JMeter test plan is a tree of elements that a set of threads walks through in order. Understanding that tree is the whole skill: the elements decide how many simulated users exist, what each one does, and in what order. The plan lives in a .jmx file — XML you can diff, review, and keep in version control next to the code it tests.

The skeleton of every plan:

<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Checkout load">
  <hashTree>
    <HTTPThreadGroup>
      <elementProp name="ThreadGroup" elementType="ThreadGroupGui">
        <stringProp name="ThreadGroup.num_threads">50</stringProp>
        <stringProp name="ThreadGroup.ramp_time">120</stringProp>
        <stringProp name="ThreadGroup.duration">600</stringProp>
      </elementProp>
    </HTTPThreadGroup>
    <hashTree>
      <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSampler"
                        testname="POST /api/login"/>
      <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSampler"
                        testname="GET /api/cart"/>
    </hashTree>
  </hashTree>
</TestPlan>

The Thread Group answers three questions: how many users (num_threads), how long they take to arrive (ramp_time), and how long they stay (duration, with the scheduler enabled). Those three knobs are your load model, and they map directly to the metrics from Performance Concepts and Baselines: 50 users over a 120-second ramp issuing a request every 2 seconds is ~25 rps — write that conversion down before the run, because "we ran 50 threads" is not a throughput number. Ramp-up deserves care: zero-second ramps manufacture a thundering herd against cold caches, which is a spike test in disguise; for a load test, ramp at least over the time it takes a new pod to become healthy.

Samplers do the actual work — HTTP Request, JDBC Request, JUnit Request, a Java Request for custom clients. Group them under a Simple Controller (a plain folder), then use the controllers to shape behavior:

Controller What it does When you need it
Loop Controller Repeats its children N times Cart browsing loops per session
Once Only Controller Runs children once per thread The login that precedes every iteration
If Controller Runs children when a condition passes Shoppers vs admins taking different paths
Transaction Controller Groups samplers into one transaction Reporting checkout as one entry with combined stats
Throughput Controller Gives children a share of executions 80 % browse, 20 % buy — a traffic mix in one plan

Check parent-sample on the Transaction Controller when you want the combined time in reports; leave it off when you want per-request granularity. The most common structural mistake is putting login inside the loop: at 1,000 iterations that is 1,000 logins hammering the auth service, which is not what production traffic looks like — real users log in once and then transact.

Plan-wide defaults keep samplers small: HTTP Request Defaults holds protocol, server, and base path; Header Manager carries Content-Type and auth headers; Cookie Manager with "Clear cookies each iteration" models fresh sessions, while unchecking it models a persistent browser. Each thread gets its own copy of these per-iteration variables, which is what the next topic exploits — Load Scripting with k6 reaches the same load model in JavaScript, but the modeling questions (users, ramp, mix) are identical.

Two lifecycle details finish the picture. SetUp Thread Group runs before the main group — use it to seed accounts or mint tokens once per engine instead of per iteration, the same division of responsibility k6 later gives setup(). TearDown Thread Group runs after the ramp-down, deleting created data. And a warning about naming: JMeter reports by sampler name, so name samplers by API and intent (GET /api/orders?status=open), not the default HTTP Request repeated thirty times — the aggregated report becomes unreadable and the p95-per-label breakdown that localizes a bottleneck turns into one useless blob.

Five Hundred Real Accounts in One Run: CSV Data Sets, Variables, and Think Time

qa-jmeter-test-plan-design-csv-timers -->

Fifty users logging in with the same account is a test of your login cache, not your login path. Realistic load needs distinct credentials, product ids, and search terms per iteration — and it needs pauses, because real users think between clicks. Both come from configuration elements, not code.

CSV Data Set Config is the standard input source:

<CSVDataSet guiclass="TestDetailsGui" testclass="CSVDataSet" testname="Users">
  <stringProp name="filename">data/users.csv</stringProp>
  <stringProp name="variableNames">user,pass,account</stringProp>
  <boolProp name="recycleOnEOF">false</boolProp>
  <boolProp name="stopThreadOnEOF">true</boolProp>
  <boolProp name="shareMode">shareMode.all</boolProp>
</CSVDataSet>

With users.csv holding alice,P4ss!w0rd,A-1001 per line, any sampler can interpolate the variables:

Username: ${user}
Password: ${pass}

Three settings decide whether the run is valid. recycleOnEOF=true restarts at the first row when the file ends — which silently turns your 500-account test into a 50-account test after 500 iterations, and half your "new user" paths start failing on unique-constraint errors. Size the file: rows ≥ threads × iterations, or stop the thread on EOF and watch the count. shareMode controls whether all threads pull from one cursor or one per thread; the default (all threads, one file) is what you want for a single CSV. The scoping rule matters: a CSV Data Set Config applies to every sampler at its own level and below, so placing it inside the thread group makes the variables thread-local and read in order — each thread gets a disjoint slice, exactly like distinct users.

Timers add think time, and each one models different behavior. They apply to every sampler in scope before it executes, so one timer at the thread-group level covers the whole journey:

Timer Behavior Models
Uniform Random Timer Fixed offset + uniformly random delay (e.g. 100 ms + 0–400 ms) Human think time between clicks
Constant Timer Fixed pause Machine-style pacing between API calls
Constant Throughput Timer Targets a request rate in samples per minute Holding a fixed rps regardless of how fast the server answers
Gaussian Random Timer Normally distributed delays Heavier-tailed human behavior

The Constant Throughput Timer has two traps worth memorizing. Its value is per minute — to hold 60 rps you enter 3600, not 60. And its "Calculate Throughput based on" mode defaults to this thread only; to shape the total rate across all threads you must select all threads together, or 50 threads each aiming for 60 rps quietly become 3,000 rps and your "baseline" melts the staging database.

Correctness under load needs the same discipline as functional runs, so add assertions to the journey's critical samplers — a Response Assertion on the status code (200, 201) and a JSONPath Assertion like $.account.id existing — because a login returning 200 with {"error":"invalid credentials"} is a failure wearing a success code. Three more assertions earn their place in load plans: a Duration Assertion (fail anything over, say, 5,000 ms so a hung request is recorded as a failure, not an outlier), a Size Assertion on payload bytes (an error page served with 200 is usually a different length than the real response), and a Response Message Assertion when your API encodes failures in the message field.

Data hygiene closes the loop. Keep the CSV out of version control — credentials in a committed file are a security finding waiting to happen — and inject the path at run time with -JusersFile=/secrets/load-users.csv, or generate a fresh file per run in the pipeline from a seeded factory. Check jmeter.save.saveservice.request_data too: it defaults to off, and turning it on to debug a sampler makes every JTL a transcript of production-shaped credentials that you then publish with the report.

Once the plan loads data, paces itself, and asserts correctness, it is ready for the command line, where real numbers come from.

The GUI Is Lying to You: Non-GUI Runs, JTL Files, and Exit Codes That Gate Builds

qa-jmeter-test-plan-design-non-gui -->

JMeter's GUI is for building plans. Running load in it distorts every number: the same JVM hosts the listeners, the charts, and the sample threads, so at 50 users the GUI's own rendering steals CPU from the workload and p95 readings inflate by tens of percent. Worse, the GUI was never designed to hold a 30-minute run's samples in memory — long GUI runs end in java.lang.OutOfMemoryError: Java heap space. The fix is a mode switch, not a hardware upgrade.

jmeter -n -t checkout.jmx \
  -l results/results.jtl \
  -e -o results/report \
  -Jusers=50 -Jduration=600 \
  -Jjmeter.save.saveservice.timestamp_format=ms

-n runs non-GUI, -t names the plan, -l writes the raw sample log, and -e -o generates the HTML dashboard from it. The -Jkey=value flags override plan properties, which is how the same plan serves a 50-user load test and a 20-user soak test without edits — bind the thread group's num_threads and duration to ${__P(users,20)} and ${__P(duration,300)} in the GUI. That trailing property is not decoration: the HTML report generator refuses to build unless the timestamp format is ms, and the default JTL format (yyyy/MM/dd HH:mm:ss.SSS) triggers Error: Results file ... does not contain any sample data on a file that plainly has samples. Set it once in bin/jmeter.properties on every machine that reads JTLs.

Reading results is a two-format world, the same split you met in reporting: the JTL is the machine format (one CSV/XML row per sample: timestamp, label, response time, code, success, bytes), and the HTML report is for humans. Keep both. The dashboard's 01-summary.png, the aggregate report, and the percentile tables all come from the same rows — sort by p95 and the slowest label names the bottleneck candidate.

Now the part that lets JMeter gate a pipeline. JMeter exits 0 even when samples fail — a run full of 500s still returns success, so a naive CI step goes green on a broken build. Two robust patterns:

# 1) Assert on the JTL after the run
jmeter -n -t checkout.jmx -l results.jtl -Jusers=50
awk -F, 'NR>1 && $8=="false" {f++} END {exit (f>5)?1:0}' results.jtl   # column 8 = success
// 2) Or fail from inside the plan: JSR223 Sampler at the end
if (prev.getDataType().equals("d") && failingSamples > 5) {
    prev.setSuccessful(false);
    prev.setResponseMessage("error-rate gate tripped");
}

Assert on error rate thresholds, not zero failures — at 50,000 samples a handful of client-side resets is normal noise, and a gate at exactly zero flaky-fails every nightly. For larger load, distribute: jmeter -n -t checkout.jmx -R10.0.0.5,10.0.0.6 -l results.jtl pushes execution to remote engines over RMI (default port 1099, same JMeter version and plugins on every engine, firewall open) so the generator never becomes the bottleneck. A distributed run aggregates all engines' samples into one JTL on the client, which is why the total rps you planned must be divided across engines — 100 rps over three engines is roughly 34 rps each, and forgetting the division triples the load you designed.

Resource discipline separates credible runs from self-inflicted ones. The generator needs headroom of its own: a laptop running the GUI, a Docker container capped at 1 CPU, or a CI runner shared with a build will silently under-deliver the load, and you will misattribute the generator's ceiling to the server under test. Check the generator before trusting it — if jmeter itself shows high CPU or the achieved rps sits below the requested rate, the numbers describe your laptop, not the service. Heap matters too: 50,000 samples at default settings is a large but survivable JTL, while enabling response-data capture multiplies it — start with -Xmx4g -Xms4g and watch the log for ERROR - jmeter.util.JMeterError: The buffer is full style failures. When the plan and its gate run reliably, the natural question is what this looks like in a language you can maintain as code — which is Load Scripting with k6.

Quiz

1. A plan with 50 threads and 1,000 iterations runs from a 50-row CSV with recycleOnEOF=true. What happens?

Question 1 options

2. You want all 50 threads combined to hold 60 requests per second. How is a Constant Throughput Timer configured?

Question 2 options

3. Why must load runs execute in non-GUI mode?

Question 3 options

4. A CLI run finishes with 30 % HTTP 500s, yet the CI step is green. Why, and what fixes it?

Question 4 options

Flashcards

Question

The three Thread Group knobs and what each maps to in a load model.

Answer

num_threads = concurrent users, ramp_time = arrival spread (ramp at least over pod startup time to avoid a fake spike), duration/scheduler = how long the load holds. Thread count alone is not throughput: users x (60 / think time seconds) estimates rps.

Question

The CSV Data Set Config trap that silently shrinks a test.

Answer

recycleOnEOF=true rewinds at end-of-file, so a 500-account test quietly becomes 50 accounts after 500 iterations. Guard with rows >= threads x iterations, or stopThreadOnEOF=true; shareMode controls whether threads share one cursor or each get their own.

Question

Timer types and the two Constant Throughput Timer traps.

Answer

Uniform Random = human think time (offset + random delay); Constant = fixed pacing; Constant Throughput targets a rate. Traps: the value is samples per MINUTE (60 rps = 3600), and the default scope is per-thread — "all threads together" is required to hold a combined rate.

Question

The non-GUI command and its required property.

Answer

jmeter -n -t plan.jmx -l results.jtl -e -o report/ -Jusers=50 -Jduration=600 — plus jmeter.save.saveservice.timestamp_format=ms, without which the HTML generator reports "Results file ... does not contain any sample data" for a file that has samples.

Question

Why JMeter cannot gate a build by itself, and the two fixes.

Answer

The process exits 0 even when samples fail. Either assert on the JTL after the run (error-rate threshold, e.g. awk over the success column) or fail from inside the plan via a final JSR223 sampler that flips setSuccessful(false). Gate on an error budget, not on zero failures.

Revision Notes

Key Takeaways

  • 1. A .jmx plan is a tree of elements; thread group knobs (users, ramp, duration) are your load model and must convert to a target rps before the run
  • 2. Once Only Controller for login, Loop/Throughput Controllers for the journey — login-inside-the-loop is the most common structural mistake
  • 3. CSV recycling silently reuses accounts at EOF; size the file or stop the thread, or your uniqueness assumptions evaporate mid-run
  • 4. Constant Throughput Timer values are samples per minute with per-thread default scope — the two traps that quietly multiply load 50x
  • 5. Non-GUI mode is mandatory (GUI distorts metrics and can OOM), the HTML report needs timestamp_format=ms, and the exit code never reflects sample failures

Interview Tips

  • • "How do you make a realistic load test?" — thread group with a real ramp, CSV-driven credentials, once-only login, random think time, and assertions; then state the rps math behind the user count
  • • "Why is my JMeter report empty?" — the HTML generator requires jmeter.save.saveservice.timestamp_format=ms; default JTL timestamps break it despite the file having samples
  • • "How do you gate CI on JMeter?" — never on exit code; assert on the JTL error rate or fail a final sampler, and gate on a budget (e.g. <0.5 % errors), not zero failures
  • • "GUI vs non-GUI?" — GUI for authoring only; listeners share the JVM with the workload, skewing p95 and risking OutOfMemoryError; distributed runs push execution to remote engines over RMI

Cheat Sheet

Designing JMeter Test Plans

Plan tree: TestPlan > ThreadGroup (threads, ramp, duration) > controllers > samplers (+ assertions). Config elements (HTTP Request Defaults, Header Manager, Cookie Manager) apply to their level and below.

Controllers: Simple (folder) · Loop · Once Only (login!) · If · Transaction (group into one transaction) · Throughput (traffic mix share).

Data: CSV Data Set Config → ${user}, ${pass}; size rows >= threads x iterations; recycleOnEOF reuses accounts silently; stopThreadOnEOF fails fast instead.

Timers: Uniform Random (think time) · Constant · Constant Throughput = samples/min, "all threads together" for a combined rate.

Assertions: Response Assertion (status codes), JSONPath Assertion ($.field exists), Duration Assertion — required so 200-with-error-body does not pass as success.

Run:

jmeter -n -t checkout.jmx -l results.jtl -e -o report -Jusers=50 -Jduration=600 \
  -Jjmeter.save.saveservice.timestamp_format=ms
jmeter -n -t checkout.jmx -R10.0.0.5,10.0.0.6 -l results.jtl   # distributed

Exit code is always 0 — gate CI by asserting on the JTL or a final JSR223 sampler.

Continue Learning