Dev Skills

Testing

Part of the toolchain, not a library you pick.

Topic 22 of 98 · 5 min to read · 45 min to build
After this topic you can
  • Write a table-driven test with named subtests and clear failure messages.
  • Write a fuzz test and a b.Loop benchmark, and run them with go test.
  • Explain the difference between t.Error and t.Fatal.

Testing is part of the toolchain

A test file ends in _test.go and sits in the same folder as the code it tests. Each test is a function starting with Test that takes a *testing.T. That is the whole setup: no framework to choose, no config file, no annotations.

You run them with go test. It compiles the package plus its tests and reports what failed.

The Go convention: table-driven tests

Rather than one function per case, you put the cases in a slice and loop over them. Adding the twelfth case becomes one line, and t.Run gives each case a name you can run on its own.

calc/calc_test.go
package calc
import "testing"
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b, want int
}{
{"positive", 2, 3, 5},
{"zero", 0, 0, 0},
{"negative", -1, -1, -2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Put the inputs, what you got and what you wanted in every failure message. A bare "failed" makes you rerun the test with print statements to find out why.

Running them

terminal
go test ./... # everything
go test -v ./... # name every test as it runs
go test -cover ./... # how much of the package is exercised
go test -run 'TestAdd/negative' ./calc # exactly one subtest
go test -race ./... # find data races — run this in CI

Fuzzing: let Go invent the inputs

A table test only checks the cases you thought of. A fuzz test makes up thousands of inputs for you and looks for one that breaks a rule you state. It is a function starting Fuzz that takes a *testing.F. f.Add gives it a few starting inputs (the seed corpus), and f.Fuzz holds the check that must hold for every input.

calc/reverse_test.go
package calc
import "testing"
func FuzzReverse(f *testing.F) {
f.Add("hello") // seed inputs: Go starts from these and mutates them
f.Add("héllo")
f.Fuzz(func(t *testing.T, s string) {
// the rule: reversing twice gives back what you started with
if got := Reverse(Reverse(s)); got != s {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, got, s)
}
})
}
A plain `go test` runs only the seed inputs, so it is fast. `go test -fuzz=FuzzReverse ./calc` keeps generating new inputs until you press Ctrl+C or it finds a failure. A failing input is saved under `testdata/fuzz/`, and from then on every plain `go test` runs it too.

Benchmarks with b.Loop

A benchmark measures how fast code runs. It is a function starting Benchmark that takes a *testing.B. Since Go 1.24 you write the timed part inside for b.Loop() { ... }. Older code uses for i := 0; i < b.N; i++, which still works, but b.Loop is better: setup written before the loop is not timed, and the compiler cannot optimise away the call you are measuring.

calc/calc_test.go
func BenchmarkAdd(b *testing.B) {
nums := []int{1, 2, 3} // setup: not timed
for b.Loop() {
Add(nums[0], nums[1])
}
}
Run it with `go test -bench=. ./calc`. The output shows how many nanoseconds one call took (`ns/op`).

Error versus Fatal

  • t.Error records the failure and keeps going. Use it when the rest of the checks still make sense.
  • t.Fatal records it and stops that subtest. Use it when continuing would panic, like after a failed setup.
  • t.Helper() inside a helper function makes failures point at the caller, not at the helper.
  • t.Cleanup(fn) runs teardown even when the test fails, and works from inside helpers where defer would not.
Note: Coverage tells you what is definitely untested. It does not tell you what is well tested. Cover your business logic and your error paths, and do not chase 100%. The last few percent tend to be tests written for the number, which assert little.
The short version
  • A _test.go file next to the code, a TestXxx(t *testing.T) function, and go test.
  • Table-driven tests are the Go standard: cases as data, one loop, t.Run for subtests.
  • go test -run "TestX/case_name" runs one case. The pattern is a regular expression.
  • t.Error continues; t.Fatal stops that subtest.
Remember: There is nothing to install and nothing to configure. Testing ships with the language.
Build this before moving on
Write a table-driven test with four cases, then run just one of them by name.
Then try these in your own editor
  1. 1Add a failing case and read how the failure is reported.
  2. 2Run go test -v -cover ./... and read the coverage number.
  3. 3Move your test into package foo_test and see what breaks.
Check yourselfTake the Core language test