Testing in Go: Unit Tests, Benchmarks, Mocks

Testing in Go: Unit Tests, Benchmarks, Mocks

Testing in Go: unit tests, table-driven tests, benchmarks, and mocks. Practical examples and patterns. A complete guide for Golang developers.

Testing in Go isn’t the pain it is in some other languages. Everything is built into the standard library, no external frameworks needed. go test and you’re off.

But there are nuances. Table-driven tests, subtests, benchmarks, mocks through interfaces — if you don’t know these patterns, you’ll end up writing tests like it’s Java circa 2005. Let’s go through it all.

The basics: your first test

In Go, tests live next to the code in *_test.go files. A test function name starts with Test and takes a *testing.T.

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

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

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

Running it:

go test              # run tests in the current package
go test -v           # verbose output
go test ./...        # all packages recursively
go test -run TestAdd # only tests with "TestAdd" in the name

t.Error vs t.Fatal

  • t.Error() / t.Errorf() — marks the test as failed but keeps running
  • t.Fatal() / t.Fatalf() — marks the test as failed and stops immediately

Use t.Fatal when continuing the test doesn’t make sense anymore (for example, when you failed to set up test data).

Table-driven tests: pattern #1 in Go

Writing a separate function for every case is the Java way. In Go, the convention is table-driven tests.

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

    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; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Advantages:

  • One test function covers a lot of cases
  • Adding a new case is just a new row in the table
  • t.Run creates a named subtest, so you can see exactly which case failed
  • You can run a specific subtest: go test -run TestAdd/positive

Subtests for grouping

Subtests can be nested for logical grouping:

func TestDivide(t *testing.T) {
    t.Run("valid cases", func(t *testing.T) {
        tests := []struct {
            a, b, expected int
        }{
            {10, 2, 5},
            {9, 3, 3},
            {100, 10, 10},
        }
        for _, tt := range tests {
            result, err := Divide(tt.a, tt.b)
            if err != nil {
                t.Errorf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        }
    })

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

Testing HTTP handlers

For HTTP testing, use httptest — another gift from the standard library.

// handlers.go
package api

import (
    "encoding/json"
    "net/http"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    user := User{ID: 1, Name: "John"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}
// handlers_test.go
package api

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

func TestGetUserHandler(t *testing.T) {
    // Create a fake request
    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    
    // Create a ResponseRecorder to capture the response
    rr := httptest.NewRecorder()

    // Call the handler
    GetUserHandler(rr, req)

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

    // Check the Content-Type
    contentType := rr.Header().Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; want application/json", contentType)
    }

    // Check the response body
    var user User
    if err := json.Unmarshal(rr.Body.Bytes(), &user); err != nil {
        t.Fatalf("failed to parse response: %v", err)
    }

    if user.ID != 1 || user.Name != "John" {
        t.Errorf("user = %+v; want {ID:1 Name:John}", user)
    }
}

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rr := httptest.NewRecorder()

    HealthHandler(rr, req)

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

    if rr.Body.String() != "OK" {
        t.Errorf("body = %s; want OK", rr.Body.String())
    }
}

Testing with a router

If you’re using a router (chi, gorilla/mux, gin), test it through httptest.Server:

func TestAPIRoutes(t *testing.T) {
    // Create a router with handlers
    mux := http.NewServeMux()
    mux.HandleFunc("/user", GetUserHandler)
    mux.HandleFunc("/health", HealthHandler)

    // Create a test server
    server := httptest.NewServer(mux)
    defer server.Close()

    // Make a real HTTP request
    resp, err := http.Get(server.URL + "/health")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
    }
}

Mocking through interfaces

Go doesn’t need mocking frameworks like Mockito. Use interfaces — it’s idiomatic and simple.

// repository.go
package user

type User struct {
    ID    int
    Name  string
    Email string
}

// Interface for the repository
type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(user *User) error
}

// The service depends on the interface, not on a concrete implementation
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.GetByID(id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    return user, nil
}

func (s *UserService) CreateUser(name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(user); err != nil {
        return nil, fmt.Errorf("failed to save user: %w", err)
    }
    return user, nil
}
// repository_test.go
package user

import (
    "errors"
    "testing"
)

// Mock repository — just a struct implementing the interface
type mockUserRepository struct {
    users map[int]*User
    err   error // for simulating errors
}

func newMockRepo() *mockUserRepository {
    return &mockUserRepository{
        users: make(map[int]*User),
    }
}

func (m *mockUserRepository) GetByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    user, ok := m.users[id]
    if !ok {
        return nil, errors.New("user not found")
    }
    return user, nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    // Simulate auto-increment ID
    user.ID = len(m.users) + 1
    m.users[user.ID] = user
    return nil
}

// Tests using the mock
func TestUserService_GetUser(t *testing.T) {
    repo := newMockRepo()
    repo.users[1] = &User{ID: 1, Name: "John", Email: "john@example.com"}
    
    service := NewUserService(repo)

    user, err := service.GetUser(1)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if user.Name != "John" {
        t.Errorf("user.Name = %s; want John", user.Name)
    }
}

func TestUserService_GetUser_NotFound(t *testing.T) {
    repo := newMockRepo()
    service := NewUserService(repo)

    _, err := service.GetUser(999)
    if err == nil {
        t.Error("expected error for non-existent user")
    }
}

func TestUserService_GetUser_RepoError(t *testing.T) {
    repo := newMockRepo()
    repo.err = errors.New("database connection failed")
    
    service := NewUserService(repo)

    _, err := service.GetUser(1)
    if err == nil {
        t.Error("expected error when repo fails")
    }
}

func TestUserService_CreateUser(t *testing.T) {
    repo := newMockRepo()
    service := NewUserService(repo)

    user, err := service.CreateUser("Alice", "alice@example.com")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if user.ID == 0 {
        t.Error("user.ID should be set after save")
    }
    if user.Name != "Alice" {
        t.Errorf("user.Name = %s; want Alice", user.Name)
    }
}

The principle: dependencies are passed through interfaces → tests substitute mocks → no global variables, no magic.

By the way, if you want to see examples of bad code without tests, check out the article on Go anti-patterns . It has code that’s essentially untestable.

Benchmarks: measuring performance

Benchmarks in Go are functions with Benchmark in the name and a *testing.B parameter.

// benchmark_test.go
package math

import "testing"

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

func BenchmarkDivide(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Divide(100, 5)
    }
}

Running them:

go test -bench=.                    # all benchmarks
go test -bench=BenchmarkAdd         # a specific benchmark
go test -bench=. -benchmem          # + memory stats
go test -bench=. -benchtime=5s      # 5 seconds per benchmark
go test -bench=. -count=5           # 5 runs for statistics

Output:

BenchmarkAdd-8          1000000000      0.2900 ns/op    0 B/op    0 allocs/op
BenchmarkDivide-8       847282562       1.418 ns/op     0 B/op    0 allocs/op
  • BenchmarkAdd-8 — the name and the number of cores (GOMAXPROCS)
  • 1000000000 — how many times the loop ran (b.N)
  • 0.2900 ns/op — nanoseconds per operation
  • 0 B/op — bytes of memory per operation
  • 0 allocs/op — allocations per operation

Comparing implementations

Benchmarks are great for comparing different implementations:

// String concatenation: three approaches
func ConcatPlus(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s
    }
    return result
}

func ConcatBuilder(strs []string) string {
    var builder strings.Builder
    for _, s := range strs {
        builder.WriteString(s)
    }
    return builder.String()
}

func ConcatJoin(strs []string) string {
    return strings.Join(strs, "")
}
// benchmark_test.go
var testStrings = []string{"hello", "world", "foo", "bar", "baz"}

func BenchmarkConcatPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatPlus(testStrings)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatBuilder(testStrings)
    }
}

func BenchmarkConcatJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatJoin(testStrings)
    }
}
BenchmarkConcatPlus-8       5765996     207.3 ns/op    56 B/op    4 allocs/op
BenchmarkConcatBuilder-8   12491854      96.01 ns/op   64 B/op    2 allocs/op
BenchmarkConcatJoin-8      21663990      55.36 ns/op   32 B/op    1 allocs/op

You can see that strings.Join is 4 times faster than naive concatenation with +.

Benchmarks with varying input sizes

func BenchmarkConcatJoin(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}
    
    for _, size := range sizes {
        strs := make([]string, size)
        for i := range strs {
            strs[i] = "x"
        }
        
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                ConcatJoin(strs)
            }
        })
    }
}
BenchmarkConcatJoin/size=10-8       15462980      77.54 ns/op
BenchmarkConcatJoin/size=100-8       2994873     400.7 ns/op
BenchmarkConcatJoin/size=1000-8       364911    3287 ns/op
BenchmarkConcatJoin/size=10000-8       38913   30823 ns/op

For more on performance optimization and when to use channels vs. mutexes, see the article on concurrency in Go .

Code coverage

go test -cover                           # coverage percentage
go test -coverprofile=coverage.out       # save to a file
go tool cover -html=coverage.out         # open in a browser
go tool cover -func=coverage.out         # coverage by function

-func output:

math.go:5:    Add         100.0%
math.go:9:    Divide      100.0%
total:        (statements) 100.0%

Coverage in CI

# .gitlab-ci.yml
test:
  script:
    - go test -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | grep total
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

TestMain: setup and teardown

If you need global setup/teardown for all tests in a package:

func TestMain(m *testing.M) {
    // Setup
    fmt.Println("Setting up tests...")
    db := setupTestDB()

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

    // Teardown
    fmt.Println("Cleaning up...")
    db.Close()

    os.Exit(code)
}

Testing with temporary files

func TestFileProcessing(t *testing.T) {
    // t.TempDir() is automatically removed after the test
    tmpDir := t.TempDir()
    
    filePath := filepath.Join(tmpDir, "test.txt")
    if err := os.WriteFile(filePath, []byte("test content"), 0644); err != nil {
        t.Fatal(err)
    }

    // Test the function that works with the file
    result, err := ProcessFile(filePath)
    if err != nil {
        t.Fatalf("ProcessFile failed: %v", err)
    }

    // Check the result
    if result != "expected" {
        t.Errorf("result = %s; want expected", result)
    }
}

Parallel tests

func TestParallel(t *testing.T) {
    tests := []struct {
        name  string
        input int
    }{
        {"case1", 1},
        {"case2", 2},
        {"case3", 3},
    }

    for _, tt := range tests {
        tt := tt // important! capture the loop variable for the goroutine
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // this subtest can run in parallel
            
            // ... test logic
            time.Sleep(100 * time.Millisecond)
        })
    }
}

Important: tt := tt is a Go quirk. Without it, every goroutine would use the last value of tt from the loop.

Running with controlled parallelism:

go test -parallel 4  # at most 4 parallel tests

Useful practices

1. Naming tests

// Good: it's clear what's being tested and which case
func TestUserService_GetUser_ReturnsUser(t *testing.T) {}
func TestUserService_GetUser_NotFound(t *testing.T) {}
func TestUserService_GetUser_DatabaseError(t *testing.T) {}

// Bad: unclear what's being tested
func TestGetUser(t *testing.T) {}
func TestGetUser2(t *testing.T) {}
func TestError(t *testing.T) {}

2. Use testify for assertions (optional)

import "github.com/stretchr/testify/assert"

func TestAdd(t *testing.T) {
    assert.Equal(t, 5, Add(2, 3))
    assert.NotNil(t, someValue)
    assert.Contains(t, "hello world", "world")
}

3. Golden files for complex outputs

func TestGenerateReport(t *testing.T) {
    result := GenerateReport(testData)
    
    goldenFile := "testdata/report.golden"
    
    if *update {
        os.WriteFile(goldenFile, []byte(result), 0644)
    }
    
    expected, _ := os.ReadFile(goldenFile)
    if result != string(expected) {
        t.Errorf("result differs from golden file")
    }
}

4. Test the public API, not internals

// Good: testing behavior
func TestCalculator_Add(t *testing.T) {
    calc := NewCalculator()
    result := calc.Add(2, 3)
    assert.Equal(t, 5, result)
}

// Bad: testing private methods
func TestCalculator_internalAdd(t *testing.T) {
    // This test will break with any refactoring
}

Conclusion

Testing in Go is simple:

  • *_test.go files, Test* functions, go test
  • Table-driven tests for handling many cases
  • Mocks through interfaces, no frameworks
  • Benchmarks for measuring performance
  • Coverage out of the box

The core principles:

  1. Test behavior, not implementation
  2. Use table-driven tests
  3. Mock through interfaces
  4. Benchmark before optimizing

If you want to automate writing tests, Cursor AI does a decent job of generating table-driven tests from an example.


Have questions about testing in Go? Leave a comment — happy to dig into specific cases.