Go Best Practices

Testing

Testing at the right granularity with real infrastructure where it matters .

Test at the right granularity

Domain logic gets unit tested with a real database but a no-op outbox . API handlers get integration tested with the full stack . Each layer tests what it owns .

Domain tests verify business logic : not found , already exists , precondition failures . The domain never sees unauthenticated or invalid argument errors , those are caught by interceptors before the handler is called .

API tests verify the full request lifecycle : interceptor rejections , error mapping , success scenarios . They exercise the real path from request to response .

Two setup functions

API tests use two setup functions to avoid spinning up testcontainers when they are not needed :

  • setupHandler(t) is lightweight , no database . It wires a panic service that explodes if a request ever reaches it . Used for tests where interceptors reject before the handler is called : unauthenticated , invalid argument , permission denied .
  • setupHandlerWithDB(t) spins up a real postgres via testcontainers . Used for tests that reach the domain layer : not found , already exists , success .
func setupHandler(t *testing.T) (*testClients[contentv1connect.ContentServiceClient], context.Context) {
    t.Helper()
    handler := apicontentv1.New(apicontentv1.Dependencies{Service: &panicService{}})
    return startServer(t, handler)
}

func setupHandlerWithDB(t *testing.T) (*testClients[contentv1connect.ContentServiceClient], context.Context) {
    t.Helper()
    ctx := context.Background()
    connStr := testkit.SetupPostgres(ctx, t)
    // ... wire real pool, queries, cache, no-op outbox
    handler := apicontentv1.New(apicontentv1.Dependencies{Service: svc})
    return startServer(t, handler)
}

Each parent test calls the setup function it needs . Subtests within a parent share the setup and run in parallel via t.Parallel() .

One file per operation , one file per route

Setup files contain only setup . No Test* functions .

  • service_test.go : domain test setup , setupService(t)
  • handler_test.go : API test setup , setupHandler(t) , setupHandlerWithDB(t) , access level helpers

Test files mirror the source :

  • op_create_test.go mirrors op_create.go
  • route_create_content_test.go mirrors route_create_content.go

Each route test file has two parent tests :

func TestCreateContent_Errors(t *testing.T) {
    clients, ctx := setupHandler(t)  // no DB

    t.Run("unauthenticated — no token", func(t *testing.T) {
        t.Parallel()
        _, err := clients.anonymous.CreateContent(ctx, connect.NewRequest(validCreateRequest()))
        require.Error(t, err)
        assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
    })

    t.Run("invalid argument — empty title", func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

func TestCreateContent_Success(t *testing.T) {
    clients, ctx := setupHandlerWithDB(t)  // with DB

    t.Run("not found — nonexistent ID", func(t *testing.T) {
        t.Parallel()
        // ...
    })

    t.Run("creates with required fields", func(t *testing.T) {
        t.Parallel()
        resp, err := clients.standard.CreateContent(ctx, connect.NewRequest(validCreateRequest()))
        require.NoError(t, err)
        assert.NotEmpty(t, resp.Msg.Content.Id)
    })
}

Access level testing

Every API has callers with different permissions . Tests need to verify that each caller sees the right behaviour : rejection , success , or something in between . Rather than constructing auth headers ad hoc in each test , the setup returns four pre-configured clients , one per access level :

type testClients[T any] struct {
    anonymous T  // no auth token , expects Unauthenticated
    standard  T  // regular user , can create / read / update
    admin     T  // admin user , can also delete
    elevated  T  // system-level , service-to-service calls
}

Each client injects its own bearer token via an interceptor . The server-side auth interceptor decodes the token and sets the caller identity . Tests pick the client that matches the scenario :

  • Every RPC tests clients.anonymous and expects Unauthenticated .
  • Standard operations (create , get , list , update) test success via clients.standard .
  • Admin-only operations (delete , bulk) test clients.standard and expect PermissionDenied , then test success via clients.admin .
  • System-level operations test clients.admin and expect PermissionDenied , then test success via clients.elevated .

The access level a given RPC requires is domain-specific , but the testing pattern is always the same .

assert vs require

Use require when failure would cause a panic on the next line . Use assert for everything else .

resp, err := client.GetContent(ctx, req)
require.NoError(t, err)               // panic guard — resp would be nil

assert.Equal(t, "expected", resp.Msg.Content.Title)  // safe to continue

require stops the test immediately . assert records the failure and continues . Rule of thumb : require.NoError after create and setup calls , assert for the actual assertions .