๐ 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 |
_test.go files. Use table-driven tests for comprehensive coverage with minimal code.
โ 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") |
$ go test -v ./... === RUN TestAdd --- PASS: TestAdd (0.00s) === RUN TestAdd_Negative --- PASS: TestAdd_Negative (0.00s) PASS ok mypackage 0.003s
Common Mistake
Wrong: func TestSomething(t *testing.T) { if result != expected { t.Error("failed") } }
Why it fails: The error message "failed" provides no context about what was tested, what was expected, or what was received.
Instead: t.Errorf("Add(2, 3) = %d, want %d", got, want) -- include the function call, actual result, and expected result.
๐ฏ 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=.-benchmemfor memory stats-benchtime=10sfor duration-cpuprofilefor profiling-count=5for 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
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numbers
=== RUN TestAdd/zero
=== RUN TestAdd/mixed
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive_numbers (0.00s)
--- PASS: TestAdd/negative_numbers (0.00s)
--- PASS: TestAdd/zero (0.00s)
--- PASS: TestAdd/mixed (0.00s)
Deep Dive: t.Parallel() and Test Isolation
Calling t.Parallel() marks a test or subtest to run concurrently with other parallel tests. This speeds up test suites but requires tests to be independent -- no shared mutable state. In table-driven tests, always capture the loop variable: tc := tc before calling t.Run with t.Parallel(). Without this, all subtests share the same loop variable and test the last table entry repeatedly.
๐ญ 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, "[email protected]", []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("[email protected]", "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 != "[email protected]" { 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("[email protected]", "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 }
type UserStore interface { GetUser(id string) (*User, error) }. Your test can provide a fake implementation without importing the real database package.
โก 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