Skip to content

Test a service

Because services depend on provider interfaces rather than backends, most tests need no database, no AWS, and no containers. The two patterns below cover almost everything.

Every module ships a recording mock in a *test package: storagetest, cachetest, emailtest, queuetest, templatetest, configtest, migratetest, authtest, and uitest. Each records every call and delegates to an optional Fn field, so you set only what the test exercises.

files/service_test.go
// Every provider ships a recording mock, so a unit test needs no backend and
// no network. Set only the methods the test exercises.
func TestDownloadUsesStorage(t *testing.T) {
mock := &storagetest.MockStorage{}
mock.DownloadFn = func(_ context.Context, key string, _ ...gas.StorageOption) (*gas.StorageObject, error) {
return &gas.StorageObject{
Body: io.NopCloser(strings.NewReader("hello")),
ContentType: "text/plain",
}, nil
}
svc := New(mock, gas.NewApp().Router())
if svc.storage != gas.StorageProvider(mock) {
t.Fatal("service should hold the mock")
}
if _, err := mock.Download(context.Background(), "notes.txt"); err != nil {
t.Fatalf("download: %v", err)
}
if mock.CallCount("Download") != 1 {
t.Fatalf("expected one Download call, got %d", mock.CallCount("Download"))
}
}

The shared shape across all of them: per-method Fn fields, a Calls slice, CallCount(method), and Reset(). They are safe for concurrent use, so a test that fans out goroutines can still assert on counts.

Unit tests miss routing, middleware, binding, and error rendering. App.Handler() returns the router behind CSRF protection, which is exactly what production serves, so an end-to-end test can exercise all of it without binding a port.

files/service_test.go
// For an end-to-end test, drive the real handler stack. App.Handler returns the
// router behind CSRF protection, so the test exercises the same middleware as
// production without binding a port.
func TestDownloadRoute(t *testing.T) {
mock := &storagetest.MockStorage{}
mock.DownloadFn = func(_ context.Context, _ string, _ ...gas.StorageOption) (*gas.StorageObject, error) {
return &gas.StorageObject{Body: io.NopCloser(strings.NewReader("hello"))}, nil
}
app := gas.NewApp(
gas.WithServiceInstance[gas.StorageProvider](mock),
gas.WithSingletonService[*Service](New),
)
if err := app.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = app.Shutdown() })
srv := httptest.NewServer(app.Handler())
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/files/notes.txt")
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close() //nolint:errcheck // documentation snippet
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || string(body) != "hello" {
t.Fatalf("got %d %q", resp.StatusCode, body)
}
}

This is how the examples test themselves, and it catches the failures unit tests cannot: a route registered on the wrong method, middleware ordering, a handler dependency that is not registered.

Start returns an error when anything is misconfigured: an unresolved dependency, a malformed service, a handler declaring a parameter the container cannot supply, or a Config that fails Validate. A test that just calls Start and asserts no error is a genuinely useful smoke test of your wiring.

Scoped services normally come from the per-request scope. In a test or a background job, make one yourself:

scope := container.NewScope()
defer scope.Close() // calls Close on every scoped Service resolved here
svc := gas.MustResolve[*MyScopedService](scope)

To run code that expects a request scope, attach one to a context with gas.WithRequestScope(ctx, scope).

When you do want the real thing, the modules’ own suites use testcontainers to run Postgres, LocalStack, and Valkey, and guard them so go test -short skips them. CI runs the short form per module, so container-backed tests stay opt-in locally.