Testing your scenarios
Scenarios are ordinary Go, so they can be exercised with go test against an httptest.Server — no coordinator, no agent, no worker.
A load test that has never been run correctly is a load test that will waste an afternoon at the worst possible moment. Scenarios written with the Go SDK are ordinary functions, so they can be exercised the way you exercise any other code.
NewVU is exported for exactly this: it builds a virtual user without a
coordinator, an agent or a worker process anywhere in sight.
The shape of it
func TestCheckout(t *testing.T) {
server := httptest.NewServer(myHandler())
defer server.Close()
factory, err := loadwave.NewHTTPClientFactory(loadwave.HTTPOptions{
BaseURL: server.URL,
})
if err != nil {
t.Fatal(err)
}
defer factory.Close()
vu := loadwave.NewVU(loadwave.VUConfig{ID: 1, HTTP: factory.New()})
defer vu.Close()
if err := checkout(t.Context(), vu); err != nil {
t.Fatal(err)
}
}That is the whole setup. myHandler() is whatever http.Handler stands in for
the service — your real router if the test can run it, or a stub returning fixed
responses if it cannot.
Testing the lifecycle hooks
OnVUStart is just a function too, and the state it stores is readable:
func TestSignInStoresToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auth/login" {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"abc123"}`))
}))
defer server.Close()
factory, err := loadwave.NewHTTPClientFactory(loadwave.HTTPOptions{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
defer factory.Close()
vu := loadwave.NewVU(loadwave.VUConfig{ID: 7, HTTP: factory.New()})
defer vu.Close()
if err := signIn(t.Context(), vu); err != nil {
t.Fatal(err)
}
token, ok := loadwave.StateOf[string](vu, stateKeyToken)
if !ok || token != "abc123" {
t.Fatalf("token = %q, ok = %v; want \"abc123\", true", token, ok)
}
}Because the id is yours to choose, you can assert on the per-user derivation too —
a VU with ID: 7 should be signing in as loadtest-user-7.
Asserting on what was recorded
VUConfig.Recorder takes any implementation of loadwave.Recorder, so a test can
capture the observations a scenario emits and make assertions about them.
type captured struct {
trends map[string][]float64
}
func (c *captured) Count(metric string, _ loadwave.Labels, _ float64) {}
func (c *captured) Rate(metric string, _ loadwave.Labels, _ bool) {}
func (c *captured) Gauge(metric string, _ loadwave.Labels, _ float64) {}
func (c *captured) Trend(metric string, _ loadwave.Labels, v float64) {
c.trends[metric] = append(c.trends[metric], v)
}
func TestCheckoutRecordsCartValue(t *testing.T) {
rec := &captured{trends: map[string][]float64{}}
vu := loadwave.NewVU(loadwave.VUConfig{ID: 1, HTTP: client, Recorder: rec})
// ...run the scenario, then:
if len(rec.trends["cart_value"]) != 1 {
t.Fatalf("cart_value recorded %d times, want 1", len(rec.trends["cart_value"]))
}
}Leaving Recorder unset defaults to discarding every observation, which is what
you want when the metrics are not the thing under test.
Making a run deterministic
The VU's random source is seeded from its id, so a scenario that picks a product at random makes the same choice every time for a given id. That turns "it failed once in CI" into something you can reproduce by pinning the id.
Pass your own generator when you want to control it explicitly:
vu := loadwave.NewVU(loadwave.VUConfig{
ID: 1,
HTTP: factory.New(),
Rand: rand.New(rand.NewPCG(42, 42)),
})What this does and does not cover
These tests exercise scenario logic: does the flow make the right calls in the right order, handle the responses correctly, and store what it should. That is where the bugs that waste an afternoon live.
They do not exercise load generation, apportionment, metric merging or the threshold verdict — LoadWave's own test suite covers those, including tests that spawn real worker subprocesses.
Validate the configuration too
The other half of "will this run work" is the YAML, and that has its own check:
loadwave validate test.yamlBoth belong in the same pre-commit hook or pull-request job:
- name: Test scenarios
run: go test ./...
- name: Validate load test configuration
run: loadwave validate test.yamlNext
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.
Thresholds
Turning a run into a verdict — how thresholds are evaluated, why a breach latches, and what "not measured" means.