Writing YAML tests
Steps, request bodies, captured values, templating and weighted scenarios — the declarative format in practice.
The configuration reference lists every field. This page is about using them.
A scenario is a list of steps
Each step is either a request or a pause. A virtual user runs the steps in order, top to bottom, then starts again.
scenarios:
- name: browse
steps:
- name: list products
get: /api/products
expect: [200]
- think: 200ms-800ms
- name: search products
get: /api/products
query:
q: widget
page: "1"
expect: [200]expect lists the acceptable status codes. Anything else fails the step's check
and ends the iteration — the remaining steps do not run, which is usually what
you want: if listing products failed, viewing one of them tests nothing.
Naming steps
name is the metric label, and it is what appears in the endpoint breakdown.
Leave it out and LoadWave derives one from the method and path, collapsing
segments that look like identifiers to * — so /users/1 and /users/2 share
one series rather than producing two million.
Set it explicitly when the heuristic would still leave you with high cardinality, or when the derived name would be hard to recognise in a table.
Request bodies
Three mutually exclusive forms:
- name: create order
post: /api/orders
json:
paymentMethod: card
items:
- sku: "${sku}"
quantity: 1
expect: [201]Marshalled as JSON with the appropriate content type. Templated at any depth, so
${sku} is substituted inside the nested array.
Carrying values between steps
capture pulls values out of the JSON response and makes them available as
variables to every step that follows, within the same iteration.
- name: list products
get: /api/products
capture:
productId: items.0.id
total: meta.total
- name: view product
get: /api/products/${productId}Paths support field access and array indexing, dotted or bracketed:
capture:
id: id
token: $.data.token
firstSku: items.0.sku
alsoFirstSku: items[0].skuThis is deliberately not JSONPath. Filters and recursive descent would be a query language to document, test and explain, in exchange for capabilities a load test almost never wants.
An unknown variable renders as an empty string, not an error. A capture that did not fire produces a request that visibly misses — a 404 you can see in the endpoint table — rather than an iteration that dies before making one. That is the more debuggable failure.
Built-in variables
All prefixed with __ so they cannot collide with your own:
| Variable | Value |
|---|---|
${__vu} | The virtual user's run-wide unique id. |
${__iteration} | Zero-based iteration number. |
${__shard} / ${__shards} | This node's data partition, and the total count. |
${__random} | A random integer from this VU's own generator. |
${__uuid} | A random v4 UUID. |
${__timestamp} | Now, RFC 3339. |
${__unixMilli} | Now, epoch milliseconds. |
${__vu} is the important one. Ids are unique across the whole fleet, not
just the process, which is what lets every simulated user have its own account
without two machines colliding:
json: { username: "loadtest-user-${__vu}" }Several scenarios, weighted
scenarios:
- name: browse
weight: 3 # three times as often as a weight-1 scenario
description: A visitor looking around.
steps: [...]
- name: search
weight: 1
steps: [...]A virtual user is assigned to one scenario for its whole life, not re-drawn each iteration. That is what makes per-user state coherent, and it means weights describe the population mix rather than a per-iteration coin flip.
Per-scenario variables
vars sets values available to that scenario's templating from the start:
scenarios:
- name: browse
vars:
category: electronics
steps:
- get: /api/products
query: { category: "${category}" }Pacing, per step
The run's betweenRequests applies after every request. Individual steps override
it, including down to none:
betweenRequests: 500ms-1s
scenarios:
- name: browse
steps:
- get: /api/products
- get: /api/products/${id}
betweenRequests: 200ms # quicker after this one
- post: /api/orders
betweenRequests: "0" # straight on to the nextNote the quotes on "0": unquoted 0 is a number, and this field is a duration
string.
Think time versus pacing
Both pause a virtual user, and both are excluded from iteration_duration. They
mean different things:
betweenRequests | think | |
|---|---|---|
| Applies | After every request, including failed ones | Where you put it |
| Purpose | A safety floor, so a failing endpoint is not hammered | Modelling a person reading the page |
| Default | 1s | none |
| Additive | — | Yes, on top of betweenRequests |
Always jitter both. think: 1s-3s, not think: 2s.
A complete file
name: storefront-browse
baseURL: http://127.0.0.1:8080
load:
executor: ramping-vus
stages:
- { duration: 5s, target: 20 }
- { duration: 10s, target: 20 }
- { duration: 5s, target: 0 }
gracefulStop: 10s
workersPerAgent: 2
betweenRequests: 500ms-1s
http:
timeout: 10s
headers:
Accept: application/json
tags:
env: local
thresholds:
- { metric: http_req_duration, stat: p95, op: "<", value: 500 }
- { metric: http_req_failed, stat: rate, op: "<", value: 0.1 }
- { metric: checks, stat: rate, op: ">", value: 0.9 }
scenarios:
- name: browse
weight: 3
description: List products, then open one of them.
steps:
- name: list products
get: /api/products
expect: [200]
capture:
productId: items.0.id
- think: 200ms-800ms
- name: view product
get: /api/products/${productId}
expect: [200]
betweenRequests: 200ms
- name: search
weight: 1
steps:
- name: search products
get: /api/products
query:
q: widget
page: "1"
expect: [200]
- think: 500msCheck it before you run it
loadwave validate test.yamlUnknown fields are rejected, not ignored — a misspelled key would otherwise
produce a run that appears to work and quietly measures something other than what
you asked for. validate also prints the effective pacing, so it is never left
implicit.
Next
YAML or Go?
What the declarative format can express, where it runs out of road, and how to keep load shape in YAML while behaviour lives in Go.
Writing tests in Go
Scenarios, the virtual-user lifecycle, per-user session state, weighted decisions and custom metrics — with the binary you build becoming a complete LoadWave node.