JMeter API Testing: From a First REST Test Plan to a CI Run

JMeter API testing means building a JMeter test plan that sends HTTP requests to an API, checks each JSON response, then replays the plan with simulated users to measure response times and errors. JMeter works at the protocol level, not in a browser. This guide takes one plan from a first GET to a CI run with the HTML report.
If you would rather have one-click API load testing than a hand-built test plan, Qodex generates the tests from your API, runs them on every pull request, and hands you the report.
Every piece you assemble, in the order you add it:
| Element | What it does | Where it sits |
|---|---|---|
| Test Plan (the .jmx file) | The root of the plan, and the file you commit | Root |
| Thread Group | Threads, ramp-up, loops, optional duration | Under the Test Plan |
| HTTP Request Defaults | Protocol and host, set once for the samplers below | Thread Group, above the samplers |
| HTTP Header Manager | Adds or overrides headers such as Accept | Thread Group, applies in scope |
| HTTP Request sampler | Sends one request: method, path, parameters, body | Thread Group, one per call |
| Response Assertion | Fails the sample on an unexpected response code | Child of a sampler |
| JSON Assertion | Fails on invalid JSON, a missing path, or a wrong value | Child of a sampler |
| JSON Extractor | Copies a JSON value into a variable | Child of a sampler |
| CSV Data Set Config | Feeds file rows into variables, so threads differ | Thread Group, above the samplers |
| Timer (Constant or Uniform Random) | Delays each thread before every sampler in scope | Thread Group |
| View Results Tree | Shows each request and response, debug only | Thread Group |
| Non-GUI run and HTML dashboard | -n runs the plan, -l writes the log, -e -o builds the report | The command line |
What is JMeter API testing?
JMeter is one tool inside the wider practice our API testing guide covers. Apache describes it as an open-source Java application designed to load test functional behavior and measure performance, able to simulate load against a server, a network or an object.
For an API that splits into two jobs, and one plan does both. Functional checks assert the response code, the shape of the JSON and the values inside it. Load measurement runs the same requests on many threads and reports response times and errors. The difference is the settings, not the plan.
Apache is explicit that JMeter is not a browser: it works at protocol level, does not execute the JavaScript in HTML pages, and does not render them. It sees your API, not your app.
The current download is Apache JMeter 5.6.3, and it needs Java 8 or later. Teams pick it for four reasons. You build the plan in a GUI rather than in code. The protocol list is long: HTTP and HTTPS, SOAP or REST web services, FTP, databases through JDBC, mail, TCP and Java objects. It runs from the command line. And it writes an HTML report at the end. Broad REST functional coverage is a wider subject our REST API testing guide handles.
Build your first REST API test plan
One plan carries this guide. It reads a post, keeps the ID from the response, uses it in a second request, and checks both.
Add the Thread Group. It defines the pool of users. Set Number of Threads to
${__P(threads,1)}, Ramp-up Period to${__P(ramp,1)}and Loop Count to${__P(loops,1)}. The__Pfunction reads a property passed with-Jand falls back to the default you give it, so one file runs as a smoke test today and a load test later.Add HTTP Request Defaults under the Thread Group. Protocol
https, server name${__P(host,jsonplaceholder.typicode.com)}. Every sampler below inherits both, so the target changes in one place.Add an HTTP Header Manager with
Accept: application/json. Multiple Header Managers merge into one list, and a later entry with the same name replaces the earlier value, which is how you set defaults and adjust one sampler. A POST with a raw JSON body needsContent-Type: application/jsontoo.Add an HTTP Request sampler named Get post. Method GET, path
/posts/1. Leave server and protocol empty so the defaults apply. On 2 September 2026 this demo endpoint returned HTTP 200 with a JSON body whoseidwas1.Add a Response Assertion under Get post that checks the response code equals
200. A fast 500 is still a failure, and this is what makes JMeter say so.Add a JSON Assertion under Get post. Path
$.id, value checking on, expected value1. It fails first if the body is not valid JSON, then if the path is not found, and last if the value does not match.Add a JSON Extractor under Get post. Variable name
post_id, expression$.id, match number1, defaultNOT_FOUND. Match number matters:1selects the first match,0selects a random one,-1extracts every match. The default is what you get when nothing matches, andNOT_FOUNDfails more clearly than an empty string.Add a second HTTP Request named Get comments. Method GET, path
/comments?postId=${post_id}. Add a Response Assertion for200and a JSON Assertion for$[0].postIdexpecting1. On 2 September 2026 that endpoint returned HTTP 200 and five records whose firstpostIdwas1.Add a Uniform Random Timer under the Thread Group. A timer delays each thread before every sampler in its scope, including the first one.
Add View Results Tree for this first run only, then disable it.
Test Plan: jmeter-api-testing
+-- Thread Group threads=${__P(threads,1)} ramp-up=${__P(ramp,1)} loops=${__P(loops,1)}
+-- HTTP Request Defaults protocol=https server=${__P(host,jsonplaceholder.typicode.com)}
+-- HTTP Header Manager Accept: application/json
+-- Uniform Random Timer constant 300 ms, random up to 700 ms (example values)
+-- HTTP Request "Get post" GET /posts/1
| +-- Response Assertion Response Code Equals 200
| +-- JSON Assertion $.id assert value expect 1
| +-- JSON Extractor post_id = $.id match 1 default NOT_FOUND
+-- HTTP Request "Get comments" GET /comments?postId=${post_id}
| +-- Response Assertion Response Code Equals 200
| +-- JSON Assertion $[0].postId assert value expect 1
+-- View Results Tree one-user GUI check only, disable before load and CI
Save the file as jmeter-api-testing.jmx and run it once in the GUI with one thread. You should see two green samples in View Results Tree, post_id holding 1, and the second request resolved to /comments?postId=1. If post_id reads NOT_FOUND, the extractor did not match.
JSONPlaceholder is a public demo service, so this plan stays a one-user, one-loop smoke run against it. Every number you raise later belongs to an environment you own.
Parameterize requests with CSV test data
Hard-coding /posts/1 means every user asks for the same record, which is not how real traffic reads an API. Feed the IDs from a file. Create ids.csv next to the plan; these three rows are an example, not a recommended size:
id
1
2
3
Add a CSV Data Set Config under the Thread Group, above the samplers. Point Filename at ids.csv and leave Variable Names empty: JMeter reads a header line for the column names, and an empty field is how you switch that on. Then change the Get post path to /posts/${id}.
Two settings decide who reads what. Sharing mode picks the scope of the file. All threads shares one open file, so each thread takes the next line. Current thread group opens it once per group, and Current thread opens it per thread.
Recycle and Stop Thread decide what happens at the end of the file. With recycle on, reading restarts at the first line. With recycle off and stop thread on, the thread stops, which uses each row exactly once.
Two changes to the assertions. The expected value for $.id is no longer a constant, so set it to ${id}, the variable the CSV supplies. Set the Get comments assertion for $[0].postId to ${post_id}, the value the extractor took from the first response. Value checking stays on in both, and the Response Assertions still apply.
Turn the functional plan into a realistic load test
The plan you just built is already a load test. Only the Thread Group fields and the target change. If the CSV is still in the plan, turn recycle on or remove it, because three rows and stop thread on will end your threads long before the numbers below.
Number of Threads is the number of users simulated. Ramp-up Period is how long JMeter takes to start them all. Loop Count is how many times each thread repeats the scenario. The scheduler adds a duration, which can end the group before the loop count completes.
A worked example, with our numbers and not Apache's: 20 threads, a 20 second ramp-up and 10 loops starts one new user per second, each running the two-request scenario ten times. That is 400 requests, not 400 requests per second. The rate falls out of how fast the server answers and how long the timer pauses, which is why the thread count is not a throughput dial.
The timer makes traffic look like traffic. A Uniform Random Timer with a constant delay of 300 ms and a random delay of up to 700 ms pauses each request 300 to 1000 ms. Those are example settings, not JMeter recommendations.
Then move the target. Point the plan at an environment you own before raising anything, and pass the numbers in rather than editing the file:
jmeter -n -t jmeter-api-testing.jmx \
-Jhost=api.staging.example.com \
-Jthreads=20 -Jramp=20 -Jloops=10 \
-l results.jtl -e -o report
Which shape of load to run, and how long to hold it, is a design question in its own right. Our API load testing guide covers the load, stress, spike and soak models.
Run JMeter without the GUI and generate the HTML report
Apache's instruction is short: build and debug in the GUI, then run the load test from the command line. Delete or empty the report directory first, then run:
jmeter -n -t jmeter-api-testing.jmx \
-Jhost=jsonplaceholder.typicode.com \
-Jthreads=1 -Jramp=1 -Jloops=1 \
-l results.jtl -e -o report
Each flag does one thing. -n runs the plan in non-GUI mode. -t names the JMX plan. -l names the file the sample results are logged to. -e generates the report dashboard after the run, and -o is the folder it goes into, which Apache requires to not exist or be empty. Each -J sets a property, and those are the properties the __P functions read.
Open report/index.html and read three things first. The error percentage says whether the run is worth interpreting: if a quarter of the requests failed, the response times belong to a broken system. Throughput is the rate the run achieved. Then the percentiles, median, 90th, 95th and 99th.
Read the percentiles rather than the average. A mean of 200 ms is the same number whether every request took 200 ms or ninety took 100 ms and ten took 1.1 seconds. A 95th percentile of 1.1 seconds says 95 percent of requests finished within 1.1 seconds. The rest took at least that long, and those are real people.
Run the same JMeter plan in CI
The plan runs from a command, so it runs anywhere a command runs. Here it is as a GitHub Actions job.
name: JMeter API smoke
on:
pull_request:
jobs:
jmeter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install JMeter 5.6.3
run: |
curl -sSLO https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-5.6.3.tgz
curl -sSLO https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-5.6.3.tgz.sha512
sha512sum -c apache-jmeter-5.6.3.tgz.sha512
tar -xzf apache-jmeter-5.6.3.tgz
echo "$PWD/apache-jmeter-5.6.3/bin" >> "$GITHUB_PATH"
- name: Run the plan with one user
run: |
jmeter -n -t jmeter-api-testing.jmx \
-Jhost=jsonplaceholder.typicode.com \
-Jthreads=1 -Jramp=1 -Jloops=1 \
-Jjmeter.save.saveservice.output_format=csv \
-Jjmeter.save.saveservice.print_field_names=true \
-l results.jtl -e -o report
- name: Fail the job if any sample failed
run: |
python3 - <<'PYEOF'
import csv, sys
rows = list(csv.DictReader(open('results.jtl')))
if not rows or 'success' not in (rows[0].keys()):
sys.exit('results.jtl has no samples or no success column')
failed = [r for r in rows if r.get('success') != 'true']
print(f"{len(rows)} samples, {len(failed)} failed")
sys.exit(1 if failed else 0)
PYEOF
- uses: actions/upload-artifact@v4
if: always()
with:
name: jmeter-results
path: |
results.jtl
report/
Read it in order. Check out the repository so the JMX file is on disk. Install a JDK. Download and unpack 5.6.3 from the Apache archive and put its bin directory on the path; the checksum line is optional. Run the same command as before. A fresh runner satisfies the empty-report-directory rule for free.
Then the step most JMeter pipelines are missing. JMeter writes a report whether the assertions passed or not, so a job that only runs JMeter is green when your API is broken. The gate reads the success column of the result log and exits non-zero if any sample is false. A JMeter result log can include that success indicator, so the run above sets jmeter.save.saveservice.output_format=csv and jmeter.save.saveservice.print_field_names=true to be sure the log is CSV with a header line. The gate also stops the job when the log holds no samples at all. The last step uploads the log and the report, with if: always() so a failing run keeps its evidence.
For a real target, pass the host as a repository variable and the token as a secret, both through -J properties, with a Header Manager entry reading Authorization: Bearer ${__P(token,)}. Keep the job at one thread: it is a smoke check, and load belongs on a schedule against an environment you own. Where this fits in the wider pipeline is covered in our guide to continuous API testing in DevOps pipelines.
Common JMeter API testing mistakes
Running the load test from the GUI. Apache says not to, in those words, and points you at CLI mode. Build and debug the plan there, then run it with -n.
Leaving View Results Tree or View Results in Table enabled. Apache says to use as few listeners as possible and to reserve those two for debugging, not load runs. Disable them and let -l write the log.
No think time. By default a thread sends its next request the instant the last one returns, which is not what a user does. Apache does not spell out the consequence, but it follows from how timers work: without one, a modest thread count hammers the server harder than a much larger real audience. Add a timer in scope.
Treating threads as a requests-per-second setting. A Thread Group models users and how fast they arrive. Apache does not spell out the consequence, but it follows from how thread groups and timers work: the rate reaching the server depends on response time and timer delays too, so 100 threads is not 100 requests per second.
Measuring fast failures. A plan with no assertions counts a 404 or a 500 as a sample like any other. This one is our reading, not Apache's: a quick error that nothing asserts on still lands in the latency numbers, where it can flatter them. Add the assertions first.
Every user reusing one record. One hard-coded ID means every thread asks for the same record, over and over, which is not the spread of data a real load has. CSV Data Set Config gives threads separate input, but only if the sharing, recycle and stop-thread settings match your intent.
When JMeter is the wrong tool: k6, Gatling or Playwright
This table is our reading of each vendor's own documentation, not a benchmark or a ranking.
| Use instead | Choose it when | What the vendor's own docs say |
|---|---|---|
| k6 | You want the load test to be a source-controlled JavaScript file, not a GUI-built plan | Grafana's guide writes the test in JavaScript, then adds Checks for correctness, Thresholds as pass and fail criteria, executors to model the workload, and data parameterization, all run from the CLI |
| Gatling | Your team works in Java, JavaScript or TypeScript, Kotlin or Scala and wants test-as-code | Gatling's simulation reference puts protocol configuration, acceptance criteria, pauses, throughput shaping and maximum duration in the simulation itself, in any of those SDKs |
| Playwright API tests | The API calls set up or verify browser state and belong in the UI suite | Playwright documents direct REST calls to test a server API, prepare state before visiting the app, and validate post-conditions after browser actions. It is not a load generator |
JMeter stays the right pick in three cases. You want to build and read the plan in a GUI. You need the protocol breadth, with JDBC, FTP, mail and TCP alongside HTTP in one tool. Or you already have an estate of JMX files and plugins, in which case note that Plugins Manager is a JMeter-Plugins.org community tool, not Apache core.
For a wider look at the field, see our comparison of load testing APIs, best tools and methods, and for a commercial alternative, NeoLoad vs JMeter.
What changes when the same API checks run on every pull request?
The boundary first. A JMX plan replays fixed HTTP requests, which is what you want in CI. What it will not do is keep itself current. Every time the API changes, somebody has to update the plan.
That authoring problem is what Qodex works on. It writes runnable scenarios from an OpenAPI spec, a Postman collection, a spreadsheet of endpoints or a single sentence. Auth and role boundaries are included, so an admin, a member and an anonymous caller are all tested against the same endpoint. The suite runs against the pull request's own preview, and on demand, on a schedule, from CI, a deploy hook or any webhook. Every failure comes back with the request, the response and a screenshot. Each one is classified first: a real bug, a stale test with the repair proposed as a diff you approve, or an environment issue flagged and not counted. A replay is Playwright and HTTP code with no model call, and the tests are standard code you own, parameterized per environment.
See how Qodex runs API tests on every pull request.
Frequently Asked Questions
Can JMeter be used for functional API testing?
Yes, at protocol level. A Response Assertion checks the status code and a JSON Assertion checks the body's shape and values, so a plan fails on wrong data and not only on slow data. It is not a browser, so UI behaviour needs a different tool.
How do I test a REST API in JMeter?
Add a Thread Group, HTTP Request Defaults for the protocol and host, a Header Manager for Accept: application/json, one HTTP Request sampler per call, and a Response Assertion plus a JSON Assertion under each. Run it in the GUI, then from the command line.
How do I send JSON headers and a JSON request body in JMeter?
Headers go in an HTTP Header Manager: Accept: application/json for the response, Content-Type: application/json when you send a body. The request body goes in the sampler itself, for a POST.
How do I extract a JSON value and use it in the next request?
Add a JSON Extractor under the first sampler. Give it a variable name, a JSON path expression, match number 1, and a default such as NOT_FOUND. Reference it as ${name} in the next sampler.
How many threads and how much ramp-up should a JMeter test use?
There is no universal number. Threads are simulated users, ramp-up is how long JMeter takes to start them. Begin at one thread for the smoke run, then model the concurrency you expect and spread the start over a realistic window. Only against an environment you own.
How do I run a JMeter test in non-GUI mode from CI?
Run jmeter -n -t plan.jmx -l results.jtl -e -o report, passing environment values with -J properties. Then add a gate step: read the success column of the result log and fail the job if any sample is false. Upload both as artifacts.
How do I generate an HTML report after a JMeter run?
Add -e -o report to the non-GUI command. The -e flag generates the dashboard after the run and -o names the folder, which must not exist or be empty. Open report/index.html and read the error percentage, throughput and percentiles.
When should I use k6, Gatling or Playwright instead of JMeter?
Use k6 or Gatling when you want the load test as source-controlled code rather than a GUI-built plan, in JavaScript for k6 or Java, JavaScript, Kotlin or Scala for Gatling. Use Playwright when the API calls serve a browser suite. Keep JMeter for GUI-built plans and existing JMX estates.
Build the plan once, run it from the command line, gate CI on the result log, and keep the load numbers for an environment you own.





