Go testing
The stdlib testing model has no assertions
A test lives in a file ending _test.go and is a function func TestXxx(t *testing.T) whose name after Test does not begin with a lowercase letter (so an uppercase letter, digit, or underscore all work, and Test_helper is valid). There is no assertEqual. You compare values yourself and report failures through t:
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 { // no assertion helper, you write the check
t.Errorf("Add(2, 3) = %d, want 5", got)
}
}
t.Errorf marks the test failed and keeps running the rest of it. t.Fatalf marks it failed and stops the current test right there: it calls runtime.Goexit, so deferred cleanup still runs. Use Fatalf when continuing is pointless (a nil value you are about to dereference), and Errorf when you want every mismatch reported in one run. For deep struct or slice equality, google/go-cmp (cmp.Diff) gives a readable diff without introducing an assertion library.
Table-driven tests with subtests
The idiom for many similar cases is a slice of named cases and one t.Run per case:
cases := []struct {
name string
in string
out string
}{
{"flag", "%a", "[%a]"},
{"dash", "%-a", "[%-a]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Format(tc.in); got != tc.out {
t.Errorf("Format(%q) = %q, want %q", tc.in, got, tc.out)
}
})
}
Each case becomes a named subtest (TestX/flag), so a failure points at the exact row and you can rerun one with go test -run 'TestX/flag'. Prefer t.Errorf inside the loop over Fatalf: one bad row should not hide the rows after it.
Benchmarks and b.N
A benchmark is func BenchmarkXxx(b *testing.B) that runs the target b.N times:
func BenchmarkParse(b *testing.B) {
data := load() // setup, re-run on every b.N probe
b.ResetTimer() // drop that setup from the measurement
for i := 0; i < b.N; i++ {
Parse(data)
}
}
Wrong "I set b.N to the number of iterations I want to measure." The framework chooses b.N, calling the function repeatedly with a larger value until the run lasts long enough to time (about a second by default), then reports nanoseconds per single iteration. A fixed count is what -benchtime=100000x is for. Because the function is re-invoked with a growing b.N, any setup before the loop runs several times, which is why ResetTimer exists. Run with go test -bench=. -benchmem; the -benchmem flag (or b.ReportAllocs()) adds B/op and allocs/op.
Coverage in one command
go test -cover prints coverage: X% of statements. For detail, write a profile and open it:
go test -coverprofile=cover.out
go tool cover -html=cover.out # green covered, red not
go tool cover -func=cover.out gives a per-function table instead. Coverage counts statements the tests execute, so a high number means these lines ran, not that they are correct.
Parallel subtests and the loop-variable trap
t.Parallel signals that a subtest runs alongside the other parallel subtests. The mechanism matters: calling it pauses the subtest and hands control back to the loop, and the siblings resume only after the parent test function returns. That timing made the classic table-test bug silent before Go 1.22:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if !isEven(tc.in) { // tc is shared pre-1.22
t.Errorf("isEven(%d) = false, want true", tc.in)
}
})
}
Before 1.22 the loop reused one tc. Each paused subtest resumed after the loop finished, when tc already held the last case, so every subtest checked that row and the odd input went untested: a green pass that verified nothing. Go 1.22 gives each iteration its own tc, so each subtest captures its own case and the odd row fails. The new scope is gated on the module: go.mod must declare go 1.22 or later, so upgrading the toolchain alone does not fix an older module, and it covers both for range and 3-clause for. The pre-1.22 fix, still valid, is tc := tc in the loop body. A related footgun: t.Setenv panics if the test or any parent called t.Parallel, because environment variables are process-global.
Keeping a benchmark honest
Two ways a benchmark lies. First, dead-code elimination: if the result is never observed, the compiler can drop the call and you measure an empty loop.
var Sink uint64 // package-level, so the write cannot be proven dead
func BenchmarkPopcount(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = popcount(uint64(i))
}
Sink = r // observed, so the loop body survives
}
Second, setup left in the timed region: put b.ResetTimer after setup, or wrap unwanted per-iteration work in b.StopTimer and b.StartTimer. Go 1.24 added b.Loop: for b.Loop() { ... } runs the body once per measurement, resets the timer on the first call (so earlier setup is excluded without ResetTimer), and stops the timer when it returns false. It also resists the optimizer removing the loop body, lowering the need for the sink.
The race detector is a build mode, not a proof
go test -race (also go run, go build, go install) instruments memory accesses at runtime. It flags a data race when two goroutines access the same variable concurrently and at least one access is a write.
Wrong "the -race suite is green, so the code is race-free." The detector sees only paths that execute during the run, so a clean pass proves those paths are clean and nothing more. It also costs roughly 5-10x memory and 2-20x CPU, so it belongs in CI and load tests rather than the production hot path. For accurate coverage of parallel tests, use -covermode=atomic; the non-atomic modes (set and count) update counters without synchronization and would race under concurrent access. (go test already switches the covermode default to atomic when you pass -race.)
Fuzzing beyond the seed corpus
Native fuzzing arrived in Go 1.18. A fuzz test is func FuzzXxx(f *testing.F): seed it with f.Add, then hand f.Fuzz a target whose first parameter is *testing.T and whose rest are the fuzzed inputs.
func FuzzReverse(f *testing.F) {
f.Add("Hello")
f.Fuzz(func(t *testing.T, s string) {
if Reverse(Reverse(s)) != s {
t.Errorf("round trip changed %q", s)
}
})
}
Plain go test runs a fuzz test as an ordinary unit test over the seed corpus only. go test -fuzz=FuzzReverse starts the engine, generating inputs until a failure or -fuzztime elapses. A failing input is written to testdata/fuzz/FuzzReverse/<hash> and checked into the repo, a permanent regression case you replay with go test -run=FuzzReverse/<hash>.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.