Files
go-patterns/patterns/testing.md
T

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:

  1. All cases visible in one place — easy to add new cases
  2. t.Run creates subtests — each case runs independently, can be filtered with -run
  3. Uniform structure — input, expected output, test name
  4. 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:

  1. Run in separate goroutines — isolated from each other
  2. Have their own contextcontext.WithCancel(context.Background())
  3. Can be filteredgo test -run TestFoo/subcase
  4. Can run in parallel — via t.Parallel() inside the subtest
  5. 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:

  1. Ignored by the go tool — not compiled as a package
  2. Available to tests — accessed via relative path "testdata/file.txt"
  3. Committed to repo — test fixtures live alongside the code
  4. 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.Fatalfstops the test immediately. Use for setup failures where subsequent assertions are meaningless.
  • t.Error / t.Errorfreports 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 -run flag 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"