Skip to content
Test a modular feature in isolation

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.

The Developer Experience team is investigating LabKit support for in-test configuration and service running (think fakes.New() and a testframework.RunService(...) helper) to trim the TestMain boilerplate. The pattern on this page works today and will translate directly.
Last updated on