Evaluating CodeRabbit? Same review, plus real test runs. See why

API Testing14 min read

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

S
Technical Writer, Qodex
Apache JMeter logo
Part of our API Testing guide. Read the guide

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:

ElementWhat it doesWhere it sits
Test Plan (the .jmx file)The root of the plan, and the file you commitRoot
Thread GroupThreads, ramp-up, loops, optional durationUnder the Test Plan
HTTP Request DefaultsProtocol and host, set once for the samplers belowThread Group, above the samplers
HTTP Header ManagerAdds or overrides headers such as AcceptThread Group, applies in scope
HTTP Request samplerSends one request: method, path, parameters, bodyThread Group, one per call
Response AssertionFails the sample on an unexpected response codeChild of a sampler
JSON AssertionFails on invalid JSON, a missing path, or a wrong valueChild of a sampler
JSON ExtractorCopies a JSON value into a variableChild of a sampler
CSV Data Set ConfigFeeds file rows into variables, so threads differThread Group, above the samplers
Timer (Constant or Uniform Random)Delays each thread before every sampler in scopeThread Group
View Results TreeShows each request and response, debug onlyThread Group
Non-GUI run and HTML dashboard-n runs the plan, -l writes the log, -e -o builds the reportThe 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.

  1. 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 __P function reads a property passed with -J and falls back to the default you give it, so one file runs as a smoke test today and a load test later.

  2. 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.

  3. 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 needs Content-Type: application/json too.

  4. 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 whose id was 1.

  5. 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.

  6. Add a JSON Assertion under Get post. Path $.id, value checking on, expected value 1. 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.

  7. Add a JSON Extractor under Get post. Variable name post_id, expression $.id, match number 1, default NOT_FOUND. Match number matters: 1 selects the first match, 0 selects a random one, -1 extracts every match. The default is what you get when nothing matches, and NOT_FOUND fails more clearly than an empty string.

  8. Add a second HTTP Request named Get comments. Method GET, path /comments?postId=${post_id}. Add a Response Assertion for 200 and a JSON Assertion for $[0].postId expecting 1. On 2 September 2026 that endpoint returned HTTP 200 and five records whose first postId was 1.

  9. Add a Uniform Random Timer under the Thread Group. A timer delays each thread before every sampler in its scope, including the first one.

  10. 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 insteadChoose it whenWhat the vendor's own docs say
k6You want the load test to be a source-controlled JavaScript file, not a GUI-built planGrafana'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
GatlingYour team works in Java, JavaScript or TypeScript, Kotlin or Scala and wants test-as-codeGatling'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 testsThe API calls set up or verify browser state and belong in the UI suitePlaywright 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.

Ship continuously. Test continuously.

Qodex explores your app, writes runnable tests, and replays them on every change at zero LLM cost.