06 · Testing with the testing Package¶
Testing is built in¶
Go ships with a test runner and a testing package in the standard library.
There is no framework to choose, no assertion library to install, no config
file. Write a function called TestSomething in a _test.go file and
go test finds and runs it.
The conventions the toolchain enforces:
- Test files end in
_test.goand live beside the code they test. - Test functions are
func TestXxx(t *testing.T)— capital letter afterTest. - Files ending in
_test.goare excluded from normal builds.
Your first test¶
Given math.go:
// mathutil/math.go
package mathutil
import "errors"
var ErrDivideByZero = errors.New("division by zero")
func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
The test file sits next to it:
// mathutil/math_test.go
package mathutil
import (
"errors"
"testing"
)
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
// t.Errorf reports a failure but keeps the test running.
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if !errors.Is(err, ErrDivideByZero) {
t.Fatalf("Divide(10, 0) error = %v; want %v", err, ErrDivideByZero)
}
}
Run it:
go test ./... # every package in the module
go test -v ./mathutil # verbose: one line per test
go test -run TestAdd # only tests matching this regexp
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok example/mathutil 0.002s
Go has no assertEqual. You compare with plain if and report with
t.Errorf. The universal message format is
"Func(input) = got; want expected" — follow it and failures read clearly
without opening the source.
Error vs Fatal vs Log¶
| Method | Marks failure | Stops this test | Use when |
|---|---|---|---|
t.Log / t.Logf |
no | no | diagnostics (shown with -v, or on failure) |
t.Error / t.Errorf |
yes | no | a bad value, but the test can keep checking |
t.Fatal / t.Fatalf |
yes | yes | setup failed; continuing would panic |
t.Skip / t.Skipf |
no | yes | precondition absent (no network, no fixture) |
t.Helper() |
— | — | mark a function as a helper so line numbers point at the caller |
Rule of thumb: Fatal when the next line would crash (a nil result, a failed
os.Open), Error otherwise so one run reports every problem.
Table-driven tests: the Go idiom¶
Rather than one function per case, put the cases in a slice and loop. This is overwhelmingly the dominant style in Go codebases, including the standard library itself.
package mathutil
import (
"errors"
"math"
"testing"
)
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr error
}{
{name: "simple", a: 10, b: 2, want: 5},
{name: "negative", a: -9, b: 3, want: -3},
{name: "fraction", a: 1, b: 4, want: 0.25},
{name: "divide by zero", a: 1, b: 0, wantErr: ErrDivideByZero},
}
for _, tt := range tests {
// t.Run creates a named subtest -- failures identify themselves.
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Divide(%v, %v) error = %v; want %v", tt.a, tt.b, err, tt.wantErr)
}
if tt.wantErr != nil {
return // error case verified; no value to check
}
if math.Abs(got-tt.want) > 1e-9 { // never compare floats with ==
t.Errorf("Divide(%v, %v) = %v; want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
Output identifies the exact case:
--- FAIL: TestDivide (0.00s)
--- FAIL: TestDivide/fraction (0.00s)
math_test.go:31: Divide(1, 4) = 0.2; want 0.25
Adding a new case is one line. go test -run 'TestDivide/fraction' runs just
that subtest.
Comparing composite values¶
== does not work on slices or maps. Use reflect.DeepEqual, or
slices.Equal / maps.Equal (Go 1.21+) which are faster and type-safe:
package mathutil
import (
"reflect"
"slices"
"testing"
)
func TestEvens(t *testing.T) {
got := Evens([]int{1, 2, 3, 4, 5, 6})
want := []int{2, 4, 6}
if !slices.Equal(got, want) { // preferred for slices
t.Errorf("Evens() = %v; want %v", got, want)
}
gotMap := Tally([]string{"a", "b", "a"})
wantMap := map[string]int{"a": 2, "b": 1}
if !reflect.DeepEqual(gotMap, wantMap) { // works for any nested structure
t.Errorf("Tally() = %v; want %v", gotMap, wantMap)
}
}
A trap worth knowing: reflect.DeepEqual([]int{}, []int(nil)) is false. An
empty slice and a nil slice are different to DeepEqual even though both have
length zero — decide which your function should return and test for it.
Setup, cleanup and temp directories¶
t.Cleanup registers teardown that runs when the test finishes, however it
finishes. t.TempDir() creates a directory that is removed automatically:
package store
import (
"os"
"path/filepath"
"testing"
)
func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir() // auto-deleted after the test
path := filepath.Join(dir, "data.json")
s := New(path)
if err := s.Save([]string{"one", "two"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
got, err := s.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if len(got) != 2 {
t.Errorf("Load() returned %d items; want 2", len(got))
}
// Example of manual cleanup when you need it:
f, _ := os.Create(filepath.Join(dir, "scratch"))
t.Cleanup(func() { f.Close() })
}
TestMain(m *testing.M) handles package-wide setup: do the work, call
os.Exit(m.Run()), and everything in the package shares it.
Testing with interfaces instead of mocks¶
Go rarely needs a mocking library. Depend on a small interface (Module 1) and pass a fake in the test:
package notify
import (
"errors"
"testing"
)
type Sender interface {
Send(to, body string) error
}
// Notifier depends on the interface, not a concrete SMTP client.
type Notifier struct{ S Sender }
func (n Notifier) Alert(user string) error { return n.S.Send(user, "alert!") }
// fakeSender records calls instead of sending anything.
type fakeSender struct {
calls []string
err error
}
func (f *fakeSender) Send(to, body string) error {
f.calls = append(f.calls, to+": "+body)
return f.err
}
func TestAlert(t *testing.T) {
fake := &fakeSender{}
n := Notifier{S: fake}
if err := n.Alert("ada@example.com"); err != nil {
t.Fatalf("Alert() error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("got %d sends; want 1", len(fake.calls))
}
if fake.calls[0] != "ada@example.com: alert!" {
t.Errorf("unexpected call: %q", fake.calls[0])
}
// Failure path.
fake.err = errors.New("smtp down")
if err := n.Alert("x@example.com"); err == nil {
t.Error("expected an error when the sender fails")
}
}
Coverage, parallelism and the race detector¶
go test -cover ./... # coverage percentage per package
go test -coverprofile=c.out ./... \
&& go tool cover -html=c.out # line-by-line coverage in a browser
go test -race ./... # detect data races (see Module 2)
go test -count=1 ./... # bypass the test result cache
go test -v -run 'TestDivide/simple' # one subtest
go test -bench=. ./... # run benchmarks too
Calling t.Parallel() at the top of a test signals it can run alongside other
parallel tests, cutting wall-clock time for I/O-heavy suites. Chase useful
coverage, not a number — 100% coverage of getters proves nothing, while one
good table test of your parsing logic prevents real bugs.
Example functions double as documentation¶
A function named ExampleXxx with an // Output: comment is compiled, run,
and its output compared. It appears in go doc output too, so your docs
cannot drift from reality:
If Add ever stops returning 5, the documentation fails the build.
How It Actually Works¶
go test doesn't run your package in place — it generates a temporary _test
binary that links your package code together with a small generated main() that
enumerates every func TestXxx(*testing.T) (found by scanning the AST for functions
matching that exact signature and name pattern, not by any annotation) and calls
them in file order. Each t.Run("name", fn) spawns the subtest as a new goroutine
so t.Parallel() can actually run subtests concurrently — calling t.Parallel()
signals the test runner to pause that subtest until all non-parallel siblings finish,
then resume it alongside other parallel subtests, up to -parallel N at a time.
t.Fatal/t.FailNow don't just set a flag and return — they call runtime.
Goexit(), which unwinds the calling goroutine's deferred calls and then terminates
that goroutine without terminating the process, which is why t.Fatal inside a
non-test goroutine (started with go func(){...}() inside a test) silently kills
that goroutine without failing the test — a classic testing.T gotcha the mechanism
directly explains.
Cheat sheet¶
| Task | Syntax |
|---|---|
| Test function | func TestX(t *testing.T) |
| Report and continue | t.Errorf("got %v; want %v", got, want) |
| Report and stop | t.Fatalf(...) |
| Named subtest | t.Run(name, func(t *testing.T){ ... }) |
| Skip | t.Skip("needs network") |
| Temp dir | dir := t.TempDir() |
| Teardown | t.Cleanup(func(){ ... }) |
| Mark a helper | t.Helper() |
| Run in parallel | t.Parallel() |
| Compare slices | slices.Equal(got, want) |
| Compare anything | reflect.DeepEqual(got, want) |
| Run all tests | go test ./... |
| Coverage | go test -cover ./... |
| Race detection | go test -race ./... |
Related lessons¶
- Fakes rely on small interfaces: Module 1.
- Asserting error identity with
errors.Is: Module 4. - Benchmarks, fuzzing and golden files: Level 3, Module 6.
- Packages and module layout: Level 1, Module 9.
Exercise¶
Take the NextID function from the
Level 1 to-do project and write a
table-driven test for it covering: an empty slice, a slice with sequential
IDs, a slice with gaps, and a slice with unsorted IDs. Use t.Run with a
descriptive name per case. Then add a TestStoreRoundTrip that uses
t.TempDir() to save tasks to a temp file, load them back, and verify with
reflect.DeepEqual that nothing changed. Run go test -v -cover ./... and
note which branches are still uncovered.