Stage 7 · Master
Phase 3 — User Service
Testing
Write table-driven unit tests against the Repository interface, real-database integration tests with testcontainers-go, and two contract tests that encode this module's non-negotiables: tenant isolation and cursor stability.
Three Layers, Three Different Guarantees
Organization Service's unit-testing and integration-testing lessons own the fake-repository seam, build tags, and testcontainers harness. User Service applies those tools only where this domain differs: profile creation must preserve identity invariants, duplicate membership invites must collapse to ErrAlreadyMember, and contract tests must freeze tenant isolation plus cursor stability.
Unit Tests Against the Interface, Not the Database
package user
import (
"context"
"testing"
"github.com/google/uuid"
)
type createSpyRepo struct{ saved User }
func (r *createSpyRepo) Create(_ context.Context, u User) error { r.saved = u; return nil }
func (r *createSpyRepo) ByID(context.Context, string) (User, error) { return User{}, nil }
func (r *createSpyRepo) Update(context.Context, User) error { return nil }
func TestServiceCreateBuildsActiveUUIDv7Profile(t *testing.T) {
repo := &createSpyRepo{}
svc := NewService(repo)
created, err := svc.Create(context.Background(), CreateRequest{
Email: "ana@maple.dev", FirstName: "Ana", LastName: "Reyes",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.ID == uuid.Nil || repo.saved.Status != StatusActive {
t.Fatalf("Create() saved ID=%s status=%s, want generated active profile", created.ID, repo.saved.Status)
}
}The assertion is specific to user-service: creating a profile must generate a durable identity id and start in the active state before membership rows exist.
Integration Tests Against a Real Postgres
package membership
import (
"context"
"testing"
"github.com/google/uuid"
)
func TestRepositoryCreateMapsDuplicateMembership(t *testing.T) {
pool := newMigratedMembershipPool(t)
repo := NewRepository(pool)
userID, orgID := uuid.New(), uuid.New()
first := Membership{ID: uuid.New(), UserID: userID, OrganizationID: orgID, Role: RoleResident, Status: StatusInvited}
if err := repo.Create(context.Background(), first); err != nil {
t.Fatalf("first Create() error = %v", err)
}
again := Membership{ID: uuid.New(), UserID: userID, OrganizationID: orgID, Role: RoleBoardMember, Status: StatusInvited}
if err := repo.Create(context.Background(), again); err != ErrAlreadyMember {
t.Fatalf("duplicate Create() error = %v, want ErrAlreadyMember", err)
}
}The harness comes from Organization Service's integration-testing lesson; this assertion is user-service-specific because it proves the membership uniqueness constraint becomes a domain error.
Two Tests This Module Must Never Fail
package user
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
// TestContract_SearchNeverCrossesTenants is the tenant-isolation guarantee
// this entire module exists to protect. If this test ever fails, treat it
// as a security incident, not a flaky test to retry.
func TestContract_SearchNeverCrossesTenants(t *testing.T) {
pool := newSearchTestPool(t) // provisions two organizations with overlapping names
repo := NewSearchRepository(pool)
orgA, orgB := uuid.New(), uuid.New()
seedUser(t, pool, orgA, "Jamie Smith")
seedUser(t, pool, orgB, "Jamie Smith")
results, err := repo.Search(context.Background(), orgA, "Jamie", 10)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
for _, u := range results {
if !belongsToOrg(t, pool, u.ID, orgA) {
t.Fatalf("search scoped to org A returned a user from a different organization: %s", u.ID)
}
}
}
// TestContract_CursorIsStableAcrossInserts proves keyset pagination's core
// promise: a page fetched with a given cursor never shifts because of
// concurrent writes, unlike OFFSET pagination.
func TestContract_CursorIsStableAcrossInserts(t *testing.T) {
pool := newListTestPool(t)
repo := NewListRepository(pool)
orgID := uuid.New()
for i := 0; i < 5; i++ {
seedUser(t, pool, orgID, "Resident "+time.Now().String())
}
firstPage, err := repo.List(context.Background(), orgID, uuid.UUID{}, 2)
if err != nil || len(firstPage) != 2 {
t.Fatalf("List() first page error = %v, len = %d", err, len(firstPage))
}
cursor := firstPage[len(firstPage)-1].ID
// Insert a new user that would shift an OFFSET-based page 2.
seedUser(t, pool, orgID, "Newly Inserted Resident")
secondPage, err := repo.List(context.Background(), orgID, cursor, 2)
if err != nil {
t.Fatalf("List() second page error = %v", err)
}
for _, u := range secondPage {
if u.ID == firstPage[0].ID || u.ID == firstPage[1].ID {
t.Fatalf("second page unexpectedly repeated a row from the first page: %s", u.ID)
}
}
}Naming these _test.go functions with a Contract_ prefix is a convention, not a language feature — it signals to every future contributor that these two tests are guarding a promise, not testing an implementation detail that's free to change.
go.work spans every service in the platform, so a bare go test ./... from the repository root would compile and run tests for services this lesson never touches. Every test invocation in this course is scoped: cd services/user-service && go test ./... — one module, one test binary, one clear failure signal.
Running the Suite Locally
Applied exercise
Write the invariant test this module is missing
The invariants.go file from the Validation lesson has no test yet covering CanSuspend.
- Write a table-driven test with at least three cases: suspending a lone org_admin, suspending one of two org_admins, and suspending a non-admin role.
- Decide whether this test belongs in the unit layer (with a fake) or the integration layer (with a real Postgres), and justify the choice.
- Name the one contract-test-worthy guarantee this invariant protects, in one sentence.
Deliverable
A Go test file (or pseudocode close enough to compile) plus the layer justification and contract statement.
Completion checks
- The three cases each assert a different, correct outcome (error vs. nil).
- The layer choice is justified by whether count(*) style aggregation is exercised.
What user-service behavior does TestServiceCreateBuildsActiveUUIDv7Profile protect?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.