Foundations Updated 2026-09 View as Markdown

Testing

Testing ships with the language. Table-driven tests, subtests, benchmarks and the race detector all come from one command.

Go's test framework is part of the standard library and the toolchain. No dependency, no configuration.

pricing.go
package pricing

func Discount(total, threshold int, pct float64) int {
    if total < threshold {
        return total
    }
    return total - int(float64(total)*pct)
}
pricing_test.go
package pricing

import "testing"

func TestDiscountBelowThreshold(t *testing.T) {
    got := Discount(500, 1000, 0.1)
    if got != 500 {
        t.Errorf("Discount(500, 1000, 0.1) = %d, want 500", got)
    }
}
shell
go test ./...
go test -v ./pricing          # show each test
go test -run TestDiscount ./pricing

The rules: the file ends in _test.go, the function starts with Test, and it takes *testing.T.

t.Errorf records a failure and continues; t.Fatalf stops the test immediately. Use Fatalf when continuing would panic — a nil result you are about to dereference.

Error messages that help#

Go has no assertion library by design, so the message you write is the diagnostic. The convention:

go
t.Errorf("Discount(%d, %d, %.2f) = %d, want %d", total, threshold, pct, got, want)

Input, actual, expected. A failure should tell you what happened without opening the test.

Table-driven tests#

The dominant Go testing idiom. One test function, many cases:

go
func TestDiscount(t *testing.T) {
    tests := []struct {
        name      string
        total     int
        threshold int
        pct       float64
        want      int
    }{
        {"below threshold", 500, 1000, 0.1, 500},
        {"at threshold", 1000, 1000, 0.1, 900},
        {"above threshold", 2000, 1000, 0.1, 1800},
        {"zero percent", 2000, 1000, 0, 2000},
        {"zero total", 0, 1000, 0.1, 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Discount(tt.total, tt.threshold, tt.pct)
            if got != tt.want {
                t.Errorf("Discount(%d, %d, %.2f) = %d, want %d",
                    tt.total, tt.threshold, tt.pct, got, tt.want)
            }
        })
    }
}

t.Run creates a subtest, so a failure names the case:

--- FAIL: TestDiscount/at_threshold

Adding a case is one line. That is what makes this style stick — it lowers the cost of testing the boundary conditions people otherwise skip.

Helpers#

go
func mustUser(t *testing.T, id string) *User {
    t.Helper()                  // failures report the CALLER's line
    u, err := LoadUser(id)
    if err != nil {
        t.Fatalf("LoadUser(%q): %v", id, err)
    }
    return u
}

t.Helper() is the important line — without it every failure points at the helper instead of the test that called it.

Setup and cleanup#

go
func TestWithTempDir(t *testing.T) {
    dir := t.TempDir()       // created, and removed automatically
    f := filepath.Join(dir, "data.json")

    srv := startServer(t)
    t.Cleanup(func() { srv.Close() })    // runs at the end, even on failure
    // ...
}

t.TempDir() and t.Cleanup() remove most of the bookkeeping. t.Context() gives you a context cancelled when the test finishes.

Testing HTTP#

httptest covers both directions — a fake server for your client, and a fake request for your handler.

go
func TestHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    rec := httptest.NewRecorder()

    Handler(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want 200", rec.Code)
    }
    if !strings.Contains(rec.Body.String(), "ada") {
        t.Errorf("body = %q, missing user", rec.Body.String())
    }
}

func TestClientRetries(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusTooManyRequests)
    }))
    defer srv.Close()

    _, err := NewClient(srv.URL).Get(t.Context(), "/x")
    if err == nil {
        t.Fatal("expected an error after retries are exhausted")
    }
}

Comparing structs#

== works for comparable types; for anything with slices or maps use go-cmp:

go
if diff := cmp.Diff(want, got); diff != "" {
    t.Errorf("mismatch (-want +got):\n%s", diff)
}

The diff output is the reason — it shows the one field that differs rather than dumping two large structs.

The flags that matter#

shell
go test -race ./...           # data race detector — always
go test -count=1 ./...        # disable the result cache
go test -cover ./...
go test -coverprofile=c.out ./... && go tool cover -html=c.out
go test -run TestX -v ./pkg
go test -short ./...          # skip long tests via testing.Short()

-race is not optional for anything concurrent. It roughly doubles runtime and finds a class of bug that is otherwise found in production, non-reproducibly. -count=1 matters when iterating, because a cached pass is not a pass.

Benchmarks#

go
func BenchmarkDiscount(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        Discount(2000, 1000, 0.1)
    }
}
shell
go test -bench=. -benchmem ./...
go test -bench=. -count=10 ./... > new.txt && benchstat old.txt new.txt

-benchmem reports allocations per operation, which is a steadier signal than nanoseconds. benchstat tells you whether a difference is real or noise — a single run is not evidence.

Fuzzing#

go
func FuzzParse(f *testing.F) {
    f.Add("key=value")                   // seed corpus
    f.Fuzz(func(t *testing.T, input string) {
        _, _ = Parse(input)              // must not panic on any input
    })
}
shell
go test -fuzz=FuzzParse -fuzztime=60s ./pkg

Worth writing for anything that parses input you do not control.

Exercise#

go
// Given: func Slugify(s string) string
// Write a table-driven test covering:
//   normal text, text with punctuation, leading/trailing spaces,
//   an empty string, and a non-ASCII string.
// Use t.Run subtests and a helpful error message format.

Common questions#

Should I use testify or another assertion library?#

You do not need one. Table-driven tests plus cmp.Diff cover almost everything, with one fewer dependency and no assertion DSL for readers to learn. Testify is widely used and fine if your team prefers it — just do not mix both styles.

What coverage percentage should I target?#

None. A target gets satisfied by tests that execute code without asserting anything useful, which is worse than no test because it looks like coverage. Use the coverage report to find untested paths that matter, then judge which are worth testing.

Internal or external test package?#

package pricing gives access to unexported identifiers; package pricing_test forces you to use the public API, which is a better test of the design. Use the internal form for genuinely internal logic and the external form for the package's contract.

Get the Go agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.