Write Effective Tests
Problem
Go’s testing package provides minimal structure compared to other languages, leading to verbose tests or inconsistent patterns. Without guidance, developers write repetitive test functions or miss important test cases. The challenge is writing idiomatic Go tests that are maintainable and comprehensive.
This guide shows effective testing patterns in Go.
Testing Package Basics
Simple Test Function
// user.go
package user
type User struct {
Name string
Email string
Age int
}
func (u *User) IsAdult() bool {
return u.Age >= 18
}
// user_test.go
package user
import "testing"
// ✅ Basic test function
func TestUserIsAdult(t *testing.T) {
user := &User{Name: "Alice", Email: "alice@example.com", Age: 25}
if !user.IsAdult() {
t.Errorf("expected user to be adult, got false")
}
}
// ✅ Test with multiple assertions
func TestUserValidation(t *testing.T) {
user := &User{Name: "Bob", Email: "bob@example.com", Age: 16}
if user.IsAdult() {
t.Errorf("expected user to not be adult, got true")
}
if user.Name != "Bob" {
t.Errorf("expected name Bob, got %s", user.Name)
}
}Test naming: Test + function/type name + specific scenario. Descriptive names document what’s being tested.
Table-Driven Tests
Idiomatic Go Pattern
// ✅ Table-driven test - idiomatic Go
func TestIsValidEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "valid email", email: "alice@example.com", want: true},
{name: "missing @", email: "invalid.email", want: false},
{name: "missing domain", email: "user@", want: false},
{name: "missing user", email: "@example.com", want: false},
{name: "empty string", email: "", want: false},
{name: "multiple @", email: "user@@example.com", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsValidEmail(tt.email)
if got != tt.want {
t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
}
})
}
}
// ❌ Repetitive individual tests
func TestValidEmail(t *testing.T) {
if !IsValidEmail("alice@example.com") {
t.Error("expected valid email to pass")
}
}
func TestInvalidEmailMissingAt(t *testing.T) {
if IsValidEmail("invalid.email") {
t.Error("expected invalid email to fail")
}
}
func TestInvalidEmailMissingDomain(t *testing.T) {
if IsValidEmail("user@") {
t.Error("expected invalid email to fail")
}
}
// ... many more repetitive functionsWhy table-driven tests: Test multiple scenarios with minimal code. Add new test cases by adding table entries. One test function covers many cases. Failures show which specific case failed.
Complex Table Tests
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
total float64
customerType string
wantDiscount float64
wantErr bool
wantErrMessage string
}{
{
name: "premium customer over 100",
total: 150.00,
customerType: "premium",
wantDiscount: 15.00,
wantErr: false,
},
{
name: "regular customer",
total: 100.00,
customerType: "regular",
wantDiscount: 0.00,
wantErr: false,
},
{
name: "negative total",
total: -50.00,
customerType: "premium",
wantDiscount: 0.00,
wantErr: true,
wantErrMessage: "total cannot be negative",
},
{
name: "unknown customer type",
total: 100.00,
customerType: "unknown",
wantDiscount: 0.00,
wantErr: true,
wantErrMessage: "invalid customer type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotDiscount, err := CalculateDiscount(tt.total, tt.customerType)
if tt.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
return
}
if err.Error() != tt.wantErrMessage {
t.Errorf("error message = %q, want %q", err.Error(), tt.wantErrMessage)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if gotDiscount != tt.wantDiscount {
t.Errorf("discount = %.2f, want %.2f", gotDiscount, tt.wantDiscount)
}
})
}
}Subtests with t.Run
Organizing Related Tests
func TestUserOperations(t *testing.T) {
// ✅ Group related tests with subtests
t.Run("Create", func(t *testing.T) {
user := CreateUser("alice@example.com", "password123")
if user == nil {
t.Fatal("expected user, got nil")
}
if user.Email != "alice@example.com" {
t.Errorf("email = %s, want alice@example.com", user.Email)
}
})
t.Run("Validate", func(t *testing.T) {
t.Run("valid user", func(t *testing.T) {
user := &User{Email: "alice@example.com", Age: 25}
if err := user.Validate(); err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("invalid email", func(t *testing.T) {
user := &User{Email: "invalid", Age: 25}
if err := user.Validate(); err == nil {
t.Error("expected validation error, got nil")
}
})
t.Run("under age", func(t *testing.T) {
user := &User{Email: "alice@example.com", Age: 15}
if err := user.Validate(); err == nil {
t.Error("expected validation error, got nil")
}
})
})
t.Run("Update", func(t *testing.T) {
user := &User{Email: "alice@example.com", Age: 25}
user.Update("newemail@example.com", 26)
if user.Email != "newemail@example.com" {
t.Errorf("email = %s, want newemail@example.com", user.Email)
}
})
}Running specific subtests:
go test
go test -run TestUserOperations
go test -run TestUserOperations/Create
go test -run TestUserOperations/Validate/valid_userTest Helpers and Setup
Setup and Teardown
// ✅ Test helper for common setup
func setupTestUser(t *testing.T) *User {
t.Helper() // Marks function as test helper
user := &User{
Name: "Test User",
Email: "test@example.com",
Age: 25,
}
return user
}
// ✅ Teardown with cleanup
func setupDatabase(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Register cleanup function
t.Cleanup(func() {
db.Close()
})
return db
}
// Usage
func TestWithDatabase(t *testing.T) {
db := setupDatabase(t) // Automatically closed after test
// Use database...
if err := createTables(db); err != nil {
t.Fatalf("failed to create tables: %v", err)
}
// Test operations...
}Table Test Helpers
// ✅ Helper for comparing slices
func assertEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Errorf("length mismatch: got %d, want %d", len(got), len(want))
return
}
for i := range got {
if got[i] != want[i] {
t.Errorf("index %d: got %q, want %q", i, got[i], want[i])
}
}
}
// Usage
func TestProcessItems(t *testing.T) {
got := ProcessItems([]string{"a", "b", "c"})
want := []string{"A", "B", "C"}
assertEqual(t, got, want)
}Testify for Better Assertions
Using testify/assert
import (
"testing"
"github.com/stretchr/testify/assert"
)
// ✅ Cleaner assertions with testify
func TestWithTestify(t *testing.T) {
user := &User{Name: "Alice", Age: 25}
assert.NotNil(t, user)
assert.Equal(t, "Alice", user.Name)
assert.True(t, user.IsAdult())
assert.Greater(t, user.Age, 18)
}
// ✅ Table test with testify
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
rate float64
want float64
}{
{"10% tax", 100.0, 0.10, 10.0},
{"20% tax", 50.0, 0.20, 10.0},
{"no tax", 100.0, 0.0, 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateTax(tt.amount, tt.rate)
assert.Equal(t, tt.want, got)
})
}
}
// ✅ Assert collections
func TestFilterItems(t *testing.T) {
items := []string{"apple", "banana", "cherry"}
filtered := FilterByPrefix(items, "b")
assert.Len(t, filtered, 1)
assert.Contains(t, filtered, "banana")
assert.NotContains(t, filtered, "apple")
}testify/require for Fatal Assertions
import (
"testing"
"github.com/stretchr/testify/require"
)
// ✅ require stops test on failure
func TestDatabaseOperations(t *testing.T) {
db, err := OpenDatabase()
require.NoError(t, err) // Stops if error (can't continue without DB)
user, err := db.FindUser("123")
require.NoError(t, err)
require.NotNil(t, user) // Stops if nil (can't check fields)
// Safe to access user fields
assert.Equal(t, "Alice", user.Name)
}
// ❌ Without require - panic on nil
func TestWithoutRequire(t *testing.T) {
db, err := OpenDatabase()
assert.NoError(t, err)
user, err := db.FindUser("123")
assert.NoError(t, err)
assert.NotNil(t, user)
// Panics if user is nil!
assert.Equal(t, "Alice", user.Name)
}Mocking Interfaces
Manual Mocks
// Interface to mock
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
// ✅ Manual mock implementation
type MockUserRepository struct {
FindByIDFunc func(id string) (*User, error)
SaveFunc func(user *User) error
FindByIDCalls []string // Track calls
SaveCalls []*User
}
func (m *MockUserRepository) FindByID(id string) (*User, error) {
m.FindByIDCalls = append(m.FindByIDCalls, id)
if m.FindByIDFunc != nil {
return m.FindByIDFunc(id)
}
return nil, nil
}
func (m *MockUserRepository) Save(user *User) error {
m.SaveCalls = append(m.SaveCalls, user)
if m.SaveFunc != nil {
return m.SaveFunc(user)
}
return nil
}
// Usage in tests
func TestUserService(t *testing.T) {
mockRepo := &MockUserRepository{
FindByIDFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, errors.New("not found")
},
}
service := NewUserService(mockRepo)
user, err := service.GetUser("123")
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
assert.Equal(t, []string{"123"}, mockRepo.FindByIDCalls)
}testify/mock for Advanced Mocking
import (
"testing"
"github.com/stretchr/testify/mock"
)
// ✅ Mock with testify/mock
type MockRepository struct {
mock.Mock
}
func (m *MockRepository) FindByID(id string) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockRepository) Save(user *User) error {
args := m.Called(user)
return args.Error(0)
}
// Usage
func TestWithMock(t *testing.T) {
mockRepo := new(MockRepository)
// Set expectations
expectedUser := &User{ID: "123", Name: "Alice"}
mockRepo.On("FindByID", "123").Return(expectedUser, nil)
mockRepo.On("Save", mock.Anything).Return(nil)
service := NewUserService(mockRepo)
// Run test
user, err := service.GetUser("123")
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
service.UpdateUser(user)
// Verify expectations
mockRepo.AssertExpectations(t)
mockRepo.AssertCalled(t, "FindByID", "123")
mockRepo.AssertCalled(t, "Save", expectedUser)
}Benchmark Tests
Writing Benchmarks
// ✅ Benchmark function
func BenchmarkStringConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ConcatenateStrings("hello", "world")
}
}
// ✅ Table-driven benchmarks
func BenchmarkStringOperations(b *testing.B) {
tests := []struct {
name string
fn func() string
}{
{"concat", func() string { return "hello" + "world" }},
{"sprintf", func() string { return fmt.Sprintf("%s%s", "hello", "world") }},
{"builder", func() string {
var builder strings.Builder
builder.WriteString("hello")
builder.WriteString("world")
return builder.String()
}},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = tt.fn()
}
})
}
}Running benchmarks:
go test -bench=.
go test -bench=BenchmarkStringConcat
go test -bench=. -benchmem
go test -bench=. -benchmem > old.txt
go test -bench=. -benchmem > new.txt
benchcmp old.txt new.txtTest Organization
File Structure
user/
├── user.go # Implementation
├── user_test.go # Tests in same package
├── export_test.go # Export internals for testing
└── integration_test.go # Integration testsPackage Test vs External Test
// user_test.go - same package (can test unexported)
package user
import "testing"
func TestUnexportedFunction(t *testing.T) {
result := internalHelper("test") // Can access unexported
if result != "expected" {
t.Errorf("got %s", result)
}
}
// user_external_test.go - external package (only exported)
package user_test
import (
"testing"
"myapp/user"
)
func TestPublicAPI(t *testing.T) {
u := user.New("alice@example.com")
// Can only access exported fields and methods
}Summary
Effective Go testing centers on table-driven tests and subtests. Table-driven tests use slices of test cases with inputs and expected outputs, enabling comprehensive coverage with minimal code. Adding test cases means adding table entries, not new functions. Subtests with t.Run organize related tests and enable selective test execution.
The testing package provides minimal structure intentionally. Test functions start with Test, take *testing.T parameter, and use t.Error for non-fatal failures or t.Fatal for fatal failures. t.Helper marks functions as test helpers so failure messages show correct line numbers.
Setup and teardown use helper functions and t.Cleanup. Helper functions return initialized test data, t.Cleanup registers functions to run after tests complete. This pattern ensures proper cleanup even when tests fail, preventing resource leaks and test interference.
Testify provides convenient assertion functions reducing boilerplate. assert functions continue test on failure, require functions stop immediately. Use require when subsequent code depends on the assertion passing - checking database connection before running queries, verifying non-nil before accessing fields.
Mocking in Go uses interfaces. Define interfaces for dependencies, create mock implementations for testing. Manual mocks track calls and allow configurable return values. testify/mock provides assertion helpers for verifying mock interactions. Test behavior not implementation by mocking only external dependencies.
Benchmarks measure performance with b.N iterations. Go’s benchmark framework automatically determines appropriate iteration count. Table-driven benchmarks compare different implementations. Run with -benchmem to see memory allocation statistics, essential for optimization work.
Test organization follows package structure with _test.go suffix. Same-package tests access unexported functions, external package tests verify public API. Use external tests to catch issues where package exports are insufficient for real usage.
Idiomatic Go testing favors simplicity over frameworks. Table-driven tests handle multiple scenarios, subtests organize related cases, helper functions reduce duplication, and interfaces enable testability. These patterns produce maintainable test suites that grow with codebases.