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. httptestprovides 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 -covermeasures test coverage. Use it as a signal, not a target.- Benchmarks use
testing.Band theb.Nloop pattern.