59. Writing tests
Testing is built into Go. No framework to choose, no assertion library to
install, no config file. You write a file ending in _test.go, functions
starting with Test, and run go test.
The anatomy of a test
Say you have this in math.go:
package calc
import "errors"
func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
The test goes in math_test.go, next to it, in the same package:
package calc
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)
}
}
func TestDivide(t *testing.T) {
got, err := Divide(10, 4)
if err != nil {
t.Fatalf("Divide(10, 4) returned an unexpected error: %v", err)
}
if got != 2.5 {
t.Errorf("Divide(10, 4) = %v; want 2.5", got)
}
}
func TestDivideByZero(t *testing.T) {
if _, err := Divide(1, 0); err == nil {
t.Error("Divide(1, 0) should have returned an error, got nil")
}
}
Then:
$ go test ./...
ok example.com/calc 0.003s
$ go test -v ./...
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDivide
--- PASS: TestDivide (0.00s)
--- PASS: TestDivideByZero (0.00s)
PASS
The rules, all of them:
- File name ends in
_test.go(excluded from normal builds). - Function name starts with
Test, followed by a capital letter. - It takes
t *testing.Tand returns nothing. - A test fails when it says so — there is no
assert.
No assertions, on purpose
Go's testing package gives you failure reporting, not assertions:
| call | effect |
|---|---|
t.Errorf(...) |
mark failed, keep running the test |
t.Fatalf(...) |
mark failed, stop this test now |
t.Log(...) |
print (only shown with -v or on failure) |
t.Skip(...) |
skip this test |
t.Helper() |
mark a function as a helper, so failures point at the caller |
Use Fatalf when continuing makes no sense (a setup step failed, an error
was returned where you needed a value — anything followed by a nil
dereference). Use Errorf when you want to report several problems from one
run.
The message format that matters
This convention is worth internalising, because a failure message is read at 2 a.m. by someone who didn't write the test:
FunctionName(inputs) = got; want expected
// good — tells you everything
t.Errorf("Add(%d, %d) = %d; want %d", a, b, got, want)
// -> Add(2, 3) = 6; want 5
// useless
t.Error("test failed")
t.Error("wrong answer")
Name the function, show the inputs, show what you got, show what you wanted.
got before want, separated by a semicolon — that's the standard-library
house style and every Go codebase follows it.
Seeing the shape without a test runner
The lesson boxes here run a package main, not a test binary — so to feel
the structure, here's the same logic as a hand-rolled runner. The comparison
and reporting are exactly what testing does for you:
package main
import "fmt"
func Add(a, b int) int { return a + b }
func main() {
type check struct {
name string
a, b int
want int
}
checks := []check{
{"positive", 2, 3, 5},
{"with zero", 5, 0, 5},
{"negative", -1, -2, -3},
{"deliberately wrong", 2, 2, 5},
}
failed := 0
for _, c := range checks {
got := Add(c.a, c.b)
if got != c.want {
failed++
fmt.Printf("--- FAIL: %s\n Add(%d, %d) = %d; want %d\n", c.name, c.a, c.b, got, c.want)
continue
}
fmt.Printf("--- PASS: %s\n", c.name)
}
if failed > 0 {
fmt.Printf("FAIL (%d of %d)\n", failed, len(checks))
return
}
fmt.Println("PASS")
}
That's a table-driven test with the testing package removed — which is the
subject of the next lesson.
Testing errors properly
func TestLoadUser(t *testing.T) {
_, err := LoadUser(999)
if err == nil {
t.Fatal("LoadUser(999) should have failed")
}
if !errors.Is(err, ErrNotFound) {
t.Errorf("LoadUser(999) error = %v; want it to wrap ErrNotFound", err)
}
}
Check errors with errors.Is, not by comparing message strings. Messages
change; sentinel identity doesn't. (Module 7's rule, in its most common
application.)
Helpers and t.Helper()
func mustParse(t *testing.T, s string) time.Time {
t.Helper() // failures report the CALLER's line, not this one
tm, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parsing %q: %v", s, err)
}
return tm
}
func TestAge(t *testing.T) {
born := mustParse(t, "1990-05-15")
// ...
}
Without t.Helper(), every failure points at the line inside mustParse —
useless when six tests call it. One line, and the report points where the
problem actually is.
Setup, cleanup and t.Cleanup
func TestWithTempDir(t *testing.T) {
dir := t.TempDir() // created now, removed automatically
// ... write files under dir ...
srv := startTestServer()
t.Cleanup(func() { srv.Close() }) // runs when the test ends, pass or fail
// ... test against srv ...
}
t.TempDir() gives you a directory that's cleaned up for you. t.Cleanup
registers teardown that runs no matter how the test exits — the defer of
the testing world, but it also runs after subtests finish.
Running tests
go test ./... # everything, recursively
go test -v ./... # show each test
go test -run TestAdd ./... # only matching tests (regex)
go test -race ./... # with the race detector
go test -cover ./... # coverage percentage
go test -count=1 ./... # disable the result cache
Three of those deserve a note:
-raceis the one from module 9. Run it in CI, always. A data race that surfaces once a week in production shows up immediately here.-count=1defeats Go's test caching. If a test "passes" suspiciously fast and you haven't changed anything, it was cached.-coverprints a percentage;-coverprofile=c.outplusgo tool cover -html=c.outshows you exactly which lines never ran. Chase uncovered branches, not a percentage target.
What to test
- Public behaviour, not internals. Test what
Dividereturns, not how it computes it. Tests that assert on internals break every refactor. - Edge cases first. Empty input, zero, nil, one element, the maximum. That's where the bugs live.
- Every bug you fix. Write the failing test first, then fix it — that way you know the test actually catches the bug.
- Not the standard library. Don't test that
appendworks.
Your turn
Complete the checker so it reports which cases fail. IsEven(3) should fail
against a want of true:
--- PASS: two
--- FAIL: three
IsEven(3) = false; want true
--- PASS: zero
FAIL (1 of 3)
package main
import "fmt"
func IsEven(n int) bool { return n%2 == 0 }
func main() {
cases := []struct {
name string
in int
want bool
}{
{"two", 2, true},
{"three", 3, true},
{"zero", 0, true},
}
failed := 0
// loop over cases, compare IsEven(c.in) to c.want, print PASS/FAIL lines
if failed > 0 {
fmt.Printf("FAIL (%d of %d)\n", failed, len(cases))
}
}
package main
import "fmt"
func IsEven(n int) bool { return n%2 == 0 }
func main() {
cases := []struct {
name string
in int
want bool
}{
{"two", 2, true},
{"three", 3, true},
{"zero", 0, true},
}
failed := 0
for _, c := range cases {
got := IsEven(c.in)
if got != c.want {
failed++
fmt.Printf("--- FAIL: %s\n IsEven(%d) = %t; want %t\n", c.name, c.in, got, c.want)
continue
}
fmt.Printf("--- PASS: %s\n", c.name)
}
if failed > 0 {
fmt.Printf("FAIL (%d of %d)\n", failed, len(cases))
}
}
Next: the way Go programmers actually write tests — tables and subtests.