Testing and Benchmarking in Go
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Testing is part of the toolchain: go test is the whole framework. No assertion library, no test runner to configure. This chapter covers how to write tests, how to run them, and how to find what is slow.
Before you start
The examples are verified on Go 1.24 and use only the standard library.
The first test
// calc/calc.go
package calc
import "errors"
var ErrDivideByZero = errors.New("division by zero")
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
// calc/calc_test.go
package calc
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
got, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 5 {
t.Errorf("Divide(10, 2) = %v, want 5", got)
}
}
The rules:
- The file ends in
_test.goand lives in the same package (package calc) or an external test package (package calc_test). - The function is
TestXxx(t *testing.T)with an uppercaseXxx. t.Errorfrecords a failure and continues;t.Fatalfstops the current test function immediately.- Test files are not part of a normal build and never end up in the binary.
Table-driven tests
Cases become a slice; adding one is a single line:
func TestDivide_Table(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr error
}{
{name: "whole", a: 10, b: 2, want: 5},
{name: "fraction", a: 1, b: 4, want: 0.25},
{name: "negative", a: -8, b: 2, want: -4},
{name: "by zero", a: 1, b: 0, wantErr: ErrDivideByZero},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got = %v, want %v", got, tt.want)
}
})
}
}
t.Run creates a subtest whose name shows up in the output (TestDivide_Table/by_zero) and can be run alone:
go test ./... -run 'TestDivide_Table/by_zero'
Since Go 1.22 the loop variable is per iteration, so tt is safe to capture. The old tt := tt ritual is unnecessary — but explicit parameters still read more clearly in goroutines.
Helpers and cleanup
func newTestDB(t *testing.T) *sql.DB {
t.Helper() // failures point at the caller's line
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() }) // runs when the test and its subtests end
return db
}
t.TempDir() gives you a directory that is removed automatically — the right tool for anything file-related:
func TestWriteFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.txt")
if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "hi" {
t.Fatalf("read = %q, %v", data, err)
}
}
For parallel tests, call t.Parallel() at the top of the test function — and be sure nothing shares mutable state.
Testing an HTTP handler
func TestHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/hello?name=go", nil)
rec := httptest.NewRecorder()
hello(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "go") {
t.Errorf("body = %s", body)
}
}
httptest never binds a port, so these tests are fast and do not collide in CI.
Benchmarks
func BenchmarkJoin(b *testing.B) {
parts := []string{"a", "b", "c", "d"}
for b.Loop() { // Go 1.24 and later
_ = strings.Join(parts, ",")
}
}
The older form, for i := 0; i < b.N; i++, still works. Migrating matters because b.Loop() keeps the loop body from being optimised away and excludes setup from the measurement automatically.
go test -bench=. -benchmem ./...
BenchmarkJoin-8 12345678 95.2 ns/op 32 B/op 1 allocs/op
Three numbers matter: time per operation, bytes allocated per operation, and allocation count. Cutting allocs/op usually pays better than shaving nanoseconds — every allocation is future GC work.
To compare two implementations, use benchstat (golang.org/x/perf/cmd/benchstat):
go test -bench=. -count=10 > old.txt
# change the code
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt
A single run is mostly noise; sample repeatedly and compare the statistics.
Coverage
go test ./... -cover
go test ./... -coverprofile=cover.out
go tool cover -html=cover.out
Coverage measures executed lines, not assertion quality. 80% is not a goal — the interesting part is which branches are missing: error paths, boundary values, empty input.
Fuzzing
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Add("abc")
f.Fuzz(func(t *testing.T, s string) {
got := Reverse(Reverse(s))
if got != s {
t.Fatalf("double reverse = %q, original %q", got, s)
}
})
}
go test -fuzz=FuzzReverse -fuzztime=30s
The fuzzer generates inputs and writes any failing case to testdata/fuzz/, where go test replays it as a regression test from then on. Parsers, serialisers and string handling benefit the most.
Example tests
func ExampleDivide() {
q, _ := Divide(10, 4)
fmt.Println(q)
// Output: 2.5
}
The // Output: comment makes go test compare stdout, so the example is both a test and documentation that cannot drift.
Habits that hold up
- Keep tests next to the package under test; no separate
tests/tree. - Use in-package tests to reach unexported identifiers,
_testpackages to test only the public API. - One behaviour per test, and every table case needs a readable
name. - Run
go test ./... -racein CI. Concurrency bugs rarely show up otherwise. - Gate slow tests with
testing.Short():
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// ...
}
go test -short ./...
Common mistakes
1. Using t.Log as an assertion
Logging never fails a test. Assertions go through t.Error / t.Fatal.
2. Calling t.Fatalf from a goroutine
Fatalf must run on the test goroutine. In a worker, use t.Errorf and report through a channel.
3. Forgetting defer res.Body.Close()
Connections stay open and a large suite can exhaust them.
4. Tests that depend on order
Go does not guarantee order, and -shuffle exposes it. Each test prepares its own state.
5. Real network and real time
External services make CI flaky. Use httptest and injected interfaces; inject now func() time.Time instead of calling time.Now() inline.
6. Measuring setup in a benchmark
Initialise before the loop and call b.ResetTimer() when preparation is unavoidable.
7. Running go test without -race
Data races often stay invisible. Add -race to CI.
Summary
- The
testingpackage is enough: table-driven tests plust.Runis the default shape. t.Helpermoves failure lines to the caller,t.Cleanupandt.TempDirmanage resources.- Benchmarks: watch
ns/op,B/op,allocs/op; sample repeatedly before believing a difference. - Coverage finds untested branches, fuzzing finds crashes at the edges, examples document behaviour.
- CI should at least run
go test ./... -race.
Next: propagating cancellation and deadlines with context.