Advanced Testing
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
- Use
t.Cleanup,t.TempDir, andt.Setenvinstead of manual teardown. - Move flaky concurrency tests into
testing/synctest. - Standardize benchmark style on
B.Loop. - Run strict CI test command:
go test ./... -race -shuffle=on -count=1Parallel 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 memorySummary
| 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=. -benchmemRunnable example
Save these two files in mathx/. Then:
cd mathx
go mod init example/mathx
go test -v
go test -bench='BenchmarkAdd$' -benchmemmathx.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.