Testing
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/testing.md |
| Description | Not 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 := tccapture 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%.
go test -race -covermode=atomic -coverprofile=cover.out ./...go tool cover -func=cover.out- The 80% floor applies to
domainandservice— 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()sogo test -shortruns the fast unit suite; CI runs the full suite. t.Cleanupterminates the container even if the test fails.- One container per package (a
TestMainthat starts it once) is fine when tests do not mutate shared state; otherwise one per test —references/testcontainers.mdcovers the tradeoff and the isolation techniques for each.