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.
package mathutil
func Add(a, b int) int {
return a + b
}
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)
}
})
}
}
go test -v ./...
=== 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.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
go test -bench=. -benchmem ./...
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:
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()
}
}
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
go test -cover ./...
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:
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
TestXxxwith many separateif/t.Errorfblocks 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 intons/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/opfrom a benchmark and ignoringallocs/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 defaultgo test -benchoutput 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.