Scripting with JSR223 and Groovy for Advanced Test Logic: Core Concepts and Mental Model
Earlier chapters deliberately preferred JMeter's built-in configuration elements, extractors, assertions, controllers, timers, protocol samplers, and result mechanisms. That keeps a test plan visible in the tree and understandable to reviewers. Sometimes, however, the required transformation or validation is too specific for those declarative components. JSR223 + Groovy is the escape hatch—but every script runs on the load generator, so scripting can also become a hidden CPU, allocation, synchronization, logging, or correctness bottleneck.
Learning objectives
- Place JSR223 elements inside the normal JMeter lifecycle rather than treating scripts as a parallel framework.
-
Understand
ctx,vars,props,prev/SampleResult,sampler,log,Parameters, andargs. -
Explain compiled-script caching and why changing
${VAR}text inside cached scripts is unsafe. - Distinguish thread-local variables from process-global properties and shared Java/Groovy objects.
- Inspect engine/cache/source/exception/generator state before adding script logic.
- Treat Groovy as minimal testable extension code, not a replacement for the JMeter tree.
1. The practical problem: scripting can hide both logic and cost
A 15-line JSR223 helper may be the clearest way to canonicalize an unusual identifier. A 400-line script that performs HTTP calls, parses JSON, implements retries, global counters, pacing, assertions, data allocation, and reporting has effectively hidden the test plan inside code. Reviewers can no longer see scope, execution order, or workload semantics from the tree, and generator CPU becomes difficult to attribute.
http://127.0.0.1:8019, synthetic names, maximum 2
threads, benchmark ≤600 HTTP samples per variant and ≤15 seconds, no
credentials, no plugins, and no external target. Never substitute a
public/shared/production endpoint merely to make a scripting example
feel realistic.
2. Mental model: JMeter lifecycle → JSR223 engine → controlled state change → downstream element
Scripting sits inside the existing JMeter execution model. It receives scoped JMeter state, performs one controlled calculation or mutation, and returns control to the test plan; it does not replace workload, protocol, target, result, or safety semantics.
flowchart TD T[Test Plan tree + thread lifecycle] --> E[JSR223 element at scoped execution point] E --> G[Groovy JSR223 engine] G --> B[Bindings: ctx / vars / props / prev or SampleResult / sampler / log / args] B --> C[Small deterministic calculation or validation] C --> M[Controlled mutation: usually vars or assertion/sample metadata] M --> N[Downstream built-in sampler/assertion/controller] N --> J[JTL + jmeter.log] N --> S[Target evidence] G --> P[Generator CPU / heap / compiled-script cache] P --> V[Validity review] J --> V S --> V
The JMeter tree still decides when code runs: a PreProcessor runs before its sampler, a PostProcessor after it, an Assertion checks a sample in its scope, and a JSR223 Sampler is itself a sample/computation. The JSR223 element asks the Groovy engine to execute source. JMeter supplies bindings such as per-thread variables and global properties. Good script code makes one controlled change—for example, canonicalizing one thread-local value—and returns to built-in JMeter components. JTL and target evidence validate behavior; generator CPU/heap/cache evidence validates that scripting has not become the bottleneck.
3. Core bindings: what each object means
| Binding | State boundary | Typical safe use |
|---|---|---|
vars |
Thread-local JMeterVariables for the current virtual user. |
Read/write correlated values:
vars.get('RAW_NAME'),
vars.put('REQUEST_NAME', value).
|
props |
JMeter Properties shared across the entire JVM. |
Read immutable run settings supplied with -J;
avoid per-user mutable state.
|
ctx |
Current JMeterContext for this thread. | Inspect thread/context/sampler state when a built-in variable is insufficient. |
sampler |
Current sampler where available. | Inspect/adjust sampler state only when component configuration cannot express it cleanly. |
prev |
Previous/current sample context in processors/assertions, depending element. | Read response/status from the scoped sampler. |
SampleResult |
Current JSR223 Sampler result. | Set response/status only when the script itself is intentionally the sampler. |
log |
SLF4J logger writing to JMeter log. | Sparse diagnostics/setup summaries/errors; never per-sample INFO spam under load. |
Parameters/args |
Explicit script parameters. | Pass stable configuration/runtime arguments without changing script source. |
4. vars versus props is a concurrency decision
vars belongs to one JMeter thread. If two users both
write vars.put('TOKEN', ...), their values remain
independent. props belongs to the JMeter process; every
thread can see the same property. That makes
props appropriate for immutable run identity such as
RUN_ID or VARIANT, but a dangerous place
for a mutable per-user token, sequence, or ordinary
ArrayList.
Distributed mode adds another boundary: each remote engine is a separate JVM with its own properties and memory. A property is “global” only inside one JMeter process, not magically shared across engines.
5. Compiled-script caching
Groovy implements JSR223 Compilable. Current JMeter
recommends either a Script File (compiled/cached when the engine
supports it) or inline script text with
Cache compiled script if available enabled. The
compiled-script cache defaults to 100 entries.
Caching removes repeated compilation from the hot path. It does not make your mutable objects thread-safe, and it does not make expensive Groovy logic free.
6. Why ${VAR} inside cached source is a correctness bug
JMeter performs variable/function replacement in inline script text before it is passed to the interpreter. If the script is then cached, the compiled source can represent the first expanded value rather than the new value from later iterations.
Broken cached source:
// BROKEN ON PURPOSE when cache is enabled:
def raw = '${RAW_NAME}'
vars.put('REQUEST_NAME', raw.trim().toLowerCase(java.util.Locale.ROOT))
Correct cache-safe source:
// Cache-safe: script text stays constant; runtime value is read from JMeterVariables.
def raw = vars.get('RAW_NAME')
vars.put('REQUEST_NAME', raw.trim().toLowerCase(java.util.Locale.ROOT))
The script text now remains constant, and
vars.get reads current thread state every invocation.
7. SampleResult power requires restraint
JSR223 Sampler can set response data/status/code/message through
SampleResult. That is legitimate when the script itself
is the synthetic sampler. It is dangerous when used in a
PostProcessor to turn a genuine HTTP 500 into a fake success.
Do not mutate prev.setSuccessful(true) merely to make
dashboards green. Fix the assertion/target or classify an expected
error explicitly without hiding it.
8. Non-destructive inspection before scripting
- Confirm JMeter 5.6.3, Java 17, and Groovy engine version.
- Search the tree for existing JSR223/BeanShell/JavaScript elements before adding another.
- Check whether a built-in extractor/assertion/controller/function already expresses the requirement.
-
Inspect
jsr223.compiled_scripts_cache_sizeoverrides; default is 100. -
Classify each value as thread-local
vars, immutable process-wideprops, external data, or target/session state. -
Inspect
jmeter.logfor script compile/runtime exceptions. - Record baseline generator CPU/heap/GC before placing Groovy in a hot path.
9. Version/cache preflight inside JMeter
A one-time JSR223 Sampler (Groovy, cache enabled) can return/log non-secret engine metadata:
def cacheSetting = props.getProperty('jsr223.compiled_scripts_cache_size', '100(default)')
def text = "groovy=${GroovySystem.version}; java=${System.getProperty('java.version')}; cache=${cacheSetting}"
log.info("PROMPT19 preflight " + text)
SampleResult.setResponseCode("200")
SampleResult.setResponseMessage("JSR223 preflight")
SampleResult.setSuccessful(true)
return text
Run this once during authoring/preflight, not inside the benchmark loop. On the course baseline it should identify Groovy 3.0.20 and Java 17.
10. State checklist before changing the plan
| State | Question |
|---|---|
| Generator | JMeter/Java/Groovy version; CPU/heap/GC; cache setting; script classpath/files? |
| Thread/arrival | Which threads/iterations invoke the script? Is it on a hot path? |
| Component scope | PreProcessor, PostProcessor, Assertion, Sampler, Timer, or function—and exactly what children/sampler does it affect? |
| Variables/properties | Which values are thread-local vars, immutable props, external data, or shared objects? |
| Protocol/session | Does script accidentally reimplement HTTP/auth/correlation already handled by built-ins? |
| Target | Is target work unchanged when comparing scripted/refactored variants? |
| Artifacts | JTL, jmeter.log, script source/hash, benchmark analyzer, exception evidence? |
| Trust/privacy | Does script read environment variables/files/secrets or log them? |
| Validity | Does configured load equal achieved valid samples, and is generator scripting overhead controlled? |
11. DevOps connection
Minimal scripts are easier to code-review, unit-test, version, benchmark, and reproduce in CI. They preserve a declarative JMeter tree while leaving one escape hatch for genuinely custom logic. The operating rule is simple: prefer JMeter components first; script the irreducible transformation; measure the generator cost.
Knowledge check
Why is vars usually correct for a per-user token or derived value?
JMeterVariables are thread-local, so each virtual user owns an independent value.
Why can ${VAR} be wrong inside a cache-enabled Groovy script?
JMeter expands the script text before compilation; the cached compiled source can capture the first replacement instead of reading later runtime values.
What does compiled-script caching remove?
Repeated script compilation overhead; it does not remove the actual script execution cost or make shared objects thread-safe.
When is props appropriate?
For JVM-wide configuration such as immutable run settings; not as a default per-user mutable state store.
Why is changing prev/SampleResult success dangerous?
It can hide a genuine sampler/assertion/target failure and corrupt the result/error taxonomy.
Official references and version notes
- JMeter Component Reference — JSR223 Sampler — compilation caching, bindings, script-file paths, SampleResult behavior, and Groovy guidance.
- JMeter Component Reference — JSR223 PreProcessor — scoped pre-sample scripting and bindings.
- JMeter Component Reference — JSR223 PostProcessor — post-sample scripting and previous-sample access.
- JMeter Component Reference — JSR223 Assertion — scripted assertion scope and bindings.
- JMeter Functions — __groovy — Groovy function bindings and cache-safe variable access.
-
JMeter Properties Reference
—
jsr223.compiled_scripts_cache_sizeand advanced Groovy/JSR223 configuration. - JMeter Best Practices — JSR223/Groovy recommendations, compiled scripting, lean results, and non-GUI load execution.
- Apache JMeter downloads — current stable release and Java requirement.
- Apache JMeter issue #6402 — JMeter 5.6.3 stack traces identify bundled Groovy 3.0.20 and document newer-JDK compatibility context.
Version-sensitive statements were rechecked against current Apache
JMeter primary documentation on 2026-09-05. The course baseline
remains Apache JMeter 5.6.3 with a
Java 17 JDK; JMeter 5.6.3 requires Java 8+. The
JMeter 5.6.3 binary line uses Groovy 3.0.20.
Groovy's JSR223 engine implements Compilable. JMeter
recommends script files (compiled/cached when supported) or inline
script text with
Cache compiled script if available checked. The
compiled-script cache defaults to
jsr223.compiled_scripts_cache_size=100. Do not put
changing ${VAR} or JMeter function replacement
directly inside cached script text: JMeter expands it before the
script reaches the engine, so the first replacement can be
captured by the cache. Read runtime state through
vars, props,
Parameters/args, or other supplied
bindings instead. JSR223 Sampler exposes log,
Label, FileName,
Parameters, args,
SampleResult, sampler, ctx,
vars, props, and OUT;
assertions/processors expose the appropriate
current/previous-sample bindings. JMeter explicitly recommends
migration from BeanShell to JSR223 + Groovy for
performance/support, and Groovy is preferred over non-Compilable
scripting engines for intensive load paths.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.