Go · 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.