Skip to content

Testing

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/testing.md
DescriptionNot specified

Source Content

Testing

Table-driven by default, -race always on, and a hard coverage floor of ≥ 80% on domain and service. Handlers and repositories earn their confidence from integration tests against a real Postgres, not from chasing line coverage.

The TDD cycle (ADR-024)

RED → GREEN → REFACTOR. Write the failing test first; make it pass with the simplest code; then clean up with the test as your safety net. The test is written against the interface, so the implementation is free to change underneath it.

Table-driven test template

One slice of cases, one t.Run loop. This is the idiomatic Go shape (Mat Ryer): the test is data, the runner is one loop.

func TestInvoice_Finalize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status string
wantErr error
}{
{name: "draft finalizes", status: "draft", wantErr: nil},
{name: "already finalized errors", status: "finalized", wantErr: domain.ErrAlreadyFinalized},
}
for _, tc := range tests {
tc := tc // capture before the closure
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
inv := domain.Invoice{ID: "inv_1", Status: tc.status}
err := inv.Finalize()
if !errors.Is(err, tc.wantErr) {
t.Fatalf("Finalize() err = %v, want %v", err, tc.wantErr)
}
})
}
}
  • t.Parallel() at both levels when cases are independent — the race detector loves the extra interleaving.
  • tc := tc capture is required before the inner closure on Go versions before 1.22; harmless to keep for clarity.
  • Assert with errors.Is / errors.As, never string-compare error messages.

Deterministic time and goroutines with synctest (Go 1.25+)

testing/synctest.Test runs a test body inside an isolated “bubble” where fake time only advances once every goroutine in the bubble is blocked. Timers, context deadlines, and polling loops become deterministic — no real time.Sleep wall-clock wait, which is a common source of flaky CI.

func pollUntilReady(ctx context.Context, ready func() bool) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
if ready() {
return nil
}
select {
case <-ticker.C:
case <-ctx.Done():
return ctx.Err()
}
}
}
func TestPollUntilReady(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
var readyAt int
err := pollUntilReady(ctx, func() bool {
readyAt++
return readyAt == 3 // ready on the third poll tick
})
if err != nil {
t.Fatalf("pollUntilReady() err = %v, want nil", err)
}
})
}

Inside the bubble, the fake clock jumps straight to each 100ms tick instead of the test actually waiting — the whole test runs in real time near zero, even though it exercises 5 seconds of simulated polling.

t.Context() (Go 1.24+) is the now-preferred replacement for context.Background() in tests: it auto-cancels when the test ends, which is one less thing a test can leak.

Unit-test the service with a fake repository

The service depends on an interface (references/layering.md), so a hand-written fake — or one from testify/mock — substitutes for the database. No DB in unit tests.

type fakeRepo struct {
byID func(ctx context.Context, id domain.InvoiceID) (domain.Invoice, error)
save func(ctx context.Context, inv domain.Invoice) error
}
func (f fakeRepo) ByID(ctx context.Context, id domain.InvoiceID) (domain.Invoice, error) {
return f.byID(ctx, id)
}
func (f fakeRepo) Save(ctx context.Context, inv domain.Invoice) error { return f.save(ctx, inv) }
func TestInvoiceService_Finalize_NotFound(t *testing.T) {
svc := service.NewInvoiceService(fakeRepo{
byID: func(context.Context, domain.InvoiceID) (domain.Invoice, error) {
return domain.Invoice{}, domain.ErrNotFound
},
})
_, err := svc.Finalize(context.Background(), "missing")
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}

Coverage floor

task test-cover runs the suite with race and atomic coverage, then fails if domain or service is under 80%.

Terminal window
go test -race -covermode=atomic -coverprofile=cover.out ./...
go tool cover -func=cover.out
  • The 80% floor applies to domain and service — the layers that hold business logic.
  • Handlers and repositories are not chased for line coverage; their correctness comes from integration tests below.
  • Coverage measures the floor, not the goal — a covered line with a weak assertion is worse than an honest gap.

Integration tests: testcontainers against real Postgres

The repository layer is tested against an actual Postgres in a container, so SQL, migrations, and row mapping are exercised for real. Container lifecycle, parallel test isolation, and image/module caching for faster repeated runs are their own topic — see references/testcontainers.md.

  • Gate integration tests behind testing.Short() so go test -short runs the fast unit suite; CI runs the full suite.
  • t.Cleanup terminates the container even if the test fails.
  • One container per package (a TestMain that starts it once) is fine when tests do not mutate shared state; otherwise one per test — references/testcontainers.md covers the tradeoff and the isolation techniques for each.