TestForge
Log in
Medium difficulty~80 min read

Apache JMeter

The mature, GUI-driven load testing tool for HTTP, JDBC, JMS and more.

What is JMeter

Apache JMeter is a Java-based load and performance testing tool. You build a test plan in a GUI from building blocks (thread groups, samplers, listeners), then run it headless (non-GUI mode) to generate load and measure throughput and latency. It supports many protocols beyond HTTP and has a rich plugin ecosystem.

  • Protocol coverage: HTTP(S), JDBC, JMS, FTP, gRPC (plugin), and more.
  • GUI test-plan authoring — approachable without coding.
  • Distributed load generation across multiple machines for high scale.
  • Trade-off vs code-first tools: XML test plans are verbose and heavier per virtual user than k6.

Target the TestForge gateway

Load-test the public path: host localhost, port 3000, paths like /api/catalog/products. That is exactly what the platform’s own perf-sim tooling targets.

Prerequisites

  • JDK 17+ (JMeter is a Java app).
  • JMeter 5.6+ (download from jmeter.apache.org; run bin/jmeter for the GUI).
  • TestForge API reachable at http://localhost:3000.

Core building blocks

  • Test Plan: the root container for everything.
  • Thread Group: defines virtual users (threads), ramp-up time, and loop count/duration. Threads ≈ concurrent users.
  • Sampler: the actual request (e.g. HTTP Request sampler).
  • Config Elements: shared settings — HTTP Request Defaults (host/port), HTTP Header Manager (headers), CSV Data Set (test data).
  • Timers: add think-time / pacing between requests.
  • Assertions: validate responses (Response Assertion, JSON Assertion).
  • Listeners: collect and view results (Summary Report, Aggregate Report). Disable heavy GUI listeners during real runs.

Build your first test plan (GUI)

  1. Open JMeter GUI (bin/jmeter). Right-click Test Plan → Add → Threads (Users) → Thread Group.
  2. Set Number of Threads = 20, Ramp-up period = 10 (seconds), Loop Count = 10.
  3. Right-click Thread Group → Add → Config Element → HTTP Request Defaults. Set Protocol=http, Server Name=localhost, Port=3000.
  4. Right-click Thread Group → Add → Sampler → HTTP Request. Method=GET, Path=/api/catalog/products.
  5. Right-click Thread Group → Add → Assertions → Response Assertion. Set "Response Code" equals 200.
  6. Right-click Thread Group → Add → Listener → Summary Report (for a first look in the GUI only).
  7. Save the plan as testforge.jmx.

Never load-test from the GUI

The GUI is for building and debugging plans only. Running load from the GUI skews results and can crash on large runs. Always run real load in non-GUI (CLI) mode.

Run in non-GUI (CLI) mode

This is how you actually generate load. It writes raw results to a .jtl file and can auto-generate an HTML dashboard.

bash
jmeter -n -t testforge.jmx -l results.jtl -e -o ./html-report
# -n non-GUI  -t plan  -l results log  -e generate report  -o output dir

Open ./html-report/index.html for percentiles (p90/p95/p99), throughput, error rate, and response-time-over-time charts.

Parameterize with CSV and correlate auth

Real load uses varied data. A CSV Data Set Config feeds each thread different values. For authenticated flows, first POST to /api/auth/login, then extract the token with a JSON Extractor and reuse it via an HTTP Header Manager.

  • CSV Data Set Config: file users.csv with columns email,password; reference as ${email} and ${password} in the login sampler body.
  • JSON Extractor (post-processor on the login sampler): JSON Path $.accessToken → variable authToken.
  • HTTP Header Manager: add Authorization = Bearer ${authToken} so later samplers are authenticated.
Login sampler — Body Data (JSON)
{ "email": "${email}", "password": "${password}" }

Load shapes: ramp, spike, soak

  • Ramp-up / steady: increase threads gradually via the Thread Group ramp-up, then hold — the default shape.
  • Spike: use the Ultimate Thread Group (plugin) to jump concurrency sharply and observe recovery.
  • Soak / endurance: run a moderate load for a long duration (set the Thread Group to run on a schedule) to surface memory leaks and slow degradation.

Distributed and CI runs

  • Distributed mode: one controller drives multiple remote JMeter servers (jmeter-server) to generate load beyond a single machine’s limits.
  • CI: run the same non-GUI command in a pipeline; publish the HTML report and .jtl as artifacts; fail the build on error-rate or latency thresholds.
.github/workflows/jmeter.yml
name: load
on: [workflow_dispatch]
jobs:
  jmeter:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: 'temurin', java-version: '17' }
      - run: |
          wget -q https://dlcdn.apache.org/jmeter/binaries/apache-jmeter-5.6.3.tgz
          tar xzf apache-jmeter-5.6.3.tgz
          apache-jmeter-5.6.3/bin/jmeter -n -t testforge.jmx -l results.jtl -e -o report
      - uses: actions/upload-artifact@v4
        with: { name: jmeter-report, path: report }

Reading the results

  • Throughput: requests/second the system sustained.
  • Average vs percentiles: focus on p95/p99, not the average — tails are what users feel.
  • Error %: any non-2xx or assertion failures; a rising error rate under load signals saturation.
  • Latency over time: watch for response times climbing as load increases (the "knee" of the curve).

Establish a baseline first

Run a light load to record normal latency, then increase to find where p95 and error rate degrade. That inflection point is your capacity limit.

Best practices

  • Always run load in non-GUI mode; use the GUI only to author/debug.
  • Disable verbose listeners (View Results Tree) during load — they consume huge memory.
  • Add think-time (timers) so virtual users behave realistically, not as a tight loop.
  • Parameterize data and correlate dynamic values (tokens, ids) — do not replay one hard-coded token.
  • Report on percentiles and error rate; define pass/fail thresholds up front.
  • Test against the public gateway path, matching real traffic — not internal service ports.

Correlation and extractors: capturing the JWT

Correlation is the act of capturing a value the server generates at runtime and feeding it into a later request. The classic case is authentication: you POST credentials to /api/auth/login, the server returns a JWT, and every subsequent request must carry that exact token. You cannot hard-code it — each thread logs in and gets its own. A post-processor extracts the value from the login response into a JMeter variable, and the HTTP Header Manager reuses it downstream.

JSON Extractor (preferred for JSON APIs)

TestForge returns JSON, so a JSON Extractor is the clean choice. Add it as a child of the login sampler. It runs against the response body and writes the matched value into a variable you name.

  • Names of created variables: authToken
  • JSON Path expressions: $.accessToken
  • Match No.: 1 (first match)
  • Default Values: NOT_FOUND — a sentinel that makes a failed extraction obvious in later requests.
POST /api/auth/login — response body
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "b3f1...9ac",
  "user": { "id": "u_123", "role": "customer" }
}

With the JSON Path $.accessToken → variable authToken, add an HTTP Header Manager to the following samplers (or the Thread Group) so the token rides along:

HTTP Header Manager
Authorization: Bearer ${authToken}
Content-Type: application/json

Regular Expression Extractor (protocol-agnostic fallback)

When the value is not clean JSON — a token embedded in HTML, a redirect header, or a hidden form field — reach for the Regular Expression Extractor. It matches against the raw response and captures whatever is inside the parentheses group.

Regular Expression Extractor — settings
Reference Name:      authToken
Regular Expression:  "accessToken"\s*:\s*"([^"]+)"
Template:            $1$
Match No.:           1
Default Value:       NOT_FOUND

Never replay one hard-coded token

Pasting a single token captured by hand from your browser is the #1 correlation mistake. It expires mid-run, and every virtual user shares one identity — which hides caching and per-user bugs. Always log in inside the plan and extract a fresh token per thread.

Assertions, timers and pacing

Under load, JMeter reports a 200 as a pass even if the body is an error page or the response is unacceptably slow. Assertions make correctness explicit; timers make the load realistic. Both are essential for a result you can trust.

Assertions — validate what "success" means

  • Response Assertion: match the status code (200) and/or a substring of the body (e.g. that /api/catalog actually returns a "products" array). A 200 with an empty body is still a failure worth catching.
  • Duration Assertion: fail any sample that exceeds a latency budget — e.g. mark responses over 800 ms as failed so your error rate reflects the SLA, not just HTTP status.
  • JSON Assertion: assert on a JSON Path (e.g. $.products[0].id exists) when you need structural validation of the payload.

A Duration Assertion is not a timer

A Duration Assertion checks how long a request took and fails it if too slow. A timer pauses the thread before the next request. One measures the server; the other shapes your load.

Timers — think time and pacing

Real users pause between clicks. Without think time, each virtual user hammers the server in a tight loop, so 20 threads behave like hundreds — inflating load and producing meaningless numbers. Add a timer as a child of the Thread Group (it applies to every sampler in scope).

  • Constant Timer: a fixed delay (e.g. 1000 ms) before each request. Simple but unrealistically uniform.
  • Uniform Random Timer: a fixed base plus a random offset spread evenly — e.g. 1000 ms constant + 2000 ms random gives 1–3 s of think time.
  • Gaussian Random Timer: a delay clustered around a mean with a normal distribution — the closest to genuine human pacing, most delays near the mean with occasional outliers.

Pacing — controlling requests per user per unit time

Pacing fixes the rate at which each virtual user completes a full iteration, independent of how fast the server responds. Model each shopper as browsing the catalog roughly once every 5 seconds regardless of server latency; use a Constant Throughput Timer (set in samples/minute, per-thread) to hold that rate steady. This decouples your intended load from the server’s response time — otherwise a faster server silently produces more load than you designed for.

Thread Group — timer layout
Thread Group (20 threads, 30 s ramp-up, run 10 min)
  HTTP Request  POST /api/auth/login  (JSON Extractor -> authToken)
  HTTP Request  GET  /api/catalog
  Gaussian Random Timer  (Deviation 300 ms, Constant Delay 1000 ms)
  Constant Throughput Timer  (target 240 samples/min, per thread)

Non-GUI mode and distributed testing

The JMeter GUI is for building and debugging a plan only — never for generating real load. The GUI’s live listeners, graphs, and Swing rendering consume so much memory and CPU that the load generator itself becomes the bottleneck, skewing every number and risking an out-of-memory crash on large runs. Real tests run headless.

Non-GUI (CLI) run with an HTML dashboard

bash
jmeter -n -t plan.jmx -l results.jtl -e -o report/
# -n  non-GUI mode
# -t  the .jmx test plan to run
# -l  raw per-sample results log (JTL)
# -e  generate the HTML report at end of run
# -o  output folder for that report (must be empty or new)

Open report/index.html for the dashboard: aggregate percentiles (p90/p95/p99), throughput over time, response-time-over-time, and error breakdowns. If you already have a .jtl from a prior run, you can regenerate the dashboard without re-running the load:

bash
jmeter -g results.jtl -o report/
# -g  generate a report from an existing results log

Distributed (remote) testing

When one machine cannot generate enough load, JMeter runs distributed: a single controller (client) coordinates several server (worker) nodes, each running jmeter-server. The controller ships the plan to every server, they all generate load in parallel against the target, and results stream back to the controller which aggregates them into one report.

bash
# On each load-generator machine (the servers/workers):
jmeter-server

# On the controller, drive all remote servers with -R (comma-separated hosts):
jmeter -n -t plan.jmx -R 10.0.0.11,10.0.0.12,10.0.0.13 -l results.jtl -e -o report/
# Each listed host runs the full plan, so total load = threads-per-plan x number-of-servers.

Load generation scales, the plan does not

Distributed mode multiplies the same plan across N servers — 20 threads with three servers is 60 concurrent users. It does not split one plan across machines. Keep clocks synced and open the RMI ports between controller and servers.

Interview questions and answers

Walk me through the three main Thread Group settings and what each one controls.

Number of Threads is the count of concurrent virtual users. Ramp-up Period is how long JMeter takes to start all of them — 20 threads over a 10-second ramp-up starts one every half second, so load builds gradually instead of all at once. Loop Count (or a scheduled duration) controls how many times each thread repeats the samplers. Together they define how many users hit the system, how fast they arrive, and for how long.

What is correlation, and which extractors do you use for it?

Correlation is capturing a server-generated dynamic value from one response and passing it into later requests — most commonly an auth token or session id. For JSON APIs I use the JSON Extractor with a JSON Path like $.accessToken. For values buried in HTML, headers, or non-JSON bodies I use the Regular Expression Extractor with a capture group. The extracted value goes into a variable, which I then reference as ${authToken}, typically via an HTTP Header Manager.

Why do you need assertions if JMeter already reports HTTP status?

Because a 200 status does not mean the response was correct or fast enough. The server can return 200 with an error page, an empty body, or a payload missing the data you expected, and it can return 200 after 5 seconds when your SLA is 800 ms. Response Assertions validate status and body content, JSON Assertions validate structure, and Duration Assertions fail responses that breach a latency budget — so your error rate reflects real success, not just connectivity.

Why must real load tests run in non-GUI mode?

The GUI is built for authoring and debugging, not load generation. Its live listeners, real-time graphs, and Swing rendering consume large amounts of memory and CPU, so the load generator itself becomes the bottleneck and your measurements are distorted — and it can crash under a heavy run. Non-GUI mode (jmeter -n) drops all that overhead, so you generate accurate, higher load and write results to a JTL file for later reporting.

What role do listeners play, and why are they a concern during a load run?

Listeners collect and display results — Summary Report, Aggregate Report, View Results Tree, and so on. The problem is that verbose listeners, especially View Results Tree, store every request and response in memory, which can exhaust the heap on a large run and slow the generator. Best practice is to disable heavy GUI listeners during the actual run and instead write raw results to a JTL file with the -l flag, then generate the HTML dashboard afterward.

How does distributed testing work in JMeter?

When a single machine cannot produce enough load, you run distributed mode: one controller coordinates multiple server nodes, each running the jmeter-server process. The controller sends the same test plan to every server with the -R flag listing their hosts, all servers generate load in parallel against the target, and results stream back to the controller for aggregation. Total load is the plan multiplied by the number of servers, so 20 threads across three servers is 60 concurrent users.

Explain timers, think time, and pacing — and how they differ.

Think time is the pause between a user’s actions; you add it with a timer (Constant, Uniform Random, or Gaussian Random) so virtual users do not hammer the server in a tight loop and inflate the load. Pacing goes a step further: it fixes the rate at which each user completes a full iteration regardless of server response time, usually via a Constant Throughput Timer. Think time shapes the gap between requests; pacing holds the overall requests-per-user rate steady so your intended load does not drift when the server speeds up or slows down.

When reading results, why do you focus on percentiles rather than the average?

The average hides the tail. A low average can mask a subset of very slow requests that real users actually feel, so I look at p90, p95, and p99 — the response times at or below which that percentage of requests fell. p99 in particular exposes the worst-case experience. Alongside percentiles I read throughput (requests per second the system sustained) and error rate (share of failed or asserted-failed requests); a rising error rate or climbing p95 under increasing load marks the capacity limit.

How do you parameterize test data so every virtual user is not identical?

I use a CSV Data Set Config: a CSV file with columns like email,password, and JMeter hands each thread the next row. I reference the columns as variables — ${email}, ${password} — in the sampler body, so every user logs in with distinct credentials. This prevents unrealistic caching, avoids all threads sharing one identity, and surfaces per-user bugs. Sharing mode and the recycle/stop-on-EOF options control how rows are distributed and what happens when the file is exhausted.

What is the difference between load, stress, and soak testing?

Load testing runs the expected or peak concurrency to confirm the system meets its SLAs under normal conditions. Stress testing pushes past that — increasing load until the system degrades or fails — to find the breaking point and observe how it recovers. Soak (endurance) testing holds a moderate load for a long duration, hours or more, to surface problems that only appear over time such as memory leaks, connection-pool exhaustion, or slow resource degradation.

Where to go next

  • Docs: jmeter.apache.org/usermanual
  • Plugins: jmeter-plugins.org (Ultimate Thread Group, custom graphs).
  • Practice: build a login → browse → add-to-cart plan against TestForge, correlate the JWT, and find the throughput at which p95 latency crosses your threshold. Compare with the k6 tutorial.