Testing
Testing is built into Go: no framework to install, no special runner. Tests are ordinary functions in files ending in _test.go, and go test finds and runs them. This lesson covers writing tests, reading failures, table-driven tests and subtests, helpers and cleanup, testing HTTP handlers, example functions that double as documentation, coverage, and fuzzing.
A first test
Here's a small package to test, in a module called example.com/textutil:
module example.com/textutil go 1.27
// Package textutil has small text helpers. package textutil import "strings" // Initials returns the upper-case first letter of each word. func Initials(name string) string { var b strings.Builder for _, w := range strings.Fields(name) { b.WriteString(strings.ToUpper(w[:1])) } return b.String() }
Tests live next to the code, in a file whose name ends in _test.go. Each test is a function named TestXxx that takes a *testing.T:
package textutil import "testing" func TestInitials(t *testing.T) { got := Initials("grace brewster hopper") want := "GBH" if got != want { t.Errorf("Initials(%q) = %q, want %q", "grace brewster hopper", got, want) } }
go test -v
=== RUN TestInitials --- PASS: TestInitials (0.00s) PASS ok example.com/textutil 2.196s
Without -v, go test prints only a short summary when everything passes, ending with the ok line. Use go test ./... to test every package in the module. _test.go files are only compiled by go test, never into your program.
When a test fails
t.Errorf records a failure and carries on; t.Fatalf records it and stops that test immediately (use it when continuing makes no sense). A good failure message shows the input, what you got, and what you wanted:
package textutil import "testing" func TestInitialsEdgeCases(t *testing.T) { if got := Initials(""); got != "" { t.Errorf("Initials(\"\") = %q, want empty", got) } if got := Initials("élodie"); got != "É" { t.Errorf("Initials(%q) = %q, want %q", "élodie", got, "É") } }
--- FAIL: TestInitialsEdgeCases (0.00s)
textutil_test.go:10: Initials("élodie") = "�", want "É"
FAIL
exit status 1
FAIL example.com/textutil 2.134sThe test found a real bug: w[:1] takes the first byte, which cuts é in half (lesson 19), and strings.ToUpper then replaces that broken byte with the Unicode replacement character �. The fix is to take the first rune — try it in the exercise at the end.
Table-driven tests and subtests
The most common Go testing style lists cases in a table and loops over them. t.Run runs each case as a named subtest, so failures say exactly which case broke, and you can run one case with -run:
package textutil import "testing" func TestInitialsTable(t *testing.T) { tests := []struct { name, in, want string }{ {"three words", "grace brewster hopper", "GBH"}, {"extra spaces", " ada lovelace ", "AL"}, {"one word", "linus", "L"}, {"empty", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Initials(tt.in); got != tt.want { t.Errorf("Initials(%q) = %q, want %q", tt.in, got, tt.want) } }) } }
go test -v -run TestInitialsTable
=== RUN TestInitialsTable
=== RUN TestInitialsTable/three_words
=== RUN TestInitialsTable/extra_spaces
=== RUN TestInitialsTable/one_word
=== RUN TestInitialsTable/empty
--- PASS: TestInitialsTable (0.00s)
--- PASS: TestInitialsTable/three_words (0.00s)
--- PASS: TestInitialsTable/extra_spaces (0.00s)
--- PASS: TestInitialsTable/one_word (0.00s)
--- PASS: TestInitialsTable/empty (0.00s)
PASS
ok example.com/textutil 2.071sAdding a case is one line. -run takes a regular expression, and spaces in subtest names become underscores, so -run 'TestInitialsTable/one_word' runs a single case. Call t.Parallel() at the start of a test or subtest to let it run alongside others.
Helpers, temporary files and cleanup
t.Helper()in a helper function makes failures report the caller's line instead of the helper's.t.TempDir()creates a temporary folder that's deleted automatically after the test.t.Cleanup(f)registers a function to run when the test finishes — likedefer, but usable from helpers.t.Context()returns a context that's cancelled when the test ends.
package main import ( "os" "path/filepath" "strings" "testing" ) func countWords(path string) (int, error) { data, err := os.ReadFile(path) if err != nil { return 0, err } return len(strings.Fields(string(data))), nil } func writeFile(t *testing.T, name, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } func TestCountWords(t *testing.T) { path := writeFile(t, "a.txt", "one two three\nfour") n, err := countWords(path) if err != nil { t.Fatal(err) } if n != 4 { t.Errorf("got %d words, want 4", n) } if _, err := countWords(filepath.Join(t.TempDir(), "missing")); err == nil { t.Error("expected an error for a missing file") } }
go test
PASS ok example 2.080s
Testing HTTP handlers
net/http/httptest, which you met in lesson 61, makes handler tests fast and self-contained:
package main import ( "fmt" "net/http" "net/http/httptest" "testing" ) func greet(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } fmt.Fprintf(w, "Hello, %s!", name) } func TestGreet(t *testing.T) { tests := []struct { url string wantStatus int wantBody string }{ {"/greet?name=Ana", 200, "Hello, Ana!"}, {"/greet", 400, "name is required\n"}, } for _, tt := range tests { rec := httptest.NewRecorder() greet(rec, httptest.NewRequest("GET", tt.url, nil)) if rec.Code != tt.wantStatus || rec.Body.String() != tt.wantBody { t.Errorf("GET %s = %d %q, want %d %q", tt.url, rec.Code, rec.Body.String(), tt.wantStatus, tt.wantBody) } } }
=== RUN TestGreet --- PASS: TestGreet (0.00s) PASS ok example 2.050s
Examples as tests
A function named ExampleXxx in a test file is shown as documentation on pkg.go.dev and run as a test: its printed output must match the // Output: comment:
package main import ( "fmt" "strings" ) func ExampleTitle() { fmt.Println(strings.ToTitle("go")) fmt.Println(strings.Repeat("ab", 2)) // Output: // GO // abab }
=== RUN ExampleTitle --- PASS: ExampleTitle (0.00s) PASS ok example 2.093s
Coverage
go test -cover reports what percentage of statements your tests run. -coverprofile=c.out followed by go tool cover -html=c.out shows the covered and uncovered lines in a browser. Coverage tells you what's untested, not whether the tests are good — aim for meaningful tests of the tricky parts rather than a number.
Fuzzing
A fuzz test, FuzzXxx(f *testing.F), lets Go generate random inputs to find cases you didn't think of. You state a property that should always hold; the fuzzer hunts for an input that breaks it. Here the property is "if the input contains only valid characters, the initials shouldn't contain the replacement character":
package textutil import ( "strings" "testing" "unicode/utf8" ) func FuzzInitials(f *testing.F) { f.Add("grace hopper") // seed input f.Fuzz(func(t *testing.T, s string) { out := Initials(s) if strings.ContainsRune(out, utf8.RuneError) && !strings.ContainsRune(s, utf8.RuneError) { t.Errorf("Initials(%q) = %q: a valid character was broken", s, out) } }) }
go test -fuzz=FuzzInitials
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 10 workers
fuzz: elapsed: 0s, execs: 2314 (15764/sec), new interesting: 15 (total: 16)
--- FAIL: FuzzInitials (0.15s)
--- FAIL: FuzzInitials (0.00s)
fuzz_test.go:14: Initials("؊") = "�": a valid character was broken
Failing input written to testdata/fuzz/FuzzInitials/59d8a8cbc60e0fec
To re-run:
go test -run=FuzzInitials/59d8a8cbc60e0fec
FAIL
exit status 1
FAIL example.com/textutil 1.129sIn a fraction of a second the fuzzer found a character (an Arabic sign) that Initials breaks. The failing input is saved under testdata/fuzz, so from now on a plain go test re-runs it as a regression test. Without -fuzz, go test runs only the seed inputs and saved cases.
Useful go test flags
| Flag | Effect |
|---|---|
-v | Show each test as it runs |
-run Regexp | Run only matching tests and subtests |
-race | Enable the race detector |
-count=1 | Don't use cached results; run again |
-cover | Report coverage |
-short | Tests can check testing.Short() and skip slow work |
-fuzz Fuzz | Run a fuzz test |
Try it yourself
Fix Initials so it works with any first letter, using utf8.DecodeRuneInString and unicode.ToUpper, then run go test -v with a table that includes "élodie".