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

A table-driven test List the cases once, loop over them, and run each as a named subtest so a failure tells you exactly which case broke. tests := []struct{name; input; want}{ {"simple", "level", true}, {"spaces", "Never Odd Or Even", true}, {"not one", "golang", false}, } for each t.Run(name, …) IsPalindrome(in) got == want? t.Errorf if not go test · go test -v · go test -cover · go test -bench=. · go test -race ./... Test files end in _test.go, functions start with Test, Benchmark and take *testing.T / *testing.B
List the cases once, loop over them, and run each as a named subtest so a failure tells you exactly which case broke.

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 Test and takes *testing.T.

  • t.Errorf records a failure and continues; t.Fatalf stops this test at once. Use Fatalf when 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.go are 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

go test ./...

run every test in every package

go test -race ./...

also look for data races (Sessions 14 to 17)

go test -run TestWordCount

run one test by name

go vet ./...

catch suspicious code, like a copied WaitGroup

go fmt ./...

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.Fatal in 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; use reflect.DeepEqual or slices.Equal.

  • Vague messages like "failed". Include input, got and want.

  • Forgetting the capital letter. testFoo is not a test, so go test ignores 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.