๐ Theory: Go's Testing Philosophy
Go has a built-in testing framework that emphasizes simplicity and convention over configuration. Tests are first-class citizens in Go, with tooling integrated into the standard library.
Testing Principles in Go
Go's testing philosophy follows these core principles:
- Simplicity: Tests are just Go code in files ending with _test.go
- Convention: Test functions start with Test, benchmarks with Benchmark
- Integration: Testing is built into the go command
- Fast Feedback: Tests should run quickly and frequently
- Table-Driven: Preferred pattern for testing multiple cases
Test Pyramid
Testing Conventions
Convention | Pattern | Example | Purpose |
---|---|---|---|
Test Files | *_test.go | math_test.go | Contains test functions |
Test Functions | Test*(t *testing.T) | TestAdd(t *testing.T) | Unit tests |
Benchmarks | Benchmark*(b *testing.B) | BenchmarkSort(b *testing.B) | Performance tests |
Examples | Example*() | ExampleAdd() | Documentation tests |
Test Package | package_test | math_test | Black-box testing |
โ Unit Testing Fundamentals
Unit tests verify individual functions and methods in isolation. Go's testing package provides everything needed for comprehensive unit testing.
Basic Test Structure
// math.go package math import "errors" func Add(a, b int) int { return a + b } func Divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil } // math_test.go package math import ( "testing" "math" ) func TestAdd(t *testing.T) { result := Add(2, 3) expected := 5 if result != expected { t.Errorf("Add(2, 3) = %d; want %d", result, expected) } } func TestDivide(t *testing.T) { // Test successful division result, err := Divide(10, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } if math.Abs(result-5.0) > 0.0001 { t.Errorf("Divide(10, 2) = %.2f; want 5.0", result) } // Test division by zero _, err = Divide(10, 0) if err == nil { t.Error("expected error for division by zero") } }
Testing Helper Methods
Method | Purpose | Continues? | Example |
---|---|---|---|
t.Error() |
Report error | Yes | t.Error("failed") |
t.Errorf() |
Report formatted error | Yes | t.Errorf("got %d", v) |
t.Fatal() |
Report and stop | No | t.Fatal("critical") |
t.Fatalf() |
Report formatted and stop | No | t.Fatalf("got %d", v) |
t.Log() |
Log information | Yes | t.Log("debug info") |
t.Skip() |
Skip test | No | t.Skip("not ready") |
๐ฏ Table-Driven Tests
Table-driven tests are Go's idiomatic way to test multiple scenarios efficiently. They reduce code duplication and make tests more maintainable.
func TestCalculateDiscount(t *testing.T) { tests := []struct { name string amount float64 customerType string expected float64 wantErr bool }{ { name: "regular customer small amount", amount: 100, customerType: "regular", expected: 95, wantErr: false, }, { name: "premium customer large amount", amount: 1000, customerType: "premium", expected: 850, wantErr: false, }, { name: "invalid customer type", amount: 100, customerType: "invalid", expected: 0, wantErr: true, }, { name: "negative amount", amount: -100, customerType: "regular", expected: 0, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := CalculateDiscount(tt.amount, tt.customerType) if (err != nil) != tt.wantErr { t.Errorf("CalculateDiscount() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && math.Abs(result-tt.expected) > 0.01 { t.Errorf("CalculateDiscount() = %v, want %v", result, tt.expected) } }) } } // Using map for named test cases func TestParseURL(t *testing.T) { tests := map[string]struct { input string wantHost string wantPath string wantErr bool }{ "valid http URL": { input: "http://example.com/path", wantHost: "example.com", wantPath: "/path", wantErr: false, }, "valid https URL": { input: "https://api.example.com/v1/users", wantHost: "api.example.com", wantPath: "/v1/users", wantErr: false, }, "invalid URL": { input: "not a url", wantHost: "", wantPath: "", wantErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { host, path, err := ParseURL(tc.input) if tc.wantErr { if err == nil { t.Fatal("expected error but got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if host != tc.wantHost || path != tc.wantPath { t.Errorf("ParseURL(%q) = (%q, %q), want (%q, %q)", tc.input, host, path, tc.wantHost, tc.wantPath) } }) } }
โก Benchmarking
Benchmarks measure code performance and help identify bottlenecks. Go's benchmarking framework provides accurate, reproducible performance measurements.
Benchmark Basics
- Function name starts with Benchmark
- Takes *testing.B parameter
- Runs b.N iterations
- Reports ns/op, allocs, B/op
- Use b.ResetTimer() after setup
Running Benchmarks
go test -bench=.
-benchmem
for memory stats-benchtime=10s
for duration-cpuprofile
for profiling-count=5
for multiple runs
func BenchmarkStringConcat(b *testing.B) { strings := []string{"Hello", " ", "World", "!"} b.ResetTimer() // Reset after setup for i := 0; i < b.N; i++ { result := "" for _, s := range strings { result += s } } } func BenchmarkStringBuilder(b *testing.B) { strings := []string{"Hello", " ", "World", "!"} b.ResetTimer() for i := 0; i < b.N; i++ { var builder strings.Builder for _, s := range strings { builder.WriteString(s) } _ = builder.String() } } // Sub-benchmarks with different inputs func BenchmarkSort(b *testing.B) { sizes := []int{10, 100, 1000, 10000} for _, size := range sizes { b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) { // Create test data data := make([]int, size) for i := range data { data[i] = rand.Intn(1000) } b.ResetTimer() for i := 0; i < b.N; i++ { tmp := make([]int, len(data)) copy(tmp, data) sort.Ints(tmp) } }) } } // Parallel benchmark func BenchmarkConcurrentMap(b *testing.B) { m := &sync.Map{} b.RunParallel(func(pb *testing.PB) { for pb.Next() { key := rand.Intn(100) m.Store(key, key*2) m.Load(key) } }) }
Benchmark Output
BenchmarkStringConcat-8 5000000 234 ns/op 16 B/op 4 allocs/op BenchmarkStringBuilder-8 20000000 67 ns/op 24 B/op 1 allocs/op BenchmarkSort/size-10-8 10000000 142 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-100-8 1000000 2145 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-1000-8 50000 31254 ns/op 0 B/op 0 allocs/op BenchmarkSort/size-10000-8 3000 428936 ns/op 0 B/op 0 allocs/op
๐ญ Mocking and Test Doubles
Mocking allows testing components in isolation by replacing dependencies with test doubles. Go's interface-based design makes mocking straightforward.
// Define interface for external dependency type EmailSender interface { Send(to, subject, body string) error } // Real implementation type SMTPEmailSender struct { host string port int auth smtp.Auth } func (s *SMTPEmailSender) Send(to, subject, body string) error { // Real SMTP implementation return smtp.SendMail( fmt.Sprintf("%s:%d", s.host, s.port), s.auth, "noreply@example.com", []string{to}, []byte(fmt.Sprintf("Subject: %s\n\n%s", subject, body)), ) } // Mock implementation for testing type MockEmailSender struct { SentEmails []Email SendFunc func(to, subject, body string) error } type Email struct { To string Subject string Body string } func (m *MockEmailSender) Send(to, subject, body string) error { m.SentEmails = append(m.SentEmails, Email{ To: to, Subject: subject, Body: body, }) if m.SendFunc != nil { return m.SendFunc(to, subject, body) } return nil } // Service that uses the interface type UserService struct { db Database emailSender EmailSender logger Logger } func (s *UserService) RegisterUser(email, password string) error { // Validate input if !isValidEmail(email) { return errors.New("invalid email") } // Create user in database user := &User{ Email: email, Password: hashPassword(password), } if err := s.db.CreateUser(user); err != nil { s.logger.Error("failed to create user", err) return err } // Send welcome email if err := s.emailSender.Send( email, "Welcome!", "Thanks for registering", ); err != nil { s.logger.Warn("failed to send welcome email", err) // Don't fail registration if email fails } return nil } // Test using mocks func TestUserRegistration(t *testing.T) { // Setup mocks mockEmail := &MockEmailSender{} mockDB := &MockDatabase{} mockLogger := &MockLogger{} service := &UserService{ db: mockDB, emailSender: mockEmail, logger: mockLogger, } // Test successful registration err := service.RegisterUser("test@example.com", "password123") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify email was sent if len(mockEmail.SentEmails) != 1 { t.Errorf("expected 1 email, got %d", len(mockEmail.SentEmails)) } if mockEmail.SentEmails[0].To != "test@example.com" { t.Errorf("email sent to wrong address: %s", mockEmail.SentEmails[0].To) } // Test with email failure mockEmail.SendFunc = func(to, subject, body string) error { return errors.New("SMTP error") } err = service.RegisterUser("test2@example.com", "password123") if err != nil { t.Error("registration should succeed even if email fails") } }
๐ Test Coverage
Test coverage measures how much of your code is executed during tests. While 100% coverage doesn't guarantee bug-free code, it helps identify untested paths.
# Run tests with coverage $ go test -cover PASS coverage: 78.5% of statements # Generate coverage profile $ go test -coverprofile=coverage.out # View coverage in terminal $ go tool cover -func=coverage.out math.go:5: Add 100.0% math.go:9: Subtract 100.0% math.go:13: Multiply 100.0% math.go:17: Divide 85.7% total: (statements) 92.9% # Generate HTML report $ go tool cover -html=coverage.out -o coverage.html # Coverage with specific packages $ go test -coverpkg=./... ./... # Coverage modes $ go test -covermode=count # Shows execution count $ go test -covermode=set # Shows covered/not covered $ go test -covermode=atomic # Thread-safe count
Example Tests for Documentation
// Example tests serve as documentation and are verified func ExampleAdd() { result := Add(2, 3) fmt.Println(result) // Output: 5 } func ExampleCalculator_Calculate() { calc := NewCalculator() result, err := calc.Calculate("2 + 3 * 4") if err != nil { log.Fatal(err) } fmt.Printf("Result: %.2f\n", result) // Output: Result: 14.00 } // Example with unordered output func ExamplePermutations() { perms := Permutations([]int{1, 2}) for _, p := range perms { fmt.Println(p) } // Unordered output: // [1 2] // [2 1] }
๐๏ธ Test Organization and Patterns
Test Helpers and Utilities
// Test helper functions func assertEqual(t testing.TB, got, want interface{}) { t.Helper() // Mark as helper for better error reporting if got != want { t.Errorf("got %v, want %v", got, want) } } func assertNoError(t testing.TB, err error) { t.Helper() if err != nil { t.Fatalf("unexpected error: %v", err) } } func assertError(t testing.TB, err error) { t.Helper() if err == nil { t.Fatal("expected error but got nil") } } // Test fixtures func setupTestDB(t testing.TB) (*sql.DB, func()) { t.Helper() db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatal(err) } // Run migrations if err := migrate(db); err != nil { t.Fatal(err) } // Return cleanup function cleanup := func() { db.Close() } return db, cleanup } // Parallel tests func TestParallelOperations(t *testing.T) { t.Parallel() // Run in parallel with other parallel tests tests := []struct{ name string input int }{ {"test1", 1}, {"test2", 2}, {"test3", 3}, } for _, tc := range tests { tc := tc // Capture range variable t.Run(tc.name, func(t *testing.T) { t.Parallel() // Test logic here }) } } // Integration tests with build tags //go:build integration // +build integration package myapp_test func TestDatabaseIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } // Integration test logic }
โก Testing Best Practices
Testing Guidelines
- Test behavior, not implementation: Focus on what, not how
- Use table-driven tests: Reduce duplication, improve readability
- Keep tests independent: Tests shouldn't depend on each other
- Use descriptive names: Test names should explain what they test
- Test edge cases: Empty, nil, negative, overflow conditions
- Mock external dependencies: Keep tests fast and deterministic
- Aim for high coverage: But don't obsess over 100%
- Run tests frequently: Integrate into CI/CD pipeline
Common Testing Pitfalls
- Testing implementation details: Makes refactoring difficult
- Ignoring error cases: Most bugs hide in error paths
- Global state in tests: Causes flaky, order-dependent tests
- Not using t.Helper(): Makes error messages less useful
- Overly complex mocks: Keep mocks simple and focused
- Ignoring race conditions: Use
go test -race
- Not cleaning up resources: Use defer for cleanup
Testing Commands
Command | Purpose |
---|---|
go test |
Run tests in current package |
go test ./... |
Run all tests recursively |
go test -v |
Verbose output |
go test -run TestName |
Run specific test |
go test -short |
Skip long tests |
go test -race |
Detect race conditions |
go test -timeout 30s |
Set test timeout |
go test -count=1 |
Disable test caching |
๐๏ธ Practice Exercises
Challenge 1: Calculator Test Suite
Create comprehensive tests for a calculator package:
- Unit tests for basic operations
- Table-driven tests for multiple scenarios
- Error handling for invalid inputs
- Benchmark different algorithms
Challenge 2: HTTP API Testing
Test a REST API with:
- httptest for server mocking
- Test different HTTP methods
- Validate response codes and bodies
- Mock external service calls
Challenge 3: Database Testing
Implement database tests with:
- In-memory test database
- Transaction rollback for isolation
- Test data fixtures
- Integration test suite
Challenge 4: Performance Optimization
Use benchmarks to optimize code:
- Write benchmarks for critical paths
- Compare algorithm implementations
- Profile CPU and memory usage
- Optimize based on results