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.
// 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})}Mock packages
Section titled “Mock packages”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 |
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.
Unit tests with a mock
Section titled “Unit tests with a mock”Build the service with a mock and call it directly:
// 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.
Testing through the app
Section titled “Testing through the app”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.
// 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:
// 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:
// 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:
// 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.
Wiring tests
Section titled “Wiring tests”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.
// 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 outside a request
Section titled “Scoped services outside a request”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:
// 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.
Testing handlers that need a principal
Section titled “Testing handlers that need a principal”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:
// 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.