Testing & Benchmarking

Idiomatic table-driven tests, writing benchmarks with go test -bench, and measuring test coverage with go test -cover.

Table-driven tests: Go's idiomatic testing style

Go's built-in testing package is deliberately minimal — no assertion library, no mocking framework baked in, just func TestXxx(t *testing.T) and a handful of methods like t.Errorf and t.Fatalf. The idiom that makes this minimal API scale to real test suites is the table-driven test: instead of writing one test function per case, define a slice of cases and loop over it, running each as its own named subtest.

Go
package mathutil

func Add(a, b int) int {
	return a + b
}
Go
package mathutil

import "testing"

func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"positive numbers", 2, 3, 5},
		{"negative numbers", -2, -3, -5},
		{"mixed signs", -2, 3, 1},
		{"zeros", 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)
			}
		})
	}
}
Bash
go test -v ./...
Plaintext
=== RUN   TestAdd
=== RUN   TestAdd/positive_numbers
=== RUN   TestAdd/negative_numbers
=== RUN   TestAdd/mixed_signs
=== RUN   TestAdd/zeros
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/positive_numbers (0.00s)
    --- PASS: TestAdd/negative_numbers (0.00s)
    --- PASS: TestAdd/mixed_signs (0.00s)
    --- PASS: TestAdd/zeros (0.00s)
PASS

t.Run is what makes this more than a loop with assertions in it: each case gets its own named subtest, reported and failed independently. If "mixed signs" broke, the output would point at TestAdd/mixed_signs specifically, rather than one generic TestAdd failure that leaves you re-reading the whole table to figure out which row was actually wrong. Subtests can also be run individually — go test -run "TestAdd/negative_numbers" — which matters once a table grows to dozens of cases and re-running all of them on every iteration gets slow.

Benchmarking with go test -bench

A benchmark function looks like a test, but takes a *testing.B and runs its body in a loop up to b.N times — the testing framework itself decides how large b.N needs to be, running the loop repeatedly with an increasing b.N until the measured time per iteration stabilizes.

Go
func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}
Bash
go test -bench=. -benchmem ./...
Plaintext
BenchmarkAdd-8   1000000000   0.25 ns/op   0 B/op   0 allocs/op

-8 is GOMAXPROCS at the time the benchmark ran; the next number is how many iterations the framework settled on; ns/op is the average time per iteration; -benchmem adds B/op and allocs/op — bytes and heap allocations per iteration — which often matters more than raw speed, since unexpected allocations are exactly what drives GC pressure at real scale.

A benchmark is more useful when it's actually deciding between two approaches. Building a string with repeated += looks harmless in a short loop but reallocates and copies a growing string on every iteration; strings.Builder grows an internal buffer geometrically instead:

Go
package mathutil

import (
	"strings"
	"testing"
)

func BenchmarkConcatPlus(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := ""
		for j := 0; j < 100; j++ {
			s += "x"
		}
		_ = s
	}
}

func BenchmarkConcatBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var sb strings.Builder
		for j := 0; j < 100; j++ {
			sb.WriteString("x")
		}
		_ = sb.String()
	}
}
Plaintext
BenchmarkConcatPlus-8      50000    28540 ns/op    15234 B/op    99 allocs/op
BenchmarkConcatBuilder-8  600000     1950 ns/op      512 B/op      3 allocs/op

The ns/op gap (roughly 14x) already makes the case, but allocs/op tells the deeper story: += allocates a new backing array almost every iteration of the inner loop (99 allocations for 100 concatenations), while strings.Builder allocates only a handful of times as its buffer doubles in size. This is the kind of decision a benchmark should actually settle, rather than guessing from first principles or, worse, from a single manually-timed run.

Two adjustments worth knowing when a benchmark has expensive setup that shouldn't count toward the measurement: b.ResetTimer() (call it right after setup, before the timed loop) and b.StopTimer()/b.StartTimer() to pause timing around a specific expensive step inside the loop itself. Skipping this silently inflates ns/op with work that has nothing to do with what's actually being benchmarked.

Test coverage with go test -cover

Bash
go test -cover ./...
Plaintext
ok  	example.com/mathutil	0.002s	coverage: 87.5% of statements

For a line-by-line view of exactly what ran and what didn't, generate a coverage profile and open it as HTML:

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

The HTML report highlights covered lines in green and uncovered ones in red directly in the source — genuinely useful for spotting an entire untested branch of business logic. What it can't tell you is whether the assertions in the tests that did run were meaningful — a test that calls a function and asserts nothing about the result still counts every line of that function as "covered." Coverage percentage is a floor on how much code ran during tests, not a ceiling on how well it was verified, which is exactly why chasing 100% as a goal is a weaker signal than it sounds.

Comparison: common go test flags

Command Purpose
go test ./... run all tests in the module
go test -v ./... verbose — show each test and subtest name with pass/fail
go test -run Pattern run only tests (or subtests, via Test/Subtest) matching a name pattern
go test -bench=. run benchmarks matching a pattern (. matches all)
go test -bench=. -benchmem benchmarks plus per-iteration allocation counts
go test -cover run tests and report overall coverage percentage
go test -coverprofile=out write detailed per-line coverage data for go tool cover -html
go test -race run tests instrumented with the data race detector

Common mistakes

  • Writing one large TestXxx with many separate if/t.Errorf blocks instead of a table-driven test with subtests — a single unnamed failure buried in a long function is much harder to trace back to which specific case broke.
  • Forgetting b.ResetTimer() when a benchmark has expensive one-time setup (building a large fixture, opening a file) — that setup cost gets folded into ns/op, skewing the result for the thing actually being measured.
  • Treating 100% test coverage as the goal — coverage measures what ran, not whether it was meaningfully checked; a line executed by a test with no real assertions still shows as covered.
  • Reading only ns/op from a benchmark and ignoring allocs/op — a function can look fast per call while quietly generating a lot of garbage that only shows up as GC pressure under real, sustained load.
  • Forgetting -benchmem — the default go test -bench output reports timing only; allocation counts have to be explicitly requested.

Interview questions

Q: Why are table-driven tests considered idiomatic in Go rather than one test function per case? Go's testing package has no built-in assertion or parameterization framework, so table-driven tests fill that gap with plain Go: a slice of cases looped with t.Run gives each case its own named, independently reportable and independently runnable subtest, without needing any third-party library.

Q: What does b.N represent in a benchmark function, and who decides its value? It's the number of iterations the benchmark's loop body runs. The testing framework — not the test author — decides its value, increasing it across repeated runs until the measured time per iteration stabilizes enough to report a reliable ns/op.

Q: Is 100% test coverage a reliable signal that code is well-tested? No — coverage only reports which lines executed during the test run, not whether the test made any meaningful assertion about the result. A test that calls a function and checks nothing still counts every executed line as covered, so coverage is best read as a floor that flags obviously untested code, not a target to maximize for its own sake.