Week 9: Testing, Benchmarking & Table-Driven Tests

Testing isn't a bolted-on framework in Go — go test is part of the toolchain itself, and the language's small-interfaces philosophy from Week 3 makes mocking dependencies unusually natural. This week builds real test coverage for the module structured last week, the idiomatic way.

Module 9 of 16 Week 9 of 16 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a test with the standard testing package and run it with go test
  • Write a table-driven test covering multiple cases without duplicating test logic
  • Mock a dependency using an interface, and measure test coverage
  • Write and interpret a benchmark with testing.B

1. The testing Package Basics

A test is a function named TestXxx, taking a *testing.T, living in a file named xxx_test.gogo test discovers and runs it with zero configuration.

math.go / math_test.go
// math.go
package mathutil

func Add(a, b int) int { return a + b }
math_test.go
package mathutil

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)
    }
}
terminal
go test ./...

t.Errorf records a failure and continues the test function; t.Fatalf records a failure and stops immediately — use the latter once a failed check makes the rest of the test meaningless to continue (a nil pointer you're about to dereference, for instance).

2. Table-Driven Tests

The idiomatic Go pattern for testing several cases of the same behavior is a slice of structs — a "table" — looped over with t.Run for named subtests, rather than a separate TestXxx function per case.

a table-driven test
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        want     int
    }{
        {"two positives", 2, 3, 5},
        {"negative and positive", -1, 1, 0},
        {"both zero", 0, 0, 0},
    }

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

Adding a new case is now a one-line addition to the table, not a new function — and go test -run TestAdd/negative can target a single named subtest directly, which becomes genuinely useful once a table grows to dozens of cases.

3. Mocking with Interfaces & Coverage

Because Go interfaces are satisfied implicitly (Week 3), testing code that depends on an interface is naturally mockable — write a fake implementation for tests, with no mocking framework required.

a mockable dependency
type UserStore interface {
    GetUser(id int) (*User, error)
}

type fakeStore struct {
    users map[int]*User
}

func (f *fakeStore) GetUser(id int) (*User, error) {
    u, ok := f.users[id]
    if !ok {
        return nil, errors.New("not found")
    }
    return u, nil
}

func TestGreetUser(t *testing.T) {
    store := &fakeStore{users: map[int]*User{1: {Name: "Asha"}}}
    got := GreetUser(store, 1) // GreetUser accepts a UserStore, not a concrete type
    want := "Hi, Asha"
    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }
}
terminal — coverage
go test -cover ./...
go tool cover -html=coverage.out # after: go test -coverprofile=coverage.out

This only works because GreetUser was written to accept the UserStore interface rather than a concrete *PostgresStore — a direct payoff of Week 3's advice to depend on small interfaces rather than concrete types wherever a dependency crosses a boundary.

4. Benchmarks with testing.B

a benchmark, in the same _test.go file
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}
terminal
go test -bench=. -benchmem
reading the output
BenchmarkAdd-8   1000000000   0.25 ns/op   0 B/op   0 allocs/op

b.N is chosen automatically by the testing framework, run repeatedly until timing stabilizes — you never set it yourself. The output above reads as: on an 8-core machine, each call took about 0.25 nanoseconds and made zero heap allocations. That last figure — allocations per operation — is often the more actionable number in practice, and is exactly what Week 15's profiling work builds on.

5. Hands-on Exercise

Hands-on

Add real test coverage to last week's restructured module

Bring table-driven tests, a mock, and a benchmark to the project from Week 8's exercise.

Requirements:

  1. A table-driven test covering at least 4 cases (including at least one edge case) for a function in your internal/ package.
  2. An interface for at least one dependency your code has (a data store, a clock, anything not trivial to call directly in a test) and a fake implementation of it used only in tests.
  3. Run go test -cover ./... and note the coverage percentage in a comment; add one more test case specifically to raise coverage on a branch you find untested.
  4. A benchmark for the function you find most likely to matter for performance, run with go test -bench=. -benchmem.
  5. A short comment interpreting the benchmark's ns/op and allocs/op output in plain language.
Hint

If go tool cover -html shows a branch as uncovered that you're sure your tests exercise, double check you actually generated a fresh coverage.out with -coverprofile before opening it — it's easy to view a stale coverage file left over from an earlier run and wrongly conclude a since-fixed gap still exists.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does the table-driven pattern scale better than writing a separate TestXxx function for each test case?

Adding a new case becomes a one-line addition to the table rather than a new function with duplicated setup and assertion logic — the actual test logic lives in exactly one place, shared by every case, which both reduces duplication and makes it much cheaper to add coverage for a newly-discovered edge case.

Q2

What specifically makes GreetUser testable with a fake store, without any mocking library?

GreetUser was written to accept the UserStore interface rather than a concrete database type — and because Go interfaces are satisfied implicitly, any type with a matching GetUser method (including a trivial in-memory fakeStore built just for tests) can stand in for the real dependency with zero special tooling.

Q3

Why is allocs/op often considered a more actionable benchmark number than raw ns/op?

Nanoseconds per operation can be affected by machine-specific noise and is a single aggregate number, while allocations per operation points directly at a specific, fixable cause of slowness — unnecessary heap allocation — that's usually traceable to a specific line of code and directly connects to the escape-analysis and GC concepts from Week 5, which is exactly what Week 15's profiling work targets.

Q4

Why does go test require zero configuration to discover and run a test function, unlike many other languages' testing setups?

Discovery is built directly into the Go toolchain by a simple naming convention — any function named TestXxx taking a *testing.T, in a file ending in _test.go, is automatically found and run by go test. There's no separate test runner to install or configuration file to write; the convention alone is the entire mechanism.