Test a modular feature in isolation
This guide shows the working pattern behind the
modular feature test isolation strategy:
boot your real service in-process, point its dependency clients at fakes you
control, and run it against real PostgreSQL. Everything here is live, reviewed
code in the
go-service-template exemplar — read
test/acceptance/
alongside this page.
Before you start
You need a scaffolded Theseus service (see Get Started) and a local PostgreSQL — either from Caproni or any local instance.
1. Put every outbound call behind a configurable client
Each service dependency gets its own client package under internal/clients/,
outside the three layers, exposing a small stable API. The one property that
makes isolation possible: the endpoint is configuration, not a constant.
// internal/clients/user/client.go
// Config holds configuration for the user client.
type Config struct {
// BaseURL is the base URL of the user service.
BaseURL string
}
// New returns a Client configured with cfg.
func New(a *app.App, cfg *Config) *Client {
return &Client{
App: a,
http: httpclient.NewWithConfig(&httpclient.Config{Logger: a.Logger(), Tracer: a.Tracer()}),
baseURL: cfg.BaseURL,
}
}The composition root (cmd/server/main.go) fills BaseURL from LabKit
configuration or an environment variable. A test fills it with a fake’s URL.
2. Wire the real stack once in TestMain
The acceptance suite boots the full application — transport → domain → store — in-process, with real listeners and a real database. Fakes stand in only for service dependencies:
// test/acceptance/main_test.go
func run(m *testing.M) int {
ctx := context.Background()
a, _ := app.New(ctx)
defer a.Shutdown(ctx)
// Real infrastructure: PostgreSQL from Caproni (or TEST_DATABASE_URL),
// with migrations applied — exactly what production runs.
pgClient, _ := postgres.NewWithConfig(&postgres.Config{DSN: dbURL, Tracer: a.Tracer()})
if err := pgClient.Start(ctx); err != nil {
log.Printf("acceptance: database unavailable, skipping suite: %v", err)
return 0 // skipped, not failed, where no database exists
}
defer pgClient.Shutdown(ctx)
migrator := &store.Migrator{Client: pgClient}
_ = migrator.Start(ctx)
// Fake service dependency: the user service is an httptest server.
mockUserSvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"id":1,"name":"Test User","email":"test@example.com"}`)
}))
defer mockUserSvc.Close()
// Feature flags: in-memory OpenFeature provider, no live Flipt needed.
// Compose the real stack, pointing the client at the fake.
userClient := user.New(a, &user.Config{BaseURL: mockUserSvc.URL})
svc := domain.NewService(store.New(pool), userClient /* ... */)
srv := transport.NewServer(a, svc /* listens on :0 */)
return m.Run()
}Everything downstream of the contract is real; everything beyond it is a black box you control.
3. Drive the service over its real API
Tests hit the running listener like any external caller would, and assert on full responses:
// test/acceptance/greetings_test.go
func TestCreateGreeting(t *testing.T) {
truncate(t)
t.Cleanup(func() { truncate(t) })
resp := postJSON(t, "/greetings", map[string]string{"name": "Alice"})
assertStatus(t, resp, http.StatusCreated)
assertContentType(t, resp, "application/json")
g := decodeJSON[greetingResponse](t, resp)
if g.Name != "Alice" {
t.Errorf("name = %q, want %q", g.Name, "Alice")
}
}4. Assert on what your service sends its dependencies
A fake is more than canned responses — it is where you verify the outbound half of your contract. Stand up a per-test fake whose handler asserts on the request your service makes, then scripts the response for the case under test:
func TestGetUserGreeting_ForwardsAuth(t *testing.T) {
userSvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The contract, enforced: method, path, headers, body shape.
if r.URL.Path != "/users/1" {
t.Errorf("path = %q, want /users/1", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got == "" {
t.Error("expected Authorization header to be forwarded")
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"id":1,"name":"Test User","email":"test@example.com"}`)
}))
defer userSvc.Close()
// Point a fresh client at this test's fake and exercise the flow.
}In a traditional spun-up-world E2E test you can only hope this data is right; here it is asserted on every run.
5. Model dependency failures
Because you script the fake, failure cases that are near-impossible to stage end-to-end become ordinary table entries:
func TestGetUserGreeting_UserServiceDown(t *testing.T) {
userSvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer userSvc.Close()
// Drive the endpoint that depends on the user service and assert your
// service degrades the way its contract promises — a clean 502/fallback,
// not a hang or a panic.
}Unreachable hosts (userSvc.Close() before the call), timeouts
(time.Sleep in the handler), malformed payloads, and every relevant status
code all follow the same shape. When an incident exposes a failure mode you
missed, encode it here and keep it — the suite gets stronger with every
incident.
6. Run the suite
Start PostgreSQL (via Caproni, or any local instance), then:
TEST_DATABASE_URL=postgres://app:app@localhost:5432/app go test ./test/acceptance/...If the database is unreachable the suite exits cleanly as skipped, so unit-test-only CI jobs are unaffected.
fakes.New() and a
testframework.RunService(...) helper) to trim the TestMain boilerplate.
The pattern on this page works today and will translate directly.