Programmatic Test Plans & DSLs

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, and Java 21 is used here), with the differences planned for JMeter 6.0 noted where they matter. JMeter 6.0 is not released: its 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. The third-party JMeter Java DSL by Abstracta is covered in version 2.3 (published to Maven Central on 2026-10-07, built on JMeter 5.6.3, Apache License 2.0). The JMeter Plugins Manager 2.0 and the jmeter-maven-plugin 3.8.x are mentioned only where code-based plans meet them. Every Java example on this page was compiled and run on Java 21 against JMeter 5.6.3 (through the installed distribution and through the Maven artifacts) and JMeter Java DSL 2.3, against a local stub server; the outputs shown are from those runs.

A JMeter test plan is usually a .jmx file built in the GUI. It does not have to be: the plan is a tree of Java objects, and code can build, save, load and run that tree. JMeter 5.6 made this an official, if experimental, feature with a Kotlin and Java DSL and a GUI action that turns any part of a plan into code. Independently, the open-source JMeter Java DSL wraps JMeter in a fluent Java API designed for performance tests that live next to unit tests, run with JUnit 5 and fail the build with ordinary assertions. This page covers both routes, builds parts of the Bookshelf plan with each, and ends with when a .jmx file is still the better choice.

It assumes the plan model from Test Plan Elements, Execution Order & Scoping, the run properties from Properties, Variables & Functions and the CLI from Running Tests in CLI Mode. The Spring Boot side of performance testing is in Performance Testing with Apache JMeter, and functional tests in Unit and Integration Testing.

Why build test plans in code

A .jmx file is XML written by the GUI. It works well for people who design plans visually, but it has known costs when a team treats load tests as code:

  • Reviews and merges. A one-field change in the GUI can rewrite many lines of XML, and two people editing the same plan produce merge conflicts that are hard to resolve by hand.

  • Reuse. The GUI offers Test Fragments, Module Controllers and Include Controllers; code offers functions, parameters, loops and libraries, which are more flexible.

  • Generation. Some plans are naturally generated: one sampler per endpoint of an OpenAPI document, one thread group per tenant, a plan per environment.

  • Assertions on the whole run. JMeter’s assertions judge single samples. Pass or fail on aggregate numbers (95th percentile, error rate) needs a post-processing step in CI; a test framework can do it directly.

The costs of code are just as real: the plan is no longer visible to people who only know the GUI, not every JMeter element or plugin has a builder, and the API of JMeter itself is marked experimental. The last section of the page weighs both sides.

Three routes, one engine

All routes end in the same place: a ListedHashTree of TestElement objects handed to StandardJMeterEngine, the engine that jmeter -n uses. They differ in who writes the tree.

flowchart LR G[JMeter GUI] -- Save --> X[(.jmx file)] X -- SaveService.loadTree --> T[ListedHashTree of TestElements] K[JMeter 5.6 Kotlin or Java DSL] -- testTree --> T A[JMeter Java DSL by Abstracta] -- builds internally --> T T -- SaveService.saveTree --> X G -- Copy Code --> K X -- jmx2dsl --> A T --> E[StandardJMeterEngine] E --> R[Samples to listeners, JTL, summariser]
  • The GUI and the CLI: the GUI writes .jmx; jmeter -n -t loads it with SaveService and runs it.

  • JMeter’s own API: code creates TestElement objects and arranges them in a ListedHashTree, by hand or with the 5.6 testTree DSL. SaveService can write that tree as a .jmx.

  • JMeter Java DSL: code calls static builders such as testPlan(...) and threadGroup(...); the library builds the tree, runs it in an embedded engine and returns statistics. It can also save and load .jmx files.

JMeter’s own API

The object model: elements, trees and order

Every node of a plan (Test Plan, Thread Group, sampler, timer, listener) is a TestElement: a Java object that holds its configuration as named properties, the same properties that appear as stringProp, intProp and boolProp in the .jmx. The elements do not point at their children. The manual is explicit that "in JMeter APIs, the elements do not form a tree": parent and child relations live in a separate structure, the ListedHashTree from org.apache.jorphan.collections.

  • ListedHashTree.add(element) adds a node and returns the subtree of that node, so children are added to the returned value.

  • ListedHashTree keeps insertion order. The plain HashTree does not, so samplers built into a HashTree may run in a shuffled order. The manual warns against confusing the two.

  • Execution order, scoping and the rule that timers and assertions apply to the samplers in their scope are the same as for a plan built in the GUI, because the engine sees the same tree.

Building a plan with the low-level API

The manual’s first example builds Test Plan, Thread Group and Debug Sampler by hand. The version below is the same idea with the declared types corrected (the manual’s snippet declares the thread group variable as TestPlan, which does not compile):

ListedHashTree root = new ListedHashTree();                     // (1) the tree
TestPlan testPlan = new TestPlan();
ListedHashTree testPlanSubtree = root.add(testPlan);           // (2) add() returns the subtree
ThreadGroup threadGroup = new ThreadGroup();
threadGroup.setName("Search Order Thread Group");
ListedHashTree threadGroupSubtree = testPlanSubtree.add(threadGroup); // (3) child of the Test Plan
DebugSampler debugSampler = new DebugSampler();
threadGroupSubtree.add(debugSampler);                          // child of the Thread Group

This works, but nesting is expressed by juggling subtree variables, and nothing in the code looks like the tree it builds. That is what the 5.6 DSL fixes.

The 5.6 Kotlin DSL: testTree { ... }

JMeter 5.6 (June 2023) added the class org.apache.jmeter.treebuilder.TreeBuilder and the function org.apache.jmeter.treebuilder.dsl.testTree, contributed in PR 678 and listed in the 5.6 changes as an "experimental Kotlin and Java DSL for programmatic test plan generation". In Kotlin the code nests exactly like the plan:

import org.apache.jmeter.sampler.DebugSampler
import org.apache.jmeter.testelement.TestPlan
import org.apache.jmeter.threads.ThreadGroup
import org.apache.jmeter.treebuilder.dsl.testTree

val root = testTree {                     // (1) creates a TreeBuilder and returns its ListedHashTree
    TestPlan::class {                     // (2) creates a TestPlan and opens its children block
        ThreadGroup::class {
            name = "Search Order Thread Group" // the block's receiver is the new ThreadGroup
            +DebugSampler::class          // (3) child without a block: unary plus
            +DebugSampler()               // (4) an instance you created yourself
        }
    }
}

The operations of TreeBuilder in JMeter 5.6.3, read from its source:

Kotlin form Java form What it does

SomeElement::class { ... }

b.add(SomeElement.class, e -> { ... })

Creates the element with its no-argument constructor, adds it under the current node, runs the block with the element as receiver (Kotlin) or parameter (Java), so elements added inside the block become its children.

+SomeElement::class

b.add(SomeElement.class)

Creates and adds an element that has no children and needs no configuration.

+existingElement

b.add(existingElement)

Adds an instance already created and configured in code.

existingElement { ... }

b.add(existingElement, e -> { ... })

Adds an existing instance and gives it children.

configureEach<HTTPSamplerProxy> { ... }

b.configureEach(HTTPSamplerProxy.class, s -> { ... })

Registers an action that runs on every element of that type (or subtype) added later in the current scope, after the element’s own block. Useful for defaults such as timeouts.

configureAll { ... }

b.configureAll(e -> { ... })

The same for every element in the scope.

tree, currentSubtree, currentElement, parent, elementStack

same names as getters

Read-only access to the resulting tree and to the position of the builder.

Two details matter. The builder sets the testClass property of each element to its Java class if it is not set already, which SaveService needs. It does not set guiClass, the property the GUI uses to choose the editor for an element; set it yourself if the .jmx will be opened in the GUI (see the complete example below).

The manual also shows how to factor a recurring pattern into an extension function of TreeBuilder, for example a thread group that always carries a Summariser. Applied to Bookshelf, a reusable search thread group looks like this:

import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy
import org.apache.jmeter.reporters.Summariser
import org.apache.jmeter.testelement.TestPlan
import org.apache.jmeter.threads.ThreadGroup
import org.apache.jmeter.treebuilder.Action
import org.apache.jmeter.treebuilder.TreeBuilder
import org.apache.jmeter.treebuilder.dsl.testTree

fun TreeBuilder.threadGroup(                      // (1) an extension of TreeBuilder
    name: String,
    numThreads: Int = 1,
    rampUp: Duration = 1.seconds,
    body: Action<ThreadGroup>
) {
    ThreadGroup::class {                          // (2) the normal DSL inside
        this.name = name
        this.numThreads = numThreads
        this.rampUp = rampUp.inWholeSeconds.toInt()
        +Summariser::class
        body(this)                                // (3) the caller's children
    }
}

val plan = testTree {
    TestPlan::class {
        threadGroup(name = "search", numThreads = 5) {    // (4) used like a built-in element
            HTTPSamplerProxy::class {
                name = "search books"
                domain = "localhost"
                port = 8080
                path = "/api/books"
                method = "GET"
                addArgument("query", "dune")
            }
        }
    }
}

A Thread Group created this way has no loop controller yet; the complete Java example below shows how to add one. The Kotlin compiler is not part of JMeter, so Kotlin plans need a Kotlin build (Gradle or Maven with the Kotlin plugin); JMeter 5.6.3 ships the Kotlin runtime (kotlin-stdlib 1.9.22 in lib/).

The 5.6 Java DSL: TreeBuilders.testTree

The same builder works from Java through the static method org.apache.jmeter.treebuilder.dsl.TreeBuilders.testTree. Java has no receivers, so every addition goes through the builder reference b, and each lambda receives the new element as a parameter:

import static org.apache.jmeter.treebuilder.dsl.TreeBuilders.testTree;

import org.apache.jmeter.sampler.DebugSampler;
import org.apache.jmeter.testelement.TestPlan;
import org.apache.jmeter.threads.ThreadGroup;
import org.apache.jorphan.collections.ListedHashTree;

ListedHashTree root = testTree(b -> {           // (1) b is the TreeBuilder
    b.add(TestPlan.class, tp -> {               // (2) tp is the new TestPlan
        b.add(ThreadGroup.class, tg -> {        // always add through b, never through tp or tg
            tg.setName("Search Order Thread Group");
            b.add(DebugSampler.class);          // (3) no children: no lambda
            b.add(new DebugSampler());          // (4) an existing instance
        });
    });
});

The lambdas are of type org.apache.jmeter.treebuilder.Action, a JMeter interface rather than java.util.function.Consumer, so the same API reads naturally from Kotlin and Java.

Generating code from the GUI: Copy Code

The GUI writes the DSL for you. Right-click any element in the tree and choose Copy Code (in the context menu, after Save Selection As…​ and Save as Test Fragment). JMeter copies Kotlin DSL for that element and all its children to the clipboard; with several elements selected it copies them one after another. The action was added in 5.6 with the DSL.

The generated code uses the long class names and the property schema introduced in 5.6 (props { it[...] = ... }). For the HTTP Request in the complete example below, the output is:

org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy::class {
    props {
        it[arguments] = org.apache.jmeter.config.Arguments().apply {
            props {
                it[arguments] = listOf(
                    org.apache.jmeter.protocol.http.util.HTTPArgument().apply {
                        props {
                            it[alwaysEncode] = true
                            it[argumentName] = "query"
                            it[value] = "dune"
                            it[metadata] = "="
                        }
                    },
                )
            }
        }
        it[name] = "search books"
        it[guiClass] = "org.apache.jmeter.protocol.http.control.gui.HttpTestSampleGui"
        it[protocol] = "http"
        it[domain] = "localhost"
        it[port] = 8080
        it[path] = "/api/books"
        it[method] = "GET"
        it[followRedirects] = true
        it[useKeepalive] = true
    }
}

What to know about the action, from its 5.6.3 implementation (CopyCodeAction and DslPrinterTraverser):

  • Kotlin only. There is no Java output; translate by hand, using the table above.

  • Only non-default properties are printed: a property is skipped when a freshly constructed element has the same value. The output is a faithful copy of what the GUI stored, not a tidy hand-written plan.

  • Secrets are copied too. The manual’s own example shows a proxy user name and password in the generated code. Clear credentials from the plan, or from the output, before committing it.

  • The same printer is public. new DslPrinterTraverser() passed to tree.traverse(...) prints any tree as Kotlin DSL, which the complete example uses to show what it built.

Complete example: build, save and run the Bookshelf search plan

The program below builds a one-sampler Bookshelf search plan with the Java DSL, saves it as bookshelf-search.jmx, prints it as Kotlin DSL, and runs it with a summariser and a JTL file, all without the jmeter script. The steps that bin/jmeter normally performs are explicit:

Step Why it is needed

JMeterUtils.setJMeterHome(home)

Elements look up files (such as bin/saveservice.properties, used by SaveService) relative to the JMeter home.

JMeterUtils.loadJMeterProperties(home + "/bin/jmeter.properties")

Loads the defaults that every element reads. Plan properties such as threads can then be set with JMeterUtils.setProperty.

JMeterUtils.initLocale()

Initialises messages and the locale used for output.

SaveService.saveTree(tree, out) / SaveService.loadTree(file)

Write and read .jmx files (XStream-based).

Summariser and ResultCollector

Without them nothing is printed or written: the CLI adds them for -l and the summariser.name property; code must add them itself.

StandardJMeterEngine.configure(tree) then runTest()

Starts the test on a background executor; awaitTermination(Duration) (5.6, experimental) waits for it. run() would run it on the calling thread instead.

import static org.apache.jmeter.treebuilder.dsl.TreeBuilders.testTree;

import java.io.FileOutputStream;
import java.io.OutputStream;
import java.time.Duration;
import org.apache.jmeter.control.LoopController;
import org.apache.jmeter.control.gui.LoopControlPanel;
import org.apache.jmeter.control.gui.TestPlanGui;
import org.apache.jmeter.dsl.DslPrinterTraverser;
import org.apache.jmeter.engine.StandardJMeterEngine;
import org.apache.jmeter.protocol.http.control.gui.HttpTestSampleGui;
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy;
import org.apache.jmeter.reporters.ResultCollector;
import org.apache.jmeter.reporters.Summariser;
import org.apache.jmeter.save.SaveService;
import org.apache.jmeter.testelement.TestElement;
import org.apache.jmeter.testelement.TestPlan;
import org.apache.jmeter.threads.ThreadGroup;
import org.apache.jmeter.threads.gui.ThreadGroupGui;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jorphan.collections.ListedHashTree;

public class BookshelfSearchPlan {

  public static void main(String[] args) throws Exception {
    String jmeterHome = args[0];                       // e.g. /opt/apache-jmeter-5.6.3
    int threads = Integer.parseInt(args.length > 1 ? args[1] : "5");
    int port = Integer.parseInt(args.length > 2 ? args[2] : "8080");

    // 1. Initialise JMeter: home, properties, locale (what bin/jmeter does for you)
    JMeterUtils.setJMeterHome(jmeterHome);
    JMeterUtils.loadJMeterProperties(jmeterHome + "/bin/jmeter.properties");
    JMeterUtils.initLocale();

    // 2. Build the tree with the experimental 5.6 Java DSL
    ListedHashTree tree = testTree(b -> {
      b.add(TestPlan.class, tp -> {
        tp.setName("bookshelf-search");
        tp.setProperty(TestElement.GUI_CLASS, TestPlanGui.class.getName());

        b.add(ThreadGroup.class, tg -> {
          tg.setName("search");
          tg.setProperty(TestElement.GUI_CLASS, ThreadGroupGui.class.getName());
          tg.setNumThreads(threads);
          tg.setRampUp(1);
          LoopController loops = new LoopController();
          loops.setLoops(20);
          loops.setContinueForever(false);
          loops.setProperty(TestElement.GUI_CLASS, LoopControlPanel.class.getName());
          tg.setSamplerController(loops);

          b.add(HTTPSamplerProxy.class, http -> {
            http.setName("search books");
            http.setProperty(TestElement.GUI_CLASS, HttpTestSampleGui.class.getName());
            http.setProtocol("http");
            http.setDomain("localhost");
            http.setPort(port);
            http.setPath("/api/books");
            http.setMethod("GET");
            http.setFollowRedirects(true);   // ticked by default in the GUI, false in code
            http.setUseKeepAlive(true);      // idem
            http.addArgument("query", "dune");
          });
        });
      });
    });

    // 3. Save it as a .jmx that the GUI and CLI can open
    try (OutputStream out = new FileOutputStream("bookshelf-search.jmx")) {
      SaveService.saveTree(tree, out);
    }

    // 4. Print the Kotlin DSL that the GUI "Copy Code" action would produce
    DslPrinterTraverser dsl = new DslPrinterTraverser();
    tree.traverse(dsl);
    System.out.println(dsl);

    // 5. Add a summariser and a JTL writer under the Test Plan, then run
    Summariser summariser = new Summariser("summary");
    ResultCollector jtl = new ResultCollector(summariser);
    jtl.setFilename("bookshelf-search.jtl");
    tree.add(tree.getArray()[0], jtl);

    StandardJMeterEngine engine = new StandardJMeterEngine();
    engine.configure(tree);
    engine.runTest();
    engine.awaitTermination(Duration.ofMinutes(5));
    System.exit(0);
  }
}

Compile and run it against an installed JMeter, with the jars in lib/ and lib/ext/ on the class path (5 threads, port 8080 for the local Bookshelf API):

JMETER_HOME=/opt/apache-jmeter-5.6.3
CP="$JMETER_HOME/lib/*:$JMETER_HOME/lib/ext/*"
javac --release 21 -cp "$CP" -d out BookshelfSearchPlan.java
java -cp "out:$CP" BookshelfSearchPlan "$JMETER_HOME" 5 8080

The program prints the Kotlin DSL of the tree (abridged here) and then the summariser line; 5 threads times 20 loops give 100 samples:

org.apache.jmeter.testelement.TestPlan::class {
    props {
        it[name] = "bookshelf-search"
        it[guiClass] = "org.apache.jmeter.control.gui.TestPlanGui"
    }

    org.apache.jmeter.threads.ThreadGroup::class {
        props {
            it[name] = "search"
            it[guiClass] = "org.apache.jmeter.threads.gui.ThreadGroupGui"
            it[numThreads] = 5
            it[rampTime] = 1
            it[mainController] = org.apache.jmeter.control.LoopController().apply {
                props {
                    it[loops] = 20
                    it[continueForever] = false
...
summary =    100 in 00:00:01 =  114.4/s Avg:     3 Min:     1 Max:    75 Err:     0 (0.00%)

The summariser formats numbers with the JVM’s default locale; with a Spanish or German locale the rate reads 114,4/s. Add -Duser.language=en -Duser.country=US when a script parses the line.

The saved bookshelf-search.jmx is an ordinary plan; jmeter -n -t bookshelf-search.jmx -l results.jtl runs it with the same result:

<?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-search"/>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="search">
        <intProp name="ThreadGroup.num_threads">5</intProp>
        <intProp name="ThreadGroup.ramp_time">1</intProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel">
          <intProp name="LoopController.loops">20</intProp>
          <boolProp name="LoopController.continue_forever">false</boolProp>
        </elementProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="search books">
          <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">dune</stringProp>
                <stringProp name="Argument.metadata">=</stringProp>
              </elementProp>
            </collectionProp>
          </elementProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.domain">localhost</stringProp>
          <intProp name="HTTPSampler.port">8080</intProp>
          <stringProp name="HTTPSampler.path">/api/books</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

To build with Maven instead of a class path of jar files, JMeter publishes its modules to Maven Central. The example needs the core and the HTTP module; it still reads jmeter.properties from an installed JMeter (or from a copy of that file shipped with the tests):

<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <jmeter.version>5.6.3</jmeter.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_core</artifactId>
    <version>${jmeter.version}</version>
  </dependency>
  <dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_http</artifactId>
    <version>${jmeter.version}</version>
  </dependency>
</dependencies>

Loading and running an existing .jmx from code

The other common use of the API is to run a GUI-built plan from a Java program or test harness, changing properties first. This mirrors what jmeter -n does internally (in JMeter.runNonGui):

import java.io.File;
import java.time.Duration;
import org.apache.jmeter.JMeter;
import org.apache.jmeter.engine.StandardJMeterEngine;
import org.apache.jmeter.reporters.ResultCollector;
import org.apache.jmeter.reporters.Summariser;
import org.apache.jmeter.save.SaveService;
import org.apache.jmeter.services.FileServer;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jorphan.collections.HashTree;

public class RunExistingPlan {

  public static void main(String[] args) throws Exception {
    String jmeterHome = args[0];
    File jmx = new File(args[1]);                      // e.g. bookshelf-load.jmx

    JMeterUtils.setJMeterHome(jmeterHome);
    JMeterUtils.loadJMeterProperties(jmeterHome + "/bin/jmeter.properties");
    JMeterUtils.initLocale();
    JMeterUtils.setProperty("threads", "2");           // same as -Jthreads=2

    FileServer.getFileServer().setBaseForScript(jmx);  // relative CSV paths resolve next to the .jmx
    HashTree tree = SaveService.loadTree(jmx);
    HashTree runnable = JMeter.convertSubTree(tree, true); // drop disabled elements, clone

    Summariser summariser = new Summariser("summary");
    ResultCollector jtl = new ResultCollector(summariser);
    jtl.setFilename("existing-plan.jtl");
    runnable.add(runnable.getArray()[0], jtl);

    StandardJMeterEngine engine = new StandardJMeterEngine();
    engine.configure(runnable);
    engine.runTest();
    engine.awaitTermination(Duration.ofMinutes(15));
    System.exit(0);
  }
}

With bookshelf-load.jmx reading ${__P(threads,1)}, the setProperty call has the same effect as -Jthreads=2. JMeter.convertSubTree(tree, true) removes disabled elements, resolves Module Controllers and clones the tree, as the CLI does; its Javadoc labels it "internal JMeter API", so pin the JMeter version when you depend on it. Plans that use Module Controllers need more of the CLI’s preparation (it builds a non-GUI tree model first); for those, prefer starting jmeter -n as a process.

Cost on the load generator

Building the tree in code costs nothing measurable; the engine, samplers and listeners are the same as in CLI mode, so the sizing advice of the section applies unchanged. Two differences come from where the engine runs:

  • It shares the JVM with your program. Heap, garbage collection and CPU are those of the Java process you started (or of the Maven Surefire fork for tests), not the HEAP settings of bin/jmeter. Size that JVM explicitly (-Xms/-Xmx in java, or argLine for Surefire) for real load.

  • Nothing is added for you. No summariser, no JTL and no dashboard unless the code adds them, and no shutdown port listener, so bin/shutdown.sh cannot stop a test started this way; engine.stopTest() from code can.

Pitfalls of the raw API

  • GUI defaults are not applied. The GUI fills new elements with defaults when you add them (5.6.3 moved this into JMeterGUIComponent.assignDefaultValues, PR 6199). In code an unset boolean is false: an HTTPSamplerProxy does not follow redirects and does not use keep-alive unless you set both, and a TestPlan does not run tearDown thread groups after a shutdown (TestPlan.tearDown_on_shutdown) unless you set it. Converting the API-built plan with jmx2dsl (below) made this visible: before the two setters were added, the generated code contained .followRedirects(false) and .useKeepAlive(false).

  • No guiClass. Without it the .jmx runs in the CLI but the GUI cannot show proper editors for the elements. Set it per element (as above) or once with configureEach.

  • A Thread Group without a controller starts, but every thread fails with NoSuchElementException: Property ThreadGroup.main_controller is unset and no sample is taken. The GUI creates the Loop Controller for you; code must call setSamplerController.

  • The documented API is small. The manual covers tree building only. Engine set-up, SaveService and result collection come from the Javadoc and the source, and some of it is internal.

  • Experimental status. TreeBuilder, testTree, DslPrinterTraverser and StandardJMeterEngine.awaitTermination carry @API(status = EXPERIMENTAL, since = "5.6"); they were still experimental on the master branch on 2026-10-14. Signatures may change in a later release.

JMeter Java DSL (Abstracta)

What it is

The JMeter Java DSL (us.abstracta.jmeter:jmeter-java-dsl) is an open-source library by Abstracta, not part of Apache JMeter. It describes itself as a "Simple JMeter performance tests Java API". A test is a JUnit (or any other framework) test that builds a plan with static factory methods, runs it in an embedded JMeter engine, and gets back a TestPlanStats object to assert on. It predates JMeter’s own DSL (first release 0.1 in August 2020, 1.0 in September 2022) and is independent of it.

Item Value (checked 2026-10-14)

Current version

2.3, released 2026-10-07 (Maven Central and GitHub release v2.3)

JMeter inside

5.6.3 (the jmeter.version property of the parent POM). Version 2.0 (September 2025) was the move to 5.6.3; the major version changed because JMeter 5.6.1 changed the default HTTP encoding to UTF-8.

Java

The library is compiled for Java 8. The jmdsl command-line tool (jmdsl.jar, jmeter-java-dsl-cli) needs Java 11 or later.

Plugins it brings

jmeter-plugins-casutg 2.10 (Concurrency and Ultimate thread groups), jmeter-plugins-tst 2.5 (Throughput Shaping Timer), dummy sampler, random CSV data set; they come as Maven dependencies, not through the Plugins Manager.

Licence

Apache License 2.0

Optional modules, each with the same version: jmeter-java-dsl-dashboard (live charts), -graphql, -jdbc, -websocket, -parallel, -wrapper (any JMeter element or GUI), -recorder and -cli (recorder and jmx2dsl), -blazemeter, -octoperf, -azure (cloud engines), -elasticsearch-listener, -prometheus, -datadog, and -bridge.

Setup

Maven, with JUnit 5 and AssertJ for the assertions:

<dependencies>
  <dependency>
    <groupId>us.abstracta.jmeter</groupId>
    <artifactId>jmeter-java-dsl</artifactId>
    <version>2.3</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.13.4</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.27.6</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Gradle (Kotlin script), as given by the guide; the exclusion avoids resolving JMeter’s bom artifact:

dependencies {
    testImplementation("us.abstracta.jmeter:jmeter-java-dsl:2.3") {
        exclude("org.apache.jmeter", "bom")
    }
}

No JMeter installation is needed: JMeter’s jars come as dependencies, and the library prepares the JMeter home and properties it needs at run time.

First test: a verified smoke run

testPlan(...) takes the plan’s children, threadGroup(name, threads, iterations, children...) creates a standard Thread Group, and httpSampler(label, url) an HTTP Request. run() blocks until the test ends and returns the statistics. This test was run against a local stub that serves a static JSON file:

package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static us.abstracta.jmeter.javadsl.JmeterDsl.*;

import java.io.IOException;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import us.abstracta.jmeter.javadsl.core.TestPlanStats;

class SearchSmokeTest {

  private static final String BASE = "http://localhost:" + System.getProperty("port", "8080");

  @Test
  void searchStaysFast() throws IOException {
    TestPlanStats stats = testPlan(
        threadGroup("search", 5, 20,
            httpSampler("search books", BASE + "/api/books?query=dune")
                .children(responseAssertion().containsSubstrings("Dune"))
        ),
        jtlWriter("target/jtls"),
        htmlReporter("target/reports")
    ).run();

    assertThat(stats.overall().samplesCount()).isEqualTo(100);
    assertThat(stats.overall().errorsCount()).isZero();
    assertThat(stats.byLabel("search books").sampleTimePercentile99())
        .isLessThan(Duration.ofMillis(500));
    System.out.println("p99=" + stats.overall().sampleTimePercentile99()
        + " samples=" + stats.overall().samplesCount()
        + " rps=" + stats.overall().samples().perSecond());
  }
}
mvn test -Dtest=SearchSmokeTest -Dport=8080
p99=PT0.05S samples=100 rps=416.6666666666667
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.209 s -- in bookshelf.SearchSmokeTest
[INFO] BUILD SUCCESS

target/jtls then holds one JTL file in the CSV format of the section (timeStamp,elapsed,label,responseCode,...), and target/reports one dashboard folder. When the same test was first pointed at a port where another server answered 404, the run failed on errorsCount() with "expected: 0L but was: 100L": HTTP errors become failed samples and failed samples fail the JUnit test.

The Bookshelf journey in the DSL

The full user journey of the section (log in, search, view a book, create a loan, view the loan), with the same run properties (threads, rampup, duration, host, port), the same CSV files and variables (username, password, term, token, bookId, loanId), JSON Extractors with the TOKEN_NOT_FOUND style defaults, and the same SLA properties (search_sla_ms, login_sla_ms). The files users.csv and search-terms.csv (placeholder values only) sit in src/test/resources. This class compiles against 2.3; it needs the Bookshelf API to run:

package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static us.abstracta.jmeter.javadsl.JmeterDsl.*;

import java.io.IOException;
import java.time.Duration;
import org.apache.http.entity.ContentType;
import org.junit.jupiter.api.Test;
import us.abstracta.jmeter.javadsl.core.TestPlanStats;
import us.abstracta.jmeter.javadsl.core.postprocessors.DslJsonExtractor.JsonQueryLanguage;

class BookshelfJourneyTest {

  // Same run properties as the .jmx plan: -Dthreads=50 -Drampup=60 -Dduration=660
  private static final int THREADS = Integer.getInteger("threads", 1);
  private static final int RAMPUP = Integer.getInteger("rampup", 1);
  private static final int DURATION = Integer.getInteger("duration", 30);
  private static final String BASE_URL = "http://" + System.getProperty("host", "localhost")
      + ":" + System.getProperty("port", "8080");

  @Test
  void loanJourneyMeetsSla() throws IOException {
    TestPlanStats stats = testPlan(
        httpDefaults()
            .url(BASE_URL)
            .connectionTimeout(Duration.ofSeconds(5))
            .responseTimeout(Duration.ofSeconds(30)),
        csvDataSet(testResource("users.csv")),
        csvDataSet(testResource("search-terms.csv")),
        threadGroup("loan journey")
            .rampToAndHold(THREADS, Duration.ofSeconds(RAMPUP),
                Duration.ofSeconds(DURATION - RAMPUP))
            .children(
                transaction("01 login",
                    httpSampler("POST /api/auth/login", "/api/auth/login")
                        .post("{\"username\":\"${username}\",\"password\":\"${password}\"}",
                            ContentType.APPLICATION_JSON)
                        .children(
                            jsonExtractor("token", "$.token")
                                .queryLanguage(JsonQueryLanguage.JSON_PATH)
                                .defaultValue("TOKEN_NOT_FOUND"))),
                uniformRandomTimer(Duration.ofSeconds(2), Duration.ofSeconds(4)),
                transaction("02 search",
                    httpSampler("GET /api/books", "/api/books")
                        .param("query", "${term}")
                        .header("Authorization", "Bearer ${token}")
                        .children(
                            jsonExtractor("bookId", "$[0].id")
                                .queryLanguage(JsonQueryLanguage.JSON_PATH)
                                .defaultValue("BOOK_NOT_FOUND"))),
                transaction("03 view book",
                    httpSampler("GET /api/books/{id}", "/api/books/${bookId}")
                        .header("Authorization", "Bearer ${token}")
                        .children(responseAssertion().containsSubstrings("\"id\""))),
                transaction("04 create loan",
                    httpSampler("POST /api/loans", "/api/loans")
                        .header("Authorization", "Bearer ${token}")
                        .post("{\"bookId\":\"${bookId}\"}", ContentType.APPLICATION_JSON)
                        .children(
                            jsonExtractor("loanId", "$.id")
                                .queryLanguage(JsonQueryLanguage.JSON_PATH)
                                .defaultValue("LOAN_NOT_FOUND"))),
                transaction("05 view loan",
                    httpSampler("GET /api/loans/{id}", "/api/loans/${loanId}")
                        .header("Authorization", "Bearer ${token}"))
            ),
        jtlWriter("target/jtls", "bookshelf-journey.jtl"),
        htmlReporter("target", "bookshelf-dashboard")
    ).run();

    assertThat(stats.overall().errors().total()).isZero();
    assertThat(stats.byLabel("02 search").sampleTime().perc95())
        .isLessThan(Duration.ofMillis(Long.getLong("search_sla_ms", 800)));
    assertThat(stats.byLabel("01 login").sampleTime().perc95())
        .isLessThan(Duration.ofMillis(Long.getLong("login_sla_ms", 1000)));
  }
}

Source: JMeter Java DSL user guide (sections CSV as input data, JSON extraction, Group requests, Emulate user delays, Timeouts, Generate HTML reports)

Run the smoke profile with the defaults and the load profile with system properties:

mvn test -Dtest=BookshelfJourneyTest                                   # smoke: 1 thread, 30 s
mvn test -Dtest=BookshelfJourneyTest -Dthreads=50 -Drampup=60 -Dduration=660   # load

Worked numbers for the load profile. rampToAndHold(50, 60 s, 600 s) becomes a standard Thread Group with 50 threads, a 60 s ramp-up and a scheduler duration of 660 s (checked in the saved .jmx), the section’s load profile. Threads start at 50 / 60 ≈ 0.83 per second. The uniformRandomTimer(2 s, 4 s) sits at thread-group level, so it delays every sampler by 3 s on average; with a typical response time of 0.2 s each thread sends one request per ≈ 3.2 s, so 50 threads send ≈ 50 / 3.2 ≈ 15.6 requests per second at full load. Over the 600 s hold that is ≈ 9,400 requests, plus ≈ 470 during the ramp (half the rate for 60 s).

Points in this example that differ from a GUI plan:

  • Strings are JMeter expressions. ${token} and ${term} are resolved by JMeter at run time, exactly as in a .jmx; Java only builds the strings.

  • jsonExtractor uses JMESPath by default. queryLanguage(JsonQueryLanguage.JSON_PATH) switches it to the JSON Extractor and JSONPath used elsewhere in the section. jsonAssertion has the same default.

  • Transactions. The DSL’s transaction writes "Include duration of timer and pre-post processors" as false explicitly and does not generate a parent sample unless generateParentSample() is called, which fits the section’s convention of always setting the property.

  • uniformRandomTimer(min, max) is not the GUI’s field pair. The guide notes that the generated Uniform Random Timer gets Constant Delay Offset = min and Random Delay Maximum = max − min.

  • Cookies and caching are on by default. The DSL adds an HTTP Cookie Manager and an HTTP Cache Manager to every plan; httpCookies().disable() and httpCache().disable() remove them.

  • CSV sharing. csvDataSet shares one file across all threads by default and sets Allow quoted data to true; sharedIn(...) changes the sharing mode.

How DSL builders map to JMeter elements

DSL builder JMeter element it creates Notes

testPlan(...)

Test Plan

Also sequentialThreadGroups() and tearDownOnlyAfterMainThreadsDone().

threadGroup(name, threads, iterations, ...) / threadGroup(name, threads, Duration, ...)

Thread Group

Iterations or a scheduler duration.

threadGroup().rampTo(...).holdFor(...)

Thread Group, or Ultimate Thread Group (plugin)

A single ramp and hold stays a standard Thread Group; multi-stage profiles become an Ultimate Thread Group.

rpsThreadGroup()

Concurrency Thread Group + Throughput Shaping Timer (plugins)

See below.

setupThreadGroup(...), teardownThreadGroup(...)

setUp / tearDown Thread Group

httpSampler(...), httpDefaults(), httpHeaders()

HTTP Request, HTTP Request Defaults, HTTP Header Manager

transaction(...)

Transaction Controller

csvDataSet(...)

CSV Data Set Config

randomOrder() uses the Random CSV Data Set plugin.

responseAssertion(), jsonAssertion(...)

Response Assertion, JSON JMESPath or JSON Assertion

jsonExtractor(...), regexExtractor(...), boundaryExtractor(...)

JSON JMESPath / JSON Extractor, Regular Expression Extractor, Boundary Extractor

constantTimer, uniformRandomTimer, throughputTimer, threadPause

Constant Timer, Uniform Random Timer, Constant Throughput Timer, Flow Control Action pause

jsr223Sampler, jsr223PreProcessor, jsr223PostProcessor

JSR223 elements (Groovy)

Also accept Java lambdas, which cannot be exported to .jmx.

jtlWriter(...)

ResultCollector writing a JTL (the class behind Simple Data Writer)

htmlReporter(...)

HTML dashboard generated after the run

influxDbListener(...), graphiteListener(...)

Backend Listener (InfluxDB or Graphite client)

Source: JMeter Java DSL user guide and the buildTestElement methods of the 2.3 sources

Load shaping: thread ramps and rpsThreadGroup

The default threadGroup() chains stages: rampTo(threads, duration), holdFor(duration), rampToAndHold(threads, ramp, hold) and holdIterating(n). showTimeline() opens a chart of the planned threads.

rpsThreadGroup() targets requests per second instead of threads. It adds and removes threads and inserts delays to match a rate profile, using the Concurrency Thread Group with the Throughput Shaping Timer’s feedback function internally (the plugin elements the section describes in Custom Thread Groups & Load Shaping). .counting(RpsThreadGroup.EventType.ITERATIONS) targets iterations per second instead.

rpsThreadGroup("search arrivals")
    .maxThreads(20)
    .rampToAndHold(15.6, Duration.ofSeconds(60), Duration.ofSeconds(600))
    .children(
        httpSampler("GET /api/books", "/api/books").param("query", "${term}"))

Worked numbers. The guide’s sizing rule is maxThreads ≈ T × R, with T the highest rate and R the highest expected response time in seconds. For 15.6 requests per second and a pessimistic 0.5 s response time, T × R ≈ 7.8, so at least 8 threads; maxThreads(20) leaves headroom for slow periods. Without maxThreads the group creates as many threads as needed, which the guide warns can exhaust the load generator. Do not add think-time timers here: the shaping timer already inserts the pauses, and extra timers make each thread slower, which the group compensates with more threads. The saved .jmx showed the ramp starting at 1 request per second, not 0, and the guide advises against rates below 1 per second.

Saved as .jmx, an rpsThreadGroup plan uses DSL subclasses of the plugin elements (for example RpsThreadGroup$ContractComplyingConcurrencyThreadGroup), so the GUI can open it only with the DSL jar copied to JMeter’s lib folder, as the guide’s "Save as JMX" tip describes. For a plan that must be maintained in the GUI, use the Open Model Thread Group or the plugin elements directly (see Open Model & Throughput Control).

Results: jtlWriter, htmlReporter and influxDbListener

Listener Options (2.3)

jtlWriter(dir) / jtlWriter(dir, fileName)

Writes a CSV JTL; without a name the file is called after the date and a UUID. withAllFields() writes every field (more cost on the generator); logOnly(SampleStatus.ERROR) with a second writer keeps full details only for failures. Use unique names: an existing file is appended to.

htmlReporter(dir) / htmlReporter(dir, name)

Generates JMeter’s HTML dashboard after the run; a fixed name must be unique or generation fails. timeGraphsGranularity(...) changes the one-minute default of the time graphs.

influxDbListener(url)

JMeter’s InfluxDB Backend Listener client. Options: token (InfluxDB 2), title, application, measurement, samplersRegex, tag(name, value), percentiles(...).

graphiteListener(url)

Graphite Backend Listener.

resultsTreeVisualizer(), dashboardVisualizer()

GUI windows during the run, for debugging on a desktop only (dashboardVisualizer comes from the jmeter-java-dsl-dashboard module).

testPlan(
    threadGroup("search", 5, Duration.ofMinutes(2),
        httpSampler("GET /api/books", "http://localhost:8080/api/books").param("query", "dune")),
    influxDbListener("http://localhost:8086/api/v2/write?org=bookshelf&bucket=jmeter")
        .token(System.getenv("INFLUXDB_TOKEN"))
        .application("bookshelf")
        .tag("profile", "smoke")
).run();

The token comes from the environment, never from source code. The JMeter side of InfluxDB and Grafana is described in Real-time Monitoring with Backend Listener, and the dashboard in HTML Dashboard Report.

Assertions on TestPlanStats with JUnit 5

run() returns TestPlanStats. It aggregates every sample of the run, overall and by label (sampler and transaction names), and is what makes the DSL attractive in CI: the build fails on an ordinary assertion.

Method (2.3) Returns

overall(), byLabel(label), labels()

A StatsSummary for all samples or one label; the set of labels.

duration()

Run time as a Duration.

samplesCount(), errorsCount()

Totals (shortcuts for samples().total() and errors().total()).

samples().perSecond(), errors().perSecond()

Rates over the run.

sampleTime().min(), max(), mean(), median(), perc90(), perc95(), perc99()

Response-time statistics as Duration; sampleTimePercentile99() is a shortcut.

receivedBytes(), sentBytes()

Byte totals and rates.

firstTime(), endTime()

First and last sample time.

Assertions are evaluated after the run, so a test that is far off target still runs to the end. The DSL’s autoStop(...) listener stops the run early when a condition on these metrics is met and fails the test. Error rates are not a field; compute them as errors().total() divided by samples().total().

Reviewing in the GUI: showInGui and showTimeline

testPlan(...).showInGui() opens the JMeter GUI with the plan loaded, so a GUI user can check what the code builds, add a View Results Tree and run it interactively. Any DSL element supports it, not only the plan. showTimeline() on a thread group draws the planned threads or rate over time. Both need a desktop session; remove them from tests that run in CI.

testPlan(
    threadGroup("search", 2, 10,
        httpSampler("GET /api/books", "http://localhost:8080/api/books").param("query", "dune"))
).showInGui();

JMX round trips: saveAsJmx, fromJmx and jmx2dsl

  • saveAsJmx(path) writes the plan as a .jmx. The DSL saved the smoke plan with JMeter 5.6.3’s own format, including its automatic Cookie and Cache Managers, and jmeter -n -t dsl-search.jmx ran it without any extra jar. Plans with DSL-specific elements (rpsThreadGroup, Java lambdas) need the DSL jar in JMeter’s lib, and lambdas do not work in the GUI at all.

  • DslTestPlan.fromJmx(path).run() runs an existing .jmx in the embedded engine and returns TestPlanStats, so a GUI-built plan can get JUnit assertions without being rewritten.

  • jmx2dsl converts a .jmx into DSL code, through jmdsl.jar from the GitHub release page or JBang:

java -jar jmdsl.jar jmx2dsl bookshelf-search.jmx
# or
jbang us.abstracta.jmeter:jmeter-java-dsl-cli:2.3 jmx2dsl bookshelf-search.jmx

Applied to the bookshelf-search.jmx produced with JMeter’s API earlier on this page, the generated test contains:

TestPlanStats stats = testPlan()
    .tearDownOnlyAfterMainThreadsDone()
    .children(
      httpCache()
        .disable(),
      httpCookies()
        .disable(),
      threadGroup("search", 5, 20,
        httpSampler("search books", "http://localhost:8080/api/books")
          .param("query", "dune")
      )
    ).run();
assertThat(stats.overall().errorsCount()).isEqualTo(0);

The conversion is a good check of what a plan really contains: it disabled the DSL’s automatic cookie and cache managers because the original plan has none, and added tearDownOnlyAfterMainThreadsDone() because the API-built Test Plan left tearDown_on_shutdown at false. The guide recommends reviewing generated code, adding assertions and running it with one thread first.

Running at scale

run() uses the EmbeddedJmeterEngine in the test’s JVM. runIn(engine) selects another engine: EmbeddedJmeterEngine with prop(name, value) or propertiesFile(file) (JMeter properties are only supported by this engine), DistributedJmeterEngine("host1", "host2") for JMeter remote testing (the servers must run the same JMeter version as the DSL, 5.6.3, and rmi_keystore.jks must be in the working directory), and the BlazeMeter, OctoPerf and Azure Load Testing engines from their modules. The guide itself recommends the managed services over a self-run RMI cluster. Remote testing in plain JMeter is described in Distributed Testing.

Cost on the load generator

The embedded engine is real JMeter, so samplers, timers and listeners cost the same as in CLI mode. What changes:

  • The test JVM is the load generator. Maven Surefire forks a JVM with its own defaults; give it a heap that fits the run (for example <argLine>-Xms1g -Xmx1g</argLine>) and keep the forked JVM to one performance test at a time. Do not run load tests in parallel with the unit test suite on the same machine.

  • htmlReporter runs after the test and parses the whole JTL; for long soak runs it takes noticeable time and memory at the end.

  • jtlWriter().withAllFields() and the GUI visualizers add per-sample work; keep them for debugging.

  • rpsThreadGroup without maxThreads can create more threads than the machine can hold.

What the DSL does not cover

  • No gRPC. Neither the guide nor the module list contains gRPC support; an open feature request in the project’s GitHub issues (number 265) asks for HTTP/2 and gRPC. For gRPC use the JMeter gRPC Request plugin in a .jmx plan as described in gRPC Load Testing, or try to wrap that plugin’s sampler with the DSL’s jmeter-java-dsl-wrapper module (not verified for this plugin).

  • No HTTP/2. The httpSampler uses JMeter’s HTTP Request (HttpClient4 or the Java implementation).

  • Not every JMeter element has a builder. The -wrapper module and custom builder classes (subclasses of BaseSampler and similar) cover the gaps, as the guide’s "Custom or yet not supported test elements" section shows.

  • Not a browser. Like JMeter, it does not run JavaScript. The guide’s Selenium section runs an existing Selenium script inside a jsr223Sampler to time a few real-browser users next to the HTTP load; the browser itself is not load-tested by JMeter.

  • JMeter version lag. Each DSL release pins one JMeter version. JMeter 6.0 support depends on a future DSL release.

Choosing between .jmx and code

Criterion .jmx built in the GUI JMeter 5.6 API / DSL JMeter Java DSL (Abstracta)

Who maintains it

Testers who know the GUI

Java or Kotlin developers who know JMeter’s classes

Java developers; JMeter knowledge helps but is not required

Status

Stable, the reference format

Experimental (@API status)

Third-party, active, release 2.3

Coverage

Every core element and every plugin

Every element (any TestElement can be added)

Most common elements; gaps through wrappers; no gRPC

Review and merge

Noisy XML diffs

Readable code

Readable code

Pass or fail in CI

Post-process JTL or statistics.json

Code you write around the engine

JUnit / AssertJ on TestPlanStats

Run in

GUI, CLI, jmeter-maven-plugin, distributed RMI

Your JVM, or save .jmx for the CLI

Test JVM, RMI cluster, BlazeMeter, OctoPerf, Azure

Converts to the others

Copy Code (Kotlin), jmx2dsl

SaveService to .jmx

saveAsJmx, fromJmx

Practical rules for Bookshelf-style projects:

  • Keep .jmx when testers maintain the plan in the GUI, when it relies on plugins (gRPC, Ultimate Thread Group edited by hand) or when the run is a large distributed test driven by the CLI or the jmeter-maven-plugin (CI/CD with Maven, Docker & GitHub Actions).

  • Use the JMeter Java DSL for performance checks that developers own and that run with every build: a smoke or short load test of a few endpoints with SLA assertions, next to the service’s other tests. fromJmx lets such a build also run GUI-built plans.

  • Use JMeter’s own API when a tool must generate or transform plans (for example one sampler per OpenAPI operation), or when a Java program must load, patch and run existing .jmx files. Accept that it is experimental and pin the JMeter version.

  • Do not maintain the same plan twice. Choose a source of truth; generate the other form (Copy Code, jmx2dsl, saveAsJmx) only to inspect or migrate.

Pitfalls checklist

  • An API-built HTTPSamplerProxy does not follow redirects or use keep-alive unless both are set; an API-built Test Plan does not run tearDown groups after shutdown.

  • An API-built plan without guiClass values runs in the CLI but does not open properly in the GUI.

  • HashTree instead of ListedHashTree can reorder samplers.

  • No Summariser or ResultCollector, no output: code must add them.

  • Copy Code and jmx2dsl copy credentials stored in the plan into source code.

  • DSL jsonExtractor and jsonAssertion default to JMESPath, not JSONPath.

  • DSL plans get a Cookie Manager and a Cache Manager unless disabled.

  • uniformRandomTimer(min, max) means minimum and maximum, not the GUI’s offset and random range.

  • rpsThreadGroup without maxThreads, or with rates below 1 per second, misbehaves; plans using it need the DSL jar to open in the GUI.

  • A unique jtlWriter file name and htmlReporter directory per run, or results are appended or generation fails.

  • showInGui() and visualizers in a CI test block or fail without a desktop.

  • Performance tests in the same Surefire run as unit tests distort both; run them in their own profile or module.

  • Summariser output uses the JVM locale for decimals.

What changed: from the books to 5.6.3 and 6.0

  • The books (JMeter 2.11 to 3.0, 2014 to 2017) build plans in the GUI only and do not cover a programmatic API or any DSL; their methodology for workload models and acceptance criteria applies to plans written in code unchanged.

  • 2020 to 2022: the JMeter Java DSL appears (0.1 in August 2020, 1.0 in September 2022) as a third-party project.

  • JMeter 5.6 (June 2023): experimental Kotlin and Java DSL (TreeBuilder, testTree, PR 678), the Copy Code action, and the property schema API used by the generated code (TestElementSchema, getProps(), PR 5944); the TestElement interface moved to Kotlin (PR 5959).

  • JMeter 5.6.3 (January 2024): improved API for creating elements programmatically in GUI components (makeTestElement, assignDefaultValues, PR 6199). The DSL classes remain experimental.

  • JMeter Java DSL 2.0 (September 2025) moves to JMeter 5.6.3; 2.3 (October 2026) is the current release.

  • JMeter 6.0 (planned per changes.xml on master): Java 17 or later and Kotlin 1.9 or later required (PRs 6220 and 6550); TreeBuilder and testTree were still marked experimental on master on 2026-10-14. Planned behaviour changes also reach code-built plans, for example the Cookie Manager no longer sending expired cookies (issue 6428), 307/308 redirects keeping their method (issue 6080) and the MongoDB elements being removed (PR 6573). Re-check after 6.0 is released; the JMeter Java DSL will need a release built on 6.0.

References