Go’s testing package is small, fast, and durable. The patterns that emerge in production codebases are uniform. This post is the working set.
Table-driven tests
The Go canonical pattern:
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
in string
want time.Duration
err bool
}{
{"seconds", "5s", 5 * time.Second, false},
{"minutes", "3m", 3 * time.Minute, false},
{"invalid", "5z", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseDuration(tt.in)
if (err != nil) != tt.err {
t.Fatalf("err = %v, want err? %v", err, tt.err)
}
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
Each entry runs as a subtest. Failure messages include the name. Rerun a single case with go test -run TestParseDuration/seconds.
Subtests for isolation
func TestUserService(t *testing.T) {
db := setupDB(t)
svc := NewService(db)
t.Run("create", func(t *testing.T) {
u, err := svc.Create(ctx, "[email protected]")
require.NoError(t, err)
require.NotZero(t, u.ID)
})
t.Run("update", func(t *testing.T) {
// ...
})
}
Each subtest is isolated; failure in one doesn’t skip others. The setup is shared.
testcontainers for real DBs
import "github.com/testcontainers/testcontainers-go/modules/postgres"
func setupDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.Run(ctx, "postgres:17",
postgres.WithDatabase("test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategyAndDeadline(
wait.ForLog("ready to accept").WithStartupTimeout(60*time.Second),
),
)
if err != nil { t.Fatal(err) }
t.Cleanup(func() { container.Terminate(ctx) })
dsn, _ := container.ConnectionString(ctx)
db, _ := sql.Open("pgx", dsn)
runMigrations(t, db)
return db
}
Real Postgres in 5 seconds; tests against it. Mocks go bye-bye for repository tests.
httptest for HTTP handlers
func TestHandler_Hello(t *testing.T) {
req := httptest.NewRequest("GET", "/hello?name=world", nil)
rec := httptest.NewRecorder()
Handler(rec, req)
res := rec.Result()
if res.StatusCode != 200 {
t.Fatalf("status = %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if string(body) != "hello, world" {
t.Errorf("body = %q", body)
}
}
In-process; fast; no network. For end-to-end with an actual server: httptest.NewServer.
Golden files
For tests with rich output (HTML rendering, JSON snapshots):
func TestRenderPost(t *testing.T) {
out := Render(post)
goldenPath := "testdata/post.golden.html"
if *update {
os.WriteFile(goldenPath, out, 0644)
}
want, _ := os.ReadFile(goldenPath)
if !bytes.Equal(out, want) {
t.Errorf("output mismatch")
}
}
var update = flag.Bool("update", false, "update golden files")
Run go test -update to regenerate. Diff in PR review confirms intentionality.
Fuzzing
Built-in since Go 1.18. Free coverage.
func FuzzParseDuration(f *testing.F) {
seeds := []string{"5s", "3m", "1h", "0", "5z"}
for _, s := range seeds { f.Add(s) }
f.Fuzz(func(t *testing.T, s string) {
d, err := ParseDuration(s)
if err != nil { return }
if d < 0 {
t.Errorf("got negative duration %v from %q", d, s)
}
})
}
go test -fuzz=FuzzParseDuration -fuzztime=30s
Found bugs in the standard library and major libraries. Run for any parser / decoder.
Parallel tests
func TestX(t *testing.T) {
t.Parallel()
// ...
}
For CPU-bound or IO-bound tests that don’t share state. Massive speed-up on multi-core CI runners.
For table-driven:
for _, tt := range tests {
tt := tt // (no longer needed in Go 1.22+, but harmless)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
Mocks via interfaces
type Mailer interface {
Send(ctx context.Context, to, subject, body string) error
}
type fakeMailer struct {
sent []sent
}
func (m *fakeMailer) Send(ctx context.Context, to, subject, body string) error {
m.sent = append(m.sent, sent{to, subject, body})
return nil
}
func TestSignup(t *testing.T) {
mail := &fakeMailer{}
svc := NewSvc(mail)
svc.Signup(ctx, "[email protected]")
if len(mail.sent) != 1 {
t.Errorf("expected 1 email, got %d", len(mail.sent))
}
}
Hand-rolled fakes are ~5 lines. No mock generator needed for most cases. For complex interfaces: mockery or gomock.
Test helpers
func mustCreateUser(t *testing.T, svc *Service, email string) *User {
t.Helper()
u, err := svc.Create(ctx, email)
if err != nil { t.Fatal(err) }
return u
}
t.Helper() makes failure point in the calling test, not in the helper. Critical for readable failures.
TestMain for global setup
func TestMain(m *testing.M) {
// global setup (e.g., spin up a shared container)
code := m.Run()
// global teardown
os.Exit(code)
}
Use sparingly — most tests should be self-contained. Useful for expensive shared resources.
Common mistakes
1. Mocks for repository tests
DB code should run against a real DB. Mocks of sql.DB give false confidence — schema bugs slip through.
2. No t.Helper()
Failure shows the helper line, not the caller. Always mark helpers.
3. Sleeps in tests
time.Sleep(100 * time.Millisecond) is flaky. Use channels, atomics, or eventually-style polling helpers.
4. Shared state across tests
Test A leaves state; test B runs and finds it. Reset between subtests, or use fresh containers.
5. Coverage as the only target
100% coverage of trivial code teaches nothing. 60% on critical business logic with great assertions teaches a lot.
Layout
cmd/
app/
main.go
internal/
user/
user.go
user_test.go # unit tests
integration_test.go # //go:build integration
testdata/
fixtures.json
Tag integration tests:
//go:build integration
package user
Run separately: go test -tags=integration ./...
Read this next
If you want my Go test harness (testcontainers + fixtures + parallel-safe), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .