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.
Mock the provider
Section titled “Mock the provider”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.
// 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.
Drive the real handler stack
Section titled “Drive the real handler stack”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.
// 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.
Startup failures are test failures
Section titled “Startup failures are test failures”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 outside a request
Section titled “Scoped services outside a request”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).
Integration tests
Section titled “Integration tests”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.