3 min read
On this page

Test Patterns

Beyond basic unit tests, Go has well-established patterns for testing HTTP handlers, mocking dependencies, comparing output against golden files, and measuring test coverage. The standard library provides most of what you need — no testing framework required.

Test Fixtures & testdata/

The testdata/ directory is special in Go. The build tool ignores it, so you can store test fixtures there without affecting your build.

parser/
  parser.go
  parser_test.go
  testdata/
    valid_input.json
    invalid_input.json
    expected_output.txt

Load fixtures in your tests using relative paths from the package directory.

func TestParseConfig(t *testing.T) {
    data, err := os.ReadFile("testdata/valid_input.json")
    if err != nil {
        t.Fatalf("failed to read test fixture: %v", err)
    }

    cfg, err := ParseConfig(data)
    if err != nil {
        t.Fatalf("ParseConfig failed: %v", err)
    }

    if cfg.Port != 8080 {
        t.Errorf("Port = %d, want 8080", cfg.Port)
    }
}

httptest for HTTP Testing

The net/http/httptest package lets you test HTTP handlers without starting a real server.

Testing a Handler Directly

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

    HealthHandler(w, req)

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

    body, _ := io.ReadAll(resp.Body)
    if string(body) != `{"status":"ok"}` {
        t.Errorf("body = %s, want {\"status\":\"ok\"}", body)
    }
}

Testing with a Full Server

For integration-style tests that exercise routing and middleware, use httptest.NewServer.

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

    resp, err := http.Get(srv.URL + "/api/users")
    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)
    }

    var users []User
    if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }

    if len(users) != 3 {
        t.Errorf("got %d users, want 3", len(users))
    }
}

Testing External Service Calls

Use httptest.NewServer to mock external APIs your code calls.

func TestFetchWeather(t *testing.T) {
    mockAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/weather" {
            t.Errorf("unexpected path: %s", r.URL.Path)
        }
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprint(w, `{"temp": 72, "condition": "sunny"}`)
    }))
    defer mockAPI.Close()

    client := NewWeatherClient(mockAPI.URL)
    weather, err := client.Fetch("Portland")
    if err != nil {
        t.Fatalf("Fetch failed: %v", err)
    }
    if weather.Temp != 72 {
        t.Errorf("temp = %d, want 72", weather.Temp)
    }
}

Mock Interfaces

In Go, you mock dependencies by implementing the interface. No mocking framework is required.

// The interface your code depends on
type OrderStore interface {
    Save(order Order) error
    FindByID(id string) (Order, error)
}

// A mock for testing
type mockOrderStore struct {
    orders map[string]Order
    saveErr error
}

func newMockStore() *mockOrderStore {
    return &mockOrderStore{orders: make(map[string]Order)}
}

func (m *mockOrderStore) Save(order Order) error {
    if m.saveErr != nil {
        return m.saveErr
    }
    m.orders[order.ID] = order
    return nil
}

func (m *mockOrderStore) FindByID(id string) (Order, error) {
    order, ok := m.orders[id]
    if !ok {
        return Order{}, fmt.Errorf("order %s not found", id)
    }
    return order, nil
}

Use the mock in tests.

func TestProcessOrder(t *testing.T) {
    store := newMockStore()
    svc := NewOrderService(store)

    err := svc.Process(Order{ID: "abc", Total: 99.99})
    if err != nil {
        t.Fatalf("Process failed: %v", err)
    }

    saved, err := store.FindByID("abc")
    if err != nil {
        t.Fatalf("order not found after processing: %v", err)
    }
    if saved.Total != 99.99 {
        t.Errorf("Total = %f, want 99.99", saved.Total)
    }
}

func TestProcessOrderSaveFailure(t *testing.T) {
    store := newMockStore()
    store.saveErr = fmt.Errorf("database unavailable")

    svc := NewOrderService(store)
    err := svc.Process(Order{ID: "abc"})
    if err == nil {
        t.Error("expected error when store fails")
    }
}

This approach is simple, type-safe, and requires no code generation.

Golden Files for Output Comparison

Golden files store the expected output of a function. Tests compare current output against the golden file. Use the -update flag to regenerate them when the output intentionally changes.

var update = flag.Bool("update", false, "update golden files")

func TestRenderTemplate(t *testing.T) {
    result := RenderTemplate(sampleData)
    goldenFile := "testdata/render_output.golden"

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

    expected, err := os.ReadFile(goldenFile)
    if err != nil {
        t.Fatalf("failed to read golden file: %v", err)
    }

    if result != string(expected) {
        t.Errorf("output does not match golden file.\ngot:\n%s\nwant:\n%s", result, expected)
    }
}
# Run normally — compares against golden files
go test ./...

# Update golden files after intentional changes
go test ./... -update

Integration Tests with Build Tags

Use build tags to separate integration tests (which need external services) from unit tests.

//go:build integration

package store

import (
    "database/sql"
    "testing"
)

func TestPostgresStore(t *testing.T) {
    db, err := sql.Open("postgres", "postgres://localhost/testdb?sslmode=disable")
    if err != nil {
        t.Fatalf("failed to connect: %v", err)
    }
    defer db.Close()

    store := NewPostgresStore(db)
    // ... test against real database
}
# Run only unit tests (default — build tags not matched)
go test ./...

# Run integration tests
go test -tags=integration ./...

Test Coverage

Go has built-in coverage analysis.

# Show coverage percentage
go test -cover ./...

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

# View coverage in the browser
go tool cover -html=coverage.out

# Show coverage by function
go tool cover -func=coverage.out
$ go test -cover ./...
ok  	myproject/user	0.003s	coverage: 87.5% of statements
ok  	myproject/order	0.005s	coverage: 92.1% of statements

Coverage is a useful signal but not a goal in itself. 100% coverage does not mean correct code, and some code (error paths for truly exceptional conditions) is not worth testing.

Benchmarks with testing.B

Benchmark functions measure performance. They start with Benchmark and take a *testing.B parameter.

func BenchmarkFullName(b *testing.B) {
    for i := 0; i < b.N; i++ {
        FullName("Jane", "Doe")
    }
}
$ go test -bench=. -benchmem
BenchmarkFullName-8   15234567   78.3 ns/op   16 B/op   1 allocs/op

The framework runs the loop b.N times, adjusting b.N until the timing is stable. The -benchmem flag reports memory allocations per operation.

Common Pitfalls

  • Testing implementation instead of behavior. Tests that break when you refactor internal details are brittle. Test the public API.
  • Not cleaning up test servers. Always defer srv.Close() after creating a test server. Leaked servers cause port conflicts and goroutine leaks.
  • Shared state between tests. Tests that depend on state from a previous test are fragile and fail when run in isolation or in parallel. Each test should set up its own state.
  • Ignoring error returns in tests. Just because it is test code does not mean you should ignore errors. Unhandled errors hide bugs.
  • Over-mocking. If everything is mocked, your tests do not verify that the pieces work together. Use integration tests for the boundaries.

Key Takeaways

  • Use testdata/ for test fixtures — the build tool ignores this directory.
  • httptest provides recorder and server utilities for testing HTTP code without a real network.
  • Mock interfaces by implementing them directly — no mocking framework needed.
  • Golden files capture expected output and can be updated with a flag.
  • Build tags separate integration tests from unit tests.
  • go test -cover measures test coverage. Use it as a signal, not a target.
  • Benchmarks use testing.B and the b.N loop pattern.