Skip to content

Week 20 - Testing Strategy: Five Surfaces, Race-Clean

20.1 Conceptual Core

  • A production Go service has five test surfaces:
  • Unit-*_test.go in the same package, table-driven, fast.
  • Integration-*_test.go with a real Postgres/Kafka/Redis via testcontainers-go.
  • Property-based-gopter or stdlib testing/quick. Less common in Go than in Haskell/Rust, but valuable for parsers and serializers.
  • Fuzz-stdlib func FuzzX(f *testing.F) (since Go 1.18). Native, well-integrated, must be in CI.
  • End-to-end-the binary, with all real dependencies, via go run or compiled artifact.
  • Each surface answers a different question. Skipping one leaves a class of bugs uncovered.

20.2 Mechanical Detail

  • Table-driven test idiom:
    func TestParse(t *testing.T) {
        tests := []struct{
            name string
            in   string
            want Result
            err  error
        }{
            {"empty", "", Result{}, ErrEmpty},
            // ...
        }
        for _, tc := range tests {
            t.Run(tc.name, func(t *testing.T) {
                got, err := Parse(tc.in)
                if !errors.Is(err, tc.err) { t.Fatalf("err: got %v, want %v", err, tc.err) }
                if !cmp.Equal(got, tc.want) { t.Fatalf("got %v, want %v", got, tc.want) }
            })
        }
    }
    
  • testify/require for terse assertions, google/go-cmp/cmp for deep equality with custom comparers.
  • Fuzz tests:
    func FuzzParse(f *testing.F) {
        f.Add("hello")
        f.Fuzz(func(t *testing.T, in string) {
            out, err := Parse(in)
            if err == nil {
                if Roundtrip(out) != in { t.Fatal("not idempotent") }
            }
        })
    }
    
    Run as go test -fuzz=FuzzParse -fuzztime=30s. Persist the corpus.
  • testcontainers-go for integration: spin a real Postgres in-test, get a connection string, run schema migrations, exercise the adapter. Per-test cost is ~1–3 s container startup; amortize via test-package-level setup.
  • Race detector economics: - race` slows tests 5–10× and uses ~5–10× memory. Always run in CI; locally optional. Always run on a freshly written concurrent test before committing.

20.3 Lab-"Test-Pyramid the URL Shortener"

  • Unit: 100% line coverage on internal/domain and internal/application using mocks for ports.
  • Integration: testcontainers-go Postgres for the postgres adapter.
  • Fuzz: fuzz the alias-generation function, persisting any crashing inputs.
  • Property: gopter test that "shorten then resolve returns original URL."
  • E2E: a make e2e target that spins the full stack via docker-compose, hits the HTTP API, asserts behavior.
  • All five surfaces run in CI under - race -count=1`.

20.4 Idiomatic & golangci-lint Drill

  • tparallel, paralleltest (encourages t.Parallel()), thelper (mark test helpers), testifylint (correct testify usage).

20.5 Production Hardening Slice

  • Add a continuous-fuzzing job (e.g., scheduled GitHub Action) that runs each Fuzz* function for 5 minutes against the latest corpus. Persist the corpus as an artifact. Any new crashing input is a P0 issue with a runbook entry.

Month 5 Capstone Deliverable

A production-shaped url-shortener-prod/ workspace with: - Hexagonal layout enforced by depguard. - Full observability stack (slog + Prometheus + OTel + pprof). - gRPC sibling service (e.g., a metrics-export gRPC) with hardened client/server config. - Five test surfaces in CI, all - race - clean. - A one-page RUNBOOK.md describing alarms, dashboards, deadline budgets, and rollback procedures.

This is the first artifact that resembles a real production service. Treat it as a portfolio piece.

Comments