Skip to content

Test services and mock the controller

Service lifecycle functions are ordinary functions and the controller is a concrete type you can drive directly, so most testing needs no mocks at all. When your own code depends on a controller, the Controllable interface (and the narrower interfaces beside it) let you substitute a fake.

Disable signals in tests. Always construct test controllers with controls.WithoutSignals() so a test never installs process-wide SIGINT/SIGTERM handlers. Drive shutdown explicitly with Stop().

Test your Start/Stop/Status functions directly

StartFunc, StopFunc, and StatusFunc are plain functions — the simplest test calls them without a controller at all:

func TestStatusProbe(t *testing.T) {
    srv := newServer()

    status := statusFunc(srv) // returns controls.StatusFunc == func() error
    require.NoError(t, status())

    srv.forceUnhealthy()
    require.Error(t, status())
}

Drive a real controller

For a lifecycle test, register against a real controller and drive it. This exercises startup ordering, health aggregation, and shutdown for real:

func TestServiceLifecycle(t *testing.T) {
    ctx := context.Background()

    c := controls.NewController(ctx, controls.WithoutSignals())

    var started, stopped atomic.Bool
    c.Register("worker",
        controls.WithStart(func(ctx context.Context) error {
            started.Store(true)
            <-ctx.Done() // serve until shutdown
            return ctx.Err()
        }),
        controls.WithStop(func(context.Context) { stopped.Store(true) }),
        controls.WithReadiness(func() error { return nil }),
    )

    c.Start()
    require.Eventually(t, started.Load, time.Second, time.Millisecond)

    // Health is read from the aggregated report — not a channel.
    require.True(t, c.Readiness().OverallHealthy)

    c.Stop()
    c.Wait() // blocks until the full shutdown sequence has finished
    require.True(t, stopped.Load())
}

Note that health is inspected with Status() / Liveness() / Readiness(), which return a HealthReport. A StatusFunc returns nil when healthy and an error otherwise; there is no health channel to read in application code.

Mock the controller with the Controllable interface

Production code should hold the concrete *controls.Controller. But code that merely registers services against a controller can depend on an interface and be tested with a fake. The module ships no mocks, so generate one for the interface you depend on (e.g. with mockery), or hand-write a small stub:

// Your code depends on the interface, not the concrete controller.
func wireServices(c controls.Controllable, deps Deps) {
    c.Register("api", controls.WithStart(deps.serve), controls.WithStop(deps.drain))
}
func TestWireServices(t *testing.T) {
    fake := NewMockControllable(t) // your generated mock
    fake.EXPECT().Register("api", mock.Anything, mock.Anything).Return()

    wireServices(fake, testDeps())
}

Depend on the narrowest interface

Controllable is the full surface. Prefer a narrower interface when your code only needs part of it — it makes the dependency (and the mock) smaller:

Interface Use when your code only needs to…
Runner Start, Stop, Wait, and query IsRunning/IsStopped/IsStopping
StateAccessor read/set the lifecycle State
Configurable apply configuration (logger, timeout, channels)
ChannelProvider access the message/health/error/signal channels
HealthReporter read Status() / Liveness() / Readiness() reports

Reserve the concrete *controls.Controller for production wiring; reach for the interfaces at test seams.