LoadWave
Guides

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].sku

This 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:

VariableValue
${__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 next

Note 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:

betweenRequeststhink
AppliesAfter every request, including failed onesWhere you put it
PurposeA safety floor, so a failing endpoint is not hammeredModelling a person reading the page
Default1snone
AdditiveYes, on top of betweenRequests

Always jitter both. think: 1s-3s, not think: 2s.

A complete file

test.yaml
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: 500ms

Check it before you run it

loadwave validate test.yaml

Unknown 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

On this page