Testing in Go

Go has testing built in: no framework to install, no config file. You put tests in a file ending in _test.go, write functions that start with Test, and run go test. In this lesson you’ll write your first test, read a failure, turn several cases into a table-driven test, test error paths, measure coverage, and write examples that double as documentation.

The code we’ll test

Tests live next to the code they test, in the same package. I’ll use a small module called example.com/stats with one package (see Packages and modules if go mod init is new to you):

stats/
├── go.mod
├── stats.go
└── stats_test.goCode language: plaintext (plaintext)

Here is stats.go. It has a plain function and one that can return an error:

// stats.go

// Package stats has small helpers for slices of numbers.
package stats

import "errors"

// ErrEmpty is returned when a function needs at least one number.
var ErrEmpty = errors.New("stats: empty slice")

// Sum returns the sum of nums.
func Sum(nums []int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

// Average returns the mean of nums, or ErrEmpty if nums is empty.
func Average(nums []int) (float64, error) {
	if len(nums) == 0 {
		return 0, ErrEmpty
	}
	return float64(Sum(nums)) / float64(len(nums)), nil
}Code language: Go (go)

Your first test

Create stats_test.go in the same folder:

// stats_test.go
package stats

import "testing"

func TestSum(t *testing.T) {
	got := Sum([]int{1, 2, 3})
	want := 6
	if got != want {
		t.Errorf("Sum([]int{1, 2, 3}) = %d, want %d", got, want)
	}
}Code language: Go (go)

Three rules make this a test:

  • The file name ends in _test.go. The go tool only compiles these files when you run go test; they never end up in your program.
  • The function name starts with Test, followed by a capital letter or nothing (TestSum, not Testsum).
  • It takes one parameter, t *testing.T, which you use to report failures.

There’s no assertEqual. A test is ordinary Go: compute a result, compare it with if, and call t.Errorf when it’s wrong. I always write the message as “got X, want Y” so a failure explains itself.

Running go test

From the module folder, run:

go testCode language: Bash (bash)
PASS
ok  	example.com/stats	0.277sCode language: plaintext (plaintext)

Quiet means good. Add -v (verbose) to see each test as it runs:

go test -vCode language: Bash (bash)
=== RUN   TestSum
--- PASS: TestSum (0.00s)
PASS
ok  	example.com/stats	0.181sCode language: plaintext (plaintext)

In a bigger module, go test ./... runs the tests of every package below the current folder. That’s the command I run before every commit.

Reading a failure

A test you’ve never seen fail might not test anything. So let’s break Sum on purpose: change total := 0 to total := 1 and run go test again:

--- FAIL: TestSum (0.00s)
    stats_test.go:10: Sum([]int{1, 2, 3}) = 7, want 6
FAIL
exit status 1
FAIL	example.com/stats	0.580sCode language: plaintext (plaintext)

You get the test name, the file and line of the t.Errorf call, and your message. go test also exits with a non-zero status, which is what makes a CI build go red. Put total := 0 back before you continue.

t.Errorf or t.Fatalf?

Both report a failure. The difference is what happens next:

  • t.Errorf marks the test as failed and keeps going. Use it when the remaining checks still make sense, so one run shows you every problem.
  • t.Fatalf marks the test as failed and stops this test right away. Use it when continuing would be pointless or would panic, for example when a function returned an error and its other result is meaningless.

Testing Average shows both. If it returns an error, checking the number makes no sense, so that check uses t.Fatalf:

func TestAverage(t *testing.T) {
	got, err := Average([]int{1, 2, 3, 4})
	if err != nil {
		t.Fatalf("Average returned an unexpected error: %v", err)
	}
	if got != 2.5 {
		t.Errorf("Average = %v, want 2.5", got)
	}
}Code language: Go (go)

There are also t.Error, t.Fatal and t.Log (no formatting, like fmt.Println). t.Log output only shows up when a test fails or when you run with -v.

Table-driven tests

One case is rarely enough. What about an empty slice, a single number, negative numbers? Copying the test four times gets old fast. The Go way is a table: a slice of anonymous structs, one per case, and a loop that runs them all.

func TestSum(t *testing.T) {
	tests := []struct {
		name string
		nums []int
		want int
	}{
		{"empty", nil, 0},
		{"one number", []int{5}, 5},
		{"several numbers", []int{1, 2, 3}, 6},
		{"negative numbers", []int{-1, -2, 3}, 0},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Sum(tt.nums)
			if got != tt.want {
				t.Errorf("Sum(%v) = %d, want %d", tt.nums, got, tt.want)
			}
		})
	}
}Code language: Go (go)

t.Run starts a subtest with its own name and its own t. If one case fails (even with t.Fatalf), the others still run, and the output tells you exactly which case broke:

=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/one_number
=== RUN   TestSum/several_numbers
=== RUN   TestSum/negative_numbers
--- PASS: TestSum (0.00s)
    --- PASS: TestSum/empty (0.00s)
    --- PASS: TestSum/one_number (0.00s)
    --- PASS: TestSum/several_numbers (0.00s)
    --- PASS: TestSum/negative_numbers (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
PASS
ok  	example.com/stats	0.286sCode language: plaintext (plaintext)

Spaces in subtest names become underscores. Adding a case is now one line, and that’s the real win: when a bug report comes in, I add the failing input to the table first, watch it fail, then fix the code.

To run only some tests, pass a pattern to -run. A slash selects subtests:

go test -v -run 'TestSum/one'Code language: Bash (bash)
=== RUN   TestSum
=== RUN   TestSum/one_number
--- PASS: TestSum (0.00s)
    --- PASS: TestSum/one_number (0.00s)
PASS
ok  	example.com/stats	0.182sCode language: plaintext (plaintext)

The closure inside the loop uses tt directly. Since Go 1.22 each loop iteration gets its own tt, so that’s safe. Older code often has a tt := tt line inside the loop; you don’t need it anymore.

Measuring coverage

How much of the code do these tests actually run? Add -cover:

go test -coverCode language: Bash (bash)
PASS
coverage: 85.7% of statements
ok  	example.com/stats	0.275sCode language: plaintext (plaintext)

Something isn’t covered. To see what, write a coverage profile and open it as an HTML page in your browser, with covered lines in green and missed lines in red:

go test -coverprofile=cover.out
go tool cover -html=cover.outCode language: Bash (bash)

The red line is return 0, ErrEmpty. No test calls Average with an empty slice. That’s typical: the happy path gets tested, the error path doesn’t.

Testing error paths

Errors are part of a function’s behavior, so test them like any other result. Because ErrEmpty is an exported sentinel error, the test can check for it with errors.Is (see Errors):

func TestAverageEmpty(t *testing.T) {
	_, err := Average(nil)
	if !errors.Is(err, ErrEmpty) {
		t.Errorf("Average(nil) error = %v, want ErrEmpty", err)
	}
}Code language: Go (go)

Add "errors" to the test file’s imports, and coverage is complete:

PASS
coverage: 100.0% of statements
ok  	example.com/stats	0.715sCode language: plaintext (plaintext)

Comparing with errors.Is instead of err.Error() == "..." keeps the test working when someone rewords the message or wraps the error. And don’t chase 100% for its own sake. Coverage tells you what you didn’t test; it can’t tell you whether your checks are any good.

Examples: tests that are also documentation

A function named ExampleXxx in a test file is an example. It prints something, and a // Output: comment at the end says what it should print. go test runs it and compares, and pkg.go.dev and go doc show it in the package documentation under Sum.

// example_test.go
package stats_test

import (
	"fmt"

	"example.com/stats"
)

func ExampleSum() {
	fmt.Println(stats.Sum([]int{1, 2, 3}))
	// Output: 6
}

func ExampleAverage() {
	avg, err := stats.Average([]int{2, 4})
	fmt.Println(avg, err)
	// Output: 2 <nil>
}Code language: Go (go)

Two details. The package is stats_test, not stats: a test file may use this separate package, which only sees exported names, exactly like a real user of your package would. That’s why the calls are stats.Sum. And I made a mistake in the second example on purpose (the average of 2 and 4 is 3), so you can see what a failing example looks like:

--- FAIL: ExampleAverage (0.00s)
got:
3 <nil>
want:
2 <nil>
FAIL
exit status 1
FAIL	example.com/stats	0.624sCode language: plaintext (plaintext)

Change the comment to // Output: 3 <nil> and it passes. An example without an // Output: comment is compiled but not run.

Benchmarks, briefly

The same testing package measures speed. A benchmark is a function BenchmarkXxx(b *testing.B) that repeats the code in a for b.Loop() loop:

func BenchmarkSum(b *testing.B) {
	nums := make([]int, 1000)
	for b.Loop() {
		Sum(nums)
	}
}Code language: Go (go)

Benchmarks don’t run with a plain go test. Run them with go test -bench ., which prints how long one iteration takes (the numbers depend on your machine). Only reach for this when you have a real performance question. The testing package documentation covers benchmarks, fuzzing and helpers like t.Helper and t.TempDir.

Practice

Add this function to stats.go:

// Max returns the largest number in nums, or ErrEmpty if nums is empty.
func Max(nums []int) (int, error) {
	if len(nums) == 0 {
		return 0, ErrEmpty
	}
	best := nums[0]
	for _, n := range nums[1:] {
		if n > best {
			best = n
		}
	}
	return best, nil
}Code language: Go (go)

1. A table for Max. Write a table-driven TestMax that covers an empty slice (expecting ErrEmpty), a single number, the largest number first, the largest number last, and only negative numbers. Give each row a wantErr error field.

Show a solution
// max_test.go
package stats

import (
	"errors"
	"testing"
)

func TestMax(t *testing.T) {
	tests := []struct {
		name    string
		nums    []int
		want    int
		wantErr error
	}{
		{"empty", nil, 0, ErrEmpty},
		{"one number", []int{7}, 7, nil},
		{"max first", []int{9, 2, 5}, 9, nil},
		{"max last", []int{1, 2, 3}, 3, nil},
		{"all negative", []int{-5, -2, -9}, -2, nil},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Max(tt.nums)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Max(%v) error = %v, want %v", tt.nums, err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("Max(%v) = %d, want %d", tt.nums, got, tt.want)
			}
		})
	}
}Code language: Go (go)

errors.Is(nil, nil) is true, so the same check works for rows that expect no error. Running go test -v -run TestMax:

=== RUN   TestMax
=== RUN   TestMax/empty
=== RUN   TestMax/one_number
=== RUN   TestMax/max_first
=== RUN   TestMax/max_last
=== RUN   TestMax/all_negative
--- PASS: TestMax (0.00s)
    --- PASS: TestMax/empty (0.00s)
    --- PASS: TestMax/one_number (0.00s)
    --- PASS: TestMax/max_first (0.00s)
    --- PASS: TestMax/max_last (0.00s)
    --- PASS: TestMax/all_negative (0.00s)
PASS
ok  	example.com/stats	0.285sCode language: plaintext (plaintext)

2. An example for Max. Add ExampleMax to example_test.go. Print the result for []int{4, 8, 1} and the error for an empty slice. Hint: for output that spans several lines, put // Output: on its own line and each expected line in its own comment below it.

Show a solution
func ExampleMax() {
	fmt.Println(stats.Max([]int{4, 8, 1}))
	_, err := stats.Max(nil)
	fmt.Println(err)
	// Output:
	// 8 <nil>
	// stats: empty slice
}Code language: Go (go)

fmt.Println prints both return values of stats.Max, the number and the nil error. With both exercises done, go test -cover reports 100.0% again.

Key takeaways

  • Tests go in _test.go files as func TestXxx(t *testing.T), and go test (or go test ./...) runs them.
  • Compare with plain if and report with “got X, want Y” messages. t.Errorf keeps going; t.Fatalf stops the test.
  • Table-driven tests with t.Run make adding a case a one-line change and name each failure.
  • Test error paths too, with errors.Is. -cover and -coverprofile show what you missed.
  • ExampleXxx functions with // Output: are checked by go test and shown in the docs.