Skip to content
Talk to our solutions team

Load Testing

Testing reuses your functional test definitions as the load workload: the same steps you run with test.svc tests are executed repeatedly by concurrent virtual users, phase by phase. There is no separate load-script format — you write a load profile describing phases and point it at an existing test collection.

Two pipelines can drive load today:

PipelineCommandStatus
tests-mode load (recommended)test.svc tests -p <dir> --load <profile.yaml> --profile <name>current; renders phase summary tables
legacy loadtest.svc load -i test.yaml -c config.yaml -r load.yaml -l <name>v1 testplan format; live terminal dashboard

The v2 kis test runner is functional-only for now — its DuckDB schema notes that load metrics tables ship when those executors land.

Terminal window
test.svc tests -p examples/06-load-test -d examples/env.json \
--load examples/06-load-test/profile.yaml --profile default -s

Workload steps are ordinary step definitions (examples/06-load-test/steps.yaml):

steps:
- name: load-get-users
rest:
url: "{{baseurl}}/users"
method: GET
- name: load-create-post
rest:
url: "{{baseurl}}/posts"
method: POST
body:
type: raw
payload: |
{
"title": "Load test post",
"body": "Created during load testing",
"userId": 1
}
headers:
- Content-Type: application/json

Use --patterntype/--pattern to narrow which tests form the workload; each virtual user executes one full pass over the filtered set.

A profile file has a top-level loadprofiles: key with named profiles. Select one with --profile <name> (default default).

loadprofiles:
default: # gentle profile for smoke testing
warmup:
duration: 10s
targetusers: 5
sustained:
duration: 30s
targetusers: 10
cooldown:
duration: 10s
targetusers: 2
stress:
warmup:
duration: 15s
targetusers: 20
sustained:
duration: 60s
targetusers: 50
cooldown:
duration: 15s
targetusers: 5
burst: # phases are optional — one is enough
sustained:
duration: 10s
targetusers: 100

Exactly three phase slots exist, always executed in this order, each optional:

warmup → sustained → cooldown

A missing phase is skipped with a log line (“no warmup phase found. skipping…”).

Each phase is a constant-concurrency plateau. There is no ramp, spike, or pause phase type: targetusers is a cap that the spawner fills as fast as admission allows, not a gradient. “Ramping” is approximated by chaining phases with increasing caps.

FieldTypeRequiredMeaning
durationGo duration string (10s, 1m)yes — parse failure abortshow long the phase spawns users
targetusersintone of the twomax concurrent virtual users (takes priority when both set)
targetrequestsintone of the twomax concurrent in-flight requests
delay.durationduration string, default 1msnopacing sleep between user spawns (legacy test.svc load pipeline only)

Profile-level fields (legacy test.svc load pipeline only)

Section titled “Profile-level fields (legacy test.svc load pipeline only)”
FieldDefaultMeaning
cpuruntime.NumCPU()GOMAXPROCS cap for the run
phasedelay0ssleep between phases

Total run duration = sum of phase durations (there is no overall duration cap).

  • test.svc tests: --load <file> + --profile <name>.
  • test.svc load: -r/--profiles <file> (default load.yaml); if you pass -r "" the profiles are read from a loadprofiles: key inside the -i input file itself.

A virtual user is a goroutine that executes one full pass of the workload (every filtered test, all steps, all data records), then releases its slot.

  • A 1 ms ticker loop runs for the phase duration. Each tick it computes how many users are missing to reach targetusers and spawns that many, each gated through the governor’s blocking admission check.
  • When a user finishes its pass, the ticker replenishes it — so short workloads recycle users rapidly; long workloads may not reach the cap before the phase ends.
  • The governor is a concurrency semaphore on atomics — it tracks current/total users and requests. There is no arrival-rate model: throughput is an emergent property of targetusers, workload latency, and (legacy pipeline) delay.duration spawn pacing.
  • Each individual HTTP call additionally acquires a request slot when targetrequests gates the phase.
  • Phase end stops the spawner only — in-flight users finish their pass naturally; nothing force-kills them.

Differences between pipelines:

  • Legacy test.svc load paces spawns with delay.duration; tests-mode does not.
  • In load mode the functional per-step table output is suppressed, and interactive userinput prompts are skipped.

Caveat: the HTTP client currently applies no request timeout in load mode (the env timeout key is read but not wired to the client). A hung endpoint holds its virtual user until the process ends.

Per request, Testing records: duration (ns), TTFB (via go-httpstat), bytes in/out, status (passed/failed), and the HTTP status code. Aggregated per phase and per run:

  • Latency (execution time): min, avg, median, max, and nearest-rank percentiles 50/90/95/99
  • TTFB: same set
  • Counts: total / passed / failed requests, total users
  • Bytes: total in / out

Notes on interpretation:

  • Averages are computed over passed records only.
  • Percentiles use nearest-rank over the raw sample arrays, aggregated at run level.
  • The Timeout and Network Failure columns in the summary table are currently always 0 (the counters are never incremented); Test Step Failure therefore equals the failed count.
  • A failed validation marks the record failed; a regex/JSON-key mismatch that produces no hard error can still count as success in the legacy pipeline — assert explicitly on status codes for reliable failure counts.
  • tests-mode: a status line every 100 ms — Status: Requests [current, total, max] | Users [current, total, max] — then per-phase summary tables (<phase> Phase Results:). When only targetusers is set (no targetrequests), the Requests counter is not admission-tracked and drifts negative — read the Users column live and take request totals from the phase table’s Total Count. Expect verbose debug logging on stdout during load runs; the phase tables print at the end of each phase.
  • legacy test.svc load: a termui full-screen dashboard (gauge, status, log window) plus a run summary (Run ID, start/end time, elapsed seconds).

Columns per step (rendered per phase): Success %, Total Count, Success Count, failure counts (Total, Timeout, Test Step Failure, Network Failure), Execution Time (Min/Avg/Median/Max/50/90/95/99), TTFB (same 8), Bytes In, Bytes Out.

The Execution Time and TTFB columns are nanoseconds — divide by 1e6 for milliseconds (e.g. 119351861 ≈ 119 ms).

PipelineFiles
tests-mode<prefix>-<timestamp>.log (from --prefix, default test), phase tables to stdout
legacy<env output prefix>test-load-<timestamp>.log, <prefix>_summary.txt (currently created but left empty — the table-writing call is disabled)

There are no configurable thresholds, SLOs, or error-rate gates. A load run “passes” by completing: the exit code is non-zero only on setup/phase errors, never on failed requests. Failed requests only show up in the metrics. If you need a CI gate, parse the phase summary (or wrap the run and inspect the failure counts) — or run a functional pass with kis test before/after the load run.

Per-record pass/fail comes from the same validation rules as functional runs (assertions and variables): response.validate expressions (in the step’s language: js/jq/celgo), else response.regex, else payload comparison. continueonerror controls whether a failure aborts the user’s pass.

For horizontal scale, run test.svc orchestrator plus one or more test.svc agent processes and submit runs over the orchestrator’s HTTP API — the same profiles and test definitions apply, with agentcount fanning scenarios out across agents. See server modes.

  • targetusers bounds concurrency, not RPS. Estimate RPS ≈ users ÷ mean pass latency × requests-per-pass, then validate against the live status line.
  • Start with the default-style profile (small warmup, modest sustained) and scale targetusers between runs; the cooldown phase mainly demonstrates the smaller-cap behavior (users above the cap finish naturally, they are not culled).
  • One process generates load from one machine; use distributed mode when a single generator saturates before the target does.