REST, JSON, GraphQL & SOAP APIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page was written against Apache JMeter 5.6.3 (latest release on 2026-10-14; it runs on Java 8 and later, Java 17 or later is recommended), with the differences planned for JMeter 6.0 noted where they matter. JMeter 6.0 is not released: the planned changes come from xdocs/changes.xml on the master branch, need Java 17 or later (Java 21 recommended) and are described as planned, not as shipped behaviour. Everything on this page is core JMeter, so no plugin is needed. The JSON elements evaluate paths with the Jayway JsonPath library (the component reference documents the syntax of JsonPath 1.2.0; 6.0 plans to move to json-path 3.0.0), the XPath2 elements run on Saxon-HE (6.0 plans Saxon-HE 13), and the GraphQL HTTP Request exists since JMeter 5.4. The books on the market target JMeter 2.11 to 3.0: they test SOAP with a dedicated sampler that no longer exists and parse JSON with a jmeter-plugins post-processor that core JMeter has since replaced. Every field name, property and rule below is taken from the official component reference, functions reference, user manual and change log, and where the documentation is silent, from the 5.6.3 source code, which is said explicitly.

The HTTP mechanics (the HTTP Request sampler, HTTP Request Defaults, implementations, timeouts, HTTPS) are covered in HTTP Requests, and the managers that carry state between requests (headers, cookies, tokens) in HTTP Sessions, Headers, Cookies & Authentication. This page builds on both and concentrates on what changes when the system under test is an API rather than a web page: bodies in JSON or XML, the right content type, every HTTP method, status codes as part of the contract, and response checks that understand the payload. The server side of the Bookshelf REST API is the kind of service built in REST APIs with Spring MVC and WebFlux, and its GraphQL endpoint the kind described in the GraphQL Reference; this page does not repeat how to build them.

Where API testing sits in a test plan

An API test in JMeter is an ordinary HTTP test with a few elements in specific places. The table maps each need to the core element that covers it.

Need Element (menu path) Where it goes

Host, port, protocol shared by all requests

HTTP Request Defaults (Add → Config Element → HTTP Request Defaults)

Thread Group level, filled from ${__P(host,localhost)} and ${__P(port,8080)}.

Content-Type, Accept and Authorization headers

HTTP Header Manager (Add → Config Element → HTTP Header Manager)

Thread Group level for headers every request needs; under a sampler for headers only that request needs.

A request with a JSON or XML body

HTTP Request (Add → Sampler → HTTP Request), Body Data tab

Inside the Thread Group or a controller, one per API operation.

A GraphQL operation

GraphQL HTTP Request (Add → Sampler → GraphQL HTTP Request)

Same place as any sampler.

Read a value (an id, a token) from a JSON response

JSON Extractor (Add → Post Processors → JSON Extractor)

As a child of the sampler whose response holds the value.

Read a value from an XML or SOAP response

XPath2 Extractor (Add → Post Processors → XPath2 Extractor)

As a child of the sampler.

Check the status code

Response Assertion (Add → Assertions → Response Assertion), Field to Test = Response Code

As a child of the sampler.

Check the JSON payload

JSON Assertion (Add → Assertions → JSON Assertion)

As a child of the sampler.

Check the XML payload, or a SOAP fault

XPath2 Assertion (Add → Assertions → XPath2 Assertion)

As a child of the sampler.

The execution order for one sampler is the one described in Test Plan Elements, Execution Order & Scoping: configuration elements (Defaults, Header Manager), pre-processors, timers, the sampler, post-processors (the extractors), assertions, then listeners. Extractors therefore run before assertions on the same response, so a JSON Extractor sets its variable even when a later assertion fails the sample; a default value that makes the failure visible matters for that reason.

flowchart TD TG["Thread Group (threads, rampup, duration)"] TG --> DEF["HTTP Request Defaults: host, port, protocol"] TG --> HM["HTTP Header Manager: Content-Type and Accept application/json"] TG --> LOGIN["HTTP Request POST /api/auth/login"] LOGIN --> JX1["JSON Extractor: token"] TG --> CREATE["HTTP Request POST /api/loans (Body Data)"] CREATE --> JX2["JSON Extractor: loanId"] CREATE --> RA["Response Assertion: code equals 201"] CREATE --> JA["JSON Assertion: status is ACTIVE"] TG --> GQL["GraphQL HTTP Request POST /graphql"] GQL --> JA2["JSON Assertion: errors absent"]

REST requests with JSON bodies

Body Data versus Parameters

The HTTP Request sampler has three tabs below the path. For APIs the choice between them decides how the request body is built:

Tab What JMeter sends Use it for

Parameters

For GET and DELETE, a query string appended to the URL. For POST and PUT, a form-encoded body (or a multipart body when Use multipart/form-data is ticked). Each row has the columns Name, Value, URL Encode?, Content-Type and Include Equals?.

Query strings on GET and DELETE; HTML-form style POSTs.

Body Data

The text of the tab, exactly as typed after variable and function substitution, as the request body. Each line is sent with CRLF appended, except the last one.

JSON, XML and SOAP bodies on POST, PUT and PATCH (and on GET or DELETE if the API really expects a body).

Files Upload

A file. A single file with an empty Parameter Name on POST, PUT or PATCH is sent as the whole body, with its MIME Type as the content type; otherwise the request becomes multipart.

Large or binary bodies kept outside the .jmx.

The component reference lists JSON REST, XML REST and SOAP requests among the uses of the Body Data tab. Once a sampler has Body Data, the GUI does not let you go back to the Parameters tab until the Body Data is cleared. Under the hood Body Data is a single parameter with no name, and the JMX records it with HTTPSampler.postBodyRaw set to true.

Two body-related fields of the sampler matter for APIs:

Field Meaning JMX property

Method

GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (PATCH is not supported by the Java implementation). With HttpClient4 the WebDAV methods are allowed too, and more can be declared with the property httpsampler.user_defined_methods.

HTTPSampler.method

Content encoding

The character encoding of the body for POST, PUT, PATCH and FILE. It is the charset JMeter uses to turn the text into bytes, not the Content-Encoding HTTP header. Since 5.6.1 the HTTP sampler uses UTF-8 by default when it is empty.

HTTPSampler.contentEncoding

JSON bodies with variables

A JSON body is typed into Body Data with variable references where the values change per user or per iteration. JMeter substitutes ${...} references and functions before sending; it does not parse or validate the JSON. The loan request of the Bookshelf journey, for a book id picked from the search results and a due date three weeks ahead:

{
  "bookId": "${bookId}",
  "dueDate": "${__timeShift(yyyy-MM-dd,,P21D,,)}",
  "channel": "load-test"
}

Steps in the GUI:

  1. Select the sampler and set Method to POST and Path to /api/loans.

  2. Open the Body Data tab and paste the JSON.

  3. Make sure an HTTP Header Manager in scope sends Content-Type: application/json (next section).

  4. Run the plan once with a View Results Tree listener enabled and check the Request tab: the body must show the substituted values, not the literal ${bookId}.

The resulting .jmx fragment:

<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="POST /api/loans">
  <stringProp name="HTTPSampler.path">/api/loans</stringProp>
  <stringProp name="HTTPSampler.method">POST</stringProp>
  <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
  <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
    <collectionProp name="Arguments.arguments">
      <elementProp name="" elementType="HTTPArgument">
        <boolProp name="HTTPArgument.always_encode">false</boolProp>
        <stringProp name="Argument.value">{"bookId": "${bookId}", "dueDate": "${__timeShift(yyyy-MM-dd,,P21D,,)}", "channel": "load-test"}</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
      </elementProp>
    </collectionProp>
  </elementProp>
  <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
  <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree/>

Three rules keep JSON bodies correct under load:

  • Quote string variables in the template, not in the data. "bookId": "${bookId}" with a bare id in the CSV file keeps the data file simple and the JSON valid. Numbers and booleans go without quotes: "copies": ${copies}.

  • Values must already be JSON-safe. JMeter inserts the text as is. A search term or a name with a double quote or a backslash breaks the body. Core JMeter has __escapeXml and __urlencode but no JSON-escaping function; clean the test data up front, or let Groovy produce a quoted JSON string with ${__groovy(groovy.json.JsonOutput.toJson(vars.get("term")))} (the result includes the surrounding quotes, so the template must not add its own).

  • Undefined variables are sent literally. If bookId was never set (an extractor failed), the server receives the text ${bookId}, usually answers 400, and the error looks like a server problem. Give extractors a visible default value (BOOK_ID_NOT_FOUND) and assert on the status code.

Large bodies can live in a file and be read once with ${__FileToString(loan-template.json,,)}; the template is re-read on every call, so for hot requests keep it in Body Data. A body that must vary in structure rather than in values belongs in a JSR223 PreProcessor that builds it in Groovy, covered on Scripting with JSR223 & Groovy.

Content-Type and Accept headers

The HTTP Request sampler does not add Content-Type: application/json on its own. The component reference is explicit for the methods that carry an entity: GET, DELETE, PUT and PATCH require a Content-Type, and if no file is used, a Header Manager attached to the sampler must define it. POST behaves the same way since JMeter 5.0: the property http.post_add_content_type_if_missing (default false, it was effectively true before 5.0) is what used to add application/x-www-form-urlencoded to POSTs without a content type. In the 5.6.3 source, a Body Data body with no Content-Type header is sent by the HttpClient4 implementation as a plain-text entity, so a strict API framework sees text/plain and answers 415 Unsupported Media Type.

The usual arrangement is one HTTP Header Manager at Thread Group level:

Field Meaning JMX

Name

Descriptive name of the element in the tree.

testname attribute

Name (header)

The request header name, for example Content-Type.

Header.name

Value

The header value. Variables and functions are allowed, for example Bearer ${token}.

Header.value

Add / Delete

Add a row, or delete the selected row.

(none)

Load / Save As

Load a saved header table and append its entries, or save the table to a file.

(none)

<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="JSON API headers">
  <collectionProp name="HeaderManager.headers">
    <elementProp name="" elementType="Header">
      <stringProp name="Header.name">Content-Type</stringProp>
      <stringProp name="Header.value">application/json</stringProp>
    </elementProp>
    <elementProp name="" elementType="Header">
      <stringProp name="Header.name">Accept</stringProp>
      <stringProp name="Header.value">application/json</stringProp>
    </elementProp>
  </collectionProp>
</HeaderManager>
<hashTree/>

The component reference says several Header Managers in scope are merged into one list for the sampler, an entry with the same name replacing the earlier one, and an empty value replacing (not removing) a header. In the 5.6.3 source the manager closest to the sampler is merged first and keeps its value for a name both define, which is what makes "defaults at Thread Group level, adjustments on the sampler" work: a SOAP request below can override Content-Type with its own Header Manager. The user manual’s test-plan chapter states the opposite rule for Header Managers, as discussed under Defaults are merged, three managers are not, so check the headers actually sent (View Results Tree, Request tab, Request Headers) whenever a plan relies on the override.

Two smaller points:

  • Accept: application/json is not required by most APIs, but content negotiation is part of production traffic and some frameworks return HTML error pages without it, which makes failures harder to read.

  • A GET without a body needs no Content-Type. A Thread-Group-level Content-Type is harmless on GETs with HttpClient4, because a header is only meaningful when there is an entity; keep the GETs in the same scope rather than adding a second manager to remove it (an empty value does not remove a header).

PUT, PATCH and DELETE

The methods work the same way in the sampler; what changes is where the data goes and what the API is expected to answer. Since 3.1 only POST supports multipart requests and file uploads; PUT and PATCH take their body from Body Data or from a single file with no parameter name.

Method Body in JMeter Bookshelf example Typical success code

GET

None; data in the path or the Parameters tab (query string).

GET /api/loans/${loanId}

200

POST

Body Data (JSON), with Content-Type.

POST /api/loans creates a loan.

201, with a Location header and the new resource

PUT

Body Data with the complete representation; Content-Type required.

PUT /api/loans/${loanId} replaces the loan (book, due date, status).

200 with the resource, or 204

PATCH

Body Data with only the changed fields; Content-Type required. Not available with the Java implementation.

PATCH /api/loans/${loanId} extends the due date.

200 or 204

DELETE

Usually none; data in the path. The Parameters tab builds a query string, as for GET.

DELETE /api/loans/${loanId} returns the book.

204, then 404 on a later GET

The 5.6.3 HttpClient4 implementation can also send a body with GET or DELETE when Body Data is set (it switches to an entity-enclosing request); use it only if the API really expects one, because proxies and servers often ignore or reject such bodies.

For PATCH the media type is whatever the API defines. A JSON merge patch is usually sent as application/merge-patch+json and a JSON Patch document as application/json-patch+json; put that header in a Header Manager under the PATCH sampler, which overrides the Thread-Group-level application/json as explained above.

Query strings and URL encoding

Search requests carry their data in the query string. There are two ways to build it, and they encode differently:

  • Parameters tab (recommended). Add rows query = ${term}, page = 0, size = 20 and tick URL Encode?. JMeter encodes each value with the HTTP sampler’s encoding (UTF-8 by default since 5.6.1) and appends ?query=...&page=0&size=20 for GET and DELETE.

  • Inline in the Path. /api/books?query=${term} works only for safe values. The component reference warns that the path is not encoded apart from replacing spaces with %20, so reserved or unsafe characters can cause errors such as URISyntaxException. If the path must be built inline, encode the value yourself with ${__urlencode(${term})}.

How the term war & peace ends up on the wire:

Built with Sent as Result

Parameters tab, URL Encode? ticked

query=war+%26+peace

Correct: one parameter whose value is war & peace.

Path /api/books?query=${term}

query=war%20&%20peace

Wrong: the & splits the value, the server sees query=war (with a trailing space) and a parameter named peace (with a leading space).

Path with ${__urlencode(${term})}

query=war+%26+peace

Correct.

__urlencode uses Java’s URLEncoder, which produces application/x-www-form-urlencoded text: spaces become +. Servers decode + as a space in query strings; in a path segment + is literal, so encode path ids (rare in practice, ids are usually safe) with care. The Include Equals? column controls whether name= is sent for an empty value; some APIs treat ?q= and ?q differently.

The query-string parameters in the .jmx:

<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET /api/books">
  <stringProp name="HTTPSampler.path">/api/books</stringProp>
  <stringProp name="HTTPSampler.method">GET</stringProp>
  <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
    <collectionProp name="Arguments.arguments">
      <elementProp name="query" elementType="HTTPArgument">
        <boolProp name="HTTPArgument.always_encode">true</boolProp>
        <stringProp name="Argument.name">query</stringProp>
        <stringProp name="Argument.value">${term}</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
        <boolProp name="HTTPArgument.use_equals">true</boolProp>
      </elementProp>
      <elementProp name="size" elementType="HTTPArgument">
        <boolProp name="HTTPArgument.always_encode">true</boolProp>
        <stringProp name="Argument.name">size</stringProp>
        <stringProp name="Argument.value">20</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
        <boolProp name="HTTPArgument.use_equals">true</boolProp>
      </elementProp>
    </collectionProp>
  </elementProp>
</HTTPSamplerProxy>
<hashTree/>

JMeter 6.0 plans (per changes.xml on master) to let each HTTP argument row be enabled or disabled individually in the sampler UI, which helps when experimenting with optional query parameters.

Checking API responses

An API is a contract: the status code, the content type and the shape of the body are all part of it. JMeter checks the first automatically and the rest only if assertions are added.

Status codes

By default the HTTP sampler marks responses with a status in the 4xx and 5xx ranges as unsuccessful and everything else as successful. That catches server errors but not contract violations: a 200 where the API should answer 201, or a 302 to a login page, passes. A Response Assertion on the response code makes the expectation explicit.

Field Meaning JMX

Name

Descriptive name in the tree.

testname

Apply to

Main sample only, Sub-samples only, Main sample and sub-samples, or JMeter Variable Name to use. For an API call, the main sample.

Sample.scope (absent means main sample)

Field to Test

Text Response, Request Data, Response Code, Response Message, Response Headers, Request Headers, URL Sampled, Document (text). For status checks, Response Code.

Assertion.test_field = Assertion.response_code

Ignore Status

Sets the sample to successful before evaluating the patterns, so a 4xx or 5xx can be asserted as expected. It clears earlier assertion failures, so use it only on the first assertion of a sampler.

Assertion.assume_success

Pattern Matching Rules

Contains and Matches (regular expressions), Equals and Substring (plain, case-sensitive text), plus Not to invert and Or to pass when any pattern matches instead of all.

Assertion.test_type: Matches 1, Contains 2, Not 4, Equals 8, Substring 16, Or 32, added together

Patterns to Test

One or more patterns, checked in turn.

Asserion.test_strings (the misspelling is JMeter’s own property name)

Custom failure message

Replaces the generated failure message.

Assertion.custom_message

201 Created on the loan request:

<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Status is 201">
  <collectionProp name="Asserion.test_strings">
    <stringProp name="49587">201</stringProp>
  </collectionProp>
  <stringProp name="Assertion.custom_message">POST /api/loans must answer 201 Created</stringProp>
  <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
  <boolProp name="Assertion.assume_success">false</boolProp>
  <intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>

An expected error, such as 404 after the loan was deleted, needs Ignore Status so the 4xx does not fail the sample before the assertion runs:

<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Deleted loan is 404">
  <collectionProp name="Asserion.test_strings">
    <stringProp name="51512">404</stringProp>
  </collectionProp>
  <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
  <boolProp name="Assertion.assume_success">true</boolProp>
  <intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>

To accept a family of codes (200 or 204 after a PUT), use Matches with the pattern 20[04] (Assertion.test_type 1), or Equals with Or and two patterns (Assertion.test_type 40).

JSON Assertion

The JSON Assertion first parses the response and fails if it is not JSON, then evaluates a JSONPath expression and fails if the path is not found, and finally, if asked, compares the value found with an expected value. If the path returns an array, the assertion passes when any element matches; [] asserts an empty array; an object is converted to a string before comparison.

Field Meaning JMX

Assert JSON Path exists

The JSONPath expression, for example $.status or $.items[*].id. Required.

JSON_PATH

Additionally assert value

Also compare the value found; without it the assertion only checks that the path exists.

JSONVALIDATION

Match as regular expression

Treat Expected Value as a regular expression. In the 5.6.3 source the property defaults to true when it is absent, so set it explicitly.

ISREGEX

Expected Value

The value, or the regular expression, to compare with.

EXPECTED_VALUE

Expect null

Pass when the value found is null.

EXPECT_NULL

Invert assertion (will fail if above conditions met)

Negate the result, for example to fail when an errors field is present.

INVERT

Two documented behaviours catch people out. With an indefinite path (one with a wildcard, filter or deep scan, such as $.items[*].id), an assertion without a value used to pass even when nothing matched; since JMeter 5.5 it fails if the path is indefinite, the result is an empty list and no value is asserted. And with Match as regular expression on, an expected value like 1.5 matches 105, because . is a regex metacharacter.

The loan must come back active:

<JSONPathAssertion guiclass="JSONPathAssertionGui" testclass="JSONPathAssertion" testname="Loan is ACTIVE">
  <stringProp name="JSON_PATH">$.status</stringProp>
  <stringProp name="EXPECTED_VALUE">ACTIVE</stringProp>
  <boolProp name="JSONVALIDATION">true</boolProp>
  <boolProp name="EXPECT_NULL">false</boolProp>
  <boolProp name="INVERT">false</boolProp>
  <boolProp name="ISREGEX">false</boolProp>
</JSONPathAssertion>
<hashTree/>

The JSON JMESPath Assertion (since 5.2) does the same with a JMESPath expression instead of JSONPath; its fields are the same apart from the expression language. The full catalogue of assertions, and how to keep their cost down, is on Assertions.

JSON Extractor

The JSON Extractor (renamed from JSON Path PostProcessor in 3.1) reads values from a JSON response into variables. It is the standard way to carry ids and tokens from one API call to the next. Its fields, in brief (the detailed treatment with multiple expressions and ForEach loops is on Correlation & Extractors):

Field Meaning JMX

Apply to

Main sample, sub-samples, both, or a JMeter variable.

Sample.scope

Names of created variables

Semicolon-separated variable names, one per expression.

JSONPostProcessor.referenceNames

JSON Path expressions

Semicolon-separated JSONPath expressions, one per variable.

JSONPostProcessor.jsonPathExprs

Default Values

Semicolon-separated defaults used when an expression finds nothing.

JSONPostProcessor.defaultValues

Match No. (0 for Random)

0 random (the default), n the n-th match, -1 all matches as name_1 to name_N plus name_matchNr.

JSONPostProcessor.match_numbers

Compute concatenation var (suffix _ALL)

Also store all matches joined with , in name_ALL.

JSONPostProcessor.compute_concat

The new loan id, read from the 201 response of POST /api/loans:

<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract loanId">
  <stringProp name="JSONPostProcessor.referenceNames">loanId</stringProp>
  <stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp>
  <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
  <stringProp name="JSONPostProcessor.defaultValues">LOAN_ID_NOT_FOUND</stringProp>
</JSONPostProcessor>
<hashTree/>

The Location header of a 201 holds the same id as a URL. A Boundary or Regular Expression Extractor on the Response Headers field can read it when the body is empty; for Bookshelf the body carries the id, so the JSON Extractor is simpler.

The Bookshelf CRUD flow

This section assembles the elements above into one complete, runnable flow over the loan resource. For this page the Bookshelf API’s loan resource is assumed to accept, besides the POST /api/loans and GET /api/loans/{id} used throughout the section, PUT /api/loans/{id} (replace), PATCH /api/loans/{id} (change the due date) and DELETE /api/loans/{id} (return the book). Point it only at the local Bookshelf stack from the Docker Compose file, or at another system you own.

sequenceDiagram autonumber participant T as JMeter thread participant A as Bookshelf API T->>A: POST /api/auth/login with username and password A-->>T: 200 and a token, read by a JSON Extractor T->>A: POST /api/loans with bookId and dueDate A-->>T: 201 and the loan, loanId extracted, status ACTIVE asserted T->>A: GET /api/loans/loanId A-->>T: 200, bookId asserted T->>A: PUT /api/loans/loanId with the full loan A-->>T: 200 or 204 T->>A: PATCH /api/loans/loanId with a new dueDate A-->>T: 200, dueDate asserted T->>A: DELETE /api/loans/loanId A-->>T: 204 T->>A: GET /api/loans/loanId A-->>T: 404 asserted with Ignore Status

The plan tree, as it appears in the GUI:

Test Plan  bookshelf-crud.jmx
└── Thread Group  (threads, rampup, duration from properties)
    ├── HTTP Request Defaults        host=${__P(host,localhost)} port=${__P(port,8080)}
    ├── HTTP Header Manager          Content-Type / Accept: application/json, Authorization: Bearer ${token}
    ├── CSV Data Set Config          users.csv  (username, password)
    ├── Once Only Controller
    │   └── POST /api/auth/login     + JSON Extractor token
    ├── Transaction Controller  "Loan CRUD"
    │   ├── POST /api/loans          + JSON Extractor loanId, Response Assertion 201, JSON Assertion ACTIVE
    │   ├── GET /api/loans/${loanId} + JSON Assertion bookId
    │   ├── PUT /api/loans/${loanId} + Response Assertion 20[04]
    │   ├── PATCH /api/loans/${loanId} + JSON Assertion dueDate
    │   ├── DELETE /api/loans/${loanId} + Response Assertion 204
    │   └── GET /api/loans/${loanId} + Response Assertion 404 (Ignore Status)
    └── Uniform Random Timer         think time between requests

The Authorization header is sent on the login request too, with an unresolved ${token} on the first iteration; the Bookshelf login endpoint ignores it. Where an API rejects that, move the header into a Header Manager under the Transaction Controller; the token flow itself is the subject of HTTP Sessions, Headers, Cookies & Authentication.

The complete .jmx, kept small (listeners are left out because a CLI run writes the JTL file with -l). The book id comes from a property here so that the file runs on its own; in bookshelf-load.jmx it comes from the search results.

<?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="bookshelf-crud"/>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Loan CRUD users">
        <stringProp name="ThreadGroup.num_threads">${__P(threads,1)}</stringProp>
        <stringProp name="ThreadGroup.ramp_time">${__P(rampup,1)}</stringProp>
        <boolProp name="ThreadGroup.scheduler">true</boolProp>
        <stringProp name="ThreadGroup.duration">${__P(duration,30)}</stringProp>
        <stringProp name="ThreadGroup.on_sample_error">startnextloop</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController">
          <intProp name="LoopController.loops">-1</intProp>
          <boolProp name="LoopController.continue_forever">false</boolProp>
        </elementProp>
      </ThreadGroup>
      <hashTree>
        <ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP Request Defaults">
          <stringProp name="HTTPSampler.domain">${__P(host,localhost)}</stringProp>
          <stringProp name="HTTPSampler.port">${__P(port,8080)}</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
        </ConfigTestElement>
        <hashTree/>
        <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="JSON API headers">
          <collectionProp name="HeaderManager.headers">
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">Content-Type</stringProp>
              <stringProp name="Header.value">application/json</stringProp>
            </elementProp>
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">Accept</stringProp>
              <stringProp name="Header.value">application/json</stringProp>
            </elementProp>
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">Authorization</stringProp>
              <stringProp name="Header.value">Bearer ${token}</stringProp>
            </elementProp>
          </collectionProp>
        </HeaderManager>
        <hashTree/>
        <CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="users.csv">
          <stringProp name="filename">users.csv</stringProp>
          <stringProp name="variableNames">username,password</stringProp>
          <boolProp name="ignoreFirstLine">true</boolProp>
          <stringProp name="delimiter">,</stringProp>
          <boolProp name="recycle">true</boolProp>
          <boolProp name="stopThread">false</boolProp>
          <stringProp name="shareMode">shareMode.all</stringProp>
        </CSVDataSet>
        <hashTree/>
        <OnceOnlyController guiclass="OnceOnlyControllerGui" testclass="OnceOnlyController" testname="Login once per user"/>
        <hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="POST /api/auth/login">
            <stringProp name="HTTPSampler.path">/api/auth/login</stringProp>
            <stringProp name="HTTPSampler.method">POST</stringProp>
            <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
            <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
              <collectionProp name="Arguments.arguments">
                <elementProp name="" elementType="HTTPArgument">
                  <boolProp name="HTTPArgument.always_encode">false</boolProp>
                  <stringProp name="Argument.value">{"username": "${username}", "password": "${password}"}</stringProp>
                  <stringProp name="Argument.metadata">=</stringProp>
                </elementProp>
              </collectionProp>
            </elementProp>
          </HTTPSamplerProxy>
          <hashTree>
            <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract token">
              <stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
              <stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
              <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
              <stringProp name="JSONPostProcessor.defaultValues">TOKEN_NOT_FOUND</stringProp>
            </JSONPostProcessor>
            <hashTree/>
          </hashTree>
        </hashTree>
        <TransactionController guiclass="TransactionControllerGui" testclass="TransactionController" testname="Loan CRUD">
          <boolProp name="TransactionController.parent">false</boolProp>
          <boolProp name="TransactionController.includeTimers">false</boolProp>
        </TransactionController>
        <hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="POST /api/loans">
            <stringProp name="HTTPSampler.path">/api/loans</stringProp>
            <stringProp name="HTTPSampler.method">POST</stringProp>
            <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
            <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
              <collectionProp name="Arguments.arguments">
                <elementProp name="" elementType="HTTPArgument">
                  <boolProp name="HTTPArgument.always_encode">false</boolProp>
                  <stringProp name="Argument.value">{"bookId": "${__P(bookId,42)}", "dueDate": "${__timeShift(yyyy-MM-dd,,P21D,,)}"}</stringProp>
                  <stringProp name="Argument.metadata">=</stringProp>
                </elementProp>
              </collectionProp>
            </elementProp>
          </HTTPSamplerProxy>
          <hashTree>
            <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract loanId">
              <stringProp name="JSONPostProcessor.referenceNames">loanId</stringProp>
              <stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp>
              <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
              <stringProp name="JSONPostProcessor.defaultValues">LOAN_ID_NOT_FOUND</stringProp>
            </JSONPostProcessor>
            <hashTree/>
            <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Status is 201">
              <collectionProp name="Asserion.test_strings">
                <stringProp name="49587">201</stringProp>
              </collectionProp>
              <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
              <boolProp name="Assertion.assume_success">false</boolProp>
              <intProp name="Assertion.test_type">8</intProp>
            </ResponseAssertion>
            <hashTree/>
            <JSONPathAssertion guiclass="JSONPathAssertionGui" testclass="JSONPathAssertion" testname="Loan is ACTIVE">
              <stringProp name="JSON_PATH">$.status</stringProp>
              <stringProp name="EXPECTED_VALUE">ACTIVE</stringProp>
              <boolProp name="JSONVALIDATION">true</boolProp>
              <boolProp name="EXPECT_NULL">false</boolProp>
              <boolProp name="INVERT">false</boolProp>
              <boolProp name="ISREGEX">false</boolProp>
            </JSONPathAssertion>
            <hashTree/>
          </hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET /api/loans/{id}">
            <stringProp name="HTTPSampler.path">/api/loans/${loanId}</stringProp>
            <stringProp name="HTTPSampler.method">GET</stringProp>
          </HTTPSamplerProxy>
          <hashTree>
            <JSONPathAssertion guiclass="JSONPathAssertionGui" testclass="JSONPathAssertion" testname="Same book">
              <stringProp name="JSON_PATH">$.bookId</stringProp>
              <stringProp name="EXPECTED_VALUE">${__P(bookId,42)}</stringProp>
              <boolProp name="JSONVALIDATION">true</boolProp>
              <boolProp name="EXPECT_NULL">false</boolProp>
              <boolProp name="INVERT">false</boolProp>
              <boolProp name="ISREGEX">false</boolProp>
            </JSONPathAssertion>
            <hashTree/>
          </hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="PUT /api/loans/{id}">
            <stringProp name="HTTPSampler.path">/api/loans/${loanId}</stringProp>
            <stringProp name="HTTPSampler.method">PUT</stringProp>
            <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
            <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
              <collectionProp name="Arguments.arguments">
                <elementProp name="" elementType="HTTPArgument">
                  <boolProp name="HTTPArgument.always_encode">false</boolProp>
                  <stringProp name="Argument.value">{"bookId": "${__P(bookId,42)}", "dueDate": "${__timeShift(yyyy-MM-dd,,P21D,,)}", "status": "ACTIVE"}</stringProp>
                  <stringProp name="Argument.metadata">=</stringProp>
                </elementProp>
              </collectionProp>
            </elementProp>
          </HTTPSamplerProxy>
          <hashTree>
            <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Status is 200 or 204">
              <collectionProp name="Asserion.test_strings">
                <stringProp name="1478545372">20[04]</stringProp>
              </collectionProp>
              <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
              <boolProp name="Assertion.assume_success">false</boolProp>
              <intProp name="Assertion.test_type">1</intProp>
            </ResponseAssertion>
            <hashTree/>
          </hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="PATCH /api/loans/{id}">
            <stringProp name="HTTPSampler.path">/api/loans/${loanId}</stringProp>
            <stringProp name="HTTPSampler.method">PATCH</stringProp>
            <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
            <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
              <collectionProp name="Arguments.arguments">
                <elementProp name="" elementType="HTTPArgument">
                  <boolProp name="HTTPArgument.always_encode">false</boolProp>
                  <stringProp name="Argument.value">{"dueDate": "${__timeShift(yyyy-MM-dd,,P28D,,newDueDate)}"}</stringProp>
                  <stringProp name="Argument.metadata">=</stringProp>
                </elementProp>
              </collectionProp>
            </elementProp>
          </HTTPSamplerProxy>
          <hashTree>
            <JSONPathAssertion guiclass="JSONPathAssertionGui" testclass="JSONPathAssertion" testname="Due date changed">
              <stringProp name="JSON_PATH">$.dueDate</stringProp>
              <stringProp name="EXPECTED_VALUE">${newDueDate}</stringProp>
              <boolProp name="JSONVALIDATION">true</boolProp>
              <boolProp name="EXPECT_NULL">false</boolProp>
              <boolProp name="INVERT">false</boolProp>
              <boolProp name="ISREGEX">false</boolProp>
            </JSONPathAssertion>
            <hashTree/>
          </hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="DELETE /api/loans/{id}">
            <stringProp name="HTTPSampler.path">/api/loans/${loanId}</stringProp>
            <stringProp name="HTTPSampler.method">DELETE</stringProp>
          </HTTPSamplerProxy>
          <hashTree>
            <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Status is 204">
              <collectionProp name="Asserion.test_strings">
                <stringProp name="49590">204</stringProp>
              </collectionProp>
              <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
              <boolProp name="Assertion.assume_success">false</boolProp>
              <intProp name="Assertion.test_type">8</intProp>
            </ResponseAssertion>
            <hashTree/>
          </hashTree>
          <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET deleted loan">
            <stringProp name="HTTPSampler.path">/api/loans/${loanId}</stringProp>
            <stringProp name="HTTPSampler.method">GET</stringProp>
          </HTTPSamplerProxy>
          <hashTree>
            <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Deleted loan is 404">
              <collectionProp name="Asserion.test_strings">
                <stringProp name="51512">404</stringProp>
              </collectionProp>
              <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
              <boolProp name="Assertion.assume_success">true</boolProp>
              <intProp name="Assertion.test_type">8</intProp>
            </ResponseAssertion>
            <hashTree/>
          </hashTree>
        </hashTree>
        <UniformRandomTimer guiclass="UniformRandomTimerGui" testclass="UniformRandomTimer" testname="Think time 1-3 s">
          <stringProp name="ConstantTimer.delay">1000</stringProp>
          <stringProp name="RandomTimer.range">2000</stringProp>
        </UniformRandomTimer>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

Details worth noticing in the file:

  • The PATCH body calls __timeShift with a fifth argument, newDueDate, which stores the value it generated in a variable, so the JSON Assertion can compare the response with exactly what was sent.

  • The Transaction Controller sets TransactionController.includeTimers explicitly. The GUI writes false for new controllers, but the 5.6.3 source reads a missing property as true, so a hand-written plan should always set it.

  • The thread group reads threads, rampup and duration with __P, so the same file serves the smoke run below and a load run with -J overrides.

  • Action to be taken after a Sampler error is Start Next Thread Loop: if the POST fails there is no loan to read, update or delete, and continuing would only add misleading 404s.

A smoke run with one user, against the local Bookshelf stack:

jmeter -n -t bookshelf-crud.jmx -l crud.jtl -Jthreads=1 -Jrampup=1 -Jduration=30 -Jhost=localhost -Jport=8080
Creating summariser <summary>
Created the tree successfully using bookshelf-crud.jmx
Starting standalone test @ 2026 Oct 14 11:00:00 CEST (1791968400000)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary +     14 in 00:00:29 =    0.5/s Avg:    38 Min:     9 Max:   161 Err:     0 (0.00%) Active: 1 Started: 1 Finished: 0
summary =     14 in 00:00:29 =    0.5/s Avg:    38 Min:     9 Max:   161 Err:     0 (0.00%)
Tidying up ...    @ 2026 Oct 14 11:00:30 CEST (1791968430000)
... end of run

The output is representative; counts and times depend on the machine. Err: 0 is the number to check first: it includes every failed assertion, so a zero here means each status code and JSON check above passed. The summariser ignores transaction samples by default, so the 14 samples are HTTP requests only: one login, two full iterations of six requests and the first request of a third, cut off when the 30 s duration ends. With the 1 s to 3 s think time before every request, each request costs about 2 s of wall time, which is why one user sends only about one request every two seconds.

Worked numbers: why the DELETE belongs in the load test

The CRUD flow above is a functional check. In the load plan, the borrow journey of the Bookshelf mix (60 % search only, 30 % browse and borrow, 10 % login-heavy) creates a loan on every pass, and the DELETE that returns the book is what keeps the data stable. With the standard load profile (50 threads, 60 s ramp-up, 660 s duration, so a 10-minute hold), assume an average response time of 0.15 s, the section’s average think time of 3 s per request (Z = 3 s), and these requests per iteration: search only 2, borrow 6 (search, view, create, view loan, extend, return), login-heavy 3.

  • Throughput at full load, by Little’s Law (see Performance Testing Fundamentals): 50 / (0.15 + 3) ≈ 15.9 requests/s.

  • Average requests per iteration: 0.6 x 2 + 0.3 x 6 + 0.1 x 3 = 3.3, so ≈ 15.9 / 3.3 ≈ 4.8 iterations/s.

  • Loans created: 30 % of iterations, ≈ 1.44 loans/s, or ≈ 870 loans over the 10-minute hold (plus about half a minute’s worth during the ramp-up).

Without the DELETE, about 870 active loans pile up in a single run and the books' available copies run out: later iterations get 409 Conflict instead of 201, the error rate climbs for reasons that have nothing to do with performance, and the next run starts from a different database. With it, a loan lives only from its POST to its DELETE, four requests or about 4 x 3.15 ≈ 12.6 s, so by Little’s Law about 1.44 x 12.6 ≈ 18 loans are open at any moment. A soak test makes the difference larger still. Test-data hygiene is discussed in full on Parameterization & Test Data.

GraphQL HTTP Request

What it is

The GraphQL HTTP Request, added in JMeter 5.4, is not a new protocol implementation. The component reference calls it a GUI variation of the HTTP Request: it shows three editors (Query, Variables and Operation Name) and converts them into ordinary HTTP arguments of the same sampler. In the .jmx it is an HTTPSamplerProxy with guiclass="GraphQLHTTPSamplerGui"; at run time it is executed exactly like an HTTP Request.

Compared with the HTTP Request it hides or changes these parts of the GUI:

  • Method: only POST and GET, as in the GraphQL-over-HTTP specification; POST is the default.

  • Parameters and Body Data tabs: replaced by the Query and Variables editors.

  • Files Upload tab: removed, as irrelevant to GraphQL queries.

  • Embedded Resources from HTML Files (Advanced tab): hidden, because GraphQL responses are JSON.

Everything else (server, port, protocol, path, implementation, timeouts, redirects, keep-alive, proxy, source address) is the HTTP Request’s and behaves the same.

Add it with Add → Sampler → GraphQL HTTP Request.

Fields

The GraphQL-specific fields:

Field Meaning Required JMX property (GUI state)

Query

The GraphQL query (or mutation) document.

Yes

GraphQLHTTPSampler.query

Variables

The operation’s variables as a valid JSON object. If the text is not valid JSON it is ignored, with an ERROR in the log.

No

GraphQLHTTPSampler.variables

Operation Name

The operation to execute, for documents that contain several operations.

No

GraphQLHTTPSampler.operationName

The inherited fields used most with GraphQL:

Field Use with GraphQL

Server Name or IP, Port Number, Protocol

Usually empty, inherited from HTTP Request Defaults.

Path

The single GraphQL endpoint, /graphql for Bookshelf.

Method

POST (default) or GET.

Content encoding

The body charset; empty means UTF-8 since 5.6.1.

Connect / Response timeouts (Advanced tab)

Worth setting: an expensive query that never returns otherwise holds the thread.

POST: the default

With POST, the GUI serialises the three fields into one JSON body and stores it as Body Data. In the 5.6.3 source the body is written as an object with the keys operationName (null when the field is empty), variables (left out when the field is empty, copied verbatim otherwise) and query (the trimmed document). The Bookshelf search as a GraphQL operation:

Query:

query SearchBooks($query: String!, $first: Int) {
  searchBooks(query: $query, first: $first) {
    id
    title
    author
    availableCopies
  }
}

Variables:

{"query": "${term}", "first": 10}

Operation Name: SearchBooks.

JMeter variables and functions are allowed in the three fields (fixed in 5.5 by bug 65108); the body is generated in the GUI and substitution happens at run time, as for any Body Data. Keep ${...} references inside JSON strings in the Variables field ("query": "${term}"), so that the field is valid JSON in the GUI; a bare "first": ${pageSize} is not valid JSON until run time. For a number that must vary, send it as a string only if the schema accepts it, or build the body with Body Data in a plain HTTP Request.

The request still needs Content-Type: application/json, the media type of a GraphQL POST body in the GraphQL-over-HTTP specification. The GraphQL sampler does not add it (the same HttpClient4 rule as for any Body Data applies, so the body would otherwise go out as text/plain). The Thread-Group-level Header Manager of the REST plan covers it.

The resulting .jmx fragment. The GraphQLHTTPSampler.* properties are what the GUI displays; the HTTPsampler.Arguments element is what is actually sent.

<HTTPSamplerProxy guiclass="GraphQLHTTPSamplerGui" testclass="HTTPSamplerProxy" testname="GraphQL SearchBooks">
  <stringProp name="HTTPSampler.path">/graphql</stringProp>
  <stringProp name="HTTPSampler.method">POST</stringProp>
  <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
  <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
  <boolProp name="HTTPSampler.BROWSER_COMPATIBLE_MULTIPART">false</boolProp>
  <stringProp name="GraphQLHTTPSampler.operationName">SearchBooks</stringProp>
  <stringProp name="GraphQLHTTPSampler.query">query SearchBooks($query: String!, $first: Int) {
  searchBooks(query: $query, first: $first) { id title author availableCopies }
}</stringProp>
  <stringProp name="GraphQLHTTPSampler.variables">{"query": "${term}", "first": 10}</stringProp>
  <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
    <collectionProp name="Arguments.arguments">
      <elementProp name="" elementType="HTTPArgument">
        <boolProp name="HTTPArgument.always_encode">false</boolProp>
        <stringProp name="Argument.value">{"operationName":"SearchBooks","variables":{"query": "${term}", "first": 10},"query":"query SearchBooks($query: String!, $first: Int) {\n  searchBooks(query: $query, first: $first) { id title author availableCopies }\n}"}</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
        <boolProp name="HTTPArgument.use_equals">true</boolProp>
      </elementProp>
    </collectionProp>
  </elementProp>
</HTTPSamplerProxy>
<hashTree/>

Because the body is generated by the GUI, editing only GraphQLHTTPSampler.query in a text editor or a script does not change the request: the arguments still hold the old body until the sampler is opened and saved in the GUI again. Generate GraphQL samplers in the GUI, or, when plans are produced by code, write the arguments too.

GET

Switching Method to GET makes the GUI produce query-string parameters instead of a body: operationName (when set), query (with every run of whitespace collapsed to a single space) and variables (when set), each with URL Encode? on. The request line for the same operation looks like this (shortened):

GET /graphql?operationName=SearchBooks&query=query+SearchBooks%28%24query%3A+String%21%2C+%24first%3A+Int%29+%7B+...&variables=%7B%22query%22%3A+%22tolkien%22%2C+%22first%22%3A+10%7D HTTP/1.1

GET has its place in a load test only if production clients use it: the GraphQL-over-HTTP specification makes GET support optional and conventionally restricted to queries, never mutations, and a server may not accept it at all. Its main effect is that responses become cacheable by HTTP caches and CDNs, so a GET-based test that hits a cache measures the cache. The trade-offs are explained in Serving GraphQL over HTTP.

Operation name and multi-operation documents

A document can define several named operations; the server executes the one named in operationName. That lets one Query editor hold a small library, with each sampler choosing its operation:

query SearchBooks($query: String!) { searchBooks(query: $query, first: 10) { id title } }

query BookDetails($id: ID!) { book(id: $id) { id title author availableCopies } }

mutation CreateLoan($bookId: ID!) { createLoan(bookId: $bookId) { id status dueDate } }

Sending the whole document on every request costs bandwidth and server parse time, so for a load test prefer one operation per sampler and use the operation name for readability in the results. How variables, fragments and directives work on the server side is covered in Variables, Directives & Fragments.

Checking GraphQL responses

A GraphQL server served as application/json conventionally answers 200 for every request that reached the GraphQL layer, including one whose data is null and whose errors array is full. JMeter’s default success rule (non-4xx, non-5xx) therefore counts failed operations as successes. Two JSON Assertions fix this:

Purpose JSON path Assert value Expected Invert

The operation produced data

$.data.searchBooks

No

(none)

No

No GraphQL errors

$.errors

No

(none)

Yes

The second assertion fails the sample when an errors field exists. A partial result (data plus errors) then counts as an error, which is the right default for a load test; relax it only for operations where partial results are expected. Servers that answer with application/graphql-response+json (when the client sends that Accept) also use 4xx codes for requests that fail validation; the assertions above still apply. The response shape is covered in Response and Error Handling.

<JSONPathAssertion guiclass="JSONPathAssertionGui" testclass="JSONPathAssertion" testname="No GraphQL errors">
  <stringProp name="JSON_PATH">$.errors</stringProp>
  <stringProp name="EXPECTED_VALUE"></stringProp>
  <boolProp name="JSONVALIDATION">false</boolProp>
  <boolProp name="EXPECT_NULL">false</boolProp>
  <boolProp name="INVERT">true</boolProp>
  <boolProp name="ISREGEX">false</boolProp>
</JSONPathAssertion>
<hashTree/>

Recording GraphQL traffic

The HTTP(S) Test Script Recorder has a Detect GraphQL Request option (in its GraphQL HTTP Sampler settings). In the 5.6.3 source, a recorded POST with an application/json content type, or a GET with GraphQL parameters, is turned into a GraphQL HTTP Request when its query starts with the keyword query or mutation; other requests stay plain HTTP Requests. Anonymous shorthand documents ({ books { id } }) are therefore not detected, and neither are documents that start with a fragment definition; those can be converted by hand. Recording itself is covered on Recording & cURL Import.

GraphQL-specific load considerations

  • The query is the workload. One endpoint serves very different costs: a list of ten titles and a nested query that fetches every loan of every book’s borrowers hit the same URL. Model the operations and their selection sets from production traffic (operation names in server logs or APM), not from a single sample query. The server-side causes of expensive queries (N+1 resolver calls, unbounded lists) are discussed in Performance & N+1.

  • Label by operation. All GraphQL requests share one path, so the sampler names (GraphQL SearchBooks, GraphQL CreateLoan) are the only way to tell them apart in the results.

  • Demand control will reject a load test that does not look like real clients. Depth limits, cost limits and persisted-query allow-lists exist on many servers; a test that sends ad-hoc documents can be rejected with errors that look like failures. See Security and Demand Control.

  • Subscriptions are out of scope. GraphQL subscriptions run over WebSocket or server-sent events, which core JMeter does not support; WebSocket samplers come from plugins, covered in plain words on the other-samplers and plugins pages later in this guide.

SOAP web services

The SOAP samplers are gone

The books test SOAP with dedicated samplers. Both were removed from core JMeter:

Element Removed in Replacement

WebService(SOAP) Request

3.0 (deprecated in 2.13)

HTTP Request with the SOAP envelope in Body Data.

SOAP/XML-RPC Request

3.2 (bug 60727)

HTTP Request; the change log points to Building a WebService Test Plan.

The replacement is not a workaround; it is how the user manual builds SOAP tests today. Start from File → Templates…​ → Building a SOAP WebService Test Plan, which creates User Defined Variables, HTTP Request Defaults, a Thread Group, an HTTP Request with an envelope in Body Data, an HTTP Header Manager with Content-Type and SOAPAction, and a Response Assertion. Adjust the server in the Defaults, the path in the request, the SOAPAction header and the envelope.

Building a SOAP request

Bookshelf is a REST and GraphQL service, so the example uses a hypothetical legacy catalogue service in the same stack, /soap/catalog, with the namespace urn:bookshelf:catalog:v1 and an operation GetBook. The steps are the same for any SOAP 1.1 service:

  1. Add → Sampler → HTTP Request. Method POST, Path /soap/catalog.

  2. Paste the envelope into Body Data, with ${bookId} where the value varies.

  3. Under the sampler, Add → Config Element → HTTP Header Manager with Content-Type: text/xml; charset=utf-8 and the SOAPAction the WSDL declares for the operation (in quotes, as in the template). This manager overrides the Thread-Group-level application/json.

  4. Add an XPath2 Extractor and an XPath2 Assertion (below), and a Response Assertion on the response code.

The user manual adds two practical notes: if the service returns errors, check that the envelope’s xmlns attributes match the WSDL exactly; and many non-.NET services do not use SOAPAction, in which case remove the header. For SOAP 1.2 the content type is application/soap+xml instead, with the action carried as its action parameter, as defined by the W3C SOAP 1.2 specification; check which version the WSDL binding uses.

<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="SOAP GetBook">
  <stringProp name="HTTPSampler.path">/soap/catalog</stringProp>
  <stringProp name="HTTPSampler.method">POST</stringProp>
  <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
  <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
    <collectionProp name="Arguments.arguments">
      <elementProp name="" elementType="HTTPArgument">
        <boolProp name="HTTPArgument.always_encode">false</boolProp>
        <stringProp name="Argument.value">&lt;soap:Envelope xmlns:soap=&quot;http://schemas.xmlsoap.org/soap/envelope/&quot; xmlns:cat=&quot;urn:bookshelf:catalog:v1&quot;&gt;
  &lt;soap:Body&gt;
    &lt;cat:GetBook&gt;
      &lt;cat:id&gt;${bookId}&lt;/cat:id&gt;
    &lt;/cat:GetBook&gt;
  &lt;/soap:Body&gt;
&lt;/soap:Envelope&gt;</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
      </elementProp>
    </collectionProp>
  </elementProp>
</HTTPSamplerProxy>
<hashTree>
  <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="SOAP 1.1 headers">
    <collectionProp name="HeaderManager.headers">
      <elementProp name="" elementType="Header">
        <stringProp name="Header.name">Content-Type</stringProp>
        <stringProp name="Header.value">text/xml; charset=utf-8</stringProp>
      </elementProp>
      <elementProp name="" elementType="Header">
        <stringProp name="Header.name">SOAPAction</stringProp>
        <stringProp name="Header.value">&quot;urn:bookshelf:catalog:v1/GetBook&quot;</stringProp>
      </elementProp>
    </collectionProp>
  </HeaderManager>
  <hashTree/>
</hashTree>

The envelope is XML inside XML, so the .jmx stores it escaped (<, "); the GUI shows it as plain XML. A value inserted into the envelope must be XML-safe: use ${__escapeXml(${title})} for free text.

XPath2 Extractor

The XPath2 Extractor (since 5.0) reads values from XML with XPath 2.0. The component reference recommends it over the older XPath Extractor for its easier namespace handling, better performance and XPath 2.0 support.

Field Meaning Required JMX

Name

Descriptive name in the tree.

No

testname

Apply to

Main sample, sub-samples, both, or a JMeter variable. Matching is applied to all qualifying samples in turn.

Yes

Sample.scope

Return entire XPath fragment instead of text content?

Return the matched element with its tags (<title>...</title>) instead of its text.

Yes

XPathExtractor2.fragment

Name of created variable

The variable that receives the result.

Yes

XPathExtractor2.refname

XPath Query

An XPath 2.0 expression; can return several matches.

Yes

XPathExtractor2.xpathQuery

Match No. (0 for Random)

0 random (default), n the n-th match, -1 all matches as name_1 to name_N.

No

XPathExtractor2.matchNumber

Default Value

Returned when nothing matches, and when the node has no value and the fragment option is off.

Yes

XPathExtractor2.default

Namespaces aliases list

One prefix=namespace declaration per line, used by the query.

No

XPathExtractor2.namespaces

The book title from the GetBookResponse:

<XPath2Extractor guiclass="XPath2ExtractorGui" testclass="XPath2Extractor" testname="Extract title">
  <stringProp name="XPathExtractor2.default">TITLE_NOT_FOUND</stringProp>
  <stringProp name="XPathExtractor2.refname">title</stringProp>
  <stringProp name="XPathExtractor2.matchNumber">1</stringProp>
  <stringProp name="XPathExtractor2.xpathQuery">//cat:GetBookResponse/cat:book/cat:title/text()</stringProp>
  <stringProp name="XPathExtractor2.namespaces">cat=urn:bookshelf:catalog:v1</stringProp>
  <boolProp name="XPathExtractor2.fragment">false</boolProp>
</XPath2Extractor>
<hashTree/>

Namespaces are the usual source of "nothing matched": a query without a prefix (//title) does not match an element in a namespace. Declare a prefix for every namespace used in the query. A global mapping can also be kept in a properties file named by the xpath.namespace.config property. Compiled queries are cached; the cache size is xpath2query.parser.cache.size (default 400).

XPath2 Assertion and SOAP faults

The XPath2 Assertion (since 5.2) checks that a document is well-formed and that an XPath expression matches; the default expression / matches any well-formed document. It also accepts boolean expressions such as count(//*error)=2.

Field Meaning Required JMX

Apply to

Main sample, sub-samples, both, or a JMeter variable.

Yes

Sample.scope

XPath2 Assertion

The XPath 2.0 expression to match.

Yes

XPath.xpath

Invert assertion

Fail if the expression matches (or returns true), succeed otherwise.

No

XPath.negate

Namespaces aliases list

One prefix=namespace declaration per line.

No

XPath.namespaces

A SOAP 1.1 service reports errors with a soap:Fault element in the body, usually with HTTP status 500, which the HTTP sampler already marks as failed. A service that returns faults with status 200, or a negative test that expects a fault, needs an explicit check. The assertion below fails the sample whenever the response contains a fault:

<XPath2Assertion guiclass="XPath2AssertionGui" testclass="XPath2Assertion" testname="No SOAP fault">
  <stringProp name="XPath.xpath">//soap:Fault</stringProp>
  <boolProp name="XPath.negate">true</boolProp>
  <stringProp name="XPath.namespaces">soap=http://schemas.xmlsoap.org/soap/envelope/</stringProp>
</XPath2Assertion>
<hashTree/>

For a negative test that expects a fault, add first a Response Assertion on the response code with Ignore Status and the pattern 500, then an XPath2 Assertion on //soap:Fault/faultcode without Invert.

A plain Response Assertion with Substring on a closing tag (as the SOAP template does) is cheaper than an XPath2 Assertion because it does not parse the document. Use XPath when the structure matters, substring checks when a marker is enough.

OpenAPI-driven plan design

JMeter has no OpenAPI (Swagger) importer. Neither the user manual nor the component reference describes one; the only import paths core JMeter offers are the HTTP(S) Test Script Recorder and Tools → Import from cURL. Designing a plan from an OpenAPI contract is therefore a manual process, and a worthwhile one: the contract says what the API can do, but only the workload model says what users actually do, and a load test needs the second.

flowchart LR OAS["OpenAPI document: paths, schemas, responses, security"] --> OPS["List the operations users call"] LOGS["Production logs or APM"] --> MIX["Workload mix: share of each operation"] OPS --> MIX MIX --> PLAN["Thread Group with controllers for the mix"] OAS --> SAMP["One sampler per operation"] SAMP --> PLAN OAS --> DATA["CSV data for path, query and body values"] DATA --> PLAN OAS --> CHK["Assertions from documented responses"] CHK --> PLAN

Each part of the contract maps to a JMeter element:

OpenAPI JMeter Notes

servers

HTTP Request Defaults with ${__P(host,localhost)}, ${__P(port,8080)}

Never hard-code a production URL; pass the target with -J.

paths and operations

One HTTP Request per operation that the workload uses, named METHOD /path

Operations users never call (admin, health) stay out of the load mix.

Path parameters (/api/loans/{id})

/api/loans/${loanId} with the value extracted from an earlier response or read from CSV

An id from an earlier call is correlation; one chosen by the user is test data.

Query parameters

Parameters tab with URL Encode? ticked

Required ones always; optional ones in the proportions real clients use them.

requestBody schema

Body Data JSON template with variables, plus Content-Type in a Header Manager

Generate example values that respect formats, enums and limits.

responses (status codes)

Response Assertion on Response Code

Assert the documented success code (201, not just "not 5xx").

Response schema

JSON Assertion on the few fields the next step depends on

Do not validate whole schemas under load; that is a functional-test job.

securitySchemes

A login request, JSON Extractor for the token, Authorization in a Header Manager

See the sessions and authentication page.

examples

Seed values for the CSV files

Useful for a smoke test; real distributions come from production data.

Two shortcuts help without being importers. API consoles such as Swagger UI typically display a cURL command for each operation they execute, and Tools → Import from cURL turns such a command into an HTTP Request with its headers (see Recording & cURL Import). And outside the Apache project, the OpenAPI Generator project ships a jmeter generator whose documentation describes it as generating a JMeter .jmx file; it is a third-party tool, not part of JMeter, and its output is a set of requests, not a workload model, so it still needs the mix, data, correlation and assertions described above. It is not covered further here.

Where the contract itself comes from in a contract-first Spring Boot project is described in API-First Development: REST and gRPC.

Load-generator cost

API tests are usually cheap to generate: small requests, small JSON responses, no embedded resources. What makes them expensive is post-processing on every response.

Element Cost on the load generator

HTTP Request with Body Data

Low. Variable substitution in a short template is negligible next to the network round trip.

__groovy or __FileToString in a body

Moderate: evaluated on every request. Prefer CSV data prepared up front, as the best-practices chapter advises.

JSON Extractor

Parses the whole response for each sample. Fine for small payloads; on large lists, extract only what the next step needs, and consider the Boundary Extractor for a single value in a big document.

JSON Assertion

Another full parse per sample. Keep one or two per sampler.

Response Assertion on the response code

Very low: no body parsing. The cheapest contract check.

XPath2 Extractor / XPath2 Assertion

Highest of the group: the response is parsed into an XML document for each element. Compiled queries are cached (xpath2query.parser.cache.size), parsing is not.

GraphQL HTTP Request

Same as an HTTP Request; the JSON body is generated in the GUI, not at run time.

The best-practices chapter of the user manual sums up the rule: use as few assertions as possible. For a 10-minute hold at ≈ 15.9 requests/s, as in the worked numbers above, each additional JSON element per request means about 9,500 extra parses per run, all on the load generator’s CPU and heap.

Pitfalls

  • No Content-Type header. The body goes out as text/plain and the API answers 415. Put application/json in a Thread-Group-level Header Manager and check the request headers once in View Results Tree.

  • Unescaped data in JSON or XML bodies. A quote in a search term breaks the JSON, an ampersand breaks the XML. Escape with __escapeXml for XML; clean data or use Groovy’s JsonOutput for JSON.

  • Query values inlined in the path. The path is not URL-encoded apart from spaces; use the Parameters tab or __urlencode.

  • Relying on "not 4xx/5xx" as success. A 200 login page, a 204 where a body was expected, or a GraphQL 200 with errors all pass. Assert the documented status code and, for GraphQL, the absence of errors.

  • Ignore Status on a later assertion. It clears failures recorded by earlier assertions; use it only on the first assertion of a sampler.

  • JSON Assertion in regex mode by accident. Match as regular expression defaults to on in the 5.6.3 source when the property is missing; set ISREGEX explicitly.

  • Editing GraphQL samplers outside the GUI. The request is the generated argument, not the GraphQLHTTPSampler.query property; re-save the sampler in the GUI after changing the query.

  • Invalid JSON in the GraphQL Variables field. It is ignored with an ERROR in the log, and the operation runs without variables. Keep ${...} inside JSON strings.

  • Creating data without cleaning it up. Loans, orders or users created under load change the database and the next run’s results; return or delete what the test creates (see the worked numbers above).

  • Copying the books' SOAP samplers. WebService(SOAP) Request and SOAP/XML-RPC Request no longer exist; plans that use them do not load in 5.6.3.

  • Expecting an OpenAPI import. There is none in JMeter; plan the requests from the contract and the workload model by hand, or use cURL import for individual operations.

From the books to 5.6 to 6.0

Topic Books (JMeter 2.11 to 3.0) JMeter 5.6.3 and planned 6.0

SOAP

WebService(SOAP) Request and SOAP/XML-RPC Request samplers (Cookbook ch. 4; PTwJ ch. 7).

WebService(SOAP) Request removed in 3.0, SOAP/XML-RPC Request removed in 3.2. Use HTTP Request with Body Data and the Building a SOAP WebService Test Plan template.

JSON extraction

The jp@gc JSON PathExtractor plugin (Cookbook ch. 2; PTwJ ch. 7).

Core JSON Extractor (added in 3.0 as the JSON Path PostProcessor, renamed in 3.1), JSON Assertion (4.0), JSON JMESPath Extractor and Assertion (5.2). The plugin is no longer needed.

XML extraction

XPath Extractor and XPath Assertion (XPath 1.0).

XPath2 Extractor (5.0) and XPath2 Assertion (5.2), recommended over the XPath 1.0 elements. 6.0 plans Saxon-HE 13.

POST content type

A POST without a content type got application/x-www-form-urlencoded automatically.

Since 5.0, http.post_add_content_type_if_missing=false: set Content-Type yourself.

Default encoding

Platform or ISO-8859-1 defaults; non-ASCII data needed explicit encoding.

UTF-8 is the HTTP sampler default since 5.6.1.

GraphQL

Not covered (it predates GraphQL’s spread).

GraphQL HTTP Request since 5.4; variables and functions supported since 5.5.

JSON Assertion with indefinite paths

Not applicable (no core JSON Assertion).

Since 5.5, an indefinite path with an empty result and no expected value fails.

JSONPath library

Plugin-specific.

Jayway JsonPath; 6.0 plans json-path 3.0.0. Re-test complex expressions when upgrading.

JSON Extractor defaults

Not applicable.

6.0 plans support for trailing empty default values, so expressions like ${VAR:-} are handled correctly.

HTTP arguments

All rows always sent.

6.0 plans to allow enabling or disabling individual arguments in the sampler UI.

Redirects

307 and 308 redirects followed as GET by some implementations.

6.0 plans to keep the original method (POST, PUT…​) when following 307 and 308 redirects, as the HTTP specification requires. An API that answers a write with 307 will see the write repeated at the new location.

No other change to the elements on this page is listed in the planned 6.0 change log at the time of writing.

References