Skip to main content

Go Testing: Unit Tests, Table Tests, Benchmarks, and Mocking

Published: December 17, 2025 Updated: August 28, 2026 Larry Qu 8 min read

Testing is built into Go from day one. The go test command, the testing package, and strong conventions around table-driven tests make Go one of the most testable languages. This guide covers everything from writing your first test to benchmarking, mocking, and measuring coverage.

The testing Package Basics

Writing Your First Test

Test files end in _test.go. Test functions start with Test, take *testing.T, and live in the same package:

// math.go
package calc

func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

// math_test.go
package calc

import (
    "testing"
    "fmt"
)

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.0 {
        t.Errorf("Divide(10, 2) = %f; want 5.0", result)
    }
}

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

Running Tests

go test ./...                    # all packages recursively
go test ./calc/...               # specific package tree
go test -v ./...                 # verbose: show all test names
go test -run TestAdd ./...       # run only tests matching regex
go test -run "TestDiv*" ./...    # wildcard matching
go test -count=3 ./...           # run each test 3 times (avoids caching)
go test -timeout 30s ./...       # fail if tests take longer than 30s

Table-Driven Tests

The most important Go testing pattern — one function tests dozens of cases:

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

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

Run a specific sub-test:

go test -run "TestAdd/negative" ./...
go test -run "TestAdd/mixed" ./...

Table Tests for Error Cases

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"normal", 10, 2, 5.0, false},
        {"negative", -10, 2, -5.0, false},
        {"fractions", 1, 3, 1.0 / 3.0, false},
        {"divide by zero", 10, 0, 0, true},
        {"zero numerator", 0, 5, 0, false},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := Divide(tc.a, tc.b)
            if (err != nil) != tc.wantErr {
                t.Fatalf("Divide(%v, %v) error = %v; wantErr %v", tc.a, tc.b, err, tc.wantErr)
            }
            if !tc.wantErr && got != tc.want {
                t.Errorf("Divide(%v, %v) = %v; want %v", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

Test Helpers

The t.Helper() call marks a function as a test helper — errors show the caller’s line, not the helper’s:

func assertEqual[T comparable](t *testing.T, got, want T, msg ...string) {
    t.Helper()
    if got != want {
        prefix := ""
        if len(msg) > 0 { prefix = msg[0] + ": " }
        t.Errorf("%sgot %v; want %v", prefix, got, want)
    }
}

func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

func assertError(t *testing.T, err error, contains string) {
    t.Helper()
    if err == nil {
        t.Fatal("expected error; got nil")
    }
    if !strings.Contains(err.Error(), contains) {
        t.Errorf("error %q does not contain %q", err.Error(), contains)
    }
}

// Usage
func TestSomething(t *testing.T) {
    result, err := doSomething()
    assertNoError(t, err)
    assertEqual(t, result, "expected", "doSomething")
}

Setup and Teardown

TestMain — Package-Level Setup

func TestMain(m *testing.M) {
    // Setup: runs before all tests in this package
    db := setupTestDB()
    testDB = db

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

    // Teardown: runs after all tests
    db.Close()

    os.Exit(code)
}

var testDB *sql.DB

func setupTestDB() *sql.DB {
    db, err := sql.Open("postgres", "postgres://localhost/testdb")
    if err != nil {
        log.Fatalf("failed to open test DB: %v", err)
    }
    // Run migrations
    runMigrations(db)
    return db
}

t.Cleanup — Per-Test Cleanup

func TestWithTempDir(t *testing.T) {
    dir, err := os.MkdirTemp("", "test-*")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { os.RemoveAll(dir) }) // runs after test, even on failure

    // Use dir...
    writeTestFile(t, filepath.Join(dir, "data.txt"), "hello")
}

func TestWithTempDB(t *testing.T) {
    db := createTestDB(t)
    t.Cleanup(func() { db.Close() })

    // Use db...
}

Mocking with Interfaces

Go’s implicit interface system makes mocking straightforward — define an interface, provide a fake implementation in tests:

// Production code: UserStore interface
type UserStore interface {
    GetUser(ctx context.Context, id string) (*User, error)
    CreateUser(ctx context.Context, user *User) error
    DeleteUser(ctx context.Context, id string) error
}

// Service uses the interface
type UserService struct {
    store UserStore
    log   *slog.Logger
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.store.GetUser(ctx, id)
    if err != nil {
        s.log.Error("get user failed", slog.String("id", id), slog.Any("error", err))
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return user, nil
}

// Test: implement the interface with a controllable fake
type mockUserStore struct {
    users map[string]*User
    err   error // injectable error for testing failure paths
}

func (m *mockUserStore) GetUser(_ context.Context, id string) (*User, error) {
    if m.err != nil { return nil, m.err }
    user, ok := m.users[id]
    if !ok { return nil, fmt.Errorf("user %s not found", id) }
    return user, nil
}

func (m *mockUserStore) CreateUser(_ context.Context, user *User) error { return m.err }
func (m *mockUserStore) DeleteUser(_ context.Context, _ string) error   { return m.err }

// Tests
func TestGetUser_Success(t *testing.T) {
    store := &mockUserStore{
        users: map[string]*User{
            "u1": {ID: "u1", Name: "Alice"},
        },
    }
    svc := &UserService{store: store, log: slog.Default()}

    user, err := svc.GetUser(context.Background(), "u1")
    if err != nil { t.Fatalf("unexpected error: %v", err) }
    if user.Name != "Alice" { t.Errorf("got name %s; want Alice", user.Name) }
}

func TestGetUser_NotFound(t *testing.T) {
    store := &mockUserStore{users: map[string]*User{}}
    svc := &UserService{store: store, log: slog.Default()}

    _, err := svc.GetUser(context.Background(), "missing")
    if err == nil { t.Error("expected error for missing user") }
}

func TestGetUser_StoreError(t *testing.T) {
    store := &mockUserStore{err: errors.New("connection refused")}
    svc := &UserService{store: store, log: slog.Default()}

    _, err := svc.GetUser(context.Background(), "u1")
    if err == nil { t.Error("expected error when store fails") }
    if !strings.Contains(err.Error(), "connection refused") {
        t.Errorf("error should wrap store error, got: %v", err)
    }
}

Using testify (Optional)

testify reduces assertion boilerplate:

go get github.com/stretchr/testify
import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    user, err := createUser("[email protected]")

    // require: fail immediately on error (like t.Fatal)
    require.NoError(t, err)
    require.NotNil(t, user)

    // assert: continue running after failure (like t.Error)
    assert.Equal(t, "[email protected]", user.Email)
    assert.NotEmpty(t, user.ID)
    assert.True(t, user.Active)

    // Error assertions
    _, err = createUser("invalid-email")
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "invalid email")
}

Benchmarks

func BenchmarkAdd(b *testing.B) {
    // b.N is set by the framework to run long enough for stable measurement
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

// Benchmark with setup — reset timer to exclude setup cost
func BenchmarkSort(b *testing.B) {
    data := generateData(1000) // setup

    b.ResetTimer() // don't count setup time

    for i := 0; i < b.N; i++ {
        b.StopTimer()
        input := make([]int, len(data))
        copy(input, data) // reset input each run
        b.StartTimer()

        sort.Ints(input)
    }
}

// Sub-benchmarks to test different sizes
func BenchmarkProcess(b *testing.B) {
    sizes := []int{10, 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++ {
                processData(data)
            }
        })
    }
}
go test -bench=. -benchmem -benchtime=5s ./...

# Output:
# BenchmarkAdd-8         1000000000    0.32 ns/op    0 B/op    0 allocs/op
# BenchmarkProcess/size-10-8    5000000    234 ns/op   160 B/op   3 allocs/op
# BenchmarkProcess/size-1000-8    10000   145234 ns/op  16384 B/op  1 allocs/op

Test Coverage

# 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

# Fail if coverage drops below threshold (CI use)
go test -cover ./... | grep -E "coverage: [0-9]+" | awk '{if ($2+0 < 80) exit 1}'

HTTP Handler Testing

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

func TestUserHandler(t *testing.T) {
    handler := &UserHandler{
        store: &mockUserStore{
            users: map[string]*User{"u1": {ID: "u1", Name: "Alice"}},
        },
    }

    tests := []struct {
        name       string
        path       string
        wantStatus int
        wantName   string
    }{
        {"existing user", "/users/u1", 200, "Alice"},
        {"missing user",  "/users/missing", 404, ""},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", tc.path, nil)
            rec := httptest.NewRecorder()

            handler.ServeHTTP(rec, req)

            if rec.Code != tc.wantStatus {
                t.Errorf("status = %d; want %d", rec.Code, tc.wantStatus)
            }

            if tc.wantName != "" {
                var user User
                json.Unmarshal(rec.Body.Bytes(), &user)
                if user.Name != tc.wantName {
                    t.Errorf("name = %s; want %s", user.Name, tc.wantName)
                }
            }
        })
    }
}

Summary

Feature Use when
t.Error / t.Errorf Test should continue after failure
t.Fatal / t.Fatalf Test cannot continue (nil pointer, setup failure)
t.Helper() Utility assertion functions
t.Run Sub-tests, table-driven tests
t.Cleanup Per-test resource cleanup
TestMain Package-level setup/teardown (DB, server)
b.ResetTimer Exclude setup from benchmark
Interface mocks Test in isolation from real DB/network

Keep tests fast (unit tests < 1s total), isolated (no shared mutable state), and deterministic (no time.Now(), random, or network in unit tests).

Resources

Comments

👍 Was this article helpful?