Testing in Go

Go has excellent built-in testing support. No external frameworks needed.

Basic Testing

Test File Naming

  • Test files end with _test.go
  • Test files can be in the same package or package_test (black-box testing)
calculator.go       # Implementation
calculator_test.go  # Tests

Writing Tests

// calculator.go
package calculator

func Add(a, b int) int {
    return a + b
}
// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
    }
}

Running Tests

# Run all tests in current directory
go test

# Run all tests in project
go test ./...

# Verbose output
go test -v

# Run specific test
go test -run TestAdd

# Run tests matching pattern
go test -run "TestAdd|TestSubtract"

# Run tests in specific package
go test ./pkg/calculator

# With coverage
go test -cover

# Generate coverage report
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Testing Package (testing.T)

Common Methods

func TestExample(t *testing.T) {
    // Log message (only shown with -v or on failure)
    t.Log("This is a log message")
    t.Logf("Formatted: %d", 42)

    // Fail test but continue
    t.Error("Test failed")
    t.Errorf("Test failed: %v", err)

    // Fail test and stop immediately
    t.Fatal("Critical failure")
    t.Fatalf("Critical failure: %v", err)

    // Mark test as failed (without message)
    t.Fail()      // Continue running
    t.FailNow()   // Stop immediately

    // Skip test
    t.Skip("Skipping because...")
    t.Skipf("Skipping: %s", reason)

    // Check if failed
    if t.Failed() {
        // ...
    }
}

Table-Driven Tests

The most common Go testing pattern:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed", -1, 5, 4},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; expected %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Benefits of Table-Driven Tests

  • Easy to add new test cases
  • Clear test documentation
  • Subtests can be run individually: go test -run TestAdd/positive

Subtests

Use t.Run() for subtests:

func TestUser(t *testing.T) {
    t.Run("Create", func(t *testing.T) {
        // Test creation
    })

    t.Run("Update", func(t *testing.T) {
        // Test update
    })

    t.Run("Delete", func(t *testing.T) {
        // Test deletion
    })
}

Parallel Subtests

func TestParallel(t *testing.T) {
    tests := []struct {
        name string
        // ...
    }{
        {"test1"},
        {"test2"},
        {"test3"},
    }

    for _, tt := range tests {
        tt := tt  // Capture range variable (not needed in Go 1.22+)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()  // Run in parallel
            // Test code...
        })
    }
}

Setup and Teardown

Per-Test

func TestSomething(t *testing.T) {
    // Setup
    db := setupDatabase()

    // Teardown (using defer)
    defer db.Close()

    // Test...
}

Using t.Cleanup

func TestSomething(t *testing.T) {
    db := setupDatabase()
    t.Cleanup(func() {
        db.Close()  // Runs after test completes
    })

    // Test...
}

TestMain (Package-Level Setup)

func TestMain(m *testing.M) {
    // Setup before all tests
    setup()

    // Run tests
    code := m.Run()

    // Teardown after all tests
    teardown()

    os.Exit(code)
}

func setup() {
    // Initialize test database, etc.
}

func teardown() {
    // Clean up
}

Test Helpers

Helper Functions

func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper()  // Marks this as a helper (better error reporting)
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

func TestAdd(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)  // Error points to this line, not helper
}

Testing Errors

func TestDivide(t *testing.T) {
    t.Run("valid division", func(t *testing.T) {
        result, err := Divide(10, 2)
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if result != 5 {
            t.Errorf("got %f; want 5", result)
        }
    })

    t.Run("division by zero", func(t *testing.T) {
        _, err := Divide(10, 0)
        if err == nil {
            t.Error("expected error for division by zero")
        }
    })
}

Mocking and Interfaces

Use interfaces for testability:

// Production code
type EmailSender interface {
    Send(to, subject, body string) error
}

type UserService struct {
    emailer EmailSender
}

func (s *UserService) Register(email string) error {
    // Create user...
    return s.emailer.Send(email, "Welcome!", "Thanks for joining")
}

// Test code
type MockEmailer struct {
    SendCalled bool
    LastTo     string
}

func (m *MockEmailer) Send(to, subject, body string) error {
    m.SendCalled = true
    m.LastTo = to
    return nil
}

func TestRegister(t *testing.T) {
    mock := &MockEmailer{}
    service := &UserService{emailer: mock}

    err := service.Register("test@example.com")

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !mock.SendCalled {
        t.Error("expected email to be sent")
    }
    if mock.LastTo != "test@example.com" {
        t.Errorf("sent to %s; want test@example.com", mock.LastTo)
    }
}

HTTP Testing

Testing Handlers

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, World!"))
}

func TestHelloHandler(t *testing.T) {
    // Create request
    req := httptest.NewRequest("GET", "/hello", nil)

    // Create response recorder
    rr := httptest.NewRecorder()

    // Call handler
    HelloHandler(rr, req)

    // Check status code
    if rr.Code != http.StatusOK {
        t.Errorf("status = %d; want %d", rr.Code, http.StatusOK)
    }

    // Check body
    if rr.Body.String() != "Hello, World!" {
        t.Errorf("body = %q; want %q", rr.Body.String(), "Hello, World!")
    }
}

Test Server

func TestAPIClient(t *testing.T) {
    // Create test server
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"status": "ok"}`))
    }))
    defer server.Close()

    // Use server.URL in your client
    client := NewAPIClient(server.URL)
    resp, err := client.GetStatus()

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if resp.Status != "ok" {
        t.Errorf("status = %s; want ok", resp.Status)
    }
}

Benchmarks

Measure performance:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// With setup
func BenchmarkProcess(b *testing.B) {
    data := generateTestData()
    b.ResetTimer()  // Don't count setup time

    for i := 0; i < b.N; i++ {
        Process(data)
    }
}

// With different inputs
func BenchmarkSort(b *testing.B) {
    sizes := []int{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++ {
                Sort(data)
            }
        })
    }
}

Running Benchmarks

# Run all benchmarks
go test -bench=.

# Run specific benchmark
go test -bench=BenchmarkAdd

# With memory stats
go test -bench=. -benchmem

# Multiple iterations for stability
go test -bench=. -count=5

Fuzzing (Go 1.18+)

Automatically generate test inputs:

func FuzzReverse(f *testing.F) {
    // Seed corpus
    f.Add("hello")
    f.Add("world")
    f.Add("")

    f.Fuzz(func(t *testing.T, s string) {
        reversed := Reverse(s)
        doubleReversed := Reverse(reversed)

        if s != doubleReversed {
            t.Errorf("double reverse of %q is %q", s, doubleReversed)
        }
    })
}
# Run fuzz test
go test -fuzz=FuzzReverse

# Limit time
go test -fuzz=FuzzReverse -fuzztime=30s

Examples as Tests

Examples serve as documentation AND tests:

func ExampleAdd() {
    result := Add(2, 3)
    fmt.Println(result)
    // Output: 5
}

func ExampleUser_Name() {
    u := User{FirstName: "John", LastName: "Doe"}
    fmt.Println(u.Name())
    // Output: John Doe
}

// Unordered output
func ExampleShuffle() {
    fmt.Println(Shuffle([]int{1, 2, 3}))
    // Unordered output:
    // 1
    // 2
    // 3
}

Testing Best Practices

Test Naming

// Function: TestXxx
// Method: TestType_Method
// Subtests: descriptive names

func TestUser(t *testing.T) {}
func TestUser_Validate(t *testing.T) {}

t.Run("returns error for empty email", func(t *testing.T) {})

Test Independence

// BAD: tests depend on order
var counter = 0

func TestA(t *testing.T) {
    counter = 5
}

func TestB(t *testing.T) {
    // Depends on TestA running first!
    if counter != 5 { ... }
}

// GOOD: each test sets up its own state
func TestA(t *testing.T) {
    counter := 5
    // ...
}

Golden Files

For complex output comparison:

func TestGenerateReport(t *testing.T) {
    result := GenerateReport(testData)

    golden := filepath.Join("testdata", "report.golden")

    if *update {
        os.WriteFile(golden, result, 0644)
    }

    expected, _ := os.ReadFile(golden)
    if !bytes.Equal(result, expected) {
        t.Errorf("output mismatch")
    }
}

// Run with: go test -update
var update = flag.Bool("update", false, "update golden files")

Test Coverage

# Show 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

# Coverage for specific packages
go test -cover ./pkg/...

Next Steps

Continue to 12-standard-library.md to learn about Go's standard library.