Skip to main content
Go · lesson 19 of 25
Go intermediate Lesson 19 of 25

Also part of: Software Testing

Testing in Go

Go testing run for real: a failing test, table-driven subtests, t.Error vs t.Fatal, httptest, examples, fuzzing that finds a bug, benchmarks, and coverage.

By SkillByExample · Updated

Go’s test tooling is built in: files ending in _test.go hold functions named TestXxx(t *testing.T), and go test finds and runs them. No framework is required. This lesson tests one small function, Reverse, from several angles, including a bug that only fuzzing finds.

A first test, failing

The code under test goes in reverse.go and its test in reverse_test.go, side by side in the same directory and package.

package textutil

// Reverse returns s with its characters in reverse order.
func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}
package textutil

import "testing"

func TestReverse(t *testing.T) {
	got := Reverse("hello")
	if got != "olleh" {
		t.Errorf("Reverse(%q) = %q, want %q", "hello", got, "olleh")
	}
}

func TestReverseUnicode(t *testing.T) {
	got := Reverse("héllo")
	if got != "olléh" {
		t.Errorf("Reverse(%q) = %q, want %q", "héllo", got, "olléh")
	}
}
$ go test
--- FAIL: TestReverseUnicode (0.00s)
    reverse_test.go:15: Reverse("héllo") = "oll\xa9\xc3h", want "olléh"
FAIL
exit status 1
FAIL	example.com/first	0.003s

The ASCII test passed; the Unicode one failed, because reversing bytes splits the two-byte é. The message format Reverse(input) = got, want expected is the Go convention: a failure should be readable without opening the test. go test exits non-zero on any failure, so CI fails too. The trailing duration (0.002s style) varies from run to run.

The fix is to reverse runes. With this reverse.go and the same test file:

package textutil

// Reverse returns s with its characters in reverse order.
func Reverse(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r)
}
$ go test -v
=== RUN   TestReverse
--- PASS: TestReverse (0.00s)
=== RUN   TestReverseUnicode
--- PASS: TestReverseUnicode (0.00s)
PASS
ok  	example.com/fixed	0.002s

-v lists every test with its result and time.

Table-driven tests and subtests

Instead of one function per case, list the cases in a table and run each as a named subtest with t.Run. This replaces reverse_test.go, testing the fixed Reverse:

package textutil

import "testing"

func TestReverse(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"empty", "", ""},
		{"single", "a", "a"},
		{"ascii", "hello", "olleh"},
		{"unicode", "héllo", "olléh"},
		{"wrong on purpose", "abc", "abc"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Reverse(tt.in); got != tt.want {
				t.Errorf("Reverse(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}
$ go test
--- FAIL: TestReverse (0.00s)
    --- FAIL: TestReverse/wrong_on_purpose (0.00s)
        reverse_test.go:18: Reverse("abc") = "cba", want "abc"
FAIL
exit status 1
FAIL	example.com/table	0.002s
$ go test -v -run 'TestReverse/unicode'
=== RUN   TestReverse
=== RUN   TestReverse/unicode
--- PASS: TestReverse (0.00s)
    --- PASS: TestReverse/unicode (0.00s)
PASS
ok  	example.com/table	0.002s

The deliberately wrong case shows how a subtest failure reads: the full name TestReverse/wrong_on_purpose (spaces become underscores) points straight at the row. -run takes a regular expression, and a / selects subtests, so you can rerun one case while debugging. Adding a case is one line in the table.

t.Error vs t.Fatal

t.Error records a failure and keeps going; t.Fatal records it and stops the test.

package config

import (
	"os"
	"testing"
)

func TestLoad(t *testing.T) {
	data, err := os.ReadFile("testdata/missing.json")
	if err != nil {
		t.Fatalf("setup: %v", err) // nothing below can work without data
	}
	t.Log("never reached", len(data))
}

func TestChecks(t *testing.T) {
	if 1+1 != 3 {
		t.Error("first check failed")
	}
	if len("go") != 3 {
		t.Error("second check failed") // still runs: t.Error doesn't stop
	}
}
$ go test
--- FAIL: TestLoad (0.00s)
    fatal_test.go:11: setup: open testdata/missing.json: no such file or directory
--- FAIL: TestChecks (0.00s)
    fatal_test.go:18: first check failed
    fatal_test.go:21: second check failed
FAIL
exit status 1
FAIL	example.com/fatal	0.002s

TestChecks reported both failures in one run, which is what you want for independent checks. TestLoad stopped at Fatalf, which is right when later steps depend on the failed one. Fatal must be called from the test’s own goroutine, not from goroutines it starts.

Testing an HTTP handler

net/http/httptest records a handler’s response without opening a port.

package web

import (
	"fmt"
	"net/http"
)

func Hello(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name")
	if name == "" {
		http.Error(w, "missing name", http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "hello, %s", name)
}
package web

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHello(t *testing.T) {
	cases := []struct {
		url, body string
		code      int
	}{
		{"/hello?name=ann", "hello, ann", http.StatusOK},
		{"/hello", "missing name\n", http.StatusBadRequest},
	}
	for _, c := range cases {
		rec := httptest.NewRecorder()
		Hello(rec, httptest.NewRequest("GET", c.url, nil))
		if rec.Code != c.code || rec.Body.String() != c.body {
			t.Errorf("GET %s = %d %q, want %d %q", c.url, rec.Code, rec.Body.String(), c.code, c.body)
		}
	}
}
$ go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok  	example.com/httptest	0.004s

The handler is called as a plain function with a fake request and a recorder; no network is involved, so the test is fast and can’t flake on ports. Note the \n in the expected 400 body: http.Error appends a newline. The HTTP server lesson uses httptest.NewServer for end-to-end tests.

Example functions are tests too

A function named ExampleXxx with an // Output: comment is compiled, run, and its printed output compared — and it appears in the package documentation.

package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleReverse() {
	fmt.Println(textutil.Reverse("Go!"))
	fmt.Println(textutil.Reverse("añb"))
	// Output:
	// !oG
	// bña
}
$ go test -v
=== RUN   ExampleReverse
--- PASS: ExampleReverse (0.00s)
PASS
ok  	example.com/textutil	0.003s

The test package is textutil_test, so it can only use exported names — exactly like a real user of the package. If Reverse regressed, the printed output would no longer match the comment and go test would fail, so the documentation can’t silently go stale.

Fuzzing finds what the table missed

A fuzz test states a property that must hold for any input, and go test -fuzz generates inputs looking for a counterexample. Here the property is “reversing twice gives back the original”, run against the fixed, rune-based Reverse:

package textutil

import "testing"

func FuzzReverse(f *testing.F) {
	f.Add("hello")
	f.Add("héllo")
	f.Fuzz(func(t *testing.T, s string) {
		if got := Reverse(Reverse(s)); got != s {
			t.Errorf("Reverse(Reverse(%q)) = %q", s, got)
		}
	})
}
$ go test -fuzz=FuzzReverse -fuzztime=30s 2>&1 | grep -v '^fuzz: elapsed' | head -12
fuzz: minimizing 48-byte failing input file
--- FAIL: FuzzReverse (0.02s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_test.go:10: Reverse(Reverse("\xc3")) = "�"

    Failing input written to testdata/fuzz/FuzzReverse/feea388fada3a73e
    To re-run:
    go test -run=FuzzReverse/feea388fada3a73e
FAIL
exit status 1
FAIL	example.com/fuzz	0.018s

Every table case passed, yet the fuzzer found a failing input within moments: a string containing a byte sequence that isn’t valid UTF-8. Converting it to []rune replaces the bad byte with U+FFFD (�), so the round trip can’t restore it. (Progress lines and the exact input vary per run and are trimmed here.) The failing input was saved under testdata/fuzz/FuzzReverse/, so a plain go test now replays it every time. Whether to reject invalid UTF-8 or document the behaviour is a design decision — the fuzzer’s job is to make you take it.

Benchmarks

A BenchmarkXxx(b *testing.B) function runs the code repeatedly; b.Loop() (Go 1.24+) decides how many iterations are needed for a stable measurement.

package textutil

import (
	"strings"
	"testing"
)

var input = strings.Repeat("gopher ", 100)

func BenchmarkReverseRunes(b *testing.B) {
	for b.Loop() {
		Reverse(input)
	}
}

func BenchmarkReverseBytes(b *testing.B) {
	for b.Loop() {
		ReverseBytes(input)
	}
}
$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: example.com/bench
cpu: Intel(R) Xeon(R) Processor @ 2.10GHz
BenchmarkReverseRunes-4   	  217885	      5447 ns/op	    3776 B/op	       2 allocs/op
BenchmarkReverseBytes-4   	 1519426	       787.0 ns/op	    1408 B/op	       2 allocs/op
PASS
ok  	example.com/bench	2.388s

These numbers are from one run on this machine and will differ on yours, and from run to run. Read them relatively: both versions allocate twice (the working slice and the result string), but the rune version’s slice takes 4 bytes per character and it must decode and re-encode UTF-8, so it costs several times more time and memory per call. -benchmem adds the allocation columns. go test doesn’t run benchmarks unless -bench is given, and to compare two versions reliably, run each several times (-count=10) and compare with benchstat.

Coverage

-cover reports the share of statements the tests executed.

package grade

func Letter(score int) string {
	switch {
	case score < 0 || score > 100:
		return "invalid"
	case score >= 90:
		return "A"
	case score >= 80:
		return "B"
	}
	return "F"
}
package grade

import "testing"

func TestLetter(t *testing.T) {
	for score, want := range map[int]string{95: "A", 85: "B"} {
		if got := Letter(score); got != want {
			t.Errorf("Letter(%d) = %q, want %q", score, got, want)
		}
	}
}
$ go test -cover
PASS
coverage: 60.0% of statements
ok  	example.com/cover	0.003s
$ go test -coverprofile=c.out >/dev/null && go tool cover -func=c.out
example.com/cover/grade.go:3:	Letter		60.0%
total:				(statements)	60.0%

Two of the four return paths are untested, so coverage is 60%. Coverage tells you which lines never ran, not whether the tests are good; go tool cover -html=c.out opens the same data colour-coded in a browser.

Practice

1. Add table cases to table’s test for a string with an emoji ("go🚀") and remove the wrong-on-purpose row so go test passes.

Solution
package textutil

import "testing"

func TestReverse(t *testing.T) {
	tests := []struct{ name, in, want string }{
		{"empty", "", ""},
		{"ascii", "hello", "olleh"},
		{"unicode", "héllo", "olléh"},
		{"emoji", "go🚀", "🚀og"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Reverse(tt.in); got != tt.want {
				t.Errorf("Reverse(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}
$ go test -v
=== RUN   TestReverse
=== RUN   TestReverse/empty
=== RUN   TestReverse/ascii
=== RUN   TestReverse/unicode
=== RUN   TestReverse/emoji
--- PASS: TestReverse (0.00s)
    --- PASS: TestReverse/empty (0.00s)
    --- PASS: TestReverse/ascii (0.00s)
    --- PASS: TestReverse/unicode (0.00s)
    --- PASS: TestReverse/emoji (0.00s)
PASS
ok  	example.com/p1	0.002s

The emoji is a single rune (four bytes), so the rune-based Reverse keeps it intact.

2. Bring grade to 100% coverage.

Solution
package grade

import "testing"

func TestLetter(t *testing.T) {
	cases := map[int]string{95: "A", 85: "B", 10: "F", -1: "invalid", 101: "invalid"}
	for score, want := range cases {
		if got := Letter(score); got != want {
			t.Errorf("Letter(%d) = %q, want %q", score, got, want)
		}
	}
}
$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/p2	0.003s

-1 and 101 cover both sides of the ||, though line coverage only needs one of them — another reminder that 100% coverage is not the same as complete testing.

3. Write a test that uses t.TempDir() to create a file, write "ok" to it, and read it back.

Solution
package files

import (
	"os"
	"path/filepath"
	"testing"
)

func TestRoundTrip(t *testing.T) {
	path := filepath.Join(t.TempDir(), "x.txt") // removed automatically after the test
	if err := os.WriteFile(path, []byte("ok"), 0o644); err != nil {
		t.Fatal(err)
	}
	got, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != "ok" {
		t.Errorf("read %q, want %q", got, "ok")
	}
}
$ go test -v
=== RUN   TestRoundTrip
--- PASS: TestRoundTrip (0.00s)
PASS
ok  	example.com/p3	0.004s

t.TempDir gives each test its own directory and deletes it when the test ends, so tests can’t interfere through the file system. t.Fatal fits here: there’s no point reading a file that couldn’t be written.

4. Write ExampleSum for a Sum(nums ...int) int function so that go test verifies the output 6.

Solution
package mathx

func Sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}
package mathx

import "fmt"

func ExampleSum() {
	fmt.Println(Sum(1, 2, 3))
	// Output: 6
}
$ go test -v
=== RUN   ExampleSum
--- PASS: ExampleSum (0.00s)
PASS
ok  	example.com/p4	0.003s

An example with no // Output: comment is compiled but not run.

Frequently Asked Questions

How do I run only a specific test in Go?
Pass a regular expression to -run: go test -run TestReverse runs every test whose name matches. Subtests are addressed with a slash, as in go test -run 'TestReverse/unicode', and -v lists each test as it runs.
What are table-driven tests?
A table-driven test lists its cases as a slice of structs holding inputs and expected outputs, then loops over them, usually calling t.Run so each case is a named subtest. Adding a case is one line, and a failure names exactly which case broke.
What is the difference between t.Error and t.Fatal?
t.Error and t.Errorf record a failure and let the test continue, so you see every problem in one run. t.Fatal and t.Fatalf record the failure and stop the current test immediately; use them when later steps would be meaningless or would panic, such as after a failed setup.
What does fuzzing do in Go?
A fuzz test, func FuzzX(f *testing.F), runs a property check against inputs the toolchain generates and mutates. go test -fuzz=FuzzX searches until a check fails, then saves the failing input under testdata/fuzz so plain go test replays it as a regression test from then on.
How do I measure test coverage in Go?
go test -cover prints the percentage of statements executed by the tests. go test -coverprofile=c.out followed by go tool cover -func=c.out lists coverage per function, and go tool cover -html=c.out shows covered and uncovered lines in a browser.