docs: idiomatic Go patterns from stdlib + Kubernetes with source citations
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
# 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`
|
||||
|
||||
```go
|
||||
// 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)
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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`
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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`
|
||||
|
||||
```go
|
||||
// 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 context** — `context.WithCancel(context.Background())`
|
||||
3. **Can be filtered** — `go 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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`
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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`
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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`
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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)
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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"` |
|
||||
Reference in New Issue
Block a user