JSR223 with Groovy for Custom Logic

9 min read

JMeter's built-in elements handle most test scenarios — but not all of them. When you need to generate a signed HMAC header, transform a complex JSON response into multiple variables, implement custom retry logic, or call a database to verify a side effect, you need a scripting layer. JSR223 with Groovy is JMeter's answer.

What JSR223 is

JSR223 is the Java Scripting API (Java Specification Request 223) — a standard interface that allows Java applications to embed scripting engines. JMeter uses it to run scripts inside the test plan without compiling a custom plugin.

JMeter supports several JSR223 languages: Groovy, JavaScript (Nashorn), BeanShell (legacy), Jython (Python 2), and others. Use Groovy. It is the only option that compiles and caches scripts at the JVM level, making it as fast as native Java code. BeanShell is interpreted and slow at scale. JavaScript via Nashorn was removed in newer JDKs.

JSR223 element types

JSR223 Elements
  • – Full custom sampler — no HTTP call
  • – Sets its own SampleResult
  • – For non-HTTP protocols or pure logic
  • – Use: compute-heavy steps, mocking
  • – Runs before parent sampler
  • – Modifies vars before request sends
  • – Use: sign payloads, build dynamic bodies
  • – Runs after parent sampler
  • – Reads prev.getResponseDataAsString()
  • – Use: complex extraction, data transform
  • Custom pass/fail logic –
  • Calls prev.setSuccessful(false) –
  • Use: business rule validation –
  • Returns dynamic delay (ms) –
  • Use: calculated think time –
  • Example: delay proportional to response size –

Built-in objects available in scripts

JMeter injects these objects into every JSR223 script automatically — no imports needed:

ObjectTypePurpose
varsJMeterVariablesRead/write thread-local variables
propsJMeterPropertiesRead/write global properties (all threads)
prevSampleResultThe previous sampler's result (Post-Processors and Assertions)
samplerAbstractSamplerThe current sampler being processed
ctxJMeterContextThread context: ctx.getThreadNum(), ctx.getVariables()
logLoggerSLF4J logger: log.info(), log.error(), log.debug()
OUTPrintStreamWrites to stdout (use log instead in most cases)

Enable "Cache compiled script"

The most important performance setting in any JSR223 element. Check it.

Without caching, Groovy recompiles the script on every execution — for a 100-user test running 500 iterations, that is 50,000 compilations. With caching, the script compiles once and the compiled bytecode is reused. The performance difference under load is 10–100x.

The checkbox appears in every JSR223 element's editor panel, just below the language selector. Enable it whenever the script does not depend on runtime-variable file paths or other dynamic class loading.

Pre-Processor: building dynamic request bodies

// JSR223 Pre-Processor on POST /api/orders
import groovy.json.JsonBuilder
 
def orderId = "ORD-${System.currentTimeMillis()}-${ctx.getThreadNum()}"
def items   = (1..5).collect { [
    productId: (int)(Math.random() * 1000) + 1,
    quantity:  (int)(Math.random() * 4)  + 1
]}
 
def body = new JsonBuilder([
    orderId:    orderId,
    customerId: vars.get("userId"),
    items:      items,
    currency:   "USD"
]).toString()
 
vars.put("orderBody", body)
vars.put("orderId",   orderId)

The HTTP Request sampler's Body Data tab contains ${orderBody}. The Pre-Processor generates a fresh, unique body before each execution.

Post-Processor: complex extraction and validation

// JSR223 Post-Processor on GET /api/inventory
import groovy.json.JsonSlurper
 
def body  = prev.getResponseDataAsString()
def json  = new JsonSlurper().parseText(body)
def items = json.items
 
// Store aggregate stats
vars.put("itemCount",    items.size().toString())
vars.put("totalValue",   items.sum { it.price * it.quantity }.toString())
vars.put("outOfStock",   items.count { it.quantity == 0 }.toString())
 
// Store first available item for next request
def available = items.find { it.quantity > 0 }
if (available) {
    vars.put("availableId",   available.id.toString())
    vars.put("availablePrice", available.price.toString())
} else {
    vars.put("availableId", "NONE")
    log.warn("No available items found in response for thread ${ctx.getThreadNum()}")
}

All five variables are immediately available to subsequent samplers in the same thread.

Assertion: custom business rule validation

// JSR223 Assertion on POST /api/transfer
import groovy.json.JsonSlurper
 
def body         = prev.getResponseDataAsString()
def json         = new JsonSlurper().parseText(body)
def originalAmt  = Double.parseDouble(vars.get("transferAmount"))
def confirmedAmt = json.transaction.amount as Double
def tolerance    = 0.01
 
if (Math.abs(confirmedAmt - originalAmt) > tolerance) {
    prev.setSuccessful(false)
    prev.setResponseMessage(
        "Amount mismatch: sent ${originalAmt}, confirmed ${confirmedAmt}"
    )
}

prev.setSuccessful(false) marks the sample as failed. prev.setResponseMessage() sets the failure message that appears in listeners and the .jtl file.

Timer: dynamic think time

// JSR223 Timer — returns delay in milliseconds
// Vary think time based on how large the response was
def bytes   = prev?.getBodySizeAsLong() ?: 1000L
def readMs  = (bytes / 500).toLong()  // ~1s per 500 bytes
def minMs   = 500L
return Math.max(minMs, readMs)

JSR223 Timer must return a numeric value (as long or int) representing the delay in milliseconds. The return statement is the output — not a vars.put().

Groovy in practice: useful patterns

Read a file at runtime (not at startup):

def content = new File(vars.get("dataDir") + "/payload.json").text
vars.put("payload", content)

Call an internal utility API to check rate limits before sending:

def url  = new URL("http://ratelimit-checker.internal/check?client=${vars.get('clientId')}")
def resp = url.text  // simple GET
if (resp.contains('"limited":true')) {
    log.info("Rate limited — sleeping 5s")
    Thread.sleep(5000)
}

Generate an HMAC-SHA256 signature:

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
 
def secret  = props.get("apiSecret")
def payload = vars.get("requestBody")
def mac     = Mac.getInstance("HmacSHA256")
mac.init(new SecretKeySpec(secret.bytes, "HmacSHA256"))
vars.put("hmacSignature",
    Base64.encoder.encodeToString(mac.doFinal(payload.bytes)))

⚠️ Common mistakes

  • Forgetting to enable "Cache compiled script". This is the most common JSR223 performance mistake. A Groovy script that runs 10,000 times without caching compiles 10,000 times, consuming significant CPU. Every JSR223 element in a load test should have caching enabled unless there is a specific reason not to.
  • Using vars.put() with non-String values. vars.put() only accepts String values. Putting an integer: vars.put("count", 5) throws a runtime error. Always convert: vars.put("count", "5") or vars.put("count", items.size().toString()).
  • Throwing unhandled exceptions in assertions. If a JSR223 Assertion script throws an uncaught exception (for example, a NullPointerException from parsing an unexpected response format), JMeter marks the sample as failed but the error message is the exception stack trace — not your intended failure message. Wrap risky code in try-catch and call prev.setResponseMessage() explicitly for readable failure output.

🎯 Practice task

Add custom Groovy logic to your test plan.

  1. Add a JSR223 Pre-Processor to a POST sampler. Write a script that:

    • Builds a JSON body using groovy.json.JsonBuilder
    • Includes the current thread number: ctx.getThreadNum()
    • Includes a timestamp: System.currentTimeMillis()
    • Stores the result in vars.put("dynamicBody", ...)
    • Enable "Cache compiled script"
  2. Set the sampler's Body Data to ${dynamicBody}. Run with 3 users, 2 loops. Check View Results Tree — confirm each request body contains a different thread number and timestamp.

  3. Add a JSR223 Post-Processor to a GET sampler that returns JSON. Parse the response with JsonSlurper, count a top-level array (json.someArray.size()), and store the count in vars. Log the count with log.info(). Open the Log Viewer during the run to see the log output.

  4. Add a JSR223 Assertion. Check whether prev.getResponseCode() equals "200". If not, call prev.setSuccessful(false) with a descriptive message. Deliberately trigger a non-200 response and confirm the assertion produces your custom message in the Assertion Results listener.

// tip to track lessons you complete and pick up where you left off across devices.