59. Writing tests

📖 Reading · 14 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

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.T and 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:

  • -race is 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=1 defeats Go's test caching. If a test "passes" suspiciously fast and you haven't changed anything, it was cached.
  • -cover prints a percentage; -coverprofile=c.out plus go tool cover -html=c.out shows you exactly which lines never ran. Chase uncovered branches, not a percentage target.

What to test

  • Public behaviour, not internals. Test what Divide returns, 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 append works.

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.