Claude Fable 5.1 & GPT-6 Astra packages are live

Go Testing

Free · MIT

Testing in Go with the standard toolchain — table-driven subtests, t.Helper, httptest and fstest, golden files, benchmarks, the race detector,…

238 lines8.3 KB Mistral Testing
Target models
Mistral Medium 3.5Mistral Large 3Mistral Small 4Mistral FamilyFuture Mistral Models
Name
go-testing
Category
Testing
Description
Testing in Go with the standard toolchain — table-driven subtests, t.Helper, httptest and fstest, golden files, benchmarks, the race detector, fuzzing, and interfaces instead of mock frameworks.
License
MIT
Author
Agent.md maintainers
Last verified
2026-09-13
Reviewed by
unreviewed

#How to apply this file

Each section opens with one imperative line; apply every rule in the section it introduces. Do not summarise or skip a section.


#Purpose

Rules for tests in Go. The testing package is deliberately small; the patterns below are how the community fills the gap without a framework. Reach for a dependency only after the standard library has demonstrably failed you.

General testing principles are Testing/unit; HTTP handler design is Backend/go-http.


#Table-driven subtests

[INST] Apply every rule in this section: Table-driven subtests. [/INST]

go
func TestDiscount(t *testing.T) {
    tests := []struct {
        name string
        qty  int
        want int
    }{
        {"below threshold", 9, 0},
        {"at threshold", 10, 100},
        {"above threshold", 11, 110},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            if got := Discount(tc.qty); got != tc.want {
                t.Errorf("Discount(%d) = %d, want %d", tc.qty, got, tc.want)
            }
        })
    }
}
  • One table, one t.Run per case, named so go test -run TestDiscount/at_threshold isolates it.
  • t.Errorf reports and continues; t.Fatalf stops the subtest. Use Fatal only when continuing is meaningless (setup failed).
  • The failure message states input, got, want. "wrong result" tells the reader nothing at 3am.
  • t.Parallel() inside subtests runs cases concurrently and is the cheapest way to make -race see something. From 1.22 the loop variable is per-iteration; before that, tc := tc.

#Helpers and cleanup

[INST] Apply every rule in this section: Helpers and cleanup. [/INST]

go
func newStore(t *testing.T) *Store {
    t.Helper()                                  // failures point at the caller
    db := openTestDB(t)
    t.Cleanup(func() { db.Close() })            // runs after the test, in LIFO order
    return NewStore(db)
}
  • t.Helper() as the first line of every helper that can fail. Without it, the reported line is inside the helper, not the test.
  • t.Cleanup instead of defer in helpers: defer runs when the helper returns, which is before the test uses the thing.
  • t.TempDir() for files — created per test, removed automatically.
  • t.Setenv sets an environment variable for the test's duration and is incompatible with t.Parallel() by design; if you need both, inject config instead of reading the environment.

#HTTP handlers with httptest

[INST] Apply every rule in this section: HTTP handlers with httptest. [/INST]

go
func TestGetOrder(t *testing.T) {
    h := New(fakeStore{orders: map[string]Order{"o1": {ID: "o1"}}}, slog.Default())

    req := httptest.NewRequest(http.MethodGet, "/orders/o1", nil)
    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body)
    }
}
  • httptest.NewRequest + httptest.NewRecorder test a handler with no port and no network. Use this for every handler test.
  • httptest.NewServer is for testing an HTTP client against a real listener. Do not use it to test your own handlers.
  • Assert the denial cases — unauthenticated, wrong tenant, malformed body — before the happy path. They are the tests that catch a missing check.

#Fakes over mocks

[INST] Apply every rule in this section: Fakes over mocks. [/INST]

go
// The consumer's interface is small, so a fake is ten lines. No framework.
type fakeStore struct{ orders map[string]Order }

func (f fakeStore) Get(_ context.Context, id string) (Order, error) {
    o, ok := f.orders[id]
    if !ok { return Order{}, ErrNotFound }
    return o, nil
}
  • Small consumer-side interfaces make hand-written fakes trivial. A generated mock with call-count assertions couples the test to the implementation.
  • Fake the boundary you own (Store), not the library underneath (*sql.DB). For the database itself, run a real one in Docker: Testing/integration.
  • testing/fstest.MapFS fakes a filesystem; io.Reader from strings.NewReader fakes input. Design for interfaces and most mocking needs disappear.

#Golden files

[INST] Apply every rule in this section: Golden files. [/INST]

go
var update = flag.Bool("update", false, "rewrite golden files")

func TestRender(t *testing.T) {
    got := Render(input)
    golden := filepath.Join("testdata", t.Name()+".golden")
    if *update {
        os.WriteFile(golden, got, 0o644)
    }
    want, _ := os.ReadFile(golden)
    if !bytes.Equal(got, want) {
        t.Errorf("output differs from %s\n%s", golden, diff(want, got))
    }
}
  • Large expected outputs (rendered templates, generated code, JSON) live in testdata/, which go build ignores by convention.
  • go test -update regenerates them; the diff in the PR is the review.

#Benchmarks, race, fuzz

[INST] Apply every rule in this section: Benchmarks, race, fuzz. [/INST]

go
func BenchmarkParse(b *testing.B) {
    for i := 0; i < b.N; i++ {          // or `for range b.N` from 1.24
        Parse(sample)
    }
}

func FuzzParse(f *testing.F) {
    f.Add("valid input")
    f.Fuzz(func(t *testing.T, s string) {
        _, _ = Parse(s)                 // must not panic on any input
    })
}
  • go test -bench . -benchmem reports ns/op and allocs/op. Run benchstat on before/after output; a single run is noise.
  • go test -race ./... in CI, always. → Backend/go-concurrency
  • Fuzz anything that parses untrusted bytes. go test -fuzz FuzzParse for ten minutes finds panics a table never would; keep found crashers in testdata/fuzz/ as regression cases.

#Anti-patterns

[INST] Apply every rule in this section: Anti-patterns. [/INST]

Anti-patternWhy it failsFix
One giant test function with if chainsFirst failure hides the restTable-driven subtests
Helper without t.Helper()Failure points into the helperFirst line of every helper
defer cleanup inside a helperRuns before the test uses the resourcet.Cleanup
httptest.NewServer for handler testsSlow, port-bound, unnecessaryNewRequest + NewRecorder
Mocking *sql.DBTests your guess about the driverFake your Store; real DB for integration
Mock framework with call countsCouples tests to implementationHand-written fakes
time.Sleep to wait for goroutinesFlaky under loadChannels, sync.WaitGroup, or errgroup
Reading time.Now() in code under testNon-deterministicInject a clock
t.Setenv with t.Parallel()Panics — by designInject config
Skipping -raceData races shipGate CI on it
Golden files edited by handDrift from real output-update flag regenerates them
Benchmarking once and trusting the numberNoisebenchstat across runs

#Checklist

  • Verify: Tests are table-driven with named t.Run subtests
  • Verify: Failure messages include input, got, and want
  • Verify: Subtests call t.Parallel() where the code under test is pure or safe
  • Verify: Every helper starts with t.Helper() and uses t.Cleanup
  • Verify: Temporary files use t.TempDir()
  • Verify: Handlers are tested with httptest.NewRequest and NewRecorder
  • Verify: Denial and error paths are asserted, not only the happy path
  • Verify: Fakes implement the consumer's small interface; no *sql.DB mocks
  • Verify: Large expected outputs are golden files under testdata/ with an -update flag
  • Verify: go test -race ./... runs in CI
  • Verify: Parsers of untrusted input have a Fuzz target with crashers kept as regressions
  • Verify: Benchmarks are compared with benchstat, never a single run