Session 18 · Testing your Go code#
The big idea#
A test is a small program that calls your code with known input and checks the answer. Run
the tests after every change and you find out within a second if you broke something. Go has
testing built in: no extra library, just a file ending in _test.go and the command go test.
Everyday example: a taste test before a dish leaves the kitchen. The cook tastes it, compares it with what it should taste like, and fixes it if it is off. Automatic tests do that tasting for every dish, every time, in milliseconds.
In plain words
Put x_test.go next to x.go. Write func TestSomething(t *testing.T). Call your function, compare
with what you expect, and call t.Errorf(...) when they differ. List many cases in a table and run each
as a subtest with t.Run.
Reach for it when:
you fix a bug (write a test that fails first, so it can never come back)
you refactor and want proof that nothing changed
the code has edge cases: empty input, zero, negative, huge
The code under test#
A small package, textx, with two functions.
// Package textx holds a few small text helpers to practise testing.
package textx
import (
"errors"
"strings"
)
var ErrEmpty = errors.New("empty input")
// WordCount returns how many times each word appears, ignoring case.
func WordCount(s string) (map[string]int, error) {
fields := strings.Fields(strings.ToLower(s))
if len(fields) == 0 {
return nil, ErrEmpty
}
counts := make(map[string]int)
for _, w := range fields {
counts[w]++
}
return counts, nil
}
// IsPalindrome reports whether s reads the same forwards and backwards,
// ignoring case and spaces.
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range strings.ToLower(s) {
if r != ' ' {
letters = append(letters, r)
}
}
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
if letters[i] != letters[j] {
return false
}
}
return true
}
The tests#
package textx
import (
"errors"
"reflect"
"testing"
)
// A simple test: name starts with Test, takes *testing.T.
func TestWordCount(t *testing.T) {
got, err := WordCount("Go go GO rocks")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]int{"go": 3, "rocks": 1}
if !reflect.DeepEqual(got, want) {
t.Errorf("WordCount = %v, want %v", got, want)
}
}
func TestWordCountEmpty(t *testing.T) {
_, err := WordCount(" ")
if !errors.Is(err, ErrEmpty) {
t.Errorf("err = %v, want ErrEmpty", err)
}
}
// Table-driven test: one loop, many cases, each with its own name.
func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"simple", "level", true},
{"with spaces and case", "Never Odd Or Even", true},
{"not one", "golang", false},
{"empty", "", true},
{"single letter", "a", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := IsPalindrome(tc.input); got != tc.want {
t.Errorf("IsPalindrome(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// Benchmarks start with Benchmark and loop b.N times.
func BenchmarkIsPalindrome(b *testing.B) {
for i := 0; i < b.N; i++ {
IsPalindrome("Never Odd Or Even")
}
}
Run them with go test -v inside the package folder:
=== RUN TestWordCount
--- PASS: TestWordCount (0.00s)
=== RUN TestWordCountEmpty
--- PASS: TestWordCountEmpty (0.00s)
=== RUN TestIsPalindrome
=== RUN TestIsPalindrome/simple
=== RUN TestIsPalindrome/with_spaces_and_case
=== RUN TestIsPalindrome/not_one
=== RUN TestIsPalindrome/empty
=== RUN TestIsPalindrome/single_letter
--- PASS: TestIsPalindrome (0.00s)
--- PASS: TestIsPalindrome/simple (0.00s)
--- PASS: TestIsPalindrome/with_spaces_and_case (0.00s)
--- PASS: TestIsPalindrome/not_one (0.00s)
--- PASS: TestIsPalindrome/empty (0.00s)
--- PASS: TestIsPalindrome/single_letter (0.00s)
PASS
ok golang-course/18-testing/textx 0.004s
A test function starts with
Testand takes*testing.T.t.Errorfrecords a failure and continues;t.Fatalfstops this test at once. UseFatalfwhen later lines would be meaningless (for example after an unexpected error).The table-driven style (
TestIsPalindrome) is the Go standard: add a case by adding a line.t.Run(name, ...)creates a named subtest, so a failure points at the exact case.Files named
_test.goare never part of your built program.
When a test fails#
If someone breaks the function (here: the code stops ignoring spaces), the failing case is named for you, with the input, the result and the expectation:
--- FAIL: TestIsPalindrome (0.00s)
--- FAIL: TestIsPalindrome/with_spaces_and_case (0.00s)
textx_test.go:44: IsPalindrome("Never Odd Or Even") = false, want true
FAIL
exit status 1
FAIL golang-course/18-testing/textx 0.003s
The message IsPalindrome("Never Odd Or Even") = false, want true is good because it tells you
the input, the actual and the wanted value. Write failure messages that way.
Coverage and benchmarks#
PASS
coverage: 100.0% of statements
ok golang-course/18-testing/textx 0.005s
go test -cover shows what percentage of statements the tests ran. 100 % does not mean “no bugs”,
but a low number points at untested code. go test -coverprofile=c.out plus go tool cover -html=c.out
shows the missed lines in colour.
A benchmark measures speed. It runs the code b.N times, and Go picks b.N until the timing is stable:
goos: linux
goarch: amd64
pkg: golang-course/18-testing/textx
BenchmarkIsPalindrome-12 8270139 148.0 ns/op
PASS
ok golang-course/18-testing/textx 1.378s
(The timing is from my machine; yours will differ.) Run benchmarks with go test -bench=..
Testing HTTP handlers#
You already know the trick from Session 11: httptest.NewRecorder() and httptest.NewRequest() let
you call a handler directly and look at the status and body, with no real network. Combine it with the
table-driven pattern and you can test every route of the Todo API in a few lines.
A command checklist#
Command |
What it does |
|---|---|
|
run every test in every package |
|
also look for data races (Sessions 14 to 17) |
|
run one test by name |
|
catch suspicious code, like a copied |
|
format everything the standard way |
Important
Run go test -race ./... in CI. Concurrency bugs are the ones you never find by reading the code.
Common mistakes#
Testing only the happy path. Add empty input, zero, negative, a missing file, very large input.
Tests that depend on each other or on order. Each test should set up what it needs.
Tests that depend on real time, the network or a live database. They become flaky. Use
httptest, in-memory databases or fakes.Using
t.Fatalin a goroutine. It must be called from the test’s own goroutine; send an error through a channel instead.Comparing maps or slices with
==. It will not compile; usereflect.DeepEqualorslices.Equal.Vague messages like
"failed". Include input, got and want.Forgetting the capital letter.
testFoois not a test, sogo testignores it silently.
Hands-on exercises#
The test files are not package main programs, so the snippets below are complete files you place
in a folder with a go.mod. I ran each one with go test and the result is shown.
Exercise 1 · Test a function. Given Abs(n int) int, write a table-driven test with positive, negative and zero.
Solution
abs.go
package mathx
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
abs_test.go
package mathx
import "testing"
func TestAbs(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{"positive", 5, 5},
{"negative", -5, 5},
{"zero", 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Abs(tc.in); got != tc.want {
t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
ok exercise/mathx 0.003s
Exercise 2 · Test the error path. Given Divide(a, b int) (int, error) that returns ErrDivideByZero, test both a normal division and a zero divisor with errors.Is.
Solution
div.go
package mathx
import "errors"
var ErrDivideByZero = errors.New("divide by zero")
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
div_test.go
package mathx
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
got, err := Divide(10, 2)
if err != nil || got != 5 {
t.Fatalf("Divide(10, 2) = %d, %v; want 5, nil", got, err)
}
_, err = Divide(1, 0)
if !errors.Is(err, ErrDivideByZero) {
t.Errorf("err = %v, want ErrDivideByZero", err)
}
}
ok exercise/mathx 0.003s
Exercise 3 · Test for a race. Write a test that increments a shared counter from 100 goroutines under a mutex and run it with go test -race.
Solution
counter_test.go
package mathx
import (
"sync"
"testing"
)
func TestCounterRace(t *testing.T) {
var (
mu sync.Mutex
n int
wg sync.WaitGroup
)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
n++
mu.Unlock()
}()
}
wg.Wait()
if n != 100 {
t.Errorf("n = %d, want 100", n)
}
}
ok exercise/mathx 1.012s
Remove the mu.Lock() and mu.Unlock() lines and run go test -race again to watch the detector fail the test.
What’s next#
You have now covered the same ground as the 40-day course’s projects and the syllabus topics around
them. Good next steps: rebuild the file tools and the Todo API from scratch without looking, add
context.Context for cancellation, try a router such as chi, and deploy one of your tools as a single
binary.