16 KiB
Go Testing Patterns
Patterns extracted from the Go standard library source code.
1. Table-Driven Tests with Subtests
Source: src/encoding/json/encode_test.go:405-430
// src/encoding/json/encode_test.go:405-430
func TestUnsupportedValues(t *testing.T) {
tests := []struct {
CaseName
in any
}{
{Name(""), math.NaN()},
{Name(""), math.Inf(-1)},
{Name(""), math.Inf(1)},
{Name(""), pointerCycle},
{Name(""), pointerCycleIndirect},
{Name(""), mapCycle},
{Name(""), sliceCycle},
{Name(""), recursiveSliceCycle},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if _, err := Marshal(tt.in); err != nil {
if _, ok := err.(*UnsupportedValueError); !ok {
t.Errorf("%s: Marshal error:\n\tgot: %T\n\twant: %T", tt.Where, err, new(UnsupportedValueError))
}
} else {
t.Errorf("%s: Marshal error: got nil, want non-nil", tt.Where)
}
})
}
}
Source: src/encoding/json/encode_test.go:270-328 (with inputs and expected outputs)
// src/encoding/json/encode_test.go:270-328
func TestRoundtripStringTag(t *testing.T) {
tests := []struct {
CaseName
in StringTag
want string
}{{
CaseName: Name("AllTypes"),
in: StringTag{
BoolStr: true,
IntStr: 42,
UintptrStr: 44,
StrStr: "xzbit",
NumberStr: "46",
},
want: `{
"BoolStr": "true",
"IntStr": "42",
...
}`,
}, {
CaseName: Name("StringDoubleEscapes"),
in: StringTag{
StrStr: "\b\f\n\r\t\"\\",
NumberStr: "0",
},
want: `{...}`,
}}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
got, err := MarshalIndent(&tt.in, "", "\t")
if err != nil {
t.Fatalf("%s: MarshalIndent error: %v", tt.Where, err)
}
if got := string(got); got != tt.want {
t.Fatalf("%s: MarshalIndent:\n\tgot: %s\n\twant: %s", tt.Where, ...)
}
// Verify round-trip
var s2 StringTag
if err := Unmarshal(got, &s2); err != nil {
t.Fatalf("%s: Decode error: %v", tt.Where, err)
}
if !reflect.DeepEqual(s2, tt.in) {
t.Fatalf("%s: Decode:\n\tinput: %s\n\tgot: %#v\n\twant: %#v", ...)
}
})
}
}
Why
Table-driven tests are Go's signature testing pattern:
- All cases visible in one place — easy to add new cases
- t.Run creates subtests — each case runs independently, can be filtered with
-run - Uniform structure — input, expected output, test name
- Failures identify which case — via the case name
Template
func TestFoo(t *testing.T) {
tests := []struct {
name string
input InputType
want OutputType
wantErr bool
}{
{name: "basic", input: ..., want: ...},
{name: "empty", input: ..., want: ...},
{name: "error case", input: ..., wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Foo(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("Foo() error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("Foo() = %v, want %v", got, tt.want)
}
})
}
}
Anti-pattern
// DON'T: Separate test functions for each case
func TestFoo_Basic(t *testing.T) { ... }
func TestFoo_Empty(t *testing.T) { ... }
func TestFoo_Error(t *testing.T) { ... }
// 50 near-identical functions — hard to maintain
// DON'T: Tests without names (hard to identify failures)
tests := []struct{ in, want int }{{1, 2}, {3, 4}}
for _, tt := range tests {
// which test failed? index 0 or 1?
}
2. t.Helper() — Clean Error Reporting
Source: src/testing/testing.go:1415-1435
// src/testing/testing.go:1415-1435
func (c *common) Helper() {
if c.isSynctest {
c = c.parent
}
c.mu.Lock()
defer c.mu.Unlock()
if c.helperPCs == nil {
c.helperPCs = make(map[uintptr]struct{})
}
var pc [1]uintptr
n := runtime.Callers(2, pc[:])
if n == 0 {
panic("testing: zero callers found")
}
if _, found := c.helperPCs[pc[0]]; !found {
c.helperPCs[pc[0]] = struct{}{}
c.helperNames = nil
}
}
Why
When a test helper calls t.Helper(), failures report the caller's line number, not the helper's. Without it, every failure points to the helper function — useless for identifying which test case failed.
Idiomatic Usage
func assertNoError(t *testing.T, err error) {
t.Helper() // failures point to the caller, not this line
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertEqual(t *testing.T, got, want any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
Anti-pattern
// DON'T: Forget t.Helper() in test helpers
func checkResult(t *testing.T, got, want string) {
// Missing t.Helper()
if got != want {
t.Errorf("got %q, want %q", got, want)
// Error points HERE, not the actual test case — confusing
}
}
3. t.Run() — Subtests and Test Organization
Source: src/testing/testing.go:2204-2260
// src/testing/testing.go:2204-2215
func (t *T) Run(name string, f func(t *T)) bool {
t.hasSub.Store(true)
testName, ok, _ := t.tstate.match.fullName(&t.common, name)
if !ok || shouldFailFast() {
return true
}
// ...
ctx, cancelCtx := context.WithCancel(context.Background())
t = &T{
common: common{
name: testName,
parent: &t.common,
// ...
},
}
go tRunner(t, f)
// ...
}
Why
Subtests:
- Run in separate goroutines — isolated from each other
- Have their own context —
context.WithCancel(context.Background()) - Can be filtered —
go test -run TestFoo/subcase - Can run in parallel — via
t.Parallel()inside the subtest - Share setup/teardown — parent test's defer runs after all subtests
Pattern: Setup/Teardown with Subtests
func TestDB(t *testing.T) {
db := setupTestDB(t) // shared setup
t.Cleanup(func() { db.Close() }) // runs after ALL subtests
t.Run("Insert", func(t *testing.T) {
// uses db
})
t.Run("Query", func(t *testing.T) {
// uses db
})
}
Anti-pattern
// DON'T: Rely on test execution order
func TestInsert(t *testing.T) { ... } // must run before TestQuery
func TestQuery(t *testing.T) { ... } // depends on TestInsert's side effects
// Tests should be independent!
4. t.Parallel() — Concurrent Test Execution
Source: src/testing/testing.go:1912
// src/testing/testing.go:1912
func (t *T) Parallel() {
// ...marks test as parallel, pauses until parent completes
}
Why
t.Parallel() signals that this test can run concurrently with other parallel tests. The test pauses until its parent test function returns, then runs alongside other parallel subtests.
Idiomatic Usage
func TestFoo(t *testing.T) {
tests := []struct{
name string
input int
}{
{"small", 1},
{"large", 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // runs concurrently with other subtests
result := expensiveOperation(tt.input)
if result != expected {
t.Errorf(...)
}
})
}
}
Anti-pattern
// DON'T: Parallel tests that share mutable state
var counter int
func TestA(t *testing.T) {
t.Parallel()
counter++ // DATA RACE
}
func TestB(t *testing.T) {
t.Parallel()
counter++ // DATA RACE
}
// DON'T: Capture loop variable in Go < 1.22 without explicit copy
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// In Go < 1.22: tt is shared — always sees last value
// In Go >= 1.22: loop variables are per-iteration (fixed)
})
}
5. t.Cleanup() — Deterministic Teardown
Source: src/testing/testing.go:1439
// src/testing/testing.go:1439-1442
// Cleanup registers a function to be called when the test (or subtest)
// and all its subtests complete. Cleanup functions will be called in
// last added, first called order.
func (c *common) Cleanup(f func()) { ... }
Why
t.Cleanup is like defer but tied to test lifecycle, not function scope. It runs after all subtests complete and works with parallel tests.
Idiomatic Usage
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
// Caller doesn't need to worry about cleanup:
func TestQueries(t *testing.T) {
db := setupTestDB(t) // automatically cleaned up
// ...
}
Anti-pattern
// DON'T: Return cleanup functions (easy to forget)
func setupDB(t *testing.T) (*sql.DB, func()) {
db := ...
return db, func() { db.Close() }
}
// Caller must remember: db, cleanup := setupDB(t); defer cleanup()
// DO: Use t.Cleanup inside the setup function
6. t.TempDir() — Automatic Temp Directories
Source: src/testing/testing.go:1575
// src/testing/testing.go:1575
func (c *common) TempDir() string { ... }
Why
Creates a temp directory that's automatically removed when the test completes. No manual cleanup needed, no leftover test artifacts.
Idiomatic Usage
func TestWriteConfig(t *testing.T) {
dir := t.TempDir() // auto-cleaned
path := filepath.Join(dir, "config.json")
err := WriteConfig(path, myConfig)
if err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(path)
// assert contents...
}
Anti-pattern
// DON'T: Create temp dirs manually and forget cleanup
func TestWrite(t *testing.T) {
dir, _ := os.MkdirTemp("", "test")
// forgot os.RemoveAll(dir) — test leaves garbage
}
7. testdata/ Directory
Source: src/net/http/testdata/, src/encoding/json/testdata/ (implicit — exists in the tree)
src/net/http/testdata/
├── file
├── index.html
└── style.css
Why
The testdata/ directory is special in Go:
- Ignored by the go tool — not compiled as a package
- Available to tests — accessed via relative path
"testdata/file.txt" - Committed to repo — test fixtures live alongside the code
- Portable — tests work without external dependencies
Idiomatic Usage
func TestParse(t *testing.T) {
input, err := os.ReadFile("testdata/input.json")
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile("testdata/expected.json")
if err != nil {
t.Fatal(err)
}
got := Parse(input)
if !bytes.Equal(got, want) {
t.Errorf("Parse mismatch")
}
}
Golden File Pattern
func TestOutput(t *testing.T) {
got := generateOutput()
golden := filepath.Join("testdata", t.Name()+".golden")
if *update { // -update flag to regenerate
os.WriteFile(golden, got, 0644)
}
want, _ := os.ReadFile(golden)
if !bytes.Equal(got, want) {
t.Errorf("output mismatch; run with -update to regenerate")
}
}
Anti-pattern
// DON'T: Embed large test fixtures as string literals
var testInput = `{
"very": "long",
"json": "string",
// 500 lines...
}`
// DON'T: Depend on external URLs for test data
func TestParse(t *testing.T) {
resp, _ := http.Get("https://example.com/test.json") // flaky!
}
8. Error Message Formatting
Source: src/encoding/json/encode_test.go (throughout)
// Pattern from encode_test.go:304
t.Fatalf("%s: MarshalIndent error: %v", tt.Where, err)
// Pattern from encode_test.go:306-307
t.Fatalf("%s: MarshalIndent:\n\tgot: %s\n\twant: %s", tt.Where, got, want)
// Pattern from encode_test.go:421-422
t.Errorf("%s: Marshal error:\n\tgot: %T\n\twant: %T", tt.Where, err, new(UnsupportedValueError))
Why
The stdlib follows a consistent error format:
- Context first — where/what was being tested
- got/want on separate lines — easy to diff visually
- Tab-indented — aligns the comparison
Convention
t.Errorf("FunctionName(%v) = %v, want %v", input, got, want)
// or for complex values:
t.Errorf("FunctionName(%v):\n\tgot: %v\n\twant: %v", input, got, want)
Anti-pattern
// DON'T: Vague error messages
t.Error("failed") // what failed? what was expected?
// DON'T: Only show the got value
t.Errorf("got %v", got) // what was expected?
// DON'T: Use assert libraries that hide the actual comparison
assert.Equal(t, got, want) // when it fails: "not equal" — which is which?
9. t.Fatal vs t.Error
Convention across stdlib
// Fatal: test cannot continue meaningfully
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err) // no point continuing without a database
}
// Error: test can continue, report all failures
for _, tt := range tests {
got := fn(tt.input)
if got != tt.want {
t.Errorf(...) // report and keep going
}
}
Why
t.Fatal/t.Fatalf— stops the test immediately. Use for setup failures where subsequent assertions are meaningless.t.Error/t.Errorf— reports failure, continues. Use in loops to collect all failures at once.
Anti-pattern
// DON'T: Fatal in loops (misses subsequent failures)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got != tt.want {
t.Fatalf(...) // stops this subtest — fine in subtests actually
}
})
}
// But without subtests:
for _, tt := range tests {
if got != tt.want {
t.Fatalf(...) // stops ALL remaining cases!
}
}
// DON'T: Error when you can't continue
file, err := os.Open(path)
if err != nil {
t.Errorf("open: %v", err) // continues to use nil file — panic!
}
file.Read(...)
10. Test Naming Conventions
Source: All stdlib tests follow these patterns
// Function tests: TestFunctionName
func TestMarshal(t *testing.T) { ... }
// Method tests: TestTypeName_MethodName
func TestEncoder_Encode(t *testing.T) { ... }
// Behavior tests: TestDescription
func TestOmitEmpty(t *testing.T) { ... }
// Edge cases: TestFunctionName_EdgeCase
func TestMarshal_NilSlice(t *testing.T) { ... }
// Benchmarks: BenchmarkFunctionName
func BenchmarkMarshal(b *testing.B) { ... }
// Examples: ExampleFunctionName
func ExampleMarshal() {
// ...
// Output: {"name":"Alice"}
}
Why
- Test names are used with
-runflag for filtering - They appear in failure output — should be self-explanatory
- Example functions become documentation (shown in godoc)
- Subtests use
/separator:TestMarshal/NilSlice
Anti-pattern
// DON'T: Numbered tests
func Test1(t *testing.T) { ... }
func Test2(t *testing.T) { ... }
// DON'T: Tests that don't start with Test
func testHelper(t *testing.T) { ... } // won't be run! (lowercase 't')
// DON'T: Overly verbose names
func TestThatMarshalCorrectlyHandlesNilSliceInputAndReturnsNullJSON(t *testing.T) { ... }
Summary: Testing Best Practices
| Pattern | When |
|---|---|
| Table-driven tests | Multiple inputs for same logic |
| t.Run subtests | Isolate cases, enable -run filtering |
| t.Helper() | Every test helper function |
| t.Parallel() | Independent tests, speed up suite |
| t.Cleanup() | Resource teardown (replaces defer in helpers) |
| t.TempDir() | Need filesystem for test |
| testdata/ | External test fixtures |
| Golden files | Complex expected output |
| t.Fatal | Setup failures (can't continue) |
| t.Error | Assertion failures (collect all) |
| got/want format | "got %v, want %v" or "\n\tgot: %v\n\twant: %v" |