Skip to content
Codeloom
Go

Testing Strategies in Go

Write effective Go tests — table-driven tests, subtests, test helpers, mocking with interfaces, benchmarks, and integration testing patterns.

·4 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Table-driven tests and subtests
  • Test helpers and cleanup with t.Helper and t.Cleanup
  • Mocking with interfaces
  • Benchmarks and integration test patterns

Prerequisites

  • Go basics (functions, structs, interfaces)
  • Familiarity with the testing package

Go ships with a powerful testing framework in the standard library. No external dependencies needed. This guide covers the patterns that make Go tests readable, fast, and maintainable.

Table-driven tests

The most idiomatic Go testing pattern. Define test cases as a slice of structs and loop over them.

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -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)
            }
        })
    }
}

t.Run creates subtests. Each runs independently, has its own name, and can be filtered with -run.

go test -run TestAdd/negative

Test helpers

Mark helper functions with t.Helper() so failures report the caller’s line, not the helper’s.

func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

Setup and cleanup

func TestDatabase(t *testing.T) {
    db := setupTestDB(t)
    t.Cleanup(func() {
        db.Close()
    })

    // tests use db...
}

t.Cleanup runs after the test (and all its subtests) finish, even if the test fails.

Mocking with interfaces

Go does not need a mocking framework. Define a small interface and provide a test implementation.

type EmailSender interface {
    Send(to, subject, body string) error
}

type mockSender struct {
    calls []struct{ to, subject, body string }
}

func (m *mockSender) Send(to, subject, body string) error {
    m.calls = append(m.calls, struct{ to, subject, body string }{to, subject, body})
    return nil
}

func TestNotify(t *testing.T) {
    sender := &mockSender{}
    svc := NewService(sender)

    svc.NotifyUser("alice@example.com", "Welcome!")

    if len(sender.calls) != 1 {
        t.Fatalf("expected 1 email, got %d", len(sender.calls))
    }
    if sender.calls[0].to != "alice@example.com" {
        t.Errorf("sent to %q", sender.calls[0].to)
    }
}

Benchmarks

func BenchmarkFibonacci(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fibonacci(20)
    }
}

Run with go test -bench=.. The framework adjusts b.N to get stable timing.

BenchmarkFibonacci-8    32456    36842 ns/op

Benchmark with setup

func BenchmarkSort(b *testing.B) {
    data := generateRandomSlice(10000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sorted := make([]int, len(data))
        copy(sorted, data)
        sort.Ints(sorted)
    }
}

TestMain

Control setup and teardown for an entire test file.

func TestMain(m *testing.M) {
    setup()
    code := m.Run()
    teardown()
    os.Exit(code)
}

Integration tests with build tags

Separate slow integration tests with build tags.

//go:build integration

package myapp

func TestAPIIntegration(t *testing.T) {
    // hits real API
}

Run only integration tests:

go test -tags=integration ./...

Golden files

Compare output against a known-good file.

func TestRender(t *testing.T) {
    got := Render(input)

    golden := filepath.Join("testdata", t.Name()+".golden")

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

    want, _ := os.ReadFile(golden)
    if got != string(want) {
        t.Errorf("output mismatch — run with -update to regenerate")
    }
}

Parallel tests

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"a"}, {"b"}, {"c"}}

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // test runs concurrently with other parallel subtests
        })
    }
}

Summary

Go testing is simple by design. Table-driven tests give you coverage and readability. Interfaces give you mocking without frameworks. Benchmarks are built in. Build tags separate fast unit tests from slow integration tests. The standard library is all you need.