Testing

Go has a built-in testing framework. No external library needed. Write a _test.go file, run go test, done.

Basic Tests

// math.go
package math

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

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d, want %d", got, want)
    }
}
go test ./...         # run all tests
go test -v ./...      # verbose output
go test -run TestAdd  # run specific test

Table-Driven Tests

The standard Go testing pattern. Define inputs and expected outputs in a slice, loop over them with subtests:

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

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

Run a single subtest:

go test -run TestAdd/negative

Tip: Name your test cases with short descriptive strings. They show up in failure output and make debugging easier.

Test Helpers

Extract common setup into helper functions. Use t.Helper() so failures report the caller’s line number:

func setupTestDB(t *testing.T) *sql.DB {
    t.Helper()
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal(err)
    }
    _, err = db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { db.Close() })
    return db
}

func TestCreateUser(t *testing.T) {
    db := setupTestDB(t)
    // test using db...
}

t.Cleanup registers a function to run after the test finishes (even if it fails). Use it instead of defer for test resources.

Test Fixtures

Read test data from a testdata/ directory (Go tooling ignores this directory during builds):

func TestParseConfig(t *testing.T) {
    data, err := os.ReadFile("testdata/valid_config.json")
    if err != nil {
        t.Fatal(err)
    }

    cfg, err := ParseConfig(data)
    if err != nil {
        t.Fatal(err)
    }
    if cfg.Port != 8080 {
        t.Errorf("port = %d, want 8080", cfg.Port)
    }
}

For golden file testing (compare output against saved expected output):

func TestRender(t *testing.T) {
    got := Render(input)
    golden := filepath.Join("testdata", t.Name()+".golden")

    if *update {
        os.WriteFile(golden, []byte(got), 0644)
        return
    }

    want, err := os.ReadFile(golden)
    if err != nil {
        t.Fatal(err)
    }
    if got != string(want) {
        t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want)
    }
}

Benchmarks

Benchmark functions start with Benchmark and take *testing.B:

func BenchmarkAdd(b *testing.B) {
    for b.Loop() {
        Add(2, 3)
    }
}

func BenchmarkConcatStrings(b *testing.B) {
    strs := []string{"hello", "world", "foo", "bar"}
    for b.Loop() {
        result := ""
        for _, s := range strs {
            result += s
        }
    }
}

func BenchmarkJoinStrings(b *testing.B) {
    strs := []string{"hello", "world", "foo", "bar"}
    for b.Loop() {
        strings.Join(strs, "")
    }
}
go test -bench=. -benchmem
# BenchmarkConcatStrings-8    5000000    312 ns/op    64 B/op    3 allocs/op
# BenchmarkJoinStrings-8     20000000     89 ns/op    32 B/op    1 allocs/op

Tip: Since Go 1.24, use b.Loop() instead of for i := 0; i < b.N; i++. The compiler cannot optimize away work inside b.Loop(), so benchmarks are more accurate without needing //go:noinline hacks.

Fuzzing (Go 1.18+)

Fuzz testing automatically generates inputs to find edge cases:

func FuzzParseURL(f *testing.F) {
    // Seed corpus - provide example inputs
    f.Add("https://example.com")
    f.Add("http://localhost:8080/path?q=1")
    f.Add("")

    f.Fuzz(func(t *testing.T, input string) {
        u, err := ParseURL(input)
        if err != nil {
            return // invalid input is fine
        }
        // Round-trip: parsed URL should produce the same string
        if u.String() != input {
            t.Errorf("round-trip failed: %q -> %q", input, u.String())
        }
    })
}
go test -fuzz=FuzzParseURL -fuzztime=30s

Failing inputs are saved to testdata/fuzz/ and replayed as regular tests on subsequent runs.

Test Coverage

go test -cover ./...
# ok  myapp/internal/server  0.003s  coverage: 82.3% of statements

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

# See which functions lack coverage
go tool cover -func=coverage.out

Integration Testing with Build Tags

Separate slow/integration tests from unit tests:

//go:build integration

package store

import "testing"

func TestDatabaseIntegration(t *testing.T) {
    db := connectToRealDB()
    defer db.Close()
    // ...
}
go test ./...                            # skips integration tests
go test -tags=integration ./...          # includes integration tests

Or use short mode:

func TestSlow(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping in short mode")
    }
    // long-running test...
}
go test -short ./...  # skips slow tests

Mocking with Interfaces

No mocking framework needed. Define an interface, implement a test double:

// Production code defines the interface where it is used
type UserStore interface {
    GetUser(ctx context.Context, id string) (*User, error)
    SaveUser(ctx context.Context, u *User) error
}

type Service struct {
    store UserStore
}

// Test double
type mockStore struct {
    users map[string]*User
    err   error
}

func (m *mockStore) GetUser(_ context.Context, id string) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    u, ok := m.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return u, nil
}

func (m *mockStore) SaveUser(_ context.Context, u *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[u.ID] = u
    return nil
}

func TestServiceGetUser(t *testing.T) {
    store := &mockStore{
        users: map[string]*User{
            "1": {ID: "1", Name: "Alice"},
        },
    }
    svc := &Service{store: store}

    user, err := svc.GetUser(context.Background(), "1")
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %q, want Alice", user.Name)
    }
}

func TestServiceGetUser_NotFound(t *testing.T) {
    store := &mockStore{users: map[string]*User{}}
    svc := &Service{store: store}

    _, err := svc.GetUser(context.Background(), "999")
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("err = %v, want ErrNotFound", err)
    }
}

Gotcha: If you find yourself mocking everything, your design might be too coupled. Mocks should replace external dependencies (databases, APIs), not internal logic.

HTTP Testing

Test HTTP handlers without starting a real server:

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

    healthHandler(w, req)

    if w.Code != 200 {
        t.Errorf("status = %d, want 200", w.Code)
    }
    if w.Body.String() != "ok" {
        t.Errorf("body = %q, want ok", w.Body.String())
    }
}

For full integration tests with middleware:

func TestAPI(t *testing.T) {
    srv := httptest.NewServer(setupRouter())
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/health")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        t.Errorf("status = %d, want 200", resp.StatusCode)
    }
}

Next: Error Handling | API Server