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")
}