Skip to main content

Testing in Go: From Unit Tests to Mocks and Benchmarks

·8 mins
Table of Contents
Go’s testing philosophy is stdlib-first and table-driven. You rarely need an external framework. The testing package, combined with interfaces for dependency injection and httptest for HTTP, covers almost everything you will encounter in production codebases.

A lot of Go developers coming from Python or Java reach for testify or gomock before they need to. Go’s standard library is remarkably complete for testing. This post walks through the patterns that experienced Go engineers actually use: table-driven tests with subtests, interface-based mocks, HTTP handler testing, benchmarks, and coverage analysis.

Package Structure
#

Start with a concrete package so the examples compile. Here is a small calc package:

calc/calc.go
package calc

// Sum returns the sum of a and b.
func Sum(a, b int) int {
    return a + b
}

// Divide returns a/b. It returns an error if b is zero.
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

And the corresponding test file:

calc/calc_test.go
package calc_test

import (
    "testing"
    "github.com/yourorg/calc"
)
Note

Using package calc_test (the external test package) rather than package calc forces you to test only the exported API. This is the idiomatic Go style and catches accidental reliance on unexported internals.

Table-Driven Tests with t.Run
#

Table-driven tests are the standard Go pattern. Instead of one test function per input, you define a slice of test cases and range over them. Each case runs as a named subtest via t.Run, which means failures are reported with a clear name and you can run a single subtest with -run.

calc/calc_test.go
func TestSum(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"both positive", 2, 3, 5},
        {"negative plus positive", -1, 4, 3},
        {"both negative", -2, -3, -5},
        {"zero identity", 0, 7, 7},
    }

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

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal division", 10, 2, 5, false},
        {"decimal result", 7, 2, 3.5, false},
        {"divide by zero", 5, 0, 0, true},
    }

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

Run a specific subtest: go test -run TestDivide/divide_by_zero ./calc/

Tip

Use t.Fatal (not t.Error) when an error makes the rest of the subtest meaningless. t.Error marks the test as failed but continues execution. t.Fatal stops the subtest immediately.

Interfaces and Mocks
#

The most important testing technique in Go is defining dependencies as interfaces and injecting fakes. No code generation required.

Define the interface at the consumer, not the producer:

store/service.go
package store

import "context"

// Store is the interface our service depends on.
// It lives in the consumer package, not the database package.
type Store interface {
    GetUser(ctx context.Context, id int64) (*User, error)
    CreateUser(ctx context.Context, u *User) error
}

type UserService struct {
    store Store
}

func NewUserService(s Store) *UserService {
    return &UserService{store: s}
}

func (svc *UserService) Register(ctx context.Context, name, email string) (*User, error) {
    u := &User{Name: name, Email: email}
    if err := svc.store.CreateUser(ctx, u); err != nil {
        return nil, fmt.Errorf("creating user: %w", err)
    }
    return u, nil
}

Now write a fake in the test file:

store/service_test.go
package store_test

import (
    "context"
    "testing"
    "github.com/yourorg/store"
)

// FakeStore implements store.Store for testing.
type FakeStore struct {
    users  map[int64]*store.User
    nextID int64
    // CreateErr lets tests inject a specific error.
    CreateErr error
}

func newFakeStore() *FakeStore {
    return &FakeStore{users: make(map[int64]*store.User)}
}

func (f *FakeStore) GetUser(_ context.Context, id int64) (*store.User, error) {
    u, ok := f.users[id]
    if !ok {
        return nil, store.ErrNotFound
    }
    return u, nil
}

func (f *FakeStore) CreateUser(_ context.Context, u *store.User) error {
    if f.CreateErr != nil {
        return f.CreateErr
    }
    f.nextID++
    u.ID = f.nextID
    f.users[f.nextID] = u
    return nil
}

func TestUserService_Register(t *testing.T) {
    t.Run("success", func(t *testing.T) {
        fake := newFakeStore()
        svc := store.NewUserService(fake)

        user, err := svc.Register(context.Background(), "Alice", "alice@example.com")
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("expected name Alice, got %s", user.Name)
        }
        if user.ID == 0 {
            t.Error("expected non-zero ID after creation")
        }
    })

    t.Run("store error propagates", func(t *testing.T) {
        fake := newFakeStore()
        fake.CreateErr = errors.New("db unavailable")
        svc := store.NewUserService(fake)

        _, err := svc.Register(context.Background(), "Bob", "bob@example.com")
        if err == nil {
            t.Fatal("expected error, got nil")
        }
    })
}

This approach requires zero external dependencies and produces fast, deterministic tests. The fake stores state in a map, which is enough for the vast majority of unit test scenarios.

HTTP Handler Testing with httptest
#

Go’s net/http/httptest package lets you test handlers without binding to a port.

api/handler_test.go
package api_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/yourorg/api"
)

func TestGetUserHandler(t *testing.T) {
    tests := []struct {
        name           string
        userID         string
        storeResp      *api.User
        storeErr       error
        expectedStatus int
    }{
        {
            name:           "found",
            userID:         "42",
            storeResp:      &api.User{ID: 42, Name: "Alice"},
            expectedStatus: http.StatusOK,
        },
        {
            name:           "not found",
            userID:         "99",
            storeErr:       api.ErrNotFound,
            expectedStatus: http.StatusNotFound,
        },
        {
            name:           "invalid id",
            userID:         "abc",
            expectedStatus: http.StatusBadRequest,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            fake := &fakeUserStore{user: tc.storeResp, err: tc.storeErr}
            handler := api.NewUserHandler(fake)

            req := httptest.NewRequest(http.MethodGet, "/users/"+tc.userID, nil)
            rec := httptest.NewRecorder()

            handler.ServeHTTP(rec, req)

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

            if tc.expectedStatus == http.StatusOK {
                var got api.User
                if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
                    t.Fatalf("decoding response: %v", err)
                }
                if got.ID != tc.storeResp.ID {
                    t.Errorf("user ID = %d; want %d", got.ID, tc.storeResp.ID)
                }
            }
        })
    }
}

httptest.NewRecorder() captures the response without a real network call. httptest.NewRequest builds a valid *http.Request. The pattern composes naturally with table-driven tests.

Benchmark Functions
#

Benchmarks live in _test.go files with the signature func BenchmarkXxx(b *testing.B). The b.N field is adjusted automatically by the framework until the benchmark runs long enough to measure reliably.

calc/calc_bench_test.go
package calc_test

import (
    "testing"
    "github.com/yourorg/calc"
)

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

// BenchmarkDivide_cached simulates a memoized path
// by measuring repeated calls with the same inputs.
func BenchmarkDivide_repeated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        calc.Divide(1000.0, 3.0) //nolint:errcheck
    }
}

Run benchmarks with: go test -bench=. -benchmem ./calc/

Sample output:

BenchmarkSum-10                1000000000    0.31 ns/op    0 B/op    0 allocs/op
BenchmarkDivide_repeated-10    500000000     2.01 ns/op    0 B/op    0 allocs/op
Tip

Add -benchmem to every benchmark run. Allocations often matter more than raw CPU time for latency-sensitive code. A function that shows 0 allocs/op can usually be inlined by the compiler.

Use benchmarks to compare two implementations. Write both, benchmark both, pick the faster one with evidence:

comparing implementations
func BenchmarkStringBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

func BenchmarkStringConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < 100; j++ {
            s += "x"
        }
        _ = s
    }
}

Coverage
#

# Generate coverage profile
go test -coverprofile=coverage.out ./...

# View per-function coverage in terminal
go tool cover -func=coverage.out

# Open an annotated HTML report in the browser
go tool cover -html=coverage.out

Coverage numbers require context. 80% on a payment processing package is concerning. 80% on a CLI flag parser might be fine. Focus coverage effort on code with high business risk or complex branching logic.

Warning

Coverage measures which lines were executed during tests, not whether the tests actually verify correct behavior. A test that calls a function but makes no assertions will inflate your coverage number without providing any safety net.

Testify vs Stdlib
#

if got != want {
    t.Errorf("Sum() = %d; want %d", got, want)
}

if err == nil {
    t.Fatal("expected error, got nil")
}

if !reflect.DeepEqual(got, want) {
    t.Errorf("got %+v; want %+v", got, want)
}

No import overhead. Error messages are explicit. Works everywhere.

assert.Equal(t, want, got)
assert.NoError(t, err)
assert.ErrorIs(t, err, ErrNotFound)
require.NotNil(t, user)  // stops test on failure like t.Fatal

Cleaner for deep equality checks, error chain assertions, and test suites. The require package is a drop-in for t.Fatal-style semantics.

The rule: start with stdlib. Add testify when the assertion code starts drowning out the test logic.

Common Mistakes
#

Testing implementation details instead of behavior
Tests that assert on private state or the exact sequence of internal calls are brittle. They break when you refactor even if the external behavior is unchanged. Test what a function returns and what effects it produces, not how it does the work internally.
Not using table-driven tests
Writing one test function per input case leads to copy-paste errors, inconsistent naming, and tests that are hard to extend. Table-driven tests with t.Run keep test cases uniform, make failures easy to identify by name, and make it trivial to add new cases.
Mocking everything
Mocking a strings.Builder or a simple utility function adds noise without benefit. Only mock things that have external effects (database, HTTP, filesystem, time) or that are slow. If a dependency is fast, pure, and has no side effects, just use the real implementation in tests.
Using reflect.DeepEqual for primitive comparisons
reflect.DeepEqual(5, 5) works but is unnecessarily heavyweight for integer or string comparisons. Use == directly. Reserve reflect.DeepEqual or cmp.Diff for structs and slices where field-by-field comparison matters.
Global test state
Tests that share mutable global state are order-dependent. They pass in isolation and fail in suites. Each test should set up its own state in the test function or t.Cleanup, and tear it down after. Use t.Parallel() to surface races early.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related