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
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 binaryBecause 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 stoppedRun 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 thisBoth 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.