Advanced Testing

Updated

July 30, 2026

Overview

Table-driven tests, subtests, and test helpers make Go tests maintainable and expressive.

Fast feedback test loop

unit tests -------> deterministic concurrency tests -----> benchmarks
    |                           |                             |
  <1s target                  race-safe                    track regressions

Table-Driven Tests

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed", -1, 5, 4},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Subtests

func TestAPI(t *testing.T) {
    t.Run("GET", func(t *testing.T) {
        t.Run("success", func(t *testing.T) { })
        t.Run("not_found", func(t *testing.T) { })
    })

    t.Run("POST", func(t *testing.T) {
        t.Run("valid", func(t *testing.T) { })
        t.Run("invalid", func(t *testing.T) { })
    })
}

Test Helpers

func newTestServer(t *testing.T) *httptest.Server {
    t.Helper()
    return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
    }))
}

func TestClient(t *testing.T) {
    server := newTestServer(t)
    defer server.Close()
    // Use server.URL
}

Cleanup

func TestWithCleanup(t *testing.T) {
    tmpDir := t.TempDir()  // Auto-removed

    f, _ := os.CreateTemp(tmpDir, "test")
    t.Cleanup(func() {
        f.Close()
    })
}

Deterministic Concurrency (testing/synctest)

In Go 1.26, testing/synctest introduces virtualized time and goroutine bubble scheduling. Time advances instantly when all goroutines in the synctest.Run bubble are blocked on timers or channel operations, eliminating artificial time.Sleep in tests and eliminating flaky timing race conditions.

sequenceDiagram
    participant T as Test Runner (synctest.Run)
    participant B as Virtual Bubble Clock
    participant G as Background Worker Goroutine

    T->>G: Spawn Worker with 10s Timer
    G->>B: Sleep 10s (Goroutine Blocks)
    Note over B: All goroutines idle! Clock instantly jumps +10s
    B->>G: Wake up immediately (0 wall-clock ms elapsed)
    G->>T: Send completion signal
    T->>B: synctest.Wait() (Verifies all goroutines settled)
import (
    "testing"
    "testing/synctest"
    "time"
)

func TestRateLimiter(t *testing.T) {
    synctest.Run(func() {
        rl := NewRateLimiter(1 * time.Second)
        // Time is virtual and controlled within synctest.Run
        if !rl.Allow() { t.Error("should allow first request") }
        if rl.Allow() { t.Error("should block immediate second request") }

        // Advance virtual time instantly without waiting real seconds
        time.Sleep(1 * time.Second)

        if !rl.Allow() { t.Error("should allow request after virtual 1s window") }
        synctest.Wait() // Wait for all bubble goroutines to settle
    })
}

Tip: keep business logic outside goroutine setup so synctest.Run stays small and focused.

Efficient Benchmarks (B.Loop)

Prefer B.Loop in modern toolchains for cleaner benchmark loops and fewer loop-control mistakes.

func BenchmarkAdd(b *testing.B) {
    for b.Loop() {
        Add(1, 2)
    }
}

Migration pattern:

// old
func BenchmarkParseOld(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Parse(input)
    }
}

// new
func BenchmarkParse(b *testing.B) {
    for b.Loop() {
        Parse(input)
    }
}

Go 1.26 Testing Upgrade Checklist

  1. Use t.Cleanup, t.TempDir, and t.Setenv instead of manual teardown.
  2. Move flaky concurrency tests into testing/synctest.
  3. Standardize benchmark style on B.Loop.
  4. Run strict CI test command:
go test ./... -race -shuffle=on -count=1

Parallel Subtests

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"a"}, {"b"}, {"c"}}

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Runs concurrently
        })
    }
}

Benchmarks

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}
go test -bench=.
go test -bench=. -benchmem  # Include memory

Summary

Technique Purpose
Table-driven Test multiple cases
Subtests Organize and filter
t.Helper() Clean error reporting
t.Cleanup() Guaranteed cleanup
t.Parallel() Concurrent tests

Worked example

Table-driven tests with nested subtests and shared helpers.

Save as clamp.go and clamp_test.go. Then:

go mod init example
go test -v
go test -run 'TestClamp/high' -v
// clamp.go
package main

func Clamp(v, lo, hi int) int {
    if lo > hi {
        lo, hi = hi, lo
    }
    if v < lo {
        return lo
    }
    if v > hi {
        return hi
    }
    return v
}

func InRange(v, lo, hi int) bool {
    return Clamp(v, lo, hi) == v
}
// clamp_test.go
package main

import "testing"

func TestClamp(t *testing.T) {
    tests := []struct {
        name       string
        v, lo, hi  int
        want       int
    }{
        {"inside", 5, 0, 10, 5},
        {"low", -1, 0, 10, 0},
        {"high", 99, 0, 10, 10},
        {"swapped bounds", 5, 10, 0, 5},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Clamp(tt.v, tt.lo, tt.hi); got != tt.want {
                t.Fatalf("Clamp(%d,%d,%d)=%d want %d", tt.v, tt.lo, tt.hi, got, tt.want)
            }
        })
    }
}

func TestInRangeTable(t *testing.T) {
    t.Run("true", func(t *testing.T) {
        cases := []int{0, 5, 10}
        for _, v := range cases {
            if !InRange(v, 0, 10) {
                t.Fatalf("%d should be in range", v)
            }
        }
    })
    t.Run("false", func(t *testing.T) {
        if InRange(-1, 0, 10) || InRange(11, 0, 10) {
            t.Fatal("out of range reported true")
        }
    })
}

Expected output:

=== RUN   TestClamp
=== RUN   TestClamp/inside
=== RUN   TestClamp/low
=== RUN   TestClamp/high
=== RUN   TestClamp/swapped_bounds
--- PASS: TestClamp (0.00s)
=== RUN   TestInRangeTable
=== RUN   TestInRangeTable/true
=== RUN   TestInRangeTable/false
--- PASS: TestInRangeTable (0.00s)
PASS

More examples

Benchmarks comparing algorithms (-benchmem).

// clamp_bench_test.go
package main

import "testing"

func minMaxClamp(v, lo, hi int) int {
    if v < lo {
        return lo
    }
    if v > hi {
        return hi
    }
    return v
}

func BenchmarkClamp(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = Clamp(i%20, 0, 10)
    }
}

func BenchmarkMinMaxClamp(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = minMaxClamp(i%20, 0, 10)
    }
}
go test -bench=. -benchmem

Runnable example

Save these two files in mathx/. Then:

cd mathx
go mod init example/mathx
go test -v
go test -bench='BenchmarkAdd$' -benchmem

mathx.go:

package mathx

func Add(a, b int) int { return a + b }

func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

mathx_test.go:

package mathx

import (
    "os"
    "path/filepath"
    "testing"
)

func TestAddTable(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed", -1, 5, 4},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.expected {
                t.Errorf("Add(%d,%d)=%d; want %d", tt.a, tt.b, got, tt.expected)
            }
        })
    }
}

func TestMaxParallel(t *testing.T) {
    cases := []struct {
        name       string
        a, b, want int
    }{
        {"a", 1, 2, 2},
        {"b", 5, 5, 5},
        {"c", -3, -1, -1},
    }
    for _, tc := range cases {
        tc := tc // capture (harmless; required before Go 1.22 loop scoping)
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            if got := Max(tc.a, tc.b); got != tc.want {
                t.Fatalf("got %d want %d", got, tc.want)
            }
        })
    }
}

func TestWithCleanup(t *testing.T) {
    dir := t.TempDir() // removed automatically after the test
    path := filepath.Join(dir, "note.txt")
    if err := writeFile(t, path, "ok"); err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        // Extra hooks run LIFO before TempDir removal.
    })
    data, err := os.ReadFile(path)
    if err != nil {
        t.Fatal(err)
    }
    if string(data) != "ok" {
        t.Fatalf("got %q", data)
    }
}

func writeFile(t *testing.T, path, body string) error {
    t.Helper()
    return os.WriteFile(path, []byte(body), 0o600)
}

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}

Expected output: (go test -v)

=== RUN   TestAddTable
=== RUN   TestAddTable/positive
=== RUN   TestAddTable/negative
=== RUN   TestAddTable/zero
=== RUN   TestAddTable/mixed
--- PASS: TestAddTable (0.00s)
=== RUN   TestMaxParallel
=== RUN   TestMaxParallel/a
=== RUN   TestMaxParallel/b
=== RUN   TestMaxParallel/c
--- PASS: TestMaxParallel (0.00s)
=== RUN   TestWithCleanup
--- PASS: TestWithCleanup (0.00s)
PASS

Benchmark numbers vary by machine:

BenchmarkAdd-8      ... ... ns/op   0 B/op  0 allocs/op

What to notice: Table-driven t.Run names show up in failures and -run filters. t.Parallel runs subtests concurrently. t.TempDir / t.Cleanup replace manual teardown. Classic b.N benchmarks work everywhere; prefer b.Loop() on newer toolchains when available.

Try next: Filter with go test -run 'TestAddTable/mixed' -v; break one table case and read the subtest name in the failure; try go test -count=1 -shuffle=on.