Go Testing

Table-driven tests, subtests, benchmarks, helpers, and setup patterns โ€” the full testing toolkit.

testing.T Table-driven t.Run Benchmarks t.Helper t.Cleanup TestMain Fakes
โœ…

Writing Tests

โ„น๏ธ
Test files end in _test.go and are excluded from normal builds. Test functions must be named TestXxx (capital X) and take a single *testing.T argument. Run with go test ./....
Basic test structure TestXxx
// math_test.go
package math_test

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5

    if got != want {
        t.Errorf("Add(2, 3) = %d; want %d", got, want)
    }
}
Error vs Fatal t.Error / t.Fatal
// Errorf โ€” marks failed, test continues
t.Errorf("got %v, want %v", got, want)

// Fatalf โ€” marks failed, stops test immediately
// Use when continuing would panic or give misleading failures
t.Fatalf("setup failed: %v", err)

// Log โ€” printed only on failure (or with -v)
t.Logf("input was: %v", input)

// Skip โ€” mark as skipped, not failed
if testing.Short() {
    t.Skip("skipping in short mode")
}
Running tests go test
// Run all tests in current package
go test .

// Run all tests recursively
go test ./...

// Verbose output โ€” show all test names
go test -v ./...

// Run only tests matching a pattern
go test -run TestAdd ./...
go test -run TestAdd/case_name

// Run with race detector
go test -race ./...

// Short mode โ€” skip slow tests
go test -short ./...
๐Ÿ“‹

Table-Driven Tests

๐Ÿ’ก
Table-driven tests are the idiomatic Go pattern. Define all inputs and expected outputs upfront in a slice, then loop over them. Adding a new case is a single line โ€” no new test function needed.
Standard table-driven pattern Table
func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"positive",   10, 2,  5,  false},
        {"negative",   -6, 3, -2,  false},
        {"divide by 0", 1,  0,  0,  true},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := Divide(tc.a, tc.b)

            if (err != nil) != tc.wantErr {
                t.Fatalf("wantErr=%v, got err=%v", tc.wantErr, err)
            }
            if !tc.wantErr && got != tc.want {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}
Named test cases with a map map variant
// map[string]struct gives free names, no separate name field needed
func TestTrim(t *testing.T) {
    tests := map[string]struct{
        input, want string
    }{
        "leading spaces":  {"  hello",   "hello"},
        "trailing spaces": {"hello  ",   "hello"},
        "both":            {"  hello  ", "hello"},
        "empty":           {"",           ""},
    }

    for name, tc := range tests {
        t.Run(name, func(t *testing.T) {
            if got := strings.TrimSpace(tc.input); got != tc.want {
                t.Errorf("got %q, want %q", got, tc.want)
            }
        })
    }
}
๐Ÿ”€

t.Run Subtests

Nested subtests & parallel execution t.Run
func TestUser(t *testing.T) {
    t.Run("create", func(t *testing.T) {
        // ...
    })

    t.Run("delete", func(t *testing.T) {
        t.Run("existing", func(t *testing.T) { /* ... */ })
        t.Run("not found", func(t *testing.T) { /* ... */ })
    })
}

// Run only delete subtests:
// go test -run TestUser/delete
// go test -run TestUser/delete/not_found   (spaces become underscores)
t.Parallel โ€” run subtests concurrently Parallel
func TestProcessItems(t *testing.T) {
    items := []string{"a", "b", "c"}

    for _, item := range items {
        item := item  // capture loop variable (required pre-Go 1.22)
        t.Run(item, func(t *testing.T) {
            t.Parallel()  // call immediately after t.Run body opens
            process(item)
        })
    }
    // Parent waits for all parallel subtests before returning
}

// t.Parallel() signals the test runner to pause this subtest,
// run the rest of the parent, then run all parallel subtests together.
โš ๏ธ
When using t.Parallel() in a loop, always shadow the loop variable inside the loop body (item := item) before Go 1.22. Otherwise every goroutine captures the same variable and reads the final value.
โฑ๏ธ

Benchmarks

  The testing harness runs your loop repeatedly, increasing b.N until
  results are stable (usually ~1 second of wall time).

  b.N = 1 โ†’ b.N = 100 โ†’ b.N = 10000 โ†’ b.N = 1234567 โ†’ report

  Output:  BenchmarkAdd-8    1234567    42.3 ns/op    0 allocs/op
Writing a benchmark BenchmarkXxx
func BenchmarkJoin(b *testing.B) {
    words := []string{"go", "is", "fast"}

    for i := 0; i < b.N; i++ {
        strings.Join(words, " ")
    }
}

// Run benchmarks (not run by default)
// go test -bench=. ./...
// go test -bench=BenchmarkJoin -benchmem ./...

// -benchmem adds: bytes/op and allocs/op columns
// BenchmarkJoin-8   3842106   31.2 ns/op   24 B/op   1 allocs/op
ResetTimer โ€” exclude setup ResetTimer
func BenchmarkSearch(b *testing.B) {
    // Setup โ€” don't measure this
    data := loadLargeDataset()

    b.ResetTimer()  // clock starts here

    for i := 0; i < b.N; i++ {
        search(data, "target")
    }
}
ReportAllocs & StopTimer allocs
func BenchmarkEncode(b *testing.B) {
    b.ReportAllocs()  // same as -benchmem for this benchmark

    payload := buildPayload()

    for i := 0; i < b.N; i++ {
        b.StopTimer()
        buf := resetBuffer()   // don't measure reset
        b.StartTimer()

        json.NewEncoder(buf).Encode(payload)
    }
}
๐Ÿ”ง

Test Helpers

โ„น๏ธ
Mark shared assertion functions with t.Helper(). Without it, failures point to the line inside the helper rather than the call site โ€” making failures hard to trace.
t.Helper() โ€” correct failure line numbers t.Helper
// Without t.Helper(): failure points to line inside assertEqual
// With t.Helper():    failure points to the assertEqual call site

func assertEqual[T comparable](t *testing.T, got, want T) {
    t.Helper()
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

func TestSomething(t *testing.T) {
    result, err := doWork()
    assertNoError(t, err)        // failure reported here โœ“
    assertEqual(t, result, "ok") // failure reported here โœ“
}
Constructing test fixtures Fixtures
// Return the thing under test and a cleanup function
func newTestServer(t *testing.T) *Server {
    t.Helper()
    s, err := NewServer(":0")  // :0 picks a free port
    if err != nil {
        t.Fatalf("newTestServer: %v", err)
    }
    t.Cleanup(func() { s.Close() })
    return s
}

func TestEndpoint(t *testing.T) {
    s := newTestServer(t)       // server closes automatically when test ends
    resp, err := http.Get(s.URL() + "/health")
    assertNoError(t, err)
    assertEqual(t, resp.StatusCode, 200)
}
๐Ÿ”„

Setup & Teardown

t.Cleanup โ€” per-test teardown t.Cleanup
// Registered cleanups run after the test (and its subtests) finish
// Called in LIFO order โ€” mirrors defer semantics
func TestWithDB(t *testing.T) {
    db := openTestDB(t)
    t.Cleanup(func() {
        db.Close()
    })

    // test runs here
    // db.Close() called automatically on exit
}

// t.TempDir() creates a temp dir and registers its
// removal as a cleanup automatically
dir := t.TempDir()
// dir removed when test ends
TestMain โ€” package-level setup TestMain
// TestMain runs before any test in the package
// Must call m.Run() and os.Exit with its result
func TestMain(m *testing.M) {
    // Setup: start a shared test database
    db, err := startTestDB()
    if err != nil {
        log.Fatalf("db setup: %v", err)
    }
    testDB = db

    code := m.Run()  // run all tests

    // Teardown
    db.Close()
    os.Exit(code)
}
๐Ÿ’ก
Prefer t.Cleanup over defer inside tests when the cleanup is registered in a helper. defer runs at the end of the helper function (too early), while t.Cleanup runs at the end of the test that called the helper.
๐ŸŽญ

Fakes & Interfaces

โ„น๏ธ
Go's implicit interfaces make testing easy without a mocking library. Define an interface where the dependency is consumed, then implement a simple fake for tests.
Interface-based fakes Fake
// Production code depends on an interface, not a concrete type
type Mailer interface {
    Send(to, subject, body string) error
}

type UserService struct{ mailer Mailer }

func (s *UserService) Register(email string) error {
    // ... create user ...
    return s.mailer.Send(email, "Welcome", "...")
}

// Fake for tests โ€” records calls, no real email sent
type fakeMailer struct {
    sent []string
}

func (f *fakeMailer) Send(to, _, _ string) error {
    f.sent = append(f.sent, to)
    return nil
}

func TestRegister(t *testing.T) {
    m := &fakeMailer{}
    svc := &UserService{mailer: m}
    svc.Register("alice@example.com")

    if len(m.sent) != 1 || m.sent[0] != "alice@example.com" {
        t.Errorf("expected welcome email, got: %v", m.sent)
    }
}
httptest โ€” test HTTP handlers without a server httptest
import "net/http/httptest"

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest("GET", "/health", nil)
    w   := httptest.NewRecorder()

    HealthHandler(w, req)

    resp := w.Result()
    if resp.StatusCode != http.StatusOK {
        t.Errorf("got %d, want 200", resp.StatusCode)
    }
}

// httptest.NewServer โ€” spin up a real HTTP server for integration tests
ts := httptest.NewServer(myHandler())
defer ts.Close()
resp, _ := http.Get(ts.URL + "/health")
๐Ÿ“‹

Quick Reference

Concept Syntax / Command Notes
Test functionfunc TestXxx(t *testing.T)Must be in a _test.go file
Mark failed, continuet.Errorf("got %v", got)Test keeps running after this
Mark failed, stopt.Fatalf("setup: %v", err)Stops this test immediately
Log (on failure)t.Logf("input: %v", x)Only printed when test fails (or -v)
Skipt.Skip("reason")Marks skipped, not failed
Short mode checktesting.Short()True when -short flag is set
Subtestt.Run("name", func(t *testing.T) {})Isolated; filterable by name
Parallel subtestt.Parallel()Call at start of t.Run body
Cleanupt.Cleanup(func() { โ€ฆ })Runs after test ends; LIFO
Temp dirt.TempDir()Auto-deleted on test exit
Helper markert.Helper()Failure line points to call site
Benchmarkfunc BenchmarkXxx(b *testing.B)Loop body runs b.N times
Reset timerb.ResetTimer()Exclude setup from measurement
Report allocsb.ReportAllocs()Same as -benchmem for this bench
Package setupfunc TestMain(m *testing.M)Must call os.Exit(m.Run())
Run all testsgo test ./...Recursive
Verbosego test -v ./...Print all test names
Filter by namego test -run TestFoo/subnameRegex matched; spaces โ†’ underscores
Run benchmarksgo test -bench=. -benchmem ./...Not run by default
Race detectorgo test -race ./...Detects data races; add to CI
Coveragego test -cover ./...-coverprofile=c.out for detail
HTTP recorderhttptest.NewRecorder()Captures handler response
HTTP test serverhttptest.NewServer(h)Real server; call defer ts.Close()