Load testing8 min read27 августа 2026 г.

Running JMeter Test Plans with PerfScale

Run an existing .jmx plan headless through the PerfScale CLI — live JMeter output translated into the k6-compatible summary — then parameterize the plan, move it to the platform, and gate it in CI.

Автор: Perfscale Team

What you'll build

By the end of this guide you will take an existing JMeter test plan (.jmx) and run it through PerfScale — first locally via the CLI, then on the platform where the plan executes on a PerfScale agent and reports into the same dashboard as your k6-style tests. PerfScale doesn't replace JMeter: it runs your plan with the real jmeter binary, streams the output live, and translates the final console summary into the k6-compatible metrics the rest of the tooling understands.

You'll need:

  • Apache JMeter installed and on PATH (jmeter --version should work; JMeter needs a JRE)
  • A perfscale binary (OSS build is enough for the CLI part)
  • A running PerfScale platform (controlplane + agent) for Steps 4–5 — the agent Docker image ships with JMeter 5.6.3, so agents need no extra setup

Step 1 — Install and verify JMeter

Download JMeter from jmeter.apache.org and put its bin/ on PATH, or use a package manager:

# macOS
brew install jmeter

# any OS with the archive: add apache-jmeter-*/bin to PATH
jmeter --version

PerfScale shells out to jmeter -n -t <plan> — the plain jmeter command must resolve. If it doesn't, the run fails immediately with a clear error:

jmeter not found in PATH — install from https://jmeter.apache.org/download_jmeter.cgi (requires a JRE)

Step 2 — Run an existing .jmx plan

Take a trimmed plan: 5 threads × 20 loops issuing GET / against a local server on port 18999, with a response assertion on status 200. Save it as plan.jmx:

<?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="demo" enabled="true">
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="users" enabled="true">
        <stringProp name="ThreadGroup.num_threads">5</stringProp>
        <stringProp name="ThreadGroup.ramp_time">1</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">20</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET /" enabled="true">
          <stringProp name="HTTPSampler.domain">127.0.0.1</stringProp>
          <stringProp name="HTTPSampler.port">18999</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.path">/</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <hashTree>
            <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="status is 200" enabled="true">
              <collectionProp name="Asserion.test_strings">
                <stringProp name="49586">200</stringProp>
              </collectionProp>
              <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
              <intProp name="Assertion.test_type">8</intProp>
            </ResponseAssertion>
            <hashTree/>
          </hashTree>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

Start a trivial target server, then run the plan through PerfScale:

python3 -m http.server 18999 &   # any local HTTP server works
perfscale run --jmeter plan.jmx

During the run you see JMeter's raw output streamed live — startup log, periodic summary + progress lines, and the final summary = aggregate. When the process exits, PerfScale appends a translated k6-compatible summary block so JMeter results land in the same format as every other engine:

summary =    100 in 00:00:01 =  109.2/s Avg:     4 Min:     3 Max:    72 Err:     0 (0.00%)

http_req_duration......: avg=4.00ms min=3.00ms max=72.00ms
http_req_failed........: 0.00%
http_reqs..............: 100 109.2/s

No percentiles. JMeter's console summary only carries the run totals, so the translated http_req_duration line has avg/min/max only — no p50/p95/p99. If you need percentiles from a JMeter plan, have the plan write its own result file (-l / a results listener) and analyze that separately, or re-express the scenario as a native perfscale config where percentiles come from the engine itself.

Two more behavior notes worth knowing:

  • Sample failures don't fail the run. JMeter exits non-zero on plan/startup errors, but failed samples (like assertion errors) don't change the exit code unless the plan itself gates on them — watch http_req_failed in the summary.
  • JMeter owns the load shape. Thread counts, loops, and timers all come from the plan; perfscale passes no -J properties (next step).

Step 3 — Parameterize the plan with ${__P(...)}

PerfScale intentionally passes no -J properties to JMeter — parameterize inside the plan itself so defaults live next to the values they tune. JMeter's ${__P(name, default)} function reads a property with a fallback, so the same plan runs standalone and can still be overridden when you wrap the invocation:

<stringProp name="ThreadGroup.num_threads">${__P(vus,5)}</stringProp>
<stringProp name="LoopController.loops">${__P(loops,20)}</stringProp>
<stringProp name="HTTPSampler.port">${__P(port,18999)}</stringProp>

If you need to inject values at runtime, wrap the jmeter call in a small script that sets -J flags and point perfscale run at the same plan — the plan stays the single source of truth either way.

Step 4 — Move the plan to the platform

Local CLI runs are for development; scheduled, tracked runs live on the platform:

  1. In the controlplane, create a new test and pick the JMeter test type.
  2. Paste the plan XML — the plan is stored in the test config, not as a loose file.
  3. Run the test. It's dispatched to an agent via POST /api/v1/run/jmeter; the agent Docker image already includes JMeter 5.6.3, so the plan just runs.
  4. Watch the live log streaming in the run view — the same raw JMeter output you saw locally, plus the translated summary at the end.
  5. The finished run shows the k6-compatible summary in the same dashboard as your k6 tests: http_reqs, http_req_failed, and http_req_duration (avg/min/max).

This is the main payoff: JMeter plans and native perfscale configs become comparable in one place — same run history, same summary shape, same dashboards.

Step 5 — CI usage

Local runs gate like any other command — JMeter exits non-zero on startup/plan errors, so a broken plan fails the pipeline:

# .github/workflows/jmeter-smoke.yml
- name: JMeter smoke test
  run: perfscale run --jmeter plan.jmx

Remember what the exit code does and doesn't cover: a plan that runs but whose requests all fail still exits 0 — gate on http_req_failed from the translated summary (or add result-gating inside the plan) if you want sample failures to fail the build. The CI runner also needs jmeter on PATH; on the platform side this is a non-issue since the agent image bundles JMeter 5.6.3.

Troubleshooting

  • jmeter not found in PATH — install JMeter and make sure its bin/ directory is on PATH for the user running perfscale; verify with jmeter --version.
  • Run passes but http_req_failed is high — expected: JMeter's exit code reflects plan/startup errors, not failed samples. Read the error percentage in the translated summary and gate on it yourself.
  • No translated summary at the end — the plan produced no final summary = console line (e.g. it crashed mid-run or the summary was redirected); you'll see a no jmeter 'summary =' line captured notice in the output. Check the raw JMeter log above it for the real error.
  • Missing percentiles in the dashboard — by design: JMeter's console summary carries avg/min/max only, and that's all the translated block reports.
  • XML edits rejected by JMeter — note that XML comments in .jmx files may not contain double hyphens (--), and keep the hashTree nesting structure intact when hand-editing.