๐Ÿงช Testing in Go

Master Go's built-in testing framework with comprehensive coverage, benchmarks, and test-driven development practices

๐Ÿ“š 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

E2E Tests Few, Slow, Expensive Integration Tests Some, Medium Speed Unit Tests Many, Fast, Isolated

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