No framework required: tests are plain functions in _test.go files next to the code, run by go test.

The building blocks

Need Go
A test func TestSave(t *testing.T) in store_test.go
Assertions if got != want { t.Errorf(...) } (or testify, if you like)
Parameterized tests Table-driven test + t.Run subtests
Test doubles Hand-written fake of a small, consumer-side interface
HTTP servers/clients in tests net/http/httptest
Per-test setup / teardown Helper function + t.Cleanup
Per-package setup / teardown TestMain(m *testing.M)
Temp files, env vars t.TempDir(), t.Setenv()
Separating slow suites Build tags (//go:build integration)
Real dependencies in Docker testcontainers-go
Browser tests playwright-go
Coverage, parallelism go test -cover, t.Parallel()

Package choice: package foo tests internals; package foo_test (black-box) only sees the exported API.


Table-driven tests

Go’s answer to parameterized tests: a slice of cases and one loop.

func TestSubmitAnswer(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name    string
        pid     ParticipantID
        option  string
        before  func(*Session)
        wantErr error
    }{
        {name: "ok", pid: "p1", option: "q1-a"},
        {name: "unknown option", pid: "p1", option: "zzz", wantErr: ErrInvalidAnswer},
        {name: "twice", pid: "p1", option: "q1-a",
            before: func(s *Session) { s.Submit("p1", "q1-a") }, wantErr: ErrAlreadyAnswered},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            s := newOpenSession(t)
            if tt.before != nil {
                tt.before(s)
            }
            err := s.Submit(tt.pid, tt.option)
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("err = %v, want %v", err, tt.wantErr)
            }
        })
    }
}

Fakes instead of mocking frameworks

Implicit interfaces make test doubles trivial: any type with the right methods fits.

// A clock where time only moves when the test says so.
type fakeClock struct {
    mu     sync.Mutex
    now    time.Time
    timers []*fakeTimer
}

func (c *fakeClock) Now() time.Time                              { ... }
func (c *fakeClock) AfterFunc(d time.Duration, f func()) Timer   { ... }
func (c *fakeClock) Advance(d time.Duration) { /* fires due timers, in order */ }

// A broadcaster that just remembers.
type recorder struct {
    mu     sync.Mutex
    events []Event
}

func (r *recorder) Broadcast(_ SessionID, ev Event) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.events = append(r.events, ev)
}

Helpers and lifecycle

func newEnv(t *testing.T) *env {
    t.Helper() // failures point at the caller's line
    st := memory.New()
    srv := httptest.NewServer(httpapi.New(httpapi.Deps{Store: st}).Handler())
    t.Cleanup(srv.Close)
    return &env{srv: srv, client: srv.Client()}
}

dir := t.TempDir()       // removed automatically
t.Setenv("APP_MODE", "x") // restored automatically

t.Helper(), t.Cleanup(), t.TempDir(), t.Setenv(), t.Context(): small building blocks instead of annotations. For a single handler, httptest.NewRecorder() avoids a server entirely.


Contract tests: one suite, many implementations

// package storetest
func RunStoreTests(t *testing.T, newStore func(*testing.T) store.Store) {
    t.Run("save and load quiz", func(t *testing.T) { ... })
    t.Run("unknown session is ErrNotFound", func(t *testing.T) { ... })
}

// memory_test.go
func TestContract(t *testing.T) {
    storetest.RunStoreTests(t, func(*testing.T) store.Store { return memory.New() })
}

The in-memory store and the Postgres store pass the same specification, so the fast one can stand in for the real one in unit tests with confidence.


Integration tests with testcontainers-go

//go:build integration

package postgres_test

var dsn string

func TestMain(m *testing.M) {
    ctx := context.Background()
    ctr, err := tcpostgres.Run(ctx, "postgres:17-alpine",
        tcpostgres.WithDatabase("app"),
        tcpostgres.BasicWaitStrategies(),
    )
    if err != nil {
        log.Fatal(err)
    }
    dsn, _ = ctr.ConnectionString(ctx, "sslmode=disable")
    code := m.Run()
    _ = ctr.Terminate(ctx)
    os.Exit(code)
}

End-to-end with playwright-go

Structure browser tests like any other Go test package, behind an e2e build tag. A fluent harness keeps scenarios readable:

//go:build e2e

func TestFullQuiz(t *testing.T) {
    s := harness.New(t) // server + chromium
    host := s.Admin().
        LoginAsAdmin().And().
        CreateQuiz(quiz).And().
        HostQuiz("Go testing").And().
        Should().SeeJoinCode()
    alice := s.Player().Join(host.Code(), "alice")
    alice.ChooseAnswer("-race").And().Should().SeeText("Answer locked in")
}

Benchmarks and fuzzing

func BenchmarkResults(b *testing.B) {
    s := sessionWithAnswers(200)
    for b.Loop() {          // Go 1.24+; older: for i := 0; i < b.N; i++
        s.Results(qid)
    }
}
go test -bench=Results -benchmem ./internal/domain
# BenchmarkResults-12   744633   1598 ns/op   272 B/op   3 allocs/op

Fuzzing is built in too: the engine mutates inputs looking for panics or broken invariants.

func FuzzNormalizeCode(f *testing.F) {
    f.Add("abc123")
    f.Fuzz(func(t *testing.T, in string) {
        out := NormalizeCode(in)
        if NormalizeCode(out) != out {
            t.Fatalf("not idempotent: %q → %q", in, out)
        }
    })
}
go test -fuzz=FuzzNormalizeCode -fuzztime=10s ./internal/domain

Profiling: measure, then decide

go test -bench=Results -cpuprofile cpu.out -memprofile mem.out ./internal/domain
go tool pprof -http=: cpu.out     # flame graph in the browser
go tool pprof -top cpu.out
go tool pprof -list 'Results' cpu.out

A worked example: the CPU profile showed 76% of a function in map assignment. The obvious hypothesis was “the map keeps growing, so pre-size it”. Measured over five runs: no change. -list showed why: 1.10s of 1.23s was one line, two hundred increments of keys that already existed. The map was never growing; the code stayed as it was.

Benchmarks tell you the number; the profiler tells you where it went.


The test pyramid with one runner

Layer Covers Command
Unit domain logic, fakes, HTTP via httptest go test ./...
Unit + race same, with the race detector go test -race ./...
Integration real database via testcontainers go test -tags integration ./...
End-to-end real browsers against the real binary go test -tags e2e ./e2e/...

Everything is go test: one runner, one output format, build tags decide what’s in scope.