Testing in Go

Difficulty

Table-driven tests are the default way most Go code gets tested, precisely because the language provides no separate parameterized-test mechanism.

The basic shape

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

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

Why this beats one function per case

// The alternative: repetitive, and easy to forget updating consistently
func TestAddPositive(t *testing.T) { ... }
func TestAddNegative(t *testing.T) { ... }
func TestAddZero(t *testing.T) { ... }

Adding a new scenario in the table-driven version means adding one line to the slice literal. In the one-function-per-case version, it means writing (and remembering to write correctly) an entirely new test function, with all the same boilerplate repeated.

Testing error cases in the same table

cases := []struct {
    name    string
    input   string
    want    int
    wantErr bool
}{
    {"valid number", "42", 42, false},
    {"invalid input", "abc", 0, true},
}

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        got, err := ParseNumber(tc.input)
        if (err != nil) != tc.wantErr {
            t.Fatalf("unexpected error state: %v", err)
        }
        if got != tc.want {
            t.Errorf("got %d, want %d", got, tc.want)
        }
    })
}

Including a wantErr field lets the same table cover both success and failure cases, keeping all related scenarios for one function in a single, readable test.

Related Resources

t.Run turns a flat test function into a tree of independently reportable, independently runnable subtests.

Granular failure reporting

func TestValidate(t *testing.T) {
    cases := map[string]struct {
        input string
        valid bool
    }{
        "empty":     {"", false},
        "valid":     {"ok@example.com", true},
        "no-at":     {"nope", false},
    }
    for name, tc := range cases {
        t.Run(name, func(t *testing.T) {
            got := Validate(tc.input)
            if got != tc.valid {
                t.Errorf("Validate(%q) = %v, want %v", tc.input, got, tc.valid)
            }
        })
    }
}
go test -v -run TestValidate
=== RUN   TestValidate/empty
--- PASS: TestValidate/empty
=== RUN   TestValidate/no-at
--- FAIL: TestValidate/no-at
=== RUN   TestValidate/valid
--- PASS: TestValidate/valid

You immediately know no-at is the failing case, without scanning error messages to figure out which table entry broke.

Running a single subtest

go test -run TestValidate/no-at

The / in -run targets a specific named subtest, which is invaluable when iterating on a fix for one failing case without re-running the entire suite every time.

Parallel subtests

for name, tc := range cases {
    tc := tc  // capture for the closure (needed pre-Go 1.22)
    t.Run(name, func(t *testing.T) {
        t.Parallel()   // this subtest runs concurrently with other parallel subtests
        // ...
    })
}

Calling t.Parallel() inside a subtest tells the test runner it can run concurrently with sibling subtests that also called t.Parallel(), which can meaningfully speed up test suites with many independent, non-interfering cases.

Why this matters practically

Subtests are what make table-driven tests genuinely useful at scale. Without t.Run, a single failing case in a 50-entry table just reports "TestFoo failed," and you're left adding print statements to figure out which one.

Related Resources

Benchmarks in Go are just another kind of test function, following a specific naming and signature convention the tooling recognizes automatically.

A basic benchmark

func BenchmarkFibonacci(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fibonacci(20)
    }
}
go test -bench=Fibonacci -benchmem
BenchmarkFibonacci-8    5000000    287 ns/op    0 B/op    0 allocs/op

b.N isn't a number you choose — the framework runs the loop with increasing b.N values until the total run time is long enough to measure reliably, then reports the stable per-iteration cost.

Excluding setup cost with ResetTimer

func BenchmarkProcessLargeFile(b *testing.B) {
    data := loadLargeTestFixture()  // expensive setup, shouldn't count toward the benchmark
    b.ResetTimer()                  // timer restarts here

    for i := 0; i < b.N; i++ {
        Process(data)
    }
}

Without b.ResetTimer(), the one-time cost of loadLargeTestFixture() would be included in the measurement, skewing the result, especially since it only happens once but b.N might be in the millions.

Comparing implementations with sub-benchmarks

func BenchmarkConcat(b *testing.B) {
    b.Run("plus-operator", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = "a" + "b" + "c"
        }
    })
    b.Run("strings-builder", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            sb.WriteString("a")
            sb.WriteString("b")
            sb.WriteString("c")
            _ = sb.String()
        }
    })
}
go test -bench=BenchmarkConcat

This runs both variants under the same parent benchmark, letting you directly compare their ns/op output to see which approach is actually faster for your specific case, rather than assuming.

Why benchmarks aren't run by default

go test          # runs only Test* functions
go test -bench=. # also runs Benchmark* functions matching "."(every benchmark)

Benchmarks are typically slower and noisier to run than unit tests, so they're opt-in via the -bench flag rather than part of every normal go test invocation.

Coverage tooling is built into go test directly, no separate installation needed.

Basic coverage percentage

go test -cover ./...
ok      myapp/pkg/validate    0.003s    coverage: 87.5% of statements

Generating a visual HTML report

go test -coverprofile=cover.out ./...
go tool cover -html=cover.out

This opens a browser view of your source code, with each line colored green (covered) or red (not covered), making it easy to spot exactly which branches your tests never exercise.

A common blind spot coverage reveals

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("divide by zero")  // often the red, uncovered line
    }
    return a / b, nil
}

It's common to write a test for the happy path and forget the error branch entirely. A coverage report makes this specific gap visually obvious in a way that reading the test file alone often doesn't.

Why a high percentage isn't the same as good tests

func TestSomething(t *testing.T) {
    result := DoSomethingComplicated()
    _ = result  // "covers" the function, but asserts NOTHING about correctness
}

This test would show 100% coverage of DoSomethingComplicated's statements, while verifying literally nothing about whether it behaves correctly. Coverage measures execution, not correctness — it's a tool for finding untested code, not a substitute for writing meaningful assertions.

Practical guidance

Use coverage reports to hunt for genuinely untested branches, especially error paths and edge cases. Don't treat a specific percentage — 80%, 90%, 100% — as a goal in itself. A team fixated purely on hitting a coverage number can end up with exactly the kind of assertion-free "coverage theater" shown above.

Related Resources

Go's structural typing means mocking rarely needs a heavyweight framework — a small interface and a hand-written struct are often enough.

Hand-rolled mock, using a consumer-defined interface

// Production code depends on a narrow interface, not a concrete *sql.DB
type UserFinder interface {
    FindUser(id int) (*User, error)
}

func GreetUser(f UserFinder, id int) (string, error) {
    u, err := f.FindUser(id)
    if err != nil {
        return "", err
    }
    return "Hello, " + u.Name, nil
}
// Test code: a fake implementation with canned behavior
type fakeUserFinder struct {
    user *User
    err  error
}
func (f *fakeUserFinder) FindUser(id int) (*User, error) {
    return f.user, f.err
}

func TestGreetUser(t *testing.T) {
    fake := &fakeUserFinder{user: &User{Name: "Alice"}}
    got, err := GreetUser(fake, 1)
    if err != nil || got != "Hello, Alice" {
        t.Errorf("got %q, %v", got, err)
    }
}

No mocking library was needed at all — just a plain struct satisfying the interface.

Generated mocks for larger interfaces

//go:generate mockgen -source=finder.go -destination=mocks/finder_mock.go
func TestGreetUser_WithGeneratedMock(t *testing.T) {
    ctrl := gomock.NewController(t)
    mockFinder := mocks.NewMockUserFinder(ctrl)
    mockFinder.EXPECT().FindUser(1).Return(&User{Name: "Alice"}, nil)

    got, _ := GreetUser(mockFinder, 1)
    if got != "Hello, Alice" {
        t.Errorf("got %q", got)
    }
}

Generated mocks add call expectations, argument matching, and call-count verification, which is worth the extra tooling once an interface is large or a test needs to assert on exactly how a dependency was called, not just what it returns.

Why this design works so well in Go

Because interfaces are satisfied implicitly, defining a small interface purely for testability costs nothing. You don't need to modify the real dependency at all. The interface can live entirely in the consuming package, scoped exactly to what that specific code needs.

Related Resources