LoadWave
Guides

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

checkout_test.go
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.yaml

Both belong in the same pre-commit hook or pull-request job:

.github/workflows/ci.yml
- name: Test scenarios
  run: go test ./...

- name: Validate load test configuration
  run: loadwave validate test.yaml

Next

On this page