servotest package

import "github.com/okian/servo/v3/servotest"

Who this is for: anyone writing a test against a generated App or TestApp.

Seven small helpers, each addressing something that is awkward to check by hand: goroutine leaks (two of them, for two different situations), real init/stop ordering, the abandoned-node path, the eviction-racing-acquire boundary of a scope’s linger window, and giving gomock a reporter inside a graph that has no *testing.T in it.

This is the only servo package with a third-party dependency — go.uber.org/goleak, used by both leak checks. The core (cmd/servo, the internals) depends on nothing beyond golang.org/x/tools.

Index

Identifier Kind  
NoLeaks func Fail if any goroutine is running when the test returns
NoNewLeaks func Fail only on goroutines this test added to a baseline
Linger func Shrinks every scope’s linger window for one test
Timeout func Shrink servo.DefaultStopBudget for one test
Recorder type An app’s init and shutdown reports, together
NewRecorder func Builds a Recorder
AssertInitOrder func Assert init ordering
AssertStopOrder func Assert stop ordering
PanicReporter type A gomock.TestReporter with no *testing.T

NoLeaks

func NoLeaks(t *testing.T)

Fails t if any goroutine is running when the test returns, beyond the ones goleak’s own default ignore list covers. Use it as the first defer in the test:

func TestApp(t *testing.T) {
	defer servotest.NoLeaks(t)
	// ...
}

Note “any”, not “any this test started.” goleak has no notion of when a goroutine appeared, so a goroutine left behind by an earlier test in the same binary is reported against whichever test calls NoLeaks next. An innocent test fails for its neighbour’s leak, and the failure names the wrong one.

That is the right check for a package whose tests all clean up after themselves — it also catches a leak inherited from a sibling, which is a real defect. It is the wrong check for a package that also has a test leaving a goroutine running on purpose, and servotest advertises exactly such a test: Timeout exists to exercise the abandoned-node path, and an abandoned node is by definition one that never returns. Use NoNewLeaks there.

The check is meaningful for a servo app specifically, because Run launches goroutines and Shutdown is supposed to end them. A leak usually means either a Runner that ignores context cancellation, or a node that was abandoned — its stop call blew its budget and its goroutine was left running by design. That second case is a real finding, not a false positive: the report will say abandoned and this is the assertion that stops it going unnoticed.

Servo’s own suite uses it; it’s exported so generated-app tests can too.

NoNewLeaks

func NoNewLeaks(t *testing.T) func()

Records the goroutines already running and returns the check for the ones this test adds on top of them. Two calls, not one:

func TestApp(t *testing.T) {
	defer servotest.NoNewLeaks(t)()
	// ...
}

The call shape is what makes it correct. The baseline has to be taken before the test body runs and the check after, so NoNewLeaks is called at the top and the function it returns is what gets deferred. defer servotest.NoNewLeaks(t) — no trailing parentheses — compiles and checks nothing; go vet reports it as an unusedresult, and so does the test that would otherwise leak.

Reach for it in any package where another test parks a goroutine deliberately. Prefer NoLeaks where nothing does: a baseline hides an inherited leak, and an inherited leak is still a leak.

Timeout

func Timeout(t *testing.T, d time.Duration)

Sets servo.DefaultStopBudget to d for the calling test and restores the previous value via t.Cleanup.

Its purpose is exercising the abandoned-node path without a slow suite. A component that deliberately blocks in Stop would otherwise cost the real 5-second budget per test:

servotest.Timeout(t, 50*time.Millisecond)

Tests using it must not run in parallel — with each other, or with any test that depends on the real default. The budget is a package variable, so t.Parallel() plus Timeout is a data race and a flake. That’s the cost of the budget being a variable rather than configuration, and it’s a fair trade for tests that would otherwise take seconds each.

Linger

func Linger(t *testing.T, d time.Duration)

Overrides every generated scope’s declared linger window for the calling test, restoring the previous setting via t.Cleanup.

It exists for the same reason Timeout does: the interesting behaviour lives at a boundary a real thirty-second window cannot be driven to in a test.

func TestEvictionRacingAcquire(t *testing.T) {
	servotest.Linger(t, 0) // die with the last holder
	app, _ := New(ctx)
	// …hammer one key from several goroutines; every acquire must end in
	// an instance or a clean error, never a hang.
}

Linger(t, 0) makes an instance evict the moment its last holder releases, which is how the eviction-racing-acquire path gets exercised deliberately instead of by luck.

Generated code reads the override once per scope, inside New, so call this before constructing the app. Since the underlying setting is a package variable, tests using Linger must not run in parallel with each other or with tests that depend on a scope’s real declared window — the same constraint Timeout carries, for the same reason.

Recorder

type Recorder struct {
	Init     servo.StartupReport
	Shutdown servo.Report
}

An app’s own init and shutdown reports, side by side. Nothing is instrumented to produce them: the generated New already records which nodes initialised and how long each took, and Shutdown already lists nodes in the order it actually stopped them. Recorder just holds both so ordering can be asserted against what happened rather than against a re-derivation of what should have happened.

NewRecorder

func NewRecorder(init servo.StartupReport, shutdown servo.Report) *Recorder

Call app.Report() right after New/NewTestApp succeeds, and app.Shutdown after driving the app through its test:

rec := servotest.NewRecorder(app.Report(), app.Shutdown(ctx))

AssertInitOrder and AssertStopOrder

func AssertInitOrder(t *testing.T, rec *Recorder, want ...string)
func AssertStopOrder(t *testing.T, rec *Recorder, want ...string)

Fail t unless want appears, in order, as a subsequence of what actually happened. Other nodes may be interspersed:

servotest.AssertStopOrder(t, rec, "*api.Server", "*postgres.DB")

That asserts the server stopped before the database. It does not assert that nothing else stopped between them, and it does not require naming every node — which is the point. A relative guarantee (“the thing that uses the database stops first”) is what servo actually promises, and what stays true when you add an unrelated component to the graph. On failure:

stop order [*api.Server *postgres.DB *logger.Logger] does not contain [*postgres.DB *api.Server] as a subsequence

Names are the node’s full type string — the same identity used everywhere else. In a real module that’s the fully qualified form, *example.com/app/api.Server; servo graph or the generated file’s header comment is the quickest way to copy the exact strings.

AssertInitOrder reads Recorder.Init (from App.Report()), AssertStopOrder reads Recorder.Shutdown. Two caveats for init ordering: only nodes implementing Initializer appear at all, and nodes sharing a level ran concurrently, so their relative order is completion order and must not be asserted. Assert across levels, not within one.

PanicReporter

type PanicReporter struct{}

func (PanicReporter) Errorf(format string, args ...any)
func (PanicReporter) Fatalf(format string, args ...any)

Satisfies gomock’s TestReporter interface structurally — without servotest importing gomock at all. Both methods panic with the formatted message.

It exists because gomock.NewController needs a reporter and there is no *testing.T reachable from inside a generated graph. A gomock adapter’s constructor supplies one:

func NewMockStoreForServo() *MockStoreForServo {
	ctrl := gomock.NewController(servotest.PanicReporter{})
	return &MockStoreForServo{MockStore: NewMockStore(ctrl), Finish: ctrl.Finish}
}

Be clear about the trade-off. A failed expectation panics rather than calling t.Fatalf. Go’s test runner still reports which test failed before the panic propagates, but the process exits — unlike a clean, isolated failure. And ctrl.Finish must be called directly by the test with a plain defer, never routed through servo’s cleanup-func shape, which would run it inside servo.RunStop’s goroutine during Shutdown where nothing can recover the panic.

For strict expectation-count verification, construct and drive the mock directly in an isolated unit test with a real gomock.NewController(t) instead of going through servo.Override. Graph injection is the right tool for exercising the wiring; it is not the right tool for gomock’s stricter verification style. The README’s gomock section has the full reasoning, and examples/mocking/gomock is a working version.

A complete test

Everything above, in the shape it’s usually used:

func TestApp(t *testing.T) {
	defer servotest.NoLeaks(t)                // clean by construction, or the test says so
	servotest.Timeout(t, 50*time.Millisecond) // don't pay 5s per abandoned node

	ctx := context.Background()
	app, err := NewTestApp(ctx) // generated because the spec declares servo.Override
	if err != nil {
		t.Fatal(err)
	}

	app.storeMock.GetFunc = func(key string) string { return "mocked:" + key }
	if got := app.server.Lookup("user:42"); got != "mocked:user:42" {
		t.Fatalf("got %q", got)
	}

	rec := servotest.NewRecorder(app.Report(), app.Shutdown(ctx))
	servotest.AssertStopOrder(t, rec, "*api.Server", "*mockstore.Store")
}