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)
}
})
}
}
- Happy paths and error cases share one table; a new case is one line.
errors.Is(nil, nil)is true, so the happy path needs no special case.- Run a single case:
go test -run 'TestSubmitAnswer/twice'. - Since Go 1.22 the loop variable is per-iteration, so capturing
ttin parallel subtests is safe.
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)
}
- No reflection: fakes are ordinary, type-checked code.
- Small interfaces keep fakes small.
- Assert on resulting state, not on which methods were called.
- Inject time (a
Clockinterface) instead of callingtime.Now()directly; timer-based logic becomes deterministic. (testing/synctestis the stdlib alternative for faking time in concurrent code.)
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)
}
- A real database in Docker, started from the test itself.
- The build tag keeps Docker-dependent tests out of a plain
go test ./...; run them withgo test -tags integration ./.... - One container per package; give each test its own schema to stay isolated and parallel.
- Modules exist for Postgres, MySQL, Kafka, Redis, LocalStack;
GenericContainercovers anything else.
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")
}
- One browser per package, one browser context per test.
- Every Playwright call returns an error; the harness turns them into
t.Fatalso scenarios stay linear. - Screenshot every actor on failure.
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.