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:

GO.MOD
module example.com/textutil

go 1.27
GO textutil.go
// 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:

GO textutil_test.go
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
Output
=== 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:

GO textutil_test.go
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, "É")
	}
}
Output
--- FAIL: TestInitialsEdgeCases (0.00s)
    textutil_test.go:10: Initials("élodie") = "�", want "É"
FAIL
exit status 1
FAIL	example.com/textutil	2.134s

The 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:

GO textutil_test.go
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
Output
=== 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.071s

Adding 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 — like defer, but usable from helpers.
  • t.Context() returns a context that's cancelled when the test ends.
GO words_test.go
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
Output
PASS
ok  	example	2.080s

Testing HTTP handlers

net/http/httptest, which you met in lesson 61, makes handler tests fast and self-contained:

GO main_test.go
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)
		}
	}
}
Output
=== 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:

GO example_test.go
package main

import (
	"fmt"
	"strings"
)

func ExampleTitle() {
	fmt.Println(strings.ToTitle("go"))
	fmt.Println(strings.Repeat("ab", 2))
	// Output:
	// GO
	// abab
}
Output
=== 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":

GO fuzz_test.go
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
Output (one run; the input found is random)
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.129s

In 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

FlagEffect
-vShow each test as it runs
-run RegexpRun only matching tests and subtests
-raceEnable the race detector
-count=1Don't use cached results; run again
-coverReport coverage
-shortTests can check testing.Short() and skip slow work
-fuzz FuzzRun 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".

Try it yourself · textutil.go
Edit the code, copy it into textutil.go in a new folder and run it with go run textutil.go (Go 1.27 or later). How to install Go
Next: benchmarks and profiling.