LoadWave
Guides

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.

YAML runs out of road as soon as a test needs a session, a branch, or an authentication flow that isn't a bearer token. At that point write Go: your program becomes the LoadWave binary, so there is nothing extra to deploy.

The smallest possible test

main.go
package main

import (
    "context"
    "net/http"
    "time"

    "github.com/SnowyFoxStudios/LoadWave/pkg/loadwave"
    "github.com/SnowyFoxStudios/LoadWave/pkg/loadwave/run"
)

func main() {
    loadwave.Register(loadwave.Scenario{
        Name: "browse",
        Run:  browse,
    })
    run.Main()
}

func browse(ctx context.Context, vu *loadwave.VU) error {
    resp, err := vu.HTTP().Get(ctx, "/api/products")
    if err != nil {
        return err
    }
    vu.Check("products ok", resp.StatusCode == http.StatusOK)
    vu.ThinkBetween(ctx, time.Second, 3*time.Second)
    return nil
}

run.Main() turns the program into a complete LoadWave node — it can run a test standalone, act as a coordinator serving the dashboard, or join an existing cluster as an agent:

go build -o browse ./cmd/browse

./browse run --url https://staging.example.com --vus 200 --duration 5m --ui
./browse serve --listen 0.0.0.0:8090
./browse agent --coordinator loadgen-controller:8090   # the same binary

Because the scenarios are compiled in, every host in the fleet is provably running the same test. That is the trade for having to ship a build rather than a YAML file.

The lifecycle

Setup           once per worker process, before any VU starts
  OnVUStart     once per virtual user
    Run         repeatedly, until the profile ends
  OnVUStop      once per virtual user
Teardown        once per worker process, after all VUs have stopped

Run is the only required field.

Setup and Teardown run once per worker process, not once per run. A run spread over four processes calls Setup four times, and a run spread over ten machines calls it forty. Anything that must happen exactly once for the whole run — seeding a database, say — belongs outside the scenario.

Per-user state

This is the main thing YAML cannot do. OnVUStart fires once per virtual user; whatever it stores survives every iteration that user runs.

const stateKeyToken = "token"

func signIn(ctx context.Context, vu *loadwave.VU) error {
    // Each virtual user gets its own account, derived from its run-wide unique
    // id so that two workers — or two machines — never collide.
    credentials := map[string]string{
        "username": fmt.Sprintf("loadtest-user-%d", vu.ID()),
        "password": "correct-horse-battery-staple",
    }

    resp, err := vu.HTTP().Do(ctx, loadwave.Request{
        Method: http.MethodPost,
        URL:    "/api/auth/login",
        Name:   "POST /api/auth/login",
        JSON:   credentials,
    })
    if err != nil {
        return fmt.Errorf("login request failed: %w", err)
    }
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("login returned %d", resp.StatusCode)
    }

    var body struct {
        Token string `json:"token"`
    }
    if err := resp.JSON(&body); err != nil {
        return fmt.Errorf("login response: %w", err)
    }

    vu.SetState(stateKeyToken, body.Token)
    return nil
}

func authHeader(vu *loadwave.VU) http.Header {
    token, _ := loadwave.StateOf[string](vu, stateKeyToken)
    return http.Header{"Authorization": []string{"Bearer " + token}}
}

Wire it up with OnVUStart:

loadwave.Register(loadwave.Scenario{
    Name:        "checkout",
    Weight:      1,
    Description: "A signed-in customer completing a purchase.",
    OnVUStart:   signIn,
    Run:         checkout,
})

Doing this per user rather than per iteration is the point. A real customer signs in once and then browses; a test that re-authenticated every iteration would spend most of its load on the login endpoint and measure that instead of the flow you care about.

loadwave.StateOf[T] is the generic accessor — it returns the zero value of T when the key is absent or holds a different type, so a missing token yields an empty string rather than a panic.

Making requests

vu.HTTP() returns the virtual user's client. The convenience methods cover the common cases:

resp, err := vu.HTTP().Get(ctx, "/api/products")
resp, err := vu.HTTP().PostJSON(ctx, "/api/orders", order{Payment: "card"})
resp, err := vu.HTTP().PutJSON(ctx, "/api/cart", cart)
resp, err := vu.HTTP().Delete(ctx, "/api/cart/items/4711")

Do takes the full request:

order, err := vu.HTTP().Do(ctx, loadwave.Request{
    Method:       http.MethodPost,
    URL:          "/api/orders",
    Name:         "POST /api/orders",       // the metric label
    Header:       authHeader(vu),
    JSON:         map[string]any{"paymentMethod": "card"},
    ExpectStatus: []int{http.StatusCreated},
    Timeout:      30 * time.Second,         // this call deserves its own budget
})

The returned error is transport-level only. A 4xx or 5xx comes back with a nil error and the status set, because at load-test altitude a 500 is a measurement, not an exception. Check the status yourself, or set ExpectStatus.

The response is always non-nil, including when the transport failed, so you can branch on resp.Err or resp.OK() without a nil check first. It carries the timings too — Duration, TTFB, Connecting, TLSHandshake, ConnReused — alongside the body.

Checks and errors

They answer different questions.

if !vu.Check("logged in", resp.StatusCode == http.StatusOK) {
    return fmt.Errorf("login failed: %d", resp.StatusCode)
}

A check records a named assertion and returns its result, so it composes into control flow. A failing check does not by itself fail the iteration.

An error returned from Run marks the iteration failed and increments the error metrics. It does not stop the run.

So: checks measure how often something held; errors say the iteration did not accomplish what it set out to. Checkf adds a message logged on failure — the message is not used as a label, so it may safely include specific values:

vu.Checkf("item added to cart", added.OK(), "add to cart returned %d", added.StatusCode)

Randomness, reproducibly

Each virtual user has its own generator, seeded deterministically from its id. It is not shared, so it needs no locking and contributes no contention — and the same run makes the same choices, which turns a flaky failure into a reproducible one.

pick := catalogue.Items[vu.Rand().IntN(len(catalogue.Items))]

Think time

vu.Think(ctx, 2*time.Second)
vu.ThinkBetween(ctx, time.Second, 3*time.Second)   // prefer this

Both are interruptible: when the run is stopping, they return early rather than holding the shutdown open. Time spent here is excluded from iteration_duration.

Constant think times make virtual users march in lockstep and produce artificial traffic spikes, so jitter is almost always what you want.

Custom metrics and tags

const cartValue = "cart_value"

vu.Metrics().Trend(cartValue, vu.Labels(), receipt.Total)
vu.Metrics().Count("orders_placed", vu.Labels(), 1)
vu.Metrics().Rate("payment_succeeded", vu.Labels(), ok)
vu.Metrics().Gauge("queue_depth", vu.Labels(), float64(depth))

They are ordinary metrics: they appear in the dashboard with percentiles and can carry thresholds. Trend values must fall in the same 0.1ms-to-60s window as the built-ins, scaled into whatever unit makes sense.

vu.Tag adds a label to everything the VU emits for the rest of the iteration:

vu.Tag("flow", "purchase")

Keep tag values to a small fixed set. Every distinct combination is a time series held in memory on the coordinator for the length of the run — a tag per customer is a series per customer. See cardinality.

Partitioning fixtures

Every virtual user has a run-wide unique id, and every node gets a static (index, count) shard so it can take its slice of a fixture arithmetically, with no coordination at runtime:

mine := loadwave.Slice(vu.Shard(), allCustomers)

Concurrency

Exactly one goroutine ever touches a given VU, so nothing on it is synchronised and scenarios may store whatever they like on it without locking.

Do not hand a VU to a goroutine you spawn yourself. If a scenario needs concurrency within one iteration, share only immutable values. Anything you share between users — a package-level map, a fixture slice being mutated — is your own problem to synchronise.

A worked example

examples/checkout in the repository is a two-scenario test with login, per-user state, a weighted traffic mix, a random product choice and a custom metric — roughly 200 lines, commented throughout.

Next

On this page