Skip to content

Testing

Services depend on provider interfaces such as gas.CacheProvider, not on backends, so most tests need no database, no AWS account, and no containers. This page tests one small service three ways: as a unit with a mock, through the app’s real handler stack, and by starting its wiring.

visits/service.go
// Service counts page visits in the cache. It depends on gas.CacheProvider,
// not on a cache backend, so a test can hand it a mock.
type Service struct {
cache gas.CacheProvider
router *gas.Router
}
func New(cache gas.CacheProvider, router *gas.Router) *Service {
return &Service{cache: cache, router: router}
}
func (s *Service) Name() string { return "visits" }
func (s *Service) Close(context.Context) error { return nil }
func (s *Service) Init(context.Context) error {
s.router.Post(s, "/visits/{page}", s.record)
return nil
}
// Visit increments the counter for page and returns the new count.
func (s *Service) Visit(ctx context.Context, page string) (int, error) {
key := "visits:" + page
n := 0
raw, err := s.cache.Get(ctx, key)
if err != nil {
return 0, err
}
if raw != nil {
if n, err = strconv.Atoi(string(raw)); err != nil {
return 0, errors.New("corrupt counter for " + page)
}
}
n++
if err := s.cache.Set(ctx, key, []byte(strconv.Itoa(n)), 24*time.Hour); err != nil {
return 0, err
}
return n, nil
}
func (s *Service) record(ctx gas.Context) error {
n, err := s.Visit(ctx, ctx.Param("page"))
if err != nil {
return gas.ServiceUnavailable("visit counter unavailable").WithCause(err)
}
return ctx.JSON(http.StatusOK, map[string]int{"visits": n})
}

These modules ship mocks in a *test package:

Module Package Mocks
auth github.com/gasmod/gas/auth/authtest MockAuthenticator, MockAuthorizer, MockRevoker
cache github.com/gasmod/gas/cache/cachetest MockCache
config github.com/gasmod/gas/config/configtest MockConfig
email github.com/gasmod/gas/email/emailtest MockEmail
migrate github.com/gasmod/gas/migrate/migratetest MockMigrationManager
queue github.com/gasmod/gas/queue/queuetest MockQueue
storage github.com/gasmod/gas/storage/storagetest MockProvider, MockPresigner
template github.com/gasmod/gas/template/templatetest MockTemplate
ui github.com/gasmod/gas/ui/uitest MockUI

database and log have no mock package. For a logger, core’s gas.NopLogger discards everything; &gas.NopLogger{} is ready to use.

The mocks share one shape. Each has an Fn field per method that the method calls when it is set, records every call in its Calls slice, and has CallCount(method) and Reset(). They are safe for concurrent use, so a test that fans out goroutines can still assert on counts. A mock is usable as its zero value; set only the Fn fields the test exercises. What a method returns when its Fn is not set differs between mocks and is listed on each module page.

configtest.NewMockConfigWithValues(values) returns a MockConfig whose methods delegate to a real configuration seeded with values, so Get, Find, and Bind behave as they do in production; see Config.

Build the service with a mock and call it directly:

visits/service_test.go
// A unit test builds the service with a mock and calls it directly.
func TestVisitIncrements(t *testing.T) {
mock := &cachetest.MockCache{
GetFn: func(context.Context, string) ([]byte, error) { return []byte("41"), nil },
}
svc := New(mock, gas.NewRouter())
n, err := svc.Visit(t.Context(), "home")
if err != nil {
t.Fatalf("visit: %v", err)
}
if n != 42 {
t.Fatalf("got %d, want 42", n)
}
if got := mock.CallCount("Set"); got != 1 {
t.Fatalf("Set called %d times, want 1", got)
}
}

The service takes the router only to register routes in Init, which this test never calls, so a bare gas.NewRouter() is enough.

Unit tests miss routing, middleware, dependency injection, binding, and error rendering. To cover those, build an App with mocks in place of the real providers and send requests through it.

visits/service_test.go
// newTestApp wires the real service into an App with the mock registered under
// the interface the service depends on, and starts it.
func newTestApp(t *testing.T, cache gas.CacheProvider) *gas.App {
t.Helper()
app := gas.NewApp(
gas.WithServiceInstance[gas.CacheProvider](cache),
gas.WithServiceInstance[gas.Logger](&gas.NopLogger{}),
gas.WithSingletonService[*Service](New),
)
if err := app.Start(t.Context()); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if err := app.Shutdown(context.Background()); err != nil {
t.Errorf("shutdown: %v", err)
}
})
return app
}

Register each mock with gas.WithServiceInstance under the interface the code depends on, as the real provider is registered in production. The container matches by exact type, so a mock registered under its own type (*cachetest.MockCache) would not satisfy a gas.CacheProvider parameter. A mock that implements gas.Service, as migratetest.MockMigrationManager does, gets Init at startup and Close at shutdown like any registered service, and a MockMigrationManager registered as gas.MigrationManager has its RunPending called by Start.

Start runs the same startup as production (services, migrations, ready hooks) and returns without serving or waiting for a signal. Pair it with Shutdown in t.Cleanup. Do not use Run in a test: it blocks until the process receives SIGINT or SIGTERM.

App.Handler() returns the handler the server would serve: the router behind cross-origin protection. Calling its ServeHTTP with an httptest.ResponseRecorder sends a request through all of it with no listener:

visits/service_test.go
// An end-to-end test sends a request through App.Handler: routing, middleware,
// dependency injection, and error rendering, with no port bound.
func TestRecordRoute(t *testing.T) {
app := newTestApp(t, &cachetest.MockCache{})
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/visits/home", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, body %s", rec.Code, rec.Body)
}
var body map[string]int
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["visits"] != 1 {
t.Fatalf("got %v", body)
}
}

Error responses decode into gas.ErrorResponse:

visits/service_test.go
// A failing dependency shows up as the error envelope the client would see.
func TestRecordRouteCacheDown(t *testing.T) {
mock := &cachetest.MockCache{
GetFn: func(context.Context, string) ([]byte, error) { return nil, errors.New("connection refused") },
}
app := newTestApp(t, mock)
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/visits/home", nil))
var resp gas.ErrorResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if rec.Code != http.StatusServiceUnavailable || resp.Error.Code != gas.CodeUnavailable {
t.Fatalf("got %d %+v", rec.Code, resp.Error)
}
if mock.CallCount("Set") != 0 {
t.Fatal("Set should not be called when Get fails")
}
}

When a test needs a real client and network round trip, httptest.NewServer(app.Handler()) serves the same handler on a loopback port:

visits/service_test.go
// httptest.NewServer serves the same handler on a real loopback listener, for
// tests that need a client and a network round trip.
func TestRecordOverHTTP(t *testing.T) {
app := newTestApp(t, &cachetest.MockCache{})
srv := httptest.NewServer(app.Handler())
t.Cleanup(srv.Close)
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/visits/home", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close() //nolint:errcheck // documentation snippet
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
}

Requests built by httptest.NewRequest or a Go http.Client carry neither a Sec-Fetch-Site nor an Origin header, so cross-origin protection lets them through.

Start returns an error when the wiring is wrong: a constructor or handler dependency that is not registered, a service that fails Init, a pending migration that fails, a ready hook that fails, or a configuration that does not bind. A test that starts the production wiring and expects no error catches all of these before deployment.

visits/service_test.go
// Start fails on wiring mistakes, so starting the production wiring is a
// useful test on its own. Here the cache is missing.
func TestWiringFailsWithoutCache(t *testing.T) {
app := gas.NewApp(
gas.WithServiceInstance[gas.Logger](&gas.NopLogger{}),
gas.WithSingletonService[*Service](New),
)
if err := app.Start(t.Context()); err == nil {
t.Fatal("expected Start to fail without a gas.CacheProvider")
}
}

Scoped services normally come from the request scope the app creates for each request (see Request scopes). In a test, create a scope from a container yourself:

visits/service_test.go
// Scoped services come from a request's scope. Outside a request, make one.
type Tracker struct{ pages []string }
func (tr *Tracker) Name() string { return "tracker" }
func (tr *Tracker) Init(context.Context) error { return nil }
func (tr *Tracker) Close(context.Context) error { tr.pages = nil; return nil }
func TestScopedService(t *testing.T) {
container := gas.NewServiceContainer()
container.RegisterScopedService[*Tracker](func() *Tracker { return &Tracker{} })
scope := container.NewScope(t.Context())
defer func() {
// Close calls Close on every scoped Service resolved in this scope.
if err := scope.Close(context.Background()); err != nil {
t.Errorf("close scope: %v", err)
}
}()
tracker := scope.MustResolve[*Tracker]()
tracker.pages = append(tracker.pages, "home")
// Code that reads the request scope finds this one on the context.
ctx := gas.WithRequestScope(t.Context(), scope)
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
same, err := gas.ResolveFromRequestScope[*Tracker](r)
if err != nil || same != tracker {
t.Fatalf("got %v, %v", same, err)
}
}

gas.WithRequestScope(ctx, scope) attaches a scope to a context, so code that calls gas.ResolveFromRequestScope or gas.RequestScope on a request built with that context finds it.

A handler that calls ctx.Principal() reads the principal an authentication middleware attached to the request context. In a test, a middleware that attaches a fixed principal with gas.WithPrincipal stands in for authentication:

visits/service_test.go
// testPrincipal is a fixed gas.Principal for tests.
type testPrincipal struct{ subject string }
func (p testPrincipal) Subject() string { return p.subject }
func (p testPrincipal) Scheme() string { return "test" }
func (p testPrincipal) CredentialID() string { return "" }
func (p testPrincipal) Metadata() gas.PrincipalMetadata { return gas.BasePrincipalMetadata{} }
// asUser attaches a principal the way an authentication middleware would.
func asUser(subject string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := gas.WithPrincipal(r.Context(), testPrincipal{subject: subject})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func TestWhoAmI(t *testing.T) {
app := gas.NewApp()
app.Router().Get(nil, "/me", func(ctx gas.Context) error {
p, err := ctx.Principal()
if err != nil {
return err
}
return ctx.Text(http.StatusOK, p.Subject())
}, gas.MiddlewareFunc(asUser("user-1")))
if err := app.Start(t.Context()); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if err := app.Shutdown(context.Background()); err != nil {
t.Errorf("shutdown: %v", err)
}
})
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/me", nil))
if rec.Body.String() != "user-1" {
t.Fatalf("got %d %q", rec.Code, rec.Body)
}
}

To test the authentication middleware itself, see Auth and its authtest mocks.