Testing is built into Go from day one. The go test command, the testing package, and strong conventions around table-driven tests make Go one of the most testable languages. This guide covers everything from writing your first test to benchmarking, mocking, and measuring coverage.
The testing Package Basics
Writing Your First Test
Test files end in _test.go. Test functions start with Test, take *testing.T, and live in the same package:
// math.go
package calc
func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
// math_test.go
package calc
import (
"testing"
"fmt"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5.0 {
t.Errorf("Divide(10, 2) = %f; want 5.0", result)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero, got nil")
}
}
Running Tests
go test ./... # all packages recursively
go test ./calc/... # specific package tree
go test -v ./... # verbose: show all test names
go test -run TestAdd ./... # run only tests matching regex
go test -run "TestDiv*" ./... # wildcard matching
go test -count=3 ./... # run each test 3 times (avoids caching)
go test -timeout 30s ./... # fail if tests take longer than 30s
Table-Driven Tests
The most important Go testing pattern — one function tests dozens of cases:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
{"mixed", 5, -3, 2},
{"zero", 0, 0, 0},
{"large", 1000000, 2000000, 3000000},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
Run a specific sub-test:
go test -run "TestAdd/negative" ./...
go test -run "TestAdd/mixed" ./...
Table Tests for Error Cases
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal", 10, 2, 5.0, false},
{"negative", -10, 2, -5.0, false},
{"fractions", 1, 3, 1.0 / 3.0, false},
{"divide by zero", 10, 0, 0, true},
{"zero numerator", 0, 5, 0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := Divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Fatalf("Divide(%v, %v) error = %v; wantErr %v", tc.a, tc.b, err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Errorf("Divide(%v, %v) = %v; want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
Test Helpers
The t.Helper() call marks a function as a test helper — errors show the caller’s line, not the helper’s:
func assertEqual[T comparable](t *testing.T, got, want T, msg ...string) {
t.Helper()
if got != want {
prefix := ""
if len(msg) > 0 { prefix = msg[0] + ": " }
t.Errorf("%sgot %v; want %v", prefix, got, want)
}
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertError(t *testing.T, err error, contains string) {
t.Helper()
if err == nil {
t.Fatal("expected error; got nil")
}
if !strings.Contains(err.Error(), contains) {
t.Errorf("error %q does not contain %q", err.Error(), contains)
}
}
// Usage
func TestSomething(t *testing.T) {
result, err := doSomething()
assertNoError(t, err)
assertEqual(t, result, "expected", "doSomething")
}
Setup and Teardown
TestMain — Package-Level Setup
func TestMain(m *testing.M) {
// Setup: runs before all tests in this package
db := setupTestDB()
testDB = db
code := m.Run() // Run all tests
// Teardown: runs after all tests
db.Close()
os.Exit(code)
}
var testDB *sql.DB
func setupTestDB() *sql.DB {
db, err := sql.Open("postgres", "postgres://localhost/testdb")
if err != nil {
log.Fatalf("failed to open test DB: %v", err)
}
// Run migrations
runMigrations(db)
return db
}
t.Cleanup — Per-Test Cleanup
func TestWithTempDir(t *testing.T) {
dir, err := os.MkdirTemp("", "test-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) }) // runs after test, even on failure
// Use dir...
writeTestFile(t, filepath.Join(dir, "data.txt"), "hello")
}
func TestWithTempDB(t *testing.T) {
db := createTestDB(t)
t.Cleanup(func() { db.Close() })
// Use db...
}
Mocking with Interfaces
Go’s implicit interface system makes mocking straightforward — define an interface, provide a fake implementation in tests:
// Production code: UserStore interface
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, user *User) error
DeleteUser(ctx context.Context, id string) error
}
// Service uses the interface
type UserService struct {
store UserStore
log *slog.Logger
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.store.GetUser(ctx, id)
if err != nil {
s.log.Error("get user failed", slog.String("id", id), slog.Any("error", err))
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
// Test: implement the interface with a controllable fake
type mockUserStore struct {
users map[string]*User
err error // injectable error for testing failure paths
}
func (m *mockUserStore) GetUser(_ context.Context, id string) (*User, error) {
if m.err != nil { return nil, m.err }
user, ok := m.users[id]
if !ok { return nil, fmt.Errorf("user %s not found", id) }
return user, nil
}
func (m *mockUserStore) CreateUser(_ context.Context, user *User) error { return m.err }
func (m *mockUserStore) DeleteUser(_ context.Context, _ string) error { return m.err }
// Tests
func TestGetUser_Success(t *testing.T) {
store := &mockUserStore{
users: map[string]*User{
"u1": {ID: "u1", Name: "Alice"},
},
}
svc := &UserService{store: store, log: slog.Default()}
user, err := svc.GetUser(context.Background(), "u1")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if user.Name != "Alice" { t.Errorf("got name %s; want Alice", user.Name) }
}
func TestGetUser_NotFound(t *testing.T) {
store := &mockUserStore{users: map[string]*User{}}
svc := &UserService{store: store, log: slog.Default()}
_, err := svc.GetUser(context.Background(), "missing")
if err == nil { t.Error("expected error for missing user") }
}
func TestGetUser_StoreError(t *testing.T) {
store := &mockUserStore{err: errors.New("connection refused")}
svc := &UserService{store: store, log: slog.Default()}
_, err := svc.GetUser(context.Background(), "u1")
if err == nil { t.Error("expected error when store fails") }
if !strings.Contains(err.Error(), "connection refused") {
t.Errorf("error should wrap store error, got: %v", err)
}
}
Using testify (Optional)
testify reduces assertion boilerplate:
go get github.com/stretchr/testify
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
user, err := createUser("[email protected]")
// require: fail immediately on error (like t.Fatal)
require.NoError(t, err)
require.NotNil(t, user)
// assert: continue running after failure (like t.Error)
assert.Equal(t, "[email protected]", user.Email)
assert.NotEmpty(t, user.ID)
assert.True(t, user.Active)
// Error assertions
_, err = createUser("invalid-email")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid email")
}
Benchmarks
func BenchmarkAdd(b *testing.B) {
// b.N is set by the framework to run long enough for stable measurement
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
// Benchmark with setup — reset timer to exclude setup cost
func BenchmarkSort(b *testing.B) {
data := generateData(1000) // setup
b.ResetTimer() // don't count setup time
for i := 0; i < b.N; i++ {
b.StopTimer()
input := make([]int, len(data))
copy(input, data) // reset input each run
b.StartTimer()
sort.Ints(input)
}
}
// Sub-benchmarks to test different sizes
func BenchmarkProcess(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
data := generateData(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
processData(data)
}
})
}
}
go test -bench=. -benchmem -benchtime=5s ./...
# Output:
# BenchmarkAdd-8 1000000000 0.32 ns/op 0 B/op 0 allocs/op
# BenchmarkProcess/size-10-8 5000000 234 ns/op 160 B/op 3 allocs/op
# BenchmarkProcess/size-1000-8 10000 145234 ns/op 16384 B/op 1 allocs/op
Test Coverage
# Coverage percentage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View in browser
go tool cover -html=coverage.out
# Coverage by function
go tool cover -func=coverage.out
# Fail if coverage drops below threshold (CI use)
go test -cover ./... | grep -E "coverage: [0-9]+" | awk '{if ($2+0 < 80) exit 1}'
HTTP Handler Testing
import (
"net/http"
"net/http/httptest"
"testing"
"encoding/json"
)
func TestUserHandler(t *testing.T) {
handler := &UserHandler{
store: &mockUserStore{
users: map[string]*User{"u1": {ID: "u1", Name: "Alice"}},
},
}
tests := []struct {
name string
path string
wantStatus int
wantName string
}{
{"existing user", "/users/u1", 200, "Alice"},
{"missing user", "/users/missing", 404, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d; want %d", rec.Code, tc.wantStatus)
}
if tc.wantName != "" {
var user User
json.Unmarshal(rec.Body.Bytes(), &user)
if user.Name != tc.wantName {
t.Errorf("name = %s; want %s", user.Name, tc.wantName)
}
}
})
}
}
Summary
| Feature | Use when |
|---|---|
t.Error / t.Errorf |
Test should continue after failure |
t.Fatal / t.Fatalf |
Test cannot continue (nil pointer, setup failure) |
t.Helper() |
Utility assertion functions |
t.Run |
Sub-tests, table-driven tests |
t.Cleanup |
Per-test resource cleanup |
TestMain |
Package-level setup/teardown (DB, server) |
b.ResetTimer |
Exclude setup from benchmark |
| Interface mocks | Test in isolation from real DB/network |
Keep tests fast (unit tests < 1s total), isolated (no shared mutable state), and deterministic (no time.Now(), random, or network in unit tests).
Comments